diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 77a526076de4d..ed32670755ff6 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -14,7 +14,7 @@ const INERT = [ // Shared test setup and vendored mobile dependencies affect every package. const RUNS_EVERYTHING = [ - /^packages\/shared\/src\/(HostProcess|testing\/longTempDir)\.ts$/, + /^packages\/shared\/src\/(HostProcess|testing\/(longTempDir|providerEnv))\.ts$/, /^apps\/mobile\/deps\//, ]; @@ -27,6 +27,8 @@ const EXTRA_OWNERS = [ [/^native\/browser-secret\//, ["@t3tools/desktop"]], [/^native\/kde-snap-shot\//, ["@t3tools/desktop"]], [/^native\/libghostty-vt\//, ["@t3tools/web"]], + // apps/server/src/cloud/ConnectReachability.test.ts checks the guide's headings. + [/^docs\/user\/t3-connect-troubleshooting\.md$/, ["t3"]], ]; const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; @@ -92,11 +94,11 @@ function readWorkspace() { // [] means inert; null means unknown and requires every job. function ownersOf(file, packages) { - if (INERT.some((pattern) => pattern.test(file))) return []; + const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); + if (INERT.some((pattern) => pattern.test(file))) return extra; // A manifest can change dependencies or versions other packages read. if (path.posix.basename(file) === "package.json") return null; if (RUNS_EVERYTHING.some((pattern) => pattern.test(file))) return null; - const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); // scripts/build-desktop-artifact.test.ts reads the crates' sources. if (RUST.test(file)) return ["@t3tools/scripts", ...extra]; diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index ca57769ed490c..09b25131e4c48 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -300,18 +300,28 @@ async function resolve({ github, context, core }) { core.setOutput("publish", "false"); return; } - const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id: source.workflow_id, - branch: pull.base.ref, - event: "push", - status: "success", - per_page: 100, - }); + // One page each for the exact base and the newest runs; paging through + // every successful main run costs up to ten requests to keep 20. + const listRuns = async (filters) => { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: source.workflow_id, + branch: pull.base.ref, + event: "push", + status: "success", + per_page: 20, + ...filters, + }); + return data.workflow_runs; + }; + const [baseRuns, recentRuns] = await Promise.all([ + listRuns({ head_sha: pull.base.sha }), + listRuns({}), + ]); const orderedRuns = [ - ...workflowRuns.filter((run) => run.head_sha === pull.base.sha), - ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha), + ...baseRuns, + ...recentRuns.filter((run) => run.head_sha !== pull.base.sha), ].slice(0, 20); let baselineRun; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 753c3eb759072..8c4595ccf0c6a 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -55,7 +55,18 @@ test("validates the fixed artifact schema", () => { test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { const outputs = {}; const listWorkflowRunArtifacts = () => {}; - const listWorkflowRuns = () => {}; + // The exact base run sits far past the newest page of main CI runs. + const mainRuns = Array.from({ length: 1_000 }, (_, index) => ({ + id: 1_000 - index, + head_sha: index === 900 ? "base-sha" : `main-sha-${index}`, + })); + const listWorkflowRuns = async ({ head_sha, per_page }) => ({ + data: { + workflow_runs: mainRuns + .filter((run) => head_sha === undefined || run.head_sha === head_sha) + .slice(0, per_page), + }, + }); const listPullRequestsAssociatedWithCommit = () => {}; const github = { paginate: async (method, input) => { @@ -77,9 +88,6 @@ test("resolves a fallback PR with a redacted head repo and exact main baseline", }, ]; } - if (method === listWorkflowRuns) { - return [{ id: 1, head_sha: "base-sha" }]; - } throw new Error("unexpected pagination call"); }, rest: { @@ -123,7 +131,7 @@ test("resolves a fallback PR with a redacted head repo and exact main baseline", assert.equal(outputs.publish, "true"); assert.equal(outputs.pull_number, "5350"); assert.equal(outputs.pr_artifact, "true"); - assert.equal(outputs.baseline_run_id, "1"); + assert.equal(outputs.baseline_run_id, "100"); assert.equal(outputs.baseline_matches_base, "true"); }); diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 047dafbdd4be1..905823d1d1a9e 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -44,7 +44,7 @@ The reference for how this codebase wants UI built is the composer banner system - `ComposerBanner.tsx` exports one object of small slot components (`Root`, `Row`, `Icon`, `Content`, `Actions`, `Dismiss`, `Children`, and so on). Each slot owns its own Tailwind classes, exposes a `data-slot` attribute, takes `className` and spreads the rest of its props, and uses `cn` so callers can adjust without overriding. Variants and density are props on `Root`, not ad hoc class strings at call sites. Where a slot needs a real control it renders `Button` from `ui/` rather than a styled ``); + const failure = (selector: string) => + ServerBrowserPage.click(page, { selector, timeoutMs: 500 }).then( + () => expect.unreachable(), + ServerBrowserPage.toOperationError, + ); + + const absent = await failure(".definitely-no-such-t3-target"); + expect(absent.tag).toBe("PreviewAutomationTimeoutError"); + expect(absent.message).toBe( + 'The click may have happened and removed its target or navigated; check the page before retrying. No element matches ".definitely-no-such-t3-target" after the timeout; take a fresh snapshot and use its locator.', + ); + expect(absent.cause).toMatchObject({ name: "TimeoutError" }); + const hidden = await failure("button"); + expect(hidden.tag).toBe("PreviewAutomationTimeoutError"); + expect(hidden.message).toBe( + 'The click may have happened; check the page before retrying. "button" still matches an element after the timeout.', + ); + }); + it("does not retarget a removed ref to a replacement node", async () => { await page.setContent(""); const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "original"); @@ -212,6 +233,11 @@ describe("server browser element refs", () => { expect(tree).not.toContain("PAYLOAD"); }); + it("shows a data: page's own URL by media type only", async () => { + await page.goto("data:text/html,KeyPAYLOAD"); + expect((await takeSnapshot()).url).toBe("data:text/html,…"); + }); + it("rejects refs from another tab", async () => { await page.setContent(""); const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "same label"); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 88308ed691eed..f128bf445e603 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -33,8 +33,8 @@ export class ServerBrowserOperationError extends Error { readonly tag: string; readonly detail: unknown; - constructor(tag: string, message: string, detail?: unknown) { - super(message); + constructor(tag: string, message: string, detail?: unknown, options?: ErrorOptions) { + super(message, options); this.tag = tag; this.detail = detail; } @@ -72,6 +72,8 @@ export const toOperationError = ( }; const DEFAULT_TIMEOUT_MS = 15_000; +const TARGET_COUNT_MS = 250; +const MAX_TARGET_CHARS = 200; const pageRefs = new WeakMap }>(); // A compact runtime namespace prevents old refs from aliasing after a server restart. @@ -583,6 +585,7 @@ const capture = async ( }); return { ...page, + url: withoutDataPayload(page.url), // Mask text before truncating so a value crossing the cut cannot leak its prefix. visibleText: mask(page.visibleText).slice(0, MAX_VISIBLE_TEXT_LENGTH), accessibilityTree, @@ -633,6 +636,58 @@ export const click = async ( ): Promise<{ readonly x: number; readonly y: number }> => { const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const locator = targetLocator(page, input); + try { + return await clickTarget(page, locator, input, timeout, pointer); + } catch (cause) { + throw locator === null ? cause : await namedTargetTimeout(locator, input, cause); + } +}; + +/** + * Playwright's timeout names neither the target nor why it waited. Counting only after the + * wait failed keeps auto-wait intact, but the count describes the page after the timeout, not + * what the click did: Playwright can dispatch the click and then time out waiting for the + * navigation it started. + */ +const namedTargetTimeout = async ( + locator: Locator, + input: { readonly locator?: string | undefined; readonly selector?: string | undefined }, + cause: unknown, +) => { + if (!(cause instanceof Error && cause.name === "TimeoutError")) return cause; + const selector = JSON.stringify(input.locator ?? input.selector); + // The warning leads and the target is capped, so a reply cut short still carries the warning. + const target = + selector.length > MAX_TARGET_CHARS ? `${selector.slice(0, MAX_TARGET_CHARS)}...` : selector; + // The host's reply must still beat the broker's short grace after the operation timeout. + let timer: ReturnType | undefined; + const matches = await Promise.race([ + locator.count().catch(() => undefined), + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), TARGET_COUNT_MS); + }), + ]).finally(() => clearTimeout(timer)); + const reason = + matches === undefined + ? `The click may have happened; check the page before retrying. Whether ${target} still matches an element is unknown.` + : matches === 0 + ? `The click may have happened and removed its target or navigated; check the page before retrying. No element matches ${target} after the timeout; take a fresh snapshot and use its locator.` + : `The click may have happened; check the page before retrying. ${target} still matches an element after the timeout.`; + return new ServerBrowserOperationError( + "PreviewAutomationTimeoutError", + reason, + { reason }, + { cause }, + ); +}; + +const clickTarget = async ( + page: Page, + locator: Locator | null, + input: PreviewAutomationClickInput, + timeout: number, + pointer: PointerReporter, +) => { const point = await targetPoint(page, locator, input, timeout); await pointer(point, "click"); const options = { button: input.button ?? "left", clickCount: input.clickCount ?? 1 } as const; diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index 5f2c685fa685c..c4a709ed8acc4 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -34,7 +34,7 @@ const makeAuth = ( const requests: string[] = []; const layer = Layer.mock(EnvironmentAuth.EnvironmentAuth, { authenticateWebSocketUpgrade: (request) => { - requests.push(request.originalUrl); + requests.push(request.url); return error ? Effect.fail(error) : Effect.succeed({ diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index 8d8164cd911c4..e46a0c76adb53 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -24,6 +24,7 @@ import { authenticateMediaRequest } from "../auth/http.ts"; import { assetResponseHeaders } from "../http.ts"; import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; import * as ServerBrowser from "./ServerBrowser.ts"; +import { requestUrl } from "../requestUrl.ts"; const PREVIEW_STREAM_ROUTE_PREFIX = "/api/preview-stream"; const TAB_GONE_CODE = 4404; @@ -53,7 +54,7 @@ const parseMessage = (chunk: Uint8Array | string): unknown => { // Request middleware owns tracing, including long-lived WebSocket sessions. const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBrowser["Service"]) { 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 }); if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/download`) { return yield* serveDownload(browser, url.value.searchParams); diff --git a/apps/server/src/preview/SessionControl.test.ts b/apps/server/src/preview/SessionControl.test.ts index e086ad854b3fb..f47be6319cb81 100644 --- a/apps/server/src/preview/SessionControl.test.ts +++ b/apps/server/src/preview/SessionControl.test.ts @@ -9,14 +9,14 @@ describe("SessionControl", () => { const started = Promise.withResolvers(); const finish = Promise.withResolvers(); const events: Array = []; - const running = control.humanLatest("viewer", "hover:0", async () => { + const running = control.humanLatest("viewer", "hover:0", undefined, async () => { events.push("running"); started.resolve(); await finish.promise; }); await started.promise; const moves = Array.from({ length: 120 }, (_, position) => - control.humanLatest("viewer", "hover:0", async () => { + control.humanLatest("viewer", "hover:0", undefined, async () => { events.push(position); }), ); @@ -36,7 +36,7 @@ describe("SessionControl", () => { const finish = Promise.withResolvers(); const events: string[] = []; const running = control.human("viewer", () => finish.promise); - const before = control.humanLatest("viewer", "hover:0", async () => { + const before = control.humanLatest("viewer", "hover:0", undefined, async () => { events.push("before"); }); const recordBarrier = async () => { @@ -44,11 +44,11 @@ describe("SessionControl", () => { }; const between = barrier === "modifiers" - ? control.humanLatest("viewer", "hover:2", recordBarrier) + ? control.humanLatest("viewer", "hover:2", undefined, recordBarrier) : barrier === "system" ? control.system(recordBarrier) : control.human("viewer", recordBarrier); - const after = control.humanLatest("viewer", "hover:0", async () => { + const after = control.humanLatest("viewer", "hover:0", undefined, async () => { events.push("after"); }); finish.resolve(); @@ -63,15 +63,15 @@ describe("SessionControl", () => { const finish = Promise.withResolvers(); const events: string[] = []; const running = control.human("viewer", () => finish.promise); - const stale = control.humanLatest("viewer", "hover", async () => { + const stale = control.humanLatest("viewer", "hover", undefined, async () => { events.push("stale"); }); await expect( - control.humanLatest("other", "hover", async () => { + control.humanLatest("other", "hover", undefined, async () => { events.push("other"); }), ).rejects.toThrow(BrowserControlInterrupted); - const latest = control.humanLatest("viewer", "hover", async () => { + const latest = control.humanLatest("viewer", "hover", undefined, async () => { events.push("latest"); }); finish.resolve(); @@ -93,16 +93,16 @@ describe("SessionControl", () => { }); await started.promise; const stale = expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push("stale"); }), ).rejects.toThrow(BrowserControlInterrupted); const released = control[handoff]("viewer"); const retaken = control.take("viewer"); - const fresh = control.humanLatest("viewer", "hover", async () => { + const fresh = control.humanLatest("viewer", "hover", undefined, async () => { events.push("fresh"); }); - const latest = control.humanLatest("viewer", "hover", async () => { + const latest = control.humanLatest("viewer", "hover", undefined, async () => { events.push("latest"); }); finish.resolve(); @@ -124,14 +124,14 @@ describe("SessionControl", () => { await started.promise; const waiting = [1, 2].map((position) => expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push(`hover ${position}`); }), ).rejects.toThrow("closed"), ); const closed = control.close(); await expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push("after close"); }), ).rejects.toThrow("closed"); @@ -147,18 +147,18 @@ describe("SessionControl", () => { const events: string[] = []; const running = control.human("viewer", () => finish.promise); const first = expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push("superseded"); }), ).rejects.toThrow("dispatch failed"); const last = expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { throw new Error("dispatch failed"); }), ).rejects.toThrow("dispatch failed"); finish.resolve(); await Promise.all([running, first, last]); - await control.humanLatest("viewer", "hover", async () => { + await control.humanLatest("viewer", "hover", undefined, async () => { events.push("recovered"); }); expect(events).toEqual(["recovered"]); @@ -177,10 +177,10 @@ describe("SessionControl", () => { ); return "started"; }); - const first = control.humanLatest("viewer", "hover", async () => { + const first = control.humanLatest("viewer", "hover", undefined, async () => { events.push("superseded"); }); - const latest = control.humanLatest("viewer", "hover", async () => { + const latest = control.humanLatest("viewer", "hover", undefined, async () => { events.push("latest"); }); await expect(navigation).resolves.toBe("started"); diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index cff65d3379a02..57644e3be7135 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -23,7 +23,7 @@ export class SessionControl { private readonly onGenerationChange: () => void; private tail: Promise = Promise.resolve(); private latestHuman: { - entry: { viewerId: string; key: string; run: () => Promise }; + entry: { viewerId: string; key: string; input: unknown; run: (input: never) => Promise }; result: Promise; } | null = null; private pending: Promise = Promise.resolve(); @@ -134,18 +134,28 @@ export class SessionControl { return this.action(() => this.owner === viewerId, run); } - /** Replace only adjacent waiting work; the running action and intervening actions keep their order. */ - humanLatest(viewerId: string, key: string, run: () => Promise): Promise { - if (this.closed || this.owner !== viewerId) return this.human(viewerId, run); + /** + * Merge into only adjacent waiting work with the same key; the running action and + * intervening actions keep their order. Work sharing a key must share its input type. + */ + humanLatest( + viewerId: string, + key: string, + input: A, + run: (input: A) => Promise, + merge: (waiting: A, next: A) => A = (_waiting, next) => next, + ): Promise { + if (this.closed || this.owner !== viewerId) return this.human(viewerId, () => run(input)); const latest = this.latestHuman; if (latest?.entry.viewerId === viewerId && latest.entry.key === key) { + latest.entry.input = merge(latest.entry.input as A, input); latest.entry.run = run; return latest.result; } - const entry = { viewerId, key, run }; + const entry = { viewerId, key, input, run }; const result = this.human(viewerId, async () => { if (this.latestHuman?.entry === entry) this.latestHuman = null; - await entry.run(); + await entry.run(entry.input); }); this.latestHuman = { entry, result }; return result; 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/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 3262c555bcf8c..9f975ee209e3d 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -5,6 +5,7 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -301,7 +302,7 @@ it.effect.skipIf(windowsHost)( assert.ok(spawned); assert.equal(spawned.command, "powershell.exe"); assert.equal(spawned.options.detached, true); - const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const encodedCommand = String(spawned.args[spawned.args.length - 1] ?? ""); const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); assert.equal( decodedCommand, @@ -714,6 +715,79 @@ it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launc }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect( + "memoizes editor discovery and refreshes in the background after the cache window", + () => { + let statCalls = 0; + const rescanned = Deferred.makeUnsafe(); + let initialScanDone = false; + const fileInfo = { type: "File" } as FileSystem.File.Info; + const layerLauncher = ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + stat: () => + Effect.sync(() => { + statCalls += 1; + return fileInfo; + }).pipe( + Effect.tap(() => + initialScanDone ? Deferred.succeed(rescanned, undefined) : Effect.void, + ), + ), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), + ), + ), + ), + ); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + + const first = yield* launcher.resolveAvailableEditors(); + assert.equal(first.includes("vscode"), true); + const statCallsAfterFirstScan = statCalls; + initialScanDone = true; + assert.isAbove(statCallsAfterFirstScan, 0); + + // Past the shared command-resolution cache TTL (30s) but within the + // discovery cache window: the memoized set is reused without any scan. + yield* TestClock.adjust("31 seconds"); + const second = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...second], [...first]); + assert.equal(statCalls, statCallsAfterFirstScan); + + // Past the discovery cache window the next call still answers from the + // memoized set and rescans in the background. + yield* TestClock.adjust("30 seconds"); + const third = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...third], [...first]); + yield* Deferred.await(rescanned); + assert.isAbove(statCalls, statCallsAfterFirstScan); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerLauncher, + Layer.succeed(HostProcess.Platform, "win32"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PATH: "C:\\t3-editor-discovery-cache-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + TestClock.layer(), + ), + ), + ); + }, +); + it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { const fileInfo = { type: "File" } as FileSystem.File.Info; const release = Deferred.makeUnsafe(); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 6a2eb145162e2..7f4417d1fb67a 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -19,24 +19,20 @@ import { resolveSpawnCommand, withPathDirectoryListings, } from "@t3tools/shared/shell"; -import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; -import * as Exit from "effect/Exit"; 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 Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { makeStaleWhileRevalidate } from "../environment/staleWhileRevalidate.ts"; export { ExternalLauncherError, ExternalLauncherBrowserSpawnError, @@ -447,13 +443,7 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana }, ); -// Shared scans must survive caller timeouts/disconnects; monotonic expiry prevents clock rollback extending stale entries. -const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; - -interface EditorDiscoveryCacheEntry { - readonly scan: Deferred.Deferred>; - readonly expiresAtNanos: bigint | undefined; -} +const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; export class ExternalLauncher extends Context.Service< ExternalLauncher, @@ -817,69 +807,27 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); - const scope = yield* Scope.Scope; - const editorDiscoveryCache = yield* Ref.make>( - Option.none(), - ); - const runEditorDiscovery = (scan: Deferred.Deferred>) => + const cachedAvailableEditors = yield* makeStaleWhileRevalidate( provideCommandResolutionServices(resolveAvailableEditors()).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.onExit((exit) => - Effect.gen(function* () { - const expiresAtNanos = - (yield* Clock.monotonicTimeNanos) + EDITOR_DISCOVERY_CACHE_TTL_NANOS; - yield* Ref.update(editorDiscoveryCache, (current) => - Option.isNone(current) || current.value.scan !== scan - ? current - : Exit.isSuccess(exit) - ? Option.some({ scan, expiresAtNanos }) - : Option.none(), - ); - yield* Deferred.done(scan, exit); - }), - ), - Effect.interruptible, - Effect.forkIn(scope), - ); - // Claim and start atomically; interruption between them would leave an entry waiting on no scan. - const acquireEditorDiscovery = Effect.gen(function* () { - const nowNanos = yield* Clock.monotonicTimeNanos; - const [scan, isNewScan] = yield* Ref.modify( - editorDiscoveryCache, - ( - current, - ): [ - [EditorDiscoveryCacheEntry["scan"], boolean], - Option.Option, - ] => { - if ( - Option.isSome(current) && - (current.value.expiresAtNanos === undefined || current.value.expiresAtNanos > nowNanos) - ) { - return [[current.value.scan, false], current]; - } - const scan = Deferred.makeUnsafe>(); - return [[scan, true], Option.some({ scan, expiresAtNanos: undefined })]; - }, - ); - if (isNewScan) { - yield* runEditorDiscovery(scan); - } - return scan; - }).pipe(Effect.uninterruptible); - const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); + ), + EDITOR_DISCOVERY_CACHE_TTL, + ); + const cachedFileManagerRevealKind = yield* makeStaleWhileRevalidate( + provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + EDITOR_DISCOVERY_CACHE_TTL, + ); // Forwarders retain the underlying launcher and discovery spans. return ExternalLauncher.of({ resolveAvailableEditors: Effect.fnUntraced(function* () { return yield* cachedAvailableEditors; }), - resolveFileManagerRevealKind: Effect.fnUntraced( - function* () { - return yield* provideCommandResolutionServices(resolveFileManagerRevealKind()); - }, - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), + resolveFileManagerRevealKind: Effect.fnUntraced(function* () { + return yield* cachedFileManagerRevealKind; + }), launchBrowser: Effect.fnUntraced( function* (target) { return yield* launchBrowser(target); diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 4248e51b55977..140dff31486b1 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "@effect/vitest"; import { type AgentSessionSource, + type ServerSettingsError, EventId, ProjectId, ProviderDriverKind, @@ -31,6 +32,7 @@ import { } from "../orchestration-v2/testkit/fixtures/shared.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as AgentSessionImporter from "./AgentSessionImporter.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; import * as ProjectService from "./ProjectService.ts"; @@ -75,6 +77,7 @@ function candidate( model: modelSelection.model, createdAt: timestamp, updatedAt: timestamp, + omittedMessageCount: 0, messages: [ { role: "user" as const, text: "Fix it", createdAt: timestamp }, { role: "assistant" as const, text: "Fixed", createdAt: timestamp }, @@ -169,6 +172,10 @@ const seedOwner = Effect.fn(function* ( const makeImporter = Effect.fn(function* ( candidates: ReadonlyArray>, beforeTransaction: Effect.Effect = Effect.void, + settings: Layer.Layer< + ServerSettings.ServerSettingsService, + ServerSettingsError + > = ServerSettings.layerTest(), ) { const sql = yield* SqlClient.SqlClient; const events = yield* EventStore.EventStoreV2; @@ -204,6 +211,7 @@ const makeImporter = Effect.fn(function* ( Layer.provide( Layer.mergeAll( sinkLayer, + settings, Layer.succeed(IdAllocator.IdAllocatorV2, yield* IdAllocator.IdAllocatorV2), Layer.succeed( ProviderSessionRuntime.ProviderSessionRuntimeRepository, @@ -273,6 +281,7 @@ it.effect( "claudeAgent", ); const unowned = candidate(CODEX_MODEL_SELECTION, "unowned"); + unowned.thread.omittedMessageCount = 1; const unownedPi = candidate(PI_MODEL_SELECTION, "unowned-pi", "pi"); const importer = yield* makeImporter([ active, @@ -307,9 +316,23 @@ it.effect( const threadId = importId(source); const records = yield* projections.getThreadRecords(threadId, [ "messages", + "turnItems", "providerThreads", ]); expect(records.messages.map((message) => message.text)).toEqual(["Fix it", "Fixed"]); + const notices = records.turnItems.filter((item) => item.type === "system_notice"); + if (source === unowned) { + expect(records.turnItems[0]).toBe(notices[0]); + expect(notices).toMatchObject([ + { + ordinal: 1, + runId: null, + message: expect.stringContaining("1 earlier message was not imported"), + }, + ]); + } else { + expect(notices).toEqual([]); + } expect(records.thread.historyOrigin).toBe("v1_import"); expect(records.thread.activeProviderThreadId).toBe(records.providerThreads[0]?.id); expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe(nativeId(source)); @@ -400,3 +423,29 @@ it.effect( }); }).pipe(Effect.scoped, Effect.provide(persistence)), ); + +it.effect("imports under a policy ceiling in the project's default, kept under the ceiling", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const source = candidate(CODEX_MODEL_SELECTION, "ceiling"); + const threadId = importId(source); + const importer = yield* makeImporter( + [source], + Effect.void, + ServerSettings.layerTest( + { defaultRuntimeMode: "full-access" }, + { + organization: "Example Corp", + lockedSettings: [], + limits: { maxRuntimeMode: "auto-accept-edits" }, + }, + ), + ); + expect(yield* importer.run).toEqual({ importedCount: 1, skippedCount: 0 }); + expect((yield* projections.getThread(threadId)).runtimeMode).toBe("auto-accept-edits"); + expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId })).runtimeMode).toBe( + "auto-accept-edits", + ); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 038b488635053..211ad06fc267a 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -3,6 +3,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + isRuntimeModeAbove, AgentSessionImportProjectChangedError, AgentSessionImportProjectNotFoundError, AgentSessionImportSource, @@ -23,6 +24,7 @@ import { type OrchestrationV2TurnItem, } from "@t3tools/contracts"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -35,6 +37,7 @@ import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; import * as ProjectService from "./ProjectService.ts"; @@ -172,6 +175,8 @@ const make = Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const settingsService = yield* ServerSettings.ServerSettingsService; + const ceiling = settingsService.managedPolicy?.limits?.maxRuntimeMode; const importRecentAgentThreads = Effect.fn("importRecentAgentThreadsV2")(function* ( input: AgentSessionImportInput, ) { @@ -192,6 +197,21 @@ const make = Effect.gen(function* () { ) { return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); } + // Imports run in full access, except under a policy ceiling: then in the project's default, + // which the policy keeps under it, so imported threads never sit above the ceiling. + const runtimeMode = + ceiling === undefined + ? DEFAULT_RUNTIME_MODE + : yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "read-settings", cause }), + ), + Effect.map((settings) => { + const mode = resolveProjectSettings(settings, input.projectId, project).settings + .defaultRuntimeMode; + return isRuntimeModeAbove(mode, ceiling) ? ceiling : mode; + }), + ); const runtimeRows = yield* runtimes .list() .pipe( @@ -259,7 +279,7 @@ const make = Effect.gen(function* () { providerName: driver, providerInstanceId: thread.providerInstanceId, adapterKey: driver, - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode, status: "stopped", lastSeenAt: thread.updatedAt, resumeCursor: @@ -303,7 +323,7 @@ const make = Effect.gen(function* () { title: thread.title.trim() === "" ? "Untitled thread" : thread.title, providerInstanceId: thread.providerInstanceId, modelSelection: { instanceId: thread.providerInstanceId, model }, - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, branch: null, worktreePath: null, @@ -365,6 +385,34 @@ const make = Effect.gen(function* () { occurredAt: createdAt, payload: appThread, }, + ...(thread.omittedMessageCount > 0 + ? [ + { + id: EventId.make(`${IMPORT_EVENT_PREFIX}:history-truncated:${threadId}`), + type: "turn-item.updated" as const, + threadId, + occurredAt: createdAt, + payload: { + id: TurnItemId.make(`${IMPORT_EVENT_PREFIX}:history-truncated:${threadId}`), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed" as const, + title: null, + startedAt: createdAt, + completedAt: createdAt, + updatedAt: createdAt, + type: "system_notice" as const, + message: `${thread.omittedMessageCount} earlier ${thread.omittedMessageCount === 1 ? "message was" : "messages were"} not imported. This thread keeps the first user message and the most recent history. The original session transcript is unchanged.`, + }, + }, + ] + : []), ...thread.messages.flatMap((message, index) => messageEvents({ threadId, index, message }), ), diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 039393eb52a6d..ce1830b8c5710 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,9 +13,11 @@ 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"; @@ -248,6 +254,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", () => @@ -1408,9 +1450,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( @@ -1421,18 +1465,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({ @@ -1440,8 +1522,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"]); }), ); @@ -1807,6 +1892,255 @@ 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`), + }); + } + + for (let attempt = 1; attempt <= 2; attempt++) { + const scan = yield* Effect.forkChild(runScan({ claudeHomePath, codexHomePath })); + const result = yield* Effect.scoped( + Effect.gen(function* () { + yield* awaitGateReader(path.join(repos[1], ".git", "config")); + 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", @@ -1823,6 +2157,48 @@ describe("parseAgentSessionTranscript", () => { }, ); + it.each([199, 200, 201, 205])( + "counts only omitted conversation messages for a %s-message Claude transcript", + (count) => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + ...Array.from({ length: count }, (_, index) => + encodeTranscriptRecord({ + type: index === 0 ? "user" : "assistant", + sessionId: "claude-session", + message: { content: `Message ${index}` }, + }), + ), + encodeTranscriptRecord({ type: "user", isMeta: true, message: { content: "Setup" } }), + encodeTranscriptRecord({ + type: "user", + isCompactSummary: true, + message: { content: "Summary" }, + }), + encodeTranscriptRecord({ + type: "assistant", + isSidechain: true, + message: { content: "Sidechain" }, + }), + encodeTranscriptRecord({ + type: "assistant", + message: { content: [{ type: "tool_use" }] }, + }), + "malformed", + ].join("\n"), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.omittedMessageCount).toBe(Math.max(0, count - 200)); + expect(thread?.messages).toHaveLength(Math.min(200, count)); + expect(thread?.messages[0]?.text).toBe("Message 0"); + expect(thread?.messages.at(-1)?.text).toBe(`Message ${count - 1}`); + }, + ); + it("keeps Claude text and titles while dropping malformed and tool records", () => { const thread = AgentSessionScanner.parseAgentSessionTranscript({ contents: [ @@ -1974,10 +2350,10 @@ describe("parseAgentSessionTranscript", () => { ]); }); - it("keeps the canonical first prompt after long Codex transcripts are capped", () => { + it.each([199, 200])("keeps the canonical first prompt with %s later Codex messages", (count) => { const canonicalPrompt = "\n Keep the canonical prompt \n"; const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; - const laterAssistantMessages = Array.from({ length: 200 }, (_, index) => + const laterAssistantMessages = Array.from({ length: count }, (_, index) => encodeTranscriptRecord({ type: "response_item", timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, @@ -2013,6 +2389,7 @@ describe("parseAgentSessionTranscript", () => { lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), }); + expect(thread?.omittedMessageCount).toBe(count - 199); expect(thread?.messages).toHaveLength(200); expect(thread?.messages[0]).toMatchObject({ role: "user", @@ -2075,6 +2452,7 @@ describe("parseAgentSessionTranscript", () => { lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), }); + expect(thread?.omittedMessageCount).toBe(1); expect(thread?.messages).toHaveLength(200); expect(thread?.messages[0]).toMatchObject({ role: "user", diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 639241db037a4..3ed97f8a7d8f4 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"; @@ -64,6 +66,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), @@ -155,6 +172,7 @@ export interface AgentSessionThread { readonly createdAt: string; readonly updatedAt: string; readonly messages: ReadonlyArray; + readonly omittedMessageCount: number; } export type AgentSessionRecentThread = @@ -449,6 +467,7 @@ function parseAgentSessionRecords( finishCodexTurn(); } + let conversationMessageCount = 0; const retainMessage = ( message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, ) => { @@ -458,6 +477,7 @@ function parseAgentSessionRecords( if (derivedTitle === undefined && message.role === "user") { derivedTitle = deriveTitle(input.source, message.text); } + conversationMessageCount += 1; messages.push(message); if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); }; @@ -529,6 +549,7 @@ function parseAgentSessionRecords( if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { if (firstUserMessage === message) firstUserMessage = undefined; messages.splice(index, 1); + conversationMessageCount -= 1; break; } } @@ -583,6 +604,7 @@ function parseAgentSessionRecords( createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, updatedAt: fallbackTimestamp, messages: retainedMessages, + omittedMessageCount: conversationMessageCount - retainedMessages.length, }; } @@ -713,6 +735,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; @@ -721,7 +744,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`. @@ -768,48 +792,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* ( @@ -898,7 +996,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; @@ -932,7 +1029,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") { @@ -992,7 +1089,7 @@ export const make = Effect.gen(function* () { if (recordStarted && !(yield* Effect.try(finishRecord))) return null; return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) - ? { records, recordCount, cwd, claudeFields } + ? { records, cwd, claudeFields } : null; }), ), @@ -1399,6 +1496,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(); @@ -1406,28 +1527,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); } @@ -1558,17 +1667,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); @@ -1591,7 +1696,6 @@ export const make = Effect.gen(function* () { } if ( transcriptsRemaining === 0 || - recordsRemaining === 0 || identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || identity.size > bytesRemaining ) { @@ -1599,16 +1703,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" }); @@ -1675,4 +1773,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..b5e3ef9a13b29 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.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 { CommandId, ProjectId, ThreadId, VcsProcessSpawnError } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -76,7 +77,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 +97,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 +125,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", () => diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index df6cb6f6b507e..c187424870c61 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 })), ), @@ -427,8 +427,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/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index 6a13af9ef9449..ed775e55a985a 100644 --- a/apps/server/src/project/ProjectCloneTracker.test.ts +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -4,6 +4,7 @@ import { OrchestrationDispatchCommandError, ProjectId, projectCloneProgressSummary, + type SourceControlCloneProtocol, SourceControlRepositoryError, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -146,12 +147,12 @@ describe("ProjectCloneTracker", () => { }); it.effect("keeps a failed clone with git's own explanation and retries it", () => { - let attempts = 0; + const protocols: Array = []; const harness = makeHarness({ clone: (input) => Effect.suspend(() => { - attempts += 1; - return attempts === 1 + protocols.push(input.protocol); + return protocols.length === 1 ? Effect.fail( new SourceControlRepositoryError({ operation: "cloneRepository", @@ -168,7 +169,7 @@ describe("ProjectCloneTracker", () => { }); return Effect.gen(function* () { const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; - yield* tracker.start(startInput, harness.hooks); + yield* tracker.start({ ...startInput, protocol: "ssh" }, harness.hooks); yield* Effect.yieldNow; const failed = yield* tracker.get(projectId); expect(failed).toMatchObject({ phase: "failed", error: "fatal: repository not found" }); @@ -177,7 +178,7 @@ describe("ProjectCloneTracker", () => { expect(harness.discarded).toEqual(["/workspace/t3code"]); yield* Effect.yieldNow; expect((yield* tracker.get(projectId))?.phase).toBe("done"); - expect(attempts).toBe(2); + expect(protocols).toEqual(["ssh", "ssh"]); }).pipe(Effect.provide(harness.layer)); }); diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index 4a0c99795155c..0a512bde2aa9b 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -5,6 +5,7 @@ import type { ProjectCloneStartInput, ProjectCloneStartResult, ProjectId, + SourceControlCloneProtocol, SourceControlRepositoryInfo, } from "@t3tools/contracts"; import { @@ -85,6 +86,7 @@ interface TrackedClone { readonly cloneUrl: string; readonly destinationPath: string; readonly repository: SourceControlRepositoryInfo | null; + readonly protocol: SourceControlCloneProtocol; }; } @@ -216,7 +218,11 @@ export const make = Effect.gen(function* () { const pulses = yield* Queue.sliding(1); yield* watchForStall(projectId, pulses).pipe(Effect.forkScoped); return yield* repositories.cloneRepository( - { remoteUrl: tracked.input.cloneUrl, destinationPath: tracked.input.destinationPath }, + { + remoteUrl: tracked.input.cloneUrl, + destinationPath: tracked.input.destinationPath, + protocol: tracked.input.protocol, + }, { onProgress: (update) => Queue.offer(pulses, undefined).pipe(Effect.andThen(progress(projectId, update))), @@ -295,6 +301,7 @@ export const make = Effect.gen(function* () { cloneUrl: prepared.cloneUrl, destinationPath: prepared.destinationPath, repository: prepared.repository, + protocol: input.protocol ?? "auto", }, }); return [true, next] as const; 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.test.ts b/apps/server/src/project/ProjectService.test.ts index 331332a84bc95..11d3fea8f064a 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"; @@ -10,6 +12,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/sql/SqlClient"; @@ -101,12 +104,94 @@ 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), ); const layerTest = layerTestFor(layerMetadata); +it.effect("resolves only the requested active project page in creation order", () => + Effect.gen(function* () { + const inspected = yield* Ref.make>(new Set()); + const note = (workspaceRoot: string) => + Ref.update(inspected, (roots) => new Set([...roots, workspaceRoot])); + const metadata = Layer.merge( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (workspaceRoot) => + note(workspaceRoot).pipe( + Effect.as({ + canonicalKey: `example.test${workspaceRoot}`, + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `https://example.test${workspaceRoot}.git`, + }, + rootPath: workspaceRoot, + }), + ), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, + resolvePath: (workspaceRoot) => note(workspaceRoot).pipe(Effect.as(null)), + }), + ); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const service = yield* ProjectService.ProjectService; + const enrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService; + const changes = yield* enrichment.subscribeChanges; + const rows = Arr.makeBy(6, (index) => ({ + project_id: `page:${index}`, + title: `Project ${index}`, + workspace_root: `/work/page-${index}`, + scripts_json: "[]", + created_at: "2026-10-01T00:00:00.000Z", + updated_at: "2026-10-01T00:00:00.000Z", + deleted_at: index === 1 ? "2026-10-02T00:00:00.000Z" : null, + auto_pull: 0, + })); + yield* sql`INSERT INTO projection_projects ${sql.insert(rows)}`; + const first = yield* service.listPage({ cursor: 0, limit: 2 }); + assert.deepEqual( + first.projects.map((project) => project.id), + ["page:0", "page:2"], + ); + assert.equal(first.nextCursor, 2); + assert.deepEqual( + first.projects.map((project) => project.repositoryIdentity?.canonicalKey), + ["example.test/work/page-0", "example.test/work/page-2"], + ); + // A sentinel queued after the page lets the enrichment workers finish + // earlier requests before checking which workspaces were inspected. + yield* enrichment.request("/work/page-sentinel"); + for (;;) { + if ((yield* PubSub.take(changes)).workspaceRoot === "/work/page-sentinel") break; + } + assert.deepEqual( + new Set(yield* Ref.get(inspected)), + new Set(["/work/page-0", "/work/page-2", "/work/page-sentinel"]), + ); + const second = yield* service.listPage({ cursor: first.nextCursor!, limit: 2 }); + assert.deepEqual( + second.projects.map((project) => project.id), + ["page:3", "page:4"], + ); + assert.equal(second.nextCursor, 4); + const last = yield* service.listPage({ cursor: second.nextCursor!, limit: 2 }); + assert.deepEqual( + last.projects.map((project) => project.id), + ["page:5"], + ); + assert.isNull(last.nextCursor); + assert.isFalse((yield* Ref.get(inspected)).has("/work/page-1")); + assert.deepEqual(yield* service.listPage({ cursor: 99, limit: 2 }), { + projects: [], + nextCursor: null, + }); + }).pipe(Effect.scoped, Effect.provide(layerTestFor(metadata))); + }), +); + const layerProjectServiceDependencies = Layer.mergeAll( RuntimeLayer.layerEventSink, ProjectStore.layer, @@ -121,7 +206,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/ProjectService.ts b/apps/server/src/project/ProjectService.ts index ee7318fb5d5b7..914456a500c51 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -555,14 +555,14 @@ export const make = Effect.gen(function* () { return yield* Effect.forEach(shells, enrichShell, { concurrency: 16 }); }); + const listRows = projects + .list() + .pipe( + Effect.mapError((cause) => new ProjectOperationError({ operation: "list-projects", cause })), + ); + const snapshot = Effect.gen(function* () { - const rows = yield* projects - .list() - .pipe( - Effect.mapError( - (cause) => new ProjectOperationError({ operation: "list-projects", cause }), - ), - ); + const rows = yield* listRows; const hydrated = yield* Effect.forEach(rows, hydrate, { concurrency: 8 }); return { projects: hydrated, @@ -572,15 +572,19 @@ export const make = Effect.gen(function* () { const listPage: ProjectService["Service"]["listPage"] = Effect.fn("ProjectService.listPage")( function* ({ cursor, limit }) { - const rows = (yield* snapshot).projects.filter((project) => project.deletedAt === null); + const rows = (yield* listRows).filter((project) => project.deletedAt === null); const projects = yield* Effect.forEach( rows.slice(cursor, cursor + limit), - (project) => - project.repositoryIdentity !== null - ? Effect.succeed(project) - : projectEnrichment - .readRepositoryIdentity(project.workspaceRoot) - .pipe(Effect.map((repositoryIdentity) => ({ ...project, repositoryIdentity }))), + (row) => + hydrate(row).pipe( + Effect.filterOrElse( + (project) => project.repositoryIdentity !== null, + (project) => + projectEnrichment + .readRepositoryIdentity(project.workspaceRoot) + .pipe(Effect.map((repositoryIdentity) => ({ ...project, repositoryIdentity }))), + ), + ), { concurrency: 8 }, ); return { projects, nextCursor: cursor + limit < rows.length ? cursor + limit : null }; 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 7cd1cc82d6fec..0cef7d96bbdfe 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -156,25 +156,43 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); - it.effect("returns null for non-git folders and repos without remotes", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-non-git-", - }); - const gitDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-no-remote-", - }); + it.effect( + "returns null for non-git, missing and no-remote folders, probing only what can exist", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-non-git-", + }); + const gitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-no-remote-", + }); + + yield* git(gitDir, ["init"]); + + const runner = yield* ProcessRunner.ProcessRunner; + const runs: Array> = []; + const resolver = yield* RepositoryIdentityResolver.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.suspend(() => { + runs.push(input.args); + return runner.run(input); + }), + }), + ); + const bareProbes = () => + runs.filter((args) => args.some((arg) => arg.startsWith("--git-dir="))); - yield* git(gitDir, ["init"]); + expect(yield* resolver.resolve(path.join(nonGitDir, "deleted"))).toBeNull(); + expect(runs).toEqual([]); - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const nonGitIdentity = yield* resolver.resolve(nonGitDir); - const noRemoteIdentity = yield* resolver.resolve(gitDir); + expect(yield* resolver.resolve(nonGitDir)).toBeNull(); + expect(bareProbes()).toEqual([]); - expect(nonGitIdentity).toBeNull(); - expect(noRemoteIdentity).toBeNull(); - }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + expect(yield* resolver.resolve(gitDir)).toBeNull(); + }).pipe(Effect.provide(ProcessRunner.layer)), ); it.effect("ignores local-path remotes such as GitButler's gb-local", () => diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 659ace3fd2b49..cdf9dc5e1b309 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -6,7 +6,9 @@ import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; 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 Path from "effect/Path"; import * as ProcessRunner from "../processRunner.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; @@ -138,8 +140,18 @@ class RepositoryIdentityCacheKey extends Data.Class<{ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const env = explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform); + // `git -C` cannot enter a missing folder or a file, so every probe below would fail. + const isFolder = yield* fileSystem.stat(cwd).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), + Effect.orElseSucceed(() => true), + ); + if (!isFolder) return null; + // git is always a real executable; cmd shell mode would split spaced paths on re-tokenization. const topLevelResult = yield* processRunner .run({ @@ -160,7 +172,25 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. } // Explicit Git directories preserve safe.bareRepository=explicit for both layouts. - for (const gitDir of [".", ".git"]) { + for (const [gitDir, entry] of [ + [".", "HEAD"], + [".git", ".git"], + ] as const) { + // An explicit --git-dir fails unless the folder has this entry; a symlink counts even + // when its target is missing, as git reads a symlinked HEAD's target text. + const target = path.join(cwd, entry); + const hasEntry = yield* fileSystem.exists(target).pipe( + Effect.flatMap((exists) => + exists + ? Effect.succeed(true) + : fileSystem.readLink(target).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + ), + Effect.orElseSucceed(() => true), + ); + if (!hasEntry) continue; const bareResult = yield* processRunner .run({ command: "git", @@ -224,6 +254,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; const refine = options.refine ?? Effect.succeed; const timeToLive = (exit: Exit.Exit) => @@ -237,6 +269,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( (cwd) => resolveRepositoryIdentityCacheKey(cwd).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ), { capacity: cacheCapacity, timeToLive }, ); 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/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index bc7c14d3b3c8a..1f45e78320e5a 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -2,7 +2,9 @@ import * as NodePath from "node:path"; import * as NodeURL from "node:url"; +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"; @@ -229,6 +231,7 @@ describe("ACP Registry probe", () => { { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ], + role: "reasoning", }, ]); }); @@ -369,7 +372,7 @@ describe("ACP Registry probe", () => { yield* logoutAcpRegistry(input); }).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/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 885053c7d0f24..c4e3b812408f8 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.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 } from "@effect/vitest"; import { ProviderInstanceId } from "@t3tools/contracts"; @@ -77,7 +78,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(ThreadManagementService)({}), Layer.mock(ThreadSearch)({}), Layer.mock(VcsStatusBroadcaster)({}), -).pipe(Layer.provideMerge(NodeServices.layer)); +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const utf8Bytes = (text: string) => Buffer.byteLength(text, "utf8"); @@ -132,7 +133,7 @@ it.effect("agent context matches the committed budget", () => }).pipe( Effect.provide( McpHttpServer.layerTools.pipe( - Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(toolHandlerServices), ), ), diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index af156a61663f4..4664c299e8a5c 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -1,4 +1,6 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { assert, it } from "@effect/vitest"; import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -145,7 +147,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( }; }); -it.layer(NodeServices.layer)("AntigravityAuth", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AntigravityAuth", (it) => { it.effect("accepts a delayed duplicate after callback completion starts", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index 67f25681a1a4b..7f19bd3a9b077 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -5,7 +5,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; 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 Effect from "effect/Effect"; @@ -137,8 +137,8 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < Runtime extends AntigravityAuthRuntime, >( options: AntigravityAuthOptions, -): Effect.fn.Return { - const crypto = yield* Crypto.Crypto; +): Effect.fn.Return { + const random = yield* SecureRandom.SecureRandom; const instanceScope = yield* Scope.Scope; const usesBrowser = options.usesBrowser ?? true; const lock = yield* Semaphore.make(1); @@ -377,8 +377,8 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < if (operation !== "idle") { return yield* setupError("start", "Antigravity setup is already in progress."); } - const flowId = yield* crypto - .randomUUIDv4() + const flowId = yield* random + .uuidV4() .pipe( Effect.mapError(() => setupError("start", "Could not start Google sign-in. Try again."), diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index 363eb4b78858a..fb142d41decdd 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -1,7 +1,9 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -58,11 +60,11 @@ const releaseAsset = Effect.fn("test.antigravityReleaseAsset")(function* ( archive: Uint8Array = completeArchive, platform: NodeJS.Platform = hostPlatform, ) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; return { version: "fixture-new", url: "https://dl.google.com/antigravity-test.zip", - sha256: Hex.encode(yield* crypto.digest("SHA-256", archive).pipe(Effect.orDie)), + sha256: Hex.encode(yield* digest.digest(Digest.sha256, archive).pipe(Effect.orDie)), archiveBytes: archive.byteLength, executable: { name: platform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par", @@ -233,7 +235,7 @@ const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease") expect((yield* installation.state).installedVersion).toBe(previousVersion); }); -it.layer(NodeServices.layer)("Antigravity installation", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("Antigravity installation", (it) => { it.effect("reports missing Node before downloading the standalone provider runtime", () => Effect.gen(function* () { const { installation, requests, validations } = yield* makeHarness(); diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 84df7d6201901..b70e5d6860000 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -6,7 +6,7 @@ import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/s import * as Clock from "effect/Clock"; 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 Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -151,7 +151,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const http = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serviceScope = yield* Effect.scope; @@ -419,7 +419,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), Effect.mapError((cause) => { if (isInstallationError(cause)) return cause; const detail = @@ -715,7 +715,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`, ); } - const operationId = yield* crypto.randomUUIDv4(); + const operationId = yield* random.uuidV4(); const next: ProviderInstallState = { driver: DRIVER, operationId, diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 1f852ad8bcc87..44f8842fd9591 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { @@ -24,6 +25,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import type { ServerProvider } from "@t3tools/contracts"; import { buildAntigravityModelsFromSession, makeAntigravityProvider, @@ -120,7 +122,7 @@ const layerTest = ProviderHostLive.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-provider-test-" }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ); @@ -129,6 +131,7 @@ type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( options: { + readonly auth?: { readonly type: string; readonly label: string }; readonly enabled?: boolean; readonly safe?: boolean; readonly probeUsage?: Effect.Effect; @@ -145,6 +148,7 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), { stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), + ...(options.auth ? { auth: options.auth } : {}), ...(options.probeUsage ? { probeUsage: options.probeUsage } : {}), probe: Ref.update(probeCalls, (count) => count + 1).pipe( Effect.andThen(Ref.get(probe)), @@ -165,6 +169,11 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( Effect.andThen(initialUpdate), Effect.asVoid, ); + // Subscribe before the trigger; yield the returned pull to await the publish. + const nextPublish = (predicate: (snapshot: ServerProvider) => boolean) => + Stream.toPull(provider.snapshot.streamChanges.pipe(Stream.filter(predicate))).pipe( + Effect.map((pull) => pull.pipe(Effect.map((snapshots) => snapshots[0]))), + ); return { provider, probe, @@ -174,6 +183,7 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( initialProbe, initialUpdate, initialize, + nextPublish, }; }); @@ -305,23 +315,35 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { it.effect("treats initialize as installation proof, not account or model discovery", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeHarness(); - expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ - installed: false, - status: "warning", - auth: { status: "unknown" }, - models: [], - }); - yield* harness.initialize; - const snapshot = yield* harness.provider.snapshot.getSnapshot; - expect(snapshot).toMatchObject({ - installed: true, - status: "warning", - version: "agy_acp_server_20260818_01_RC01", - auth: { status: "unknown" }, - models: [], - }); - expect(yield* Ref.get(harness.probeCalls)).toBe(1); + for (const auth of [ + undefined, + { type: "oauth-personal", label: "Google account" }, + { type: "oauth-business", label: "Gemini Enterprise" }, + { type: "gemini-api-key", label: "Gemini API key" }, + { type: "agent-platform", label: "Agent Platform" }, + ]) { + const harness = yield* makeHarness(auth ? { auth } : {}); + expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ + installed: false, + status: "warning", + auth: { status: "unknown" }, + models: [], + }); + yield* harness.initialize; + const snapshot = yield* harness.provider.snapshot.getSnapshot; + expect(snapshot).toMatchObject({ + installed: true, + status: "warning", + version: "agy_acp_server_20260818_01_RC01", + auth: { status: "unknown", ...(auth ? { type: auth.type } : {}) }, + message: + auth?.type === "gemini-api-key" || auth?.type === "agent-platform" + ? `Antigravity is installed. Connect with ${auth.label} to load models.` + : "Antigravity is installed. Google account access is not checked yet.", + models: [], + }); + expect(yield* Ref.get(harness.probeCalls)).toBe(1); + } }), ), ); @@ -360,7 +382,8 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { it.effect("clears all account metadata on sign-out and authentication failure", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeHarness(); + const auth = { type: "gemini-api-key", label: "Gemini API key" }; + const harness = yield* makeHarness({ auth }); yield* harness.initialize; for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) { yield* harness.provider.onSessionStarted(started, "/workspace"); @@ -369,7 +392,8 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ installed: true, status: "warning", - auth: { status: "unauthenticated" }, + auth: { status: "unauthenticated", type: auth.type, label: auth.label }, + message: "Connect with Gemini API key to use Antigravity and load models.", models: [], slashCommands: [], skills: [], @@ -382,7 +406,14 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); } const refreshed = yield* harness.provider.snapshot.refresh; - expect(refreshed.auth.status).toBe("unauthenticated"); + expect(refreshed.auth).toEqual({ + status: "unauthenticated", + type: auth.type, + label: auth.label, + }); + expect(refreshed.message).toBe( + "Connect with Gemini API key to use Antigravity and load models.", + ); expect(refreshed.supportsTextGeneration).toBe(false); }), ), @@ -664,15 +695,29 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { }; const usage = yield* Ref.make(limits); const harness = yield* makeHarness({ probeUsage: Ref.get(usage) }); + const published = yield* harness.nextPublish((snapshot) => !!snapshot.usageLimits); yield* harness.initialize; - expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual(limits); - yield* Ref.set(harness.probe, Effect.succeed(initializeResult)); + expect((yield* published)?.usageLimits).toEqual(limits); + // The second health check is the one the failed background read triggers. + const checks = yield* Ref.make(0); + const failedReadApplied = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Ref.updateAndGet(checks, (count) => count + 1).pipe( + Effect.tap((count) => + count === 2 ? Deferred.succeed(failedReadApplied, undefined) : Effect.void, + ), + Effect.as(initializeResult), + ), + ); yield* Ref.set(usage, { checkedAt: "2026-10-09T12:01:00.000Z", windows: [], unavailable: { reason: "probeFailed" }, }); - expect((yield* harness.provider.snapshot.refresh).usageLimits).toEqual(limits); + yield* harness.provider.snapshot.refresh; + yield* Deferred.await(failedReadApplied); + expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual(limits); yield* harness.provider.onSignedOut; expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); yield* Ref.set(usage, { @@ -680,7 +725,11 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { windows: [], unavailable: { reason: "unsupported" }, }); - expect((yield* harness.provider.snapshot.refresh).usageLimits?.windows).toEqual([]); + const unsupported = yield* harness.nextPublish( + (snapshot) => snapshot.usageLimits?.unavailable?.reason === "unsupported", + ); + yield* harness.provider.snapshot.refresh; + expect((yield* unsupported)?.usageLimits?.windows).toEqual([]); }), ), ); @@ -714,4 +763,74 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { }), ), ); + + it.effect("reports ready and starts sessions while one shared quota read is pending", () => + Effect.scoped( + Effect.gen(function* () { + const reads = yield* Ref.make(0); + const readStarted = yield* Deferred.make(); + const readResult = yield* Deferred.make(); + const harness = yield* makeHarness({ + probeUsage: Ref.update(reads, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(readStarted, undefined)), + Effect.andThen(Deferred.await(readResult)), + ), + }); + const published = yield* harness.nextPublish((snapshot) => !!snapshot.usageLimits); + yield* harness.initialize; + yield* Deferred.await(readStarted); + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onSessionStarted(started, "/other"); + const ready = yield* harness.provider.snapshot.getSnapshot; + expect(ready).toMatchObject({ status: "ready", auth: { status: "authenticated" } }); + expect(ready.usageLimits).toBeUndefined(); + expect(yield* Ref.get(reads)).toBe(1); + const limits = { + checkedAt: "2026-10-09T12:00:00.000Z", + windows: [ + { id: "gemini-5h", kind: "session", label: "Gemini · 5-hour", usedPercent: 10 }, + ], + } satisfies ServerProviderUsageLimits; + yield* Deferred.succeed(readResult, limits); + expect((yield* published)?.usageLimits).toEqual(limits); + }), + ), + ); + + it.effect("a quota read for the previous account is dropped after a new sign-in", () => + Effect.scoped( + Effect.gen(function* () { + const oldReadStarted = yield* Deferred.make(); + const newLimits = { + checkedAt: "2026-10-09T12:01:00.000Z", + windows: [{ id: "3p-weekly", kind: "weekly", label: "Weekly", usedPercent: 40 }], + } satisfies ServerProviderUsageLimits; + const usage = yield* Ref.make>( + Deferred.succeed(oldReadStarted, undefined).pipe(Effect.andThen(Effect.never)), + ); + const harness = yield* makeHarness({ probeUsage: Ref.get(usage).pipe(Effect.flatten) }); + const published = yield* harness.nextPublish((snapshot) => !!snapshot.usageLimits); + yield* harness.initialize; + yield* Deferred.await(oldReadStarted); + yield* Ref.set(usage, Effect.succeed(newLimits)); + yield* harness.provider.onSignedIn; + yield* harness.provider.onSessionStarted(started); + expect((yield* published)?.usageLimits).toEqual(newLimits); + // Switching again must not republish the previous account's limits, even if the read fails. + const failed = { + checkedAt: "2026-10-09T12:02:00.000Z", + windows: [], + unavailable: { reason: "probeFailed" }, + } satisfies ServerProviderUsageLimits; + yield* Ref.set(usage, Effect.succeed(failed)); + const republished = yield* harness.nextPublish( + (snapshot) => snapshot.usageLimits?.unavailable?.reason === "probeFailed", + ); + yield* harness.provider.onSignedIn; + expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); + yield* harness.provider.onSessionStarted(started); + expect((yield* republished)?.usageLimits).toEqual(failed); + }), + ), + ); }); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index b5e9f257d17b3..ff8805f43b371 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -9,8 +9,11 @@ import { type ServerProviderUsageLimits, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FiberHandle from "effect/FiberHandle"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Result from "effect/Result"; @@ -41,6 +44,10 @@ const HEALTH_CHECK_TIMEOUT = "90 seconds"; const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; const AUTH_UNCHECKED_MESSAGE = "Antigravity is installed. Google account access is not checked yet."; +// Set while a finished quota read republishes, so that health check does not read again. +const PublishingUsage = Context.Reference("t3/provider/AntigravityPublishingUsage", { + defaultValue: () => false, +}); type SessionSetupResult = Pick< AcpSessionRuntime.AcpSessionRuntimeStartResult["sessionSetupResult"], @@ -164,6 +171,14 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func settings: AntigravitySettings, options: AntigravityProviderOptions, ) { + const usesCredentials = + options.auth?.type === "gemini-api-key" || options.auth?.type === "agent-platform"; + const signInMessage = usesCredentials + ? `Connect with ${options.auth.label} to use Antigravity and load models.` + : SIGN_IN_MESSAGE; + const authUncheckedMessage = usesCredentials + ? `Antigravity is installed. Connect with ${options.auth.label} to load models.` + : AUTH_UNCHECKED_MESSAGE; const checkedAt = DateTime.formatIso(yield* DateTime.now); const initialDraft = { ...buildServerProvider({ @@ -207,6 +222,47 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func Effect.flatMap((state) => options.stampIdentity(state.draft)), ); + // The quota read can take 30 seconds, so it never blocks readiness or a session start. + // One read runs at a time; sign-in and sign-out interrupt it so a stale account never lands. + const usageRead = yield* FiberHandle.make(); + const republish = yield* Deferred.make>(); + const scope = yield* Effect.scope; + const refreshUsage = Effect.fn("AntigravityProvider.refreshUsage")(function* () { + if (!options.probeUsage || (yield* PublishingUsage)) return; + yield* FiberHandle.run( + usageRead, + options.probeUsage.pipe( + Effect.flatMap((probed) => + SubscriptionRef.update(metadata, (state) => ({ + ...state, + draft: { + ...state.draft, + usageLimits: resolveUsageLimitsAfterProbe({ + published: state.draft.usageLimits, + probed, + }), + }, + })), + ), + // Enrichment cannot change published limits; a health check publishes them. + Effect.andThen( + Deferred.await(republish).pipe( + Effect.flatten, + Effect.provideService(PublishingUsage, true), + Effect.ignoreCause({ log: true }), + Effect.forkIn(scope), + ), + ), + ), + // Store the fiber before it runs, so a sign-out during the read can interrupt it. + { onlyIfMissing: true, startImmediately: false }, + ); + }); + const resetUsage = Effect.gen(function* () { + yield* FiberHandle.clear(usageRead); + if (options.clearUsage) yield* options.clearUsage; + }); + const checkProvider = Effect.fn("checkAntigravityProvider")(function* () { if (!settings.enabled) return yield* getSnapshot; const before = yield* SubscriptionRef.get(metadata); @@ -231,8 +287,6 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; const supportsTextGeneration = initialized !== undefined ? yield* options.supportsTextGeneration : false; - const usageLimits = - initialized !== undefined && options.probeUsage ? yield* options.probeUsage : undefined; const updatedAt = DateTime.formatIso(yield* DateTime.now); const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { if (state.authRevision !== before.authRevision) return state; @@ -243,8 +297,8 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func (authenticated ? undefined : draft.auth.status === "unauthenticated" - ? SIGN_IN_MESSAGE - : AUTH_UNCHECKED_MESSAGE); + ? signInMessage + : authUncheckedMessage); return { ...state, draft: { @@ -254,12 +308,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func version: version === "unknown" ? draft.version : version || draft.version, status: errorMessage ? "error" : authenticated ? "ready" : "warning", checkedAt: updatedAt, - usageLimits: missingInstallation - ? undefined - : resolveUsageLimitsAfterProbe({ - published: draft.usageLimits, - probed: usageLimits ?? draft.usageLimits, - }), + usageLimits: missingInstallation ? undefined : draft.usageLimits, ...(missingInstallation ? { models: [], @@ -279,6 +328,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }, } satisfies AntigravityProviderState; }); + if (initialized !== undefined) yield* refreshUsage(); return yield* options.stampIdentity(next.draft); }); @@ -302,6 +352,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func ), ), }); + yield* Deferred.succeed(republish, managed.refresh); const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( started: AcpSessionRuntime.AcpSessionRuntimeStartResult, @@ -311,10 +362,6 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func observeThinkingLevels(started.sessionSetupResult.configOptions); const before = yield* SubscriptionRef.get(metadata); const supportsTextGeneration = yield* options.supportsTextGeneration; - const usageLimits = - options.probeUsage && (!before.draft.usageLimits || before.draft.usageLimits.unavailable) - ? yield* options.probeUsage - : before.draft.usageLimits; const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* SubscriptionRef.update(metadata, (state) => { if ( @@ -339,10 +386,6 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func label: options.auth?.label ?? "Google account", }, checkedAt: updatedAt, - usageLimits: resolveUsageLimitsAfterProbe({ - published: draft.usageLimits, - probed: usageLimits ?? draft.usageLimits, - }), models: buildAntigravityModelsFromSession(started.sessionSetupResult, thinkingLevels), supportsTextGeneration, ...(cwd @@ -361,6 +404,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }, } satisfies AntigravityProviderState; }); + if (!before.draft.usageLimits || before.draft.usageLimits.unavailable) yield* refreshUsage(); }); const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( @@ -410,7 +454,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { - if (options.clearUsage) yield* options.clearUsage; + yield* resetUsage; if (!settings.enabled) return; const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* SubscriptionRef.update( @@ -420,9 +464,12 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func authRevision: state.authRevision + 1, draft: { ...state.draft, - auth: { status: "unauthenticated" }, + auth: { + status: "unauthenticated", + ...(usesCredentials ? options.auth : {}), + }, status: "warning", - message: SIGN_IN_MESSAGE, + message: signInMessage, checkedAt: updatedAt, models: [], slashCommands: [], @@ -470,6 +517,23 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func onSessionStarted, onConfigOptionsUpdated, onAvailableCommands, + // The new sign-in may be another account. Clear and republish its limits so a failed read + // cannot fall back to them; the session start then reads the new account's limits. + onSignedIn: resetUsage.pipe( + Effect.andThen( + SubscriptionRef.update(metadata, (state) => { + const { usageLimits: _previousAccount, ...draft } = state.draft; + return { ...state, draft }; + }), + ), + Effect.andThen( + Deferred.await(republish).pipe( + Effect.flatten, + Effect.provideService(PublishingUsage, true), + ), + ), + Effect.asVoid, + ), onSignedOut: clearAccountMetadata(), onAuthRequired: clearAccountMetadata(), snapshotForCwd, diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 95f5c6709a4ea..a596f6131035c 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -37,7 +37,7 @@ import { } from "@t3tools/provider-core/server/snapshotProbe"; import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts"; import { hasClaudeManagedMcpConfig, makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts"; -import { discoverClaudeSkills, readClaudeSettingsBaseUrl } from "./Drivers/ClaudeSkills.ts"; +import { discoverClaudeSkills, readClaudeSettingsEnv } from "./Drivers/ClaudeSkills.ts"; import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; import { @@ -601,7 +601,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( // First-party account fields cannot verify custom endpoint credentials. const baseUrl = - (yield* readClaudeSettingsBaseUrl(claudeSettings, cwd, resolvedEnvironment)) ?? + (yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ?? resolvedEnvironment.ANTHROPIC_BASE_URL; const endpoint = URL.parse(baseUrl?.trim() ?? ""); const customApiEndpoint = diff --git a/apps/server/src/provider/ClaudeTurnTokenUsage.ts b/apps/server/src/provider/ClaudeTurnTokenUsage.ts index 79eaaa60a11ce..2fa6a11bac1e5 100644 --- a/apps/server/src/provider/ClaudeTurnTokenUsage.ts +++ b/apps/server/src/provider/ClaudeTurnTokenUsage.ts @@ -49,12 +49,96 @@ function withEarlierSegmentUsage( return total; } +/** Per-model totals from the SDK's `modelUsage`, which grows for the life of one CLI process. */ +export type ClaudeModelLedger = ReadonlyMap< + string, + { readonly input: number; readonly output: number } +>; + +export interface ClaudeLedgerUsage { + readonly inputTokens: number; + readonly outputTokens: number; +} + +/** Unknown when absent or empty: a process that did model work never reports an empty ledger. */ +export function claudeModelLedger(modelUsage: unknown): ClaudeModelLedger | undefined { + if (typeof modelUsage !== "object" || modelUsage === null) return undefined; + if (Object.keys(modelUsage).length === 0) return undefined; + const ledger = new Map(); + for (const [model, entry] of Object.entries(modelUsage as Record)) { + const fields = (entry ?? {}) as Record; + const input = finiteNonNegativeInteger(fields.inputTokens); + const output = finiteNonNegativeInteger(fields.outputTokens); + if (input === undefined || output === undefined) return undefined; + const cacheRead = finiteNonNegativeInteger(fields.cacheReadInputTokens) ?? 0; + const cacheWrite = finiteNonNegativeInteger(fields.cacheCreationInputTokens) ?? 0; + ledger.set(model, { input: input + cacheRead + cacheWrite, output }); + } + return ledger; +} + +/** Ledger growth since `previous`. Unknown without a baseline or when a counter goes backwards. */ +export function claudeLedgerDelta( + previous: ClaudeModelLedger | undefined, + current: ClaudeModelLedger | undefined, +): ClaudeLedgerUsage | undefined { + if (previous === undefined || current === undefined) return undefined; + let inputTokens = 0; + let outputTokens = 0; + for (const [model, before] of previous) { + const after = current.get(model); + if (after === undefined || after.input < before.input || after.output < before.output) { + return undefined; + } + } + for (const [model, after] of current) { + const before = previous.get(model); + inputTokens += after.input - (before?.input ?? 0); + outputTokens += after.output - (before?.output ?? 0); + } + return { inputTokens, outputTokens }; +} + +export function addClaudeLedgerUsage( + left: ClaudeLedgerUsage | undefined, + right: ClaudeLedgerUsage | undefined, +): ClaudeLedgerUsage | undefined { + return left === undefined || right === undefined + ? undefined + : { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + }; +} + +export interface ClaudeTurnObservations { + // Models on the turn's root assistant messages. + readonly rootModels?: ReadonlySet; + // Ledger growth across every result this turn consumed, including steered segments. + readonly ledger?: ClaudeLedgerUsage | undefined; +} + export function normalizeClaudeTurnTokenUsage( - result: { readonly subtype: SDKResultMessage["subtype"]; readonly usage?: unknown } | undefined, + result: + | { + readonly subtype: SDKResultMessage["subtype"]; + readonly usage?: unknown; + readonly local_command?: unknown; + } + | undefined, hasSubagents: boolean, terminalStatus: RuntimeTurnState, earlierSegmentUsage: ReadonlyArray = [], + observed: ClaudeTurnObservations = {}, ): TurnTokenUsage { + const observedFields = { + ...(observed.rootModels !== undefined && observed.rootModels.size > 0 + ? { models: [...observed.rootModels] } + : {}), + ...(typeof result?.local_command === "string" && result.local_command.length > 0 + ? { localCommand: true } + : {}), + }; const finalUsage = result?.usage as Record | undefined; const usage = withEarlierSegmentUsage(finalUsage ?? {}, earlierSegmentUsage); const uncachedInputTokens = finiteNonNegativeInteger(usage.input_tokens); @@ -89,10 +173,25 @@ export function normalizeClaudeTurnTokenUsage( usageStatus: "unavailable", usageScope: "main_agent", hasSubagents, + ...observedFields, }; } + // The result omits subagent and compaction work that the process-wide ledger records. + const otherUsage = + observed.ledger !== undefined && + inputTokens !== undefined && + rawOutputTokens !== undefined && + observed.ledger.inputTokens >= inputTokens && + observed.ledger.outputTokens >= rawOutputTokens + ? { + inputTokens: observed.ledger.inputTokens - inputTokens, + outputTokens: observed.ledger.outputTokens - rawOutputTokens, + } + : undefined; const commonUsage = { + ...observedFields, + ...(otherUsage === undefined ? {} : { otherUsage }), usageScope: "main_agent", ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), ...(cacheCreationTokens !== undefined ? { cacheCreationTokens } : {}), diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index 3e521103d56cf..f629111264294 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -6,6 +6,7 @@ import * as ServerConfig from "../config.ts"; import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; import * as NodeHttp from "node:http"; import * as NodeCrypto from "node:crypto"; +import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; @@ -403,7 +404,9 @@ const makeHarness = makeHarnessFor(instanceId); const provision = (effect: Effect.Effect) => effect.pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer)), + Effect.provide( + Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer, NodePlatformCrypto.layer), + ), ); it.effect( "changing account registers a new user-owned client and then reuses that registration", @@ -920,7 +923,9 @@ it.effect( assert.strictEqual(harness.exchanges.length, 2); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + Effect.provide( + Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer, FetchHttpClient.layer), + ), ), ); @@ -957,7 +962,9 @@ it.effect( yield* harness.phase("succeeded"); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + Effect.provide( + Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer, FetchHttpClient.layer), + ), ), ); @@ -991,7 +998,12 @@ it.effect("keeps simultaneous remote accounts and environments independent", () yield* second.phase("succeeded"); assert.strictEqual(Option.getOrThrow(yield* first.auth.read).subject, "user-test"); assert.strictEqual(Option.getOrThrow(yield* second.auth.read).subject, "other-user"); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer, FetchHttpClient.layer), + ), + ), ); it.effect( @@ -1762,7 +1774,7 @@ const provideBroker = (effect: Effect.Effect) => Effect.scoped, Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-renewal-test-" }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer)), ), ), ); diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 3e51b66a0e5ee..457e85791b6db 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -14,7 +14,8 @@ import * as Clock from "effect/Clock"; import * as Cause from "effect/Cause"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { openAiUserId } from "../telemetry/Identify.ts"; -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 Exit from "effect/Exit"; import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; @@ -162,7 +163,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* options.instanceId, ); const secrets = yield* ServerSecretStore.ServerSecretStore; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; + const digest = yield* Digest.Digest; const environmentLocks = sessionLocks.get(secrets) ?? new Map(); sessionLocks.set(secrets, environmentLocks); const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1)); @@ -321,7 +323,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* }; }).pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), + Effect.provideService(Digest.Digest, digest), ); const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) { const saved = yield* readRegistrations; @@ -728,9 +731,9 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* // ID tokens survive renewal but change on sign-in. Bind active children to the // launch profile without putting an identity token or subject in their argv. const profileKey = (record: CodexChatGptCredentials) => - crypto + digest .digest( - "SHA-256", + Digest.sha256, new TextEncoder().encode( JSON.stringify([record.clientId, record.subject, record.idToken ?? null]), ), diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 87473e8da2f11..e8939f2368ada 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -65,7 +65,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/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index e0683199ffd5f..ab266b7a8c01d 100644 --- a/apps/server/src/provider/CodexInstallation.test.ts +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -1,8 +1,10 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import * as ModelManifest from "./ModelManifest.ts"; import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -56,8 +58,8 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( ); } let downloads = 0; - const crypto = yield* Crypto.Crypto; - const sha256 = Hex.encode(yield* crypto.digest("SHA-256", archive).pipe(Effect.orDie)); + const digest = yield* Digest.Digest; + const sha256 = Hex.encode(yield* digest.digest(Digest.sha256, archive).pipe(Effect.orDie)); let runtimeRenameFailures = input.runtimeRenameFailures ?? 0; const failedRenames = yield* Queue.unbounded(); const lockingFs: FileSystem.FileSystem = { @@ -166,7 +168,7 @@ it.effect.each(["0.156.0", "0.156.1", "0.156.2", "0.157.0"])( yield* h.installation.remove(); expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); expect((yield* h.installation.state).source).toBe("local"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ { version: "0.128.9" }, @@ -189,7 +191,7 @@ it.effect.each([ expect(installed.executablePath).toBe(executable.executablePath); expect(h.downloads()).toBe(1); expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const compileNative = Effect.fn("test.compileNative")(function* ( @@ -219,7 +221,7 @@ it.effect("rechecks compatibility after the local executable is replaced", () => expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); expect((yield* h.installation.resolve()).source).toBe("managed"); expect(h.downloads()).toBe(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("rechecks a cached local executable when the shared manifest policy changes", () => @@ -251,7 +253,7 @@ it.effect("rechecks a cached local executable when the shared manifest policy ch "--version", "app-server --help", ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("uses bundled Codex compatibility when the remote manifest omits its policy", () => @@ -265,7 +267,7 @@ it.effect("uses bundled Codex compatibility when the remote manifest omits its p }); expect((yield* h.installation.start).source).toBe("local"); expect(h.downloads()).toBe(0); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ @@ -304,7 +306,7 @@ int main(int argc, char **argv) { expect(yield* h.installation.start).toMatchObject({ source: "local", phase: "succeeded" }); expect((yield* h.installation.resolve()).executablePath).toBe(h.localBinaryPath); expect(h.downloads()).toBe(0); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -324,7 +326,7 @@ it.effect( ); const restarted = yield* makeHarness({ baseDir }); expect((yield* restarted.installation.resolve()).version).toBe("0.156.1"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("requires an update before running an older activated managed installation", () => @@ -354,7 +356,7 @@ it.effect("requires an update before running an older activated managed installa expect((yield* terminalState(restarted.installation)).phase).toBe("succeeded"); expect((yield* restarted.installation.resolve()).version).toBe("0.156.1"); expect(restarted.downloads()).toBe(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("rejects corrupted downloads without publishing a runtime", () => @@ -368,7 +370,7 @@ it.effect("rejects corrupted downloads without publishing a runtime", () => expect(state.message).toContain("SHA-256"); expect(yield* fs.exists(`${installation.managedDirectory}/active.json`)).toBe(false); expect(yield* fs.exists(`${installation.managedDirectory}/0.156.1`)).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each(["interrupted body", "HTTP 404"] as const)( @@ -405,7 +407,7 @@ it.effect.each(["interrupted body", "HTTP 404"] as const)( }); expect(h.downloads()).toBe(1); expect(yield* h.fs.readDirectory(h.installation.managedDirectory)).toEqual([]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -424,7 +426,7 @@ it.effect( }); expect(h.downloads()).toBe(0); expect(yield* fs.readFileString(baseDir)).toBe("existing file"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("reports a download timeout and cleans the staging directory", () => @@ -443,7 +445,7 @@ it.effect("reports a download timeout and cleans the staging directory", () => }); expect(h.downloads()).toBe(1); expect(yield* h.fs.readDirectory(h.installation.managedDirectory)).toEqual([]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("cancels an in-flight download and cleans the staging directory", () => @@ -462,7 +464,7 @@ it.effect("cancels an in-flight download and cleans the staging directory", () = ), ).toEqual([]); expect(yield* fs.exists(`${installation.managedDirectory}/active.json`)).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("protects active process leases and removes the runtime after release", () => @@ -478,7 +480,7 @@ it.effect("protects active process leases and removes the runtime after release" expect(yield* fs.exists(installation.managedDirectory)).toBe(false); expect((yield* installation.state).installedVersion).toBeNull(); expect((yield* installation.state).executablePath).toBeNull(); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("keeps an activated runtime when a later update fails verification", () => @@ -496,7 +498,7 @@ it.effect("keeps an activated runtime when a later update fails verification", ( yield* update.installation.start; expect((yield* terminalState(update.installation)).phase).toBe("failed"); expect((yield* update.installation.resolve()).version).toBe("0.156.1"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Stands in for Windows' bsdtar: CRLF listings, and extraction writes the Windows layout. diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index 7cd973b73fa76..83e9e4e15864d 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -10,7 +10,7 @@ import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; 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 Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -170,7 +170,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function const manifestService = yield* ModelManifest.ModelManifest; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const http = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serviceScope = yield* Effect.scope; @@ -653,7 +653,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function `OpenAI does not publish a Codex runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`, ); } - const operationId = yield* crypto.randomUUIDv4(); + const operationId = yield* random.uuidV4(); const next: ProviderInstallState = { driver: DRIVER, operationId, diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 8f8408bdaee1f..754716ac53106 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -4,6 +4,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; @@ -271,7 +272,7 @@ it.live.each( Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-managed-runtime-", - }).pipe(Layer.provideMerge(NodeServices.layer)), + }).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ), ), ); 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/CodexTurnTokenUsage.ts b/apps/server/src/provider/CodexTurnTokenUsage.ts index 7b0f20a123ffe..7b2524464b0e0 100644 --- a/apps/server/src/provider/CodexTurnTokenUsage.ts +++ b/apps/server/src/provider/CodexTurnTokenUsage.ts @@ -133,27 +133,23 @@ export function accumulateCodexTurnTokenUsage( } } +/** `model` is the thread model Codex last reported, which a reroute replaces. */ export function completeCodexTurnTokenUsage( state: CodexTurnTokenUsageState, turnId: string, completed: boolean, + model?: string, ): TurnTokenUsage { const usage = state.byTurnId.get(turnId); state.byTurnId.delete(turnId); if (state.activeTurnId === turnId) state.activeTurnId = undefined; - if (!usage) { + const models = model === undefined ? {} : { models: [model] }; + if (!usage?.observed) { return { usageStatus: "unavailable", usageScope: "main_agent", - hasSubagents: false, - }; - } - - if (!usage.observed) { - return { - usageStatus: "unavailable", - usageScope: "main_agent", - hasSubagents: usage.hasSubagents, + hasSubagents: usage?.hasSubagents ?? false, + ...models, }; } @@ -169,5 +165,6 @@ export function completeCodexTurnTokenUsage( outputTokens: usage.outputTokens, reasoningTokens: Math.min(usage.outputTokens, usage.reasoningTokens), hasSubagents: usage.hasSubagents, + ...models, }; } diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 74f4daa1323af..c4486916c7d3c 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { @@ -10,6 +11,7 @@ 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 Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HttpClient } from "effect/http"; @@ -160,20 +162,24 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( return yield* Effect.die("Unexpected process pipeline."); const handle = yield* spawner.spawn(command); const environment = command.options.env ?? {}; + const env = (name: string) => { + const value = environment[name]; + return Redacted.isRedacted(value) ? Redacted.value(value) : value; + }; launches.push({ command: command.command, - args: command.args, + args: command.args.map(String), cwd: command.options.cwd, extendEnv: command.options.extendEnv, - profileDirectory: environment.GEMINI_HOME, - harnessPath: environment.ANTIGRAVITY_HARNESS_PATH, - forceFileStorage: environment.AGY_ACP_FORCE_FILE_STORAGE, + profileDirectory: env("GEMINI_HOME"), + harnessPath: env("ANTIGRAVITY_HARNESS_PATH"), + forceFileStorage: env("AGY_ACP_FORCE_FILE_STORAGE"), credentialKeys: Object.keys(environment).filter((key) => blockedCredentialKeys.has(key.toUpperCase()), ), - geminiApiKey: environment.GEMINI_API_KEY, + geminiApiKey: env("GEMINI_API_KEY"), tempDirectory: - environment.ANTIGRAVITY_HARNESS_PATH === undefined ? undefined : environment.TMPDIR, + environment.ANTIGRAVITY_HARNESS_PATH === undefined ? undefined : env("TMPDIR"), handle, }); return handle; @@ -240,7 +246,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const layerDeps = ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-driver-config-", }).pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge( Layer.succeed( HttpClient.HttpClient, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index f1f39e104b44c..a8f97b0f14743 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -6,7 +6,8 @@ import { NodeRuntimeUnavailableError, nodeRuntimeUnavailableMessage, } from "@t3tools/shared/nodeRuntime"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +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"; @@ -46,8 +47,8 @@ import { import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; import { - removeAntigravityRuntimeTempDirs, removeAntigravitySessionFiles, + retireAntigravityRuntimeTempDirs, } from "../acp/AntigravitySessionFiles.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -75,7 +76,8 @@ export type AntigravityDriverEnv = | AntigravityInstallation.AntigravityInstallation | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto + | Digest.Digest + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | IdAllocator.IdAllocatorV2 @@ -102,7 +104,8 @@ export const AntigravityDriver: ProviderDriver< enabled, config, }) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; + const random = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; const httpClient = yield* HttpClient.HttpClient; const path = yield* Path.Path; @@ -133,7 +136,7 @@ export const AntigravityDriver: ProviderDriver< host.paths.stateDir, instanceId, ).pipe( - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), Effect.provideService(Path.Path, path), Effect.mapError( (cause) => @@ -150,9 +153,11 @@ export const AntigravityDriver: ProviderDriver< directories.runtimeTemp, path.join(profileDirectory, "antigravity-acp", "tmp"), ]) { - yield* removeAntigravityRuntimeTempDirs(directory).pipe( + const removeRetired = yield* retireAntigravityRuntimeTempDirs(directory).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ); + yield* Effect.forkScoped(removeRetired); } const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER, @@ -242,6 +247,7 @@ export const AntigravityDriver: ProviderDriver< ); const runtime = yield* makeAntigravityAcpRuntime({ ...input, + runtimeVersion: executable.version, authMethod: auth.authMethod, childProcessSpawner: spawner, spawn: buildAntigravityAcpSpawnInput({ @@ -252,7 +258,7 @@ export const AntigravityDriver: ProviderDriver< auth, runtimeTempDirectory, }), - }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + }).pipe(Effect.provideService(SecureRandom.SecureRandom, random)); return { ...runtime, start: () => @@ -345,7 +351,11 @@ export const AntigravityDriver: ProviderDriver< const authFlow: AntigravityAuth = yield* makeAntigravityAuth({ instanceId, makeRuntime: makeDisposableRuntime, - onAuthenticated: publishCatalog, + // A new sign-in may be another account; drop any in-flight quota read first. + onAuthenticated: (started, runtime) => + Effect.suspend(() => provider.onSignedIn).pipe( + Effect.andThen(publishCatalog(started, runtime)), + ), onSignedOut: Effect.suspend(() => provider.onSignedOut), usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), }); @@ -395,7 +405,7 @@ export const AntigravityDriver: ProviderDriver< }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), Effect.provideService(HttpClient.HttpClient, httpClient), ); const provider = yield* makeAntigravityProvider(settings, { @@ -427,6 +437,7 @@ export const AntigravityDriver: ProviderDriver< makeRuntime, withProcess: authFlow.withProcess, defaultModel, + profileDirectory, onSessionStarted: provider.onSessionStarted, onSessionEvent: (event) => { if (event._tag === "ConfigOptionsUpdated") { diff --git a/apps/server/src/provider/Drivers/AntigravityUsage.ts b/apps/server/src/provider/Drivers/AntigravityUsage.ts index 02168fdd83149..5cb55ed77ac09 100644 --- a/apps/server/src/provider/Drivers/AntigravityUsage.ts +++ b/apps/server/src/provider/Drivers/AntigravityUsage.ts @@ -8,7 +8,7 @@ import type { } from "@t3tools/provider-core/server/usage"; import * as HostProcess from "@t3tools/shared/HostProcess"; 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 FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -28,7 +28,7 @@ export class AntigravityUsage extends Context.Service< >()("t3/provider/Drivers/AntigravityUsage") {} const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const host = yield* ProviderHost.ProviderHost; @@ -69,7 +69,7 @@ const make = Effect.gen(function* () { host.paths.stateDir, instanceId, ).pipe( - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), Effect.provideService(Path.Path, path), Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e1fbcd30080fd..c0a9c5698d92c 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"; @@ -42,14 +42,7 @@ import { type ProviderInstance, } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { - enrichProviderSnapshotWithVersionAdvisory, - makeCachedProviderMaintenanceResolution, - makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, - resolveProviderMaintenanceCapabilitiesEffect, -} from "@t3tools/provider-core/server/maintenanceResolver"; +import { enrichProviderSnapshotWithVersionAdvisory } from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -58,9 +51,11 @@ import { import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, + mergeClaudeProcessEnvironment, resolveClaudeHomePath, } 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"); @@ -72,30 +67,12 @@ 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 | ResetCreditCoordinator.ResetCreditCoordinator - | Crypto.Crypto + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions @@ -134,7 +111,7 @@ export const ClaudeDriver: ProviderDriver { }), ); }); + + it.effect( + "drops the launching Claude session and labels processes t3-code unless overridden", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-" }); + const host = { + CLAUDE_CODE_ENTRYPOINT: "claude-desktop", + CLAUDECODE: "1", + CLAUDE_CODE_SESSION_ID: "host-session", + CLAUDE_CODE_MESSAGING_SOCKET: "/tmp/host-claude.sock", + CLAUDE_EFFORT: "high", + KEEP: "1", + }; + const instance = [ + { name: "CLAUDE_CODE_ENTRYPOINT", value: "instance", sensitive: false }, + { name: "CLAUDE_EFFORT", value: "low", sensitive: false }, + ]; + + expect(yield* mergeClaudeProcessEnvironment({ homePath }, undefined, host)).toEqual({ + CLAUDE_CODE_ENTRYPOINT: "t3-code", + KEEP: "1", + }); + expect(yield* mergeClaudeProcessEnvironment({ homePath }, instance, host)).toEqual({ + CLAUDE_CODE_ENTRYPOINT: "instance", + CLAUDE_EFFORT: "low", + KEEP: "1", + }); + + yield* fileSystem.writeFileString( + path.join(homePath, "settings.json"), + JSON.stringify({ env: { CLAUDE_CODE_ENTRYPOINT: "custom" } }), + ); + expect( + (yield* mergeClaudeProcessEnvironment({ homePath }, instance, host)) + .CLAUDE_CODE_ENTRYPOINT, + ).toBe("custom"); + }), + ); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index bd81aa10bce7a..ed5d8fca2ad7c 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,12 +1,15 @@ -import type { ClaudeSettings } from "@t3tools/contracts"; +import type { ClaudeSettings, ProviderInstanceEnvironment } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { readClaudeSettingsEnv } from "./ClaudeSkills.ts"; + const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( @@ -42,6 +45,45 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function }; }); +// A Claude Code session that launched T3 exports these. A claude spawned with them acts as part of +// that session: its session id, messaging socket and token, and effort. +const CLAUDE_HOST_SESSION_VARIABLES = [ + "CLAUDECODE", + "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_HOST_SESSION_ID", + "CLAUDE_CODE_CHILD_SESSION", + "CLAUDE_CODE_SESSION_ATTENDED", + "CLAUDE_CODE_MESSAGING_SOCKET", + "CLAUDE_CODE_MESSAGING_TOKEN", + "CLAUDE_CODE_EXECPATH", + "CLAUDE_CODE_DESKTOP_APP_VERSION", + "CLAUDE_EFFORT", + "CLAUDE_PID", + "CLAUDE_AGENT_SDK_VERSION", +]; + +/** + * Base env for the Claude processes T3 Code runs, before `makeClaudeEnvironment`. + * Claude Code reads its entrypoint from the process env, not its settings `env`, so T3 Code applies that override itself. + */ +export const mergeClaudeProcessEnvironment = Effect.fn("mergeClaudeProcessEnvironment")(function* ( + config: Pick, + instanceEnvironment: ProviderInstanceEnvironment | undefined, + hostEnvironment: NodeJS.ProcessEnv, +) { + const baseEnvironment: NodeJS.ProcessEnv = { + ...hostEnvironment, + CLAUDE_CODE_ENTRYPOINT: "t3-code", + }; + for (const name of CLAUDE_HOST_SESSION_VARIABLES) delete baseEnvironment[name]; + const environment = yield* mergeProviderInstanceEnvironment(instanceEnvironment, baseEnvironment); + const settingsEntrypoint = (yield* readClaudeSettingsEnv(config, undefined, environment)) + .CLAUDE_CODE_ENTRYPOINT; + return settingsEntrypoint + ? { ...environment, CLAUDE_CODE_ENTRYPOINT: settingsEntrypoint } + : environment; +}); + /** managed-mcp.json excludes other MCP config and forbids --strict-mcp-config; omit the flag when present. */ export const hasClaudeManagedMcpConfig = Effect.fn("hasClaudeManagedMcpConfig")(function* () { const fileSystem = yield* FileSystem.FileSystem; 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.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 6aff76d250a76..c73385374fbc0 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -428,24 +428,25 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); -const SettingsBaseUrl = fromLenientJson( - Schema.Struct({ - env: Schema.optional(Schema.Struct({ ANTHROPIC_BASE_URL: Schema.optional(Schema.String) })), - }), +const SettingsEnv = Schema.Struct({ + ANTHROPIC_BASE_URL: Schema.optional(Schema.String), + CLAUDE_CODE_ENTRYPOINT: Schema.optional(Schema.String), +}); +const decodeSettingsEnv = Schema.decodeUnknownEffect( + fromLenientJson(Schema.Struct({ env: Schema.optional(SettingsEnv) })), ); -const decodeSettingsBaseUrl = Schema.decodeUnknownEffect(SettingsBaseUrl); -/** ANTHROPIC_BASE_URL from Claude's settings `env`, which Claude Code applies over the process env. */ -export const readClaudeSettingsBaseUrl = Effect.fn("readClaudeSettingsBaseUrl")(function* ( +/** The `env` values T3 Code reads from Claude's user and managed settings; later files win. */ +export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function* ( config: Pick, cwd: string | undefined, environment: NodeJS.ProcessEnv, -): Effect.fn.Return { +): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcess.Platform; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd); - let baseUrl: string | undefined; + const settingsEnv: typeof SettingsEnv.Type = {}; for (const settingsPath of claudeSettingsPaths( path, configDirPath, @@ -457,12 +458,10 @@ export const readClaudeSettingsBaseUrl = Effect.fn("readClaudeSettingsBaseUrl")( .readFileString(settingsPath) .pipe(Effect.orElseSucceed(() => undefined)); if (contents === undefined) continue; - const parsed = yield* decodeSettingsBaseUrl(contents).pipe( - Effect.orElseSucceed(() => undefined), - ); - baseUrl = parsed?.env?.ANTHROPIC_BASE_URL ?? baseUrl; + const parsed = yield* decodeSettingsEnv(contents).pipe(Effect.orElseSucceed(() => undefined)); + Object.assign(settingsEnv, parsed?.env); } - return baseUrl; + return settingsEnv; }); // Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 38de3c92a9570..985c14df21103 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -3,6 +3,7 @@ import * as CodexManagedTokenBroker from "../CodexManagedTokenBroker.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.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"; @@ -45,7 +46,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( @@ -478,6 +479,24 @@ it.layer(layerTest)("CodexDriver", (it) => { }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), ); + it.effect("uses each instance's environment CODEX_HOME for continuation", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }); + for (const [index, account] of ["work", "personal", "work "].entries()) { + const codexHome = NodePath.join(tempDir, account); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make(`account-${index}`), + displayName: account, + enabled: false, + environment: [{ name: "CODEX_HOME", value: codexHome, sensitive: false }], + config: CodexDriver.defaultConfig(), + }); + expect(instance.continuationIdentity.continuationKey).toBe(`codex:home:${codexHome}`); + } + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), + ); + for (const fixture of [ { name: "leaves mise npm-backend installations manual-only", @@ -635,7 +654,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 97ec4954ed840..c34aa00a19646 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,5 +1,6 @@ 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 * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -98,7 +99,8 @@ export type CodexDriverEnv = | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner | ResetCreditCoordinator.ResetCreditCoordinator - | Crypto.Crypto + | Digest.Digest + | SecureRandom.SecureRandom | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions @@ -150,8 +152,16 @@ export const CodexDriver: ProviderDriver< const installation = yield* CodexInstallation.CodexInstallation; const modelCatalog = yield* ModelCatalog.ModelCatalog; const currentCatalog = modelCatalog.current(DRIVER_KIND); - const processEnv = yield* mergeProviderInstanceEnvironment(environment); - const homeLayout = yield* resolveCodexHomeLayout(config); + const processEnv = yield* mergeProviderInstanceEnvironment( + environment, + yield* HostProcess.Environment, + ); + const environmentHome = processEnv.CODEX_HOME; + const homeLayout = yield* resolveCodexHomeLayout( + !config.homePath.trim() && !config.shadowHomePath.trim() && environmentHome?.trim() + ? { ...config, homePath: environmentHome } + : config, + ); const continuationIdentity = codexContinuationIdentity(homeLayout); const stampIdentity = withInstanceIdentity({ instanceId, diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts index 3820ce0cf5234..74812bafb5099 100644 --- a/apps/server/src/provider/Drivers/codexUsage.test.ts +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -1,7 +1,18 @@ import { describe, expect, it } from "@effect/vitest"; import { totalTokens } from "@t3tools/provider-core/server/usage"; -import { codexUsageFormat, initialCodexScanState, parseCodexLine } from "./codexUsage.ts"; +import { + type CodexScanState, + codexUsageFormat, + initialCodexScanState, + parseCodexLine as parseCodexRecords, +} from "./codexUsage.ts"; + +function parseCodexLine(line: string, state: CodexScanState) { + const records = parseCodexRecords(line, state); + expect(records.length).toBeLessThanOrEqual(1); + return records[0] ?? null; +} describe("parseCodexLine", () => { const sessionMeta = JSON.stringify({ @@ -326,6 +337,119 @@ describe("parseCodexLine", () => { expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(1); }); + it("bills only the reported request when usage records sit between reconciling snapshots", () => { + const state = initialCodexScanState(); + const usageRecord = (usage: unknown) => + JSON.stringify({ + type: "token_usage_record", + timestamp: "2026-08-01T05:17:45.000Z", + payload: { usage }, + }); + parseCodexLine(turnContext, state); + parseCodexLine(counted(counters(100, 20), counters(100, 20)), state); + for (const usage of [counters(40, 5), counters(60, 15), counters(30, 3)]) { + expect(parseCodexLine(usageRecord(usage), state)).toBeNull(); + } + expect( + parseCodexLine(counted(counters(100, 20), counters(200, 40)), state)?.totals.outputTokens, + ).toBe(20); + // Records explained by an earlier snapshot cannot bridge a later gap. + parseCodexLine(usageRecord(counters(10, 2)), state); + expect(parseCodexLine(counted(counters(10, 2), counters(340, 65)), state)).toBeNull(); + expect(state.malformedRecords).toBe(1); + }); + + it("bills each interrupted turn's request with the model, tier and time it was read under", () => { + const state = initialCodexScanState(); + const line = (type: string, time: string, payload: Record) => + JSON.stringify({ type, timestamp: `2026-11-0${time}Z`, payload }); + const context = (time: string, turn_id: string, model: string) => + line("turn_context", time, { turn_id, model }); + const tier = (time: string, service_tier: string) => + line("event_msg", time, { + type: "thread_settings_applied", + thread_settings: { service_tier }, + }); + const request = (time: string, usage: unknown) => line("token_usage_record", time, { usage }); + const snapshot = (time: string, last: unknown, total: unknown) => + line("event_msg", time, { + type: "token_count", + info: { last_token_usage: last, total_token_usage: total }, + }); + const parsed = [ + line("session_meta", "1T23:59:50", { id: "session-a", model_provider: "openai" }), + context("1T23:59:50", "turn-1", "gpt-6-sol"), + request("1T23:59:51", counters(100, 20)), + snapshot("1T23:59:52", counters(100, 20), counters(100, 20)), + // One interrupted turn whose requests straddle midnight. + request("1T23:59:59", counters(150, 10)), + request("2T00:00:01", counters(50, 5)), + tier("2T00:00:02", "priority"), + context("2T00:00:02", "turn-2", "gpt-6-astra"), + request("2T00:00:03", counters(50, 5)), + request("2T00:00:04", counters(30, 5)), + tier("2T00:00:05", "default"), + context("2T00:00:05", "turn-3", "gpt-6-sol"), + request("2T00:00:06", counters(200, 30)), + snapshot("2T00:00:07", counters(200, 30), counters(580, 75)), + ].flatMap((value) => parseCodexRecords(value, state)); + + const at = (time: string) => Date.parse(`2026-11-0${time}Z`); + expect( + parsed.map((record) => ({ + tokens: totalTokens(record.totals), + model: record.model, + speed: record.speed, + timestampMs: record.timestampMs, + unresolvedServiceTierTurnId: record.unresolvedServiceTierTurnId, + })), + ).toEqual([ + { + tokens: 120, + model: "gpt-6-sol", + speed: "standard", + timestampMs: at("1T23:59:52"), + unresolvedServiceTierTurnId: "turn-1", + }, + { + tokens: 160, + model: "gpt-6-sol", + speed: "standard", + timestampMs: at("1T23:59:59"), + unresolvedServiceTierTurnId: "turn-1", + }, + { + tokens: 55, + model: "gpt-6-sol", + speed: "standard", + timestampMs: at("2T00:00:01"), + unresolvedServiceTierTurnId: "turn-1", + }, + { + tokens: 55, + model: "gpt-6-astra", + speed: "fast", + timestampMs: at("2T00:00:03"), + unresolvedServiceTierTurnId: undefined, + }, + { + tokens: 35, + model: "gpt-6-astra", + speed: "fast", + timestampMs: at("2T00:00:04"), + unresolvedServiceTierTurnId: undefined, + }, + { + tokens: 230, + model: "gpt-6-sol", + speed: "standard", + timestampMs: at("2T00:00:07"), + unresolvedServiceTierTurnId: undefined, + }, + ]); + expect(state.malformedRecords).toBe(0); + }); + it("does not bill a context-window estimate or lose the following real request", () => { const state = initialCodexScanState(); parseCodexLine(turnContext, state); @@ -664,5 +788,26 @@ describe("parseCodexLine", () => { skipped: codexUsageFormat.readMalformedRecords?.(state), }).toEqual({ processed: [120, 0, 120, 35], skipped: 0 }); }); + + it("bills the child's first request when its turn is interrupted before any snapshot", () => { + const state = initialCodexScanState(); + const forkInstant = "2026-08-01T05:00:00.000Z"; + const request = (timestamp: string, usage: unknown) => + JSON.stringify({ type: "token_usage_record", timestamp, payload: { usage } }); + const billed = [ + meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), + stamped(forkInstant, turnContext), + request("2026-08-01T05:00:00.001Z", counters(100, 20)), + stamped("2026-08-01T05:00:00.001Z", counted(counters(100, 20), counters(100, 20))), + request("2026-08-01T05:00:06.000Z", counters(150, 10)), + request("2026-08-01T05:00:10.000Z", counters(200, 30)), + stamped("2026-08-01T05:00:10.000Z", counted(counters(200, 30), counters(450, 60))), + ].flatMap((line) => parseCodexRecords(line, state)); + + expect({ + processed: billed.map((record) => totalTokens(record.totals)), + skipped: codexUsageFormat.readMalformedRecords?.(state), + }).toEqual({ processed: [160, 230], skipped: 0 }); + }); }); }); diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index 20939e54db799..90f7c78b849f8 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -5,7 +5,6 @@ import * as Schema from "effect/Schema"; import { parseTimestampMs, - totalTokens, type ProviderUsageReader, type TranscriptUsageFormat, type UsageRecord, @@ -58,6 +57,7 @@ function readCodexTokenUsage(value: unknown): CodexTokenUsage | null { // A zeroed baseline rebases the counters, so the sequence start measured against it is forgotten. function rebaseCodexUsage(state: CodexScanState, cumulative: CodexTokenUsage) { state.lastCumulativeUsage = cumulative; + delete state.unreportedRequests; if (cumulative.input_tokens + cumulative.output_tokens === 0) delete state.sequenceStartSignature; } @@ -67,18 +67,66 @@ function advanceCodexUsage(previous: CodexTokenUsage | null, last: CodexTokenUsa ); } +const CodexSpeed = Schema.Literals(["standard", "fast", "ultrafast"]); + +// What a request is billed as is fixed when it is read, not when a later snapshot reconciles it. +const CodexUnreportedRequest = Schema.Struct({ + timestampMs: Schema.Finite, + model: Schema.String, + turnId: Schema.String, + speed: CodexSpeed, + sawThreadSettings: Schema.Boolean, + usage: CodexTokenUsage, +}); +type CodexUnreportedRequest = typeof CodexUnreportedRequest.Type; +type CodexRequestAttribution = Omit; + +// An interrupted turn can end before a snapshot reports its last request. Bill those requests only +// when the `token_usage_record`s since the previous snapshot explain the whole gap; `last` is the +// final one. +function unreportedRequests( + previous: CodexTokenUsage | null, + cumulative: CodexTokenUsage, + last: CodexTokenUsage, + pending: readonly CodexUnreportedRequest[] | undefined, +) { + if ( + previous === null || + pending === undefined || + CODEX_USAGE_FIELDS.some( + (key) => + cumulative[key] - previous[key] !== + pending.reduce((sum, request) => sum + request.usage[key], 0), + ) + ) { + return null; + } + const final = pending.at(-1)!; + const remainder = readCodexTokenUsage( + Object.fromEntries(CODEX_USAGE_FIELDS.map((key) => [key, final.usage[key] - last[key]])), + ); + if (remainder === null) return null; + return [...pending.slice(0, -1), { ...final, usage: remainder }].filter( + (request) => request.usage.input_tokens + request.usage.output_tokens > 0, + ); +} + /** * Codex `token_count` events carry no model or service tier, so both are carried forward: the model * from the most recent `turn_context`, the tier from the most recent `thread_settings_applied`. */ export const CodexScanState = Schema.Struct({ model: Schema.mutableKey(Schema.String), - speed: Schema.mutableKey(Schema.Literals(["standard", "fast", "ultrafast"])), + speed: Schema.mutableKey(CodexSpeed), sessionId: Schema.mutableKey(Schema.String), lastUsageSignature: Schema.mutableKey(Schema.NullOr(Schema.String)), lastCumulativeUsage: Schema.mutableKey(Schema.NullOr(CodexTokenUsage)), // Optional so scan states cached before this field existed stay valid. sequenceStartSignature: Schema.mutableKey(Schema.optionalKey(Schema.String)), + // `token_usage_record` usage since the last cumulative snapshot, one entry per record. + unreportedRequests: Schema.mutableKey( + Schema.optionalKey(Schema.mutable(Schema.Array(CodexUnreportedRequest))), + ), malformedRecords: Schema.mutableKey(TokenCount), sawSessionMeta: Schema.mutableKey(Schema.Boolean), modelProvider: Schema.mutableKey(Schema.String), @@ -120,32 +168,44 @@ function isForkedSessionMeta(payload: Record): boolean { return typeof (spawn as Record)["parent_thread_id"] === "string"; } +// The first usage line past the copied burst ends suppression for the rest of the rollout. +function isForkCopy(state: CodexScanState, timestampMs: number): boolean { + if (!state.suppressingForkCopies) return false; + if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { + state.forkCopyAnchorMs = timestampMs; + return true; + } + state.suppressingForkCopies = false; + state.lastUsageSignature = null; + return false; +} + /** * Cumulative counters validate request deltas. Older events without cumulative counters use * normalized consecutive-request deduplication. */ -export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { +export function parseCodexLine(line: string, state: CodexScanState): readonly UsageRecord[] { let parsed: unknown; try { parsed = JSON.parse(line); } catch { - return null; + return []; } return parseCodexRecord(parsed, state); } -function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null { - if (typeof parsed !== "object" || parsed === null) return null; +function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly UsageRecord[] { + if (typeof parsed !== "object" || parsed === null) return []; const record = parsed as Record; const payload = record["payload"]; - if (typeof payload !== "object" || payload === null) return null; + if (typeof payload !== "object" || payload === null) return []; const payloadRecord = payload as Record; const payloadType = payloadRecord["type"]; if (record["type"] === "session_meta") { // Only the first meta identifies this session; copied ancestor metas would misattribute later records. - if (state.sawSessionMeta) return null; + if (state.sawSessionMeta) return []; state.sawSessionMeta = true; const id = payloadRecord["id"] ?? payloadRecord["session_id"]; if (typeof id === "string") state.sessionId = id; @@ -157,7 +217,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | state.suppressingForkCopies = true; state.forkCopyAnchorMs = metaTimestampMs; } - return null; + return []; } if (record["type"] === "turn_context") { @@ -169,7 +229,18 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | payloadRecord["model"].length > 0 ? payloadRecord["turn_id"] : ""; - return null; + return []; + } + + // Several of these may sit between two snapshots, so they only explain a gap in the counters. + if (record["type"] === "token_usage_record") { + const timestampMs = parseTimestampMs(record["timestamp"]); + const usage = readCodexTokenUsage(payloadRecord["usage"]); + if (timestampMs === null || usage === null || state.model.length === 0) return []; + // Copied history is already billed in the parent. + if (isForkCopy(state, timestampMs)) return []; + (state.unreportedRequests ??= []).push({ ...codexAttribution(state, timestampMs), usage }); + return []; } if (payloadType === "thread_settings_applied") { @@ -178,53 +249,49 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | if (typeof settings === "object" && settings !== null) { state.speed = codexSpeed((settings as Record)["service_tier"]); } - return null; + return []; } - if (payloadType !== "token_count") return null; + if (payloadType !== "token_count") return []; const info = payloadRecord["info"]; - if (typeof info !== "object" || info === null) return null; + if (typeof info !== "object" || info === null) return []; const infoRecord = info as Record; // Codex may re-emit these counters after the model or timestamp becomes available. const timestampMs = parseTimestampMs(record["timestamp"]); - if (timestampMs === null || state.model.length === 0) return null; + if (timestampMs === null || state.model.length === 0) return []; const last = readCodexTokenUsage(infoRecord["last_token_usage"]); const rawCumulative = infoRecord["total_token_usage"]; const hasCumulative = rawCumulative !== undefined && rawCumulative !== null; const cumulative = hasCumulative ? readCodexTokenUsage(rawCumulative) : null; - if (state.suppressingForkCopies) { - if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { - state.forkCopyAnchorMs = timestampMs; - // Copied history is already billed in the parent, but establishes the child's baseline. - const signature = last === null ? null : JSON.stringify(last); - if (cumulative !== null) rebaseCodexUsage(state, cumulative); - else if (!hasCumulative && last !== null && signature !== state.lastUsageSignature) { - state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, last); - } - state.lastUsageSignature = signature; - return null; + if (isForkCopy(state, timestampMs)) { + // Copied history is already billed in the parent, but establishes the child's baseline. + const signature = last === null ? null : JSON.stringify(last); + if (cumulative !== null) rebaseCodexUsage(state, cumulative); + else if (!hasCumulative && last !== null && signature !== state.lastUsageSignature) { + state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, last); } - state.suppressingForkCopies = false; - state.lastUsageSignature = null; + state.lastUsageSignature = signature; + return []; } const previous = state.lastCumulativeUsage; + const pending = state.unreportedRequests; // A valid snapshot remains the next baseline even when its paired request is malformed. if (cumulative !== null) rebaseCodexUsage(state, cumulative); if (last === null || (hasCumulative && cumulative === null)) { state.malformedRecords += 1; - return null; + return []; } // Zero-component counters report context size, not a request: fill_to_context_window, or the // post-compaction marker whose total is the new context size (its cumulative may reset to zero). if (last.input_tokens + last.output_tokens === 0) { state.lastUsageSignature = null; - return null; + return []; } if ( @@ -232,15 +299,16 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | previous !== null && CODEX_USAGE_FIELDS.every((key) => cumulative[key] === previous[key]) ) { - return null; + return []; } if (last.total_tokens !== last.input_tokens + last.output_tokens) { state.malformedRecords += 1; - return null; + return []; } const signature = JSON.stringify(last); + let unreported: readonly CodexUnreportedRequest[] = []; if (cumulative !== null) { // A session resumed in a new Codex process restarts its cumulative counters at this request. const restarted = CODEX_USAGE_FIELDS.every((key) => cumulative[key] === last[key]); @@ -248,7 +316,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | // Only the same event is a replay; a later request can restart with identical counters. if (restarted && sequenceStart === state.sequenceStartSignature) { state.lastCumulativeUsage = previous; - return null; + return []; } // Forks and paginated continuations can inherit totals without copying a counter snapshot. // Only bill the latest request, and require the unseen prefix to be valid usage too. @@ -259,26 +327,51 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | ) !== null : CODEX_USAGE_FIELDS.every((key) => cumulative[key] - previous[key] === last[key]); if (!restarted && !reconciled) { - state.malformedRecords += 1; - return null; + const bridged = unreportedRequests(previous, cumulative, last, pending); + if (bridged === null) { + state.malformedRecords += 1; + return []; + } + unreported = bridged; } if (restarted) state.sequenceStartSignature = sequenceStart; } else { - if (signature === state.lastUsageSignature) return null; + if (signature === state.lastUsageSignature) return []; // Advance expected totals for billed legacy requests so a cumulative re-emission is stale. const advanced = advanceCodexUsage(previous, last); if (advanced === null) { state.malformedRecords += 1; - return null; + return []; } state.lastCumulativeUsage = advanced; } state.lastUsageSignature = signature; - const inputTokens = last.input_tokens; - const cachedInputTokens = last.cached_input_tokens; - const cacheCreationTokens = last.cache_write_input_tokens; - const outputTokens = last.output_tokens; + return [ + ...unreported.map((request) => codexRequestRecord(state, request.usage, request)), + codexRequestRecord(state, last, codexAttribution(state, timestampMs)), + ]; +} + +function codexAttribution(state: CodexScanState, timestampMs: number): CodexRequestAttribution { + return { + timestampMs, + model: state.model, + turnId: state.turnId, + speed: state.speed, + sawThreadSettings: state.sawThreadSettings, + }; +} + +function codexRequestRecord( + state: CodexScanState, + usage: CodexTokenUsage, + { timestampMs, model, turnId, speed, sawThreadSettings }: CodexRequestAttribution, +): UsageRecord { + const inputTokens = usage.input_tokens; + const cachedInputTokens = usage.cached_input_tokens; + const cacheCreationTokens = usage.cache_write_input_tokens; + const outputTokens = usage.output_tokens; const totals: UsageTokenTotals = { // Codex reports `input_tokens` inclusive of the cached portion. @@ -287,24 +380,22 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | cacheCreationTokens, outputTokens, // Reported inside output_tokens, surfaced separately for the token mix. - reasoningTokens: last.reasoning_output_tokens, + reasoningTokens: usage.reasoning_output_tokens, }; - if (totalTokens(totals) === 0) return null; - return { provider: "codex", timestampMs, - model: state.model, + model, sessionId: state.sessionId, totals, reportedCostUsd: null, - speed: state.speed, - ...(!state.sawThreadSettings && + speed, + ...(!sawThreadSettings && state.modelProvider === "openai" && state.sessionId.length > 0 && - state.turnId.length > 0 - ? { unresolvedServiceTierTurnId: state.turnId } + turnId.length > 0 + ? { unresolvedServiceTierTurnId: turnId } : {}), dedupeKey: null, }; @@ -317,9 +408,6 @@ function codexSpeed(serviceTier: unknown): UsageSpeed { return "standard"; } -const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] => - record === null ? [] : [record]; - export const codexUsageFormat: TranscriptUsageFormat = { selectFields: { type: true, @@ -335,12 +423,19 @@ export const codexUsageFormat: TranscriptUsageFormat = { forked_from_id: true, source: { subagent: { thread_spawn: { parent_thread_id: true } } }, info: { last_token_usage: true, total_token_usage: true }, + usage: true, }, }, // Metadata lines carry model, tier and session even without usage. - usageMarkers: ['"token_count"', '"turn_context"', '"thread_settings_applied"', '"session_meta"'], - parseLine: (line, state) => orEmpty(parseCodexLine(line, state)), - parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)), + usageMarkers: [ + '"token_count"', + '"token_usage_record"', + '"turn_context"', + '"thread_settings_applied"', + '"session_meta"', + ], + parseLine: parseCodexLine, + parseProjected: parseCodexRecord, state: { initial: initialCodexScanState, schema: CodexScanState }, readMalformedRecords: (state) => state.malformedRecords, // A rollout that moved after it was read leaves a copy in both places. diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 4ecc0ad28971b..9cc1a9a7e3238 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -2,12 +2,21 @@ import * as NodePath from "node:path"; import * as NodeURL from "node:url"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/http"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; import { GrokSettings } from "@t3tools/provider-grok/settings"; import { @@ -129,9 +138,9 @@ describe("buildGrokModelCapabilities", () => { type: "select", currentValue: "medium", options: [ - { id: "xhigh", label: "Extra High Effort", isDefault: true }, - { id: "high", label: "High Effort" }, { id: "medium", label: "Medium Effort" }, + { id: "high", label: "High Effort" }, + { id: "xhigh", label: "Extra High Effort", isDefault: true }, ], }, ]); @@ -189,7 +198,7 @@ describe("buildGrokModelCapabilities", () => { }); }); -it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("checkGrokProviderStatus", (it) => { it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => Effect.gen(function* () { const secretStderr = "broken grok install: secret-token-value"; @@ -221,6 +230,68 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); + it.effect.each([ + { + label: "answers after 6 s", + versionDelay: "6 seconds", + version: "1.0.52", + message: "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", + probesAfterVersion: true, + }, + { + label: "never answers", + versionDelay: "Infinity", + version: null, + message: "Grok CLI is installed but timed out while running `grok --version`.", + probesAfterVersion: false, + }, + ] as const)("gives `grok --version` 10 s when it $label", (row) => + Effect.gen(function* () { + // A fake CLI runs on wall time, so a stub answers `--version` on the test clock. + const spawned: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + spawned.push(args.join(" ")); + if (!args.includes("--version")) { + return Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + description: "Only `grok --version` is stubbed.", + }), + ); + } + const handle = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(900_000_201), + exitCode: Effect.delay(Effect.succeed(ChildProcessSpawner.ExitCode(0)), row.versionDelay), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText( + Stream.fromEffect(Effect.delay(Effect.succeed("grok 1.0.52\n"), row.versionDelay)), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + return Effect.succeed(handle); + }); + const fiber = yield* checkGrokProviderStatus(decodeGrokSettings({ enabled: true }), {}).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* TestClock.adjust("10 seconds"); + const snapshot = yield* Fiber.join(fiber); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe(row.version); + expect(snapshot.message).toBe(row.message); + expect(spawned.length > 1).toBe(row.probesAfterVersion); + }), + ); + // A stand-in for the Grok CLI: `--version` and `models` print canned text, // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. // A list of `models` outputs is served in call order, repeating the last one. @@ -482,7 +553,7 @@ describe("Grok usage limits", () => { }); }); -it.layer(NodeServices.layer)("readGrokAccount", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("readGrokAccount", (it) => { it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -653,7 +724,7 @@ it.layer(NodeServices.layer)("readGrokAccount", (it) => { ); }); -it.layer(NodeServices.layer)("readGrokAccount email", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("readGrokAccount email", (it) => { it.effect("names the account whose limits were read, and nothing for other auth", () => Effect.gen(function* () { const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"'; diff --git a/apps/server/src/provider/KiroProvider.test.ts b/apps/server/src/provider/KiroProvider.test.ts new file mode 100644 index 0000000000000..b0b9143d97a55 --- /dev/null +++ b/apps/server/src/provider/KiroProvider.test.ts @@ -0,0 +1,448 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { KiroSettings } from "@t3tools/provider-kiro/settings"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +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 { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { + checkKiroProviderStatus, + kiroAgentDescriptor, + kiroDefaultModelId, +} from "@t3tools/provider-kiro/testing"; + +const encoder = new TextEncoder(); +const settings = Schema.decodeSync(KiroSettings)({ enabled: true }); + +interface CliOutput { + readonly stdout: string; + readonly code: number; +} + +function processHandle(output: CliOutput) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(900_000_002), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(output.code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: + output.stdout.length === 0 ? Stream.empty : Stream.succeed(encoder.encode(output.stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +const KIRO_ACP_ARGS = "acp --agent-engine=v3 --auth-method=cli"; +const decodeRequest = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ id: Schema.Union([Schema.String, Schema.Number]), method: Schema.String }), + ), +); + +/** + * A `kiro-cli acp` that answers `initialize` and `_kiro/config/template` and + * fails anything else, so a probe that opened a session would fail. + */ +const acpProcess = (template: unknown) => + Effect.gen(function* () { + const output = yield* Queue.unbounded(); + const exited = yield* Deferred.make(); + const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe( + Effect.andThen(Queue.shutdown(output)), + Effect.asVoid, + ); + const reply = (line: string) => { + const request = decodeRequest(line); + const response = + request.method === "initialize" + ? { result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } } + : request.method === "_kiro/config/template" + ? { result: template } + : { error: { code: -32601, message: `${request.method} is not scripted` } }; + return Queue.offer( + output, + encoder.encode(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, ...response })}\n`), + ); + }; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(900_000_003), + exitCode: Deferred.await(exited), + isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)), + kill: () => terminate, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach((bytes: Uint8Array) => + Effect.forEach( + new TextDecoder() + .decode(bytes) + .split("\n") + .filter((line) => line.trim().length > 0), + reply, + ), + ), + stdout: Stream.fromQueue(output), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }); + +/** Answers `kiro-cli` invocations by their arguments and records which ran. */ +function kiroCli(outputs: Record, template?: unknown) { + const invoked: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args.join(" ") : ""; + invoked.push(args); + return args === KIRO_ACP_ARGS && template !== undefined + ? acpProcess(template) + : Effect.succeed(processHandle(outputs[args] ?? { stdout: "", code: 127 })); + }); + return { invoked, spawner }; +} + +const check = ( + outputs: Record, + options: { readonly template?: unknown; readonly settings?: KiroSettings } = {}, +) => + Effect.gen(function* () { + const cli = kiroCli(outputs, options.template); + const snapshot = yield* checkKiroProviderStatus(options.settings ?? settings, {}).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, cli.spawner), + ); + return { snapshot, invoked: cli.invoked }; + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); + +describe("KiroProvider", () => { + it.effect("reports a CLI whose version probe exits nonzero as failing to run", () => + Effect.gen(function* () { + const { snapshot, invoked } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 1 }, + }); + assert.equal(snapshot.status, "error"); + assert.isTrue(snapshot.installed); + assert.equal(snapshot.message, "Kiro CLI is installed but failed to run."); + // A broken CLI is not asked for its sign-in. + assert.deepEqual(invoked, ["--version"]); + }), + ); + + // `whoami` outputs recorded from Kiro CLI 2.27.0. + it.effect("asks for kiro-cli login when Kiro is signed out", () => + Effect.gen(function* () { + const { snapshot } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"account":null}\n', code: 1 }, + }); + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.version, "2.27.0"); + assert.equal(snapshot.auth.status, "unauthenticated"); + assert.include(snapshot.message ?? "", "kiro-cli login"); + }), + ); + + it.effect("is ready once Kiro reports a signed-in account", () => + Effect.gen(function* () { + const { snapshot } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"ApiKey","email":null}\n', code: 0 }, + }); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + // The version advisory is what the compatibility check and update UI read. + assert.equal(snapshot.versionAdvisory?.currentVersion, "2.27.0"); + assert.isFalse(snapshot.versionAdvisory?.canUpdate); + }), + ); + + // Shape of `kiro-cli chat --list-models --format json` on Kiro CLI 2.27.0, trimmed to three models. + const KIRO_MODEL_LIST = JSON.stringify({ + models: [ + { model_name: "auto", model_id: "auto", description: "Models chosen by task" }, + { model_name: "claude-sonnet-4.5", model_id: "claude-sonnet-4.5" }, + { model_name: "claude-haiku-4.5", model_id: "claude-haiku-4.5" }, + ], + default_model: "auto", + }); + + it.effect("lists the signed-in account's models with auto as the Kiro default", () => + Effect.gen(function* () { + const { snapshot } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 }, + "chat --list-models --format json": { stdout: `${KIRO_MODEL_LIST}\n`, code: 0 }, + }); + assert.deepEqual( + snapshot.models.map((model) => [model.slug, model.name, model.isDefault === true]), + [ + ["default", "auto", true], + ["claude-sonnet-4.5", "claude-sonnet-4.5", false], + ["claude-haiku-4.5", "claude-haiku-4.5", false], + ], + ); + }), + ); + + it.effect("names Kiro default after the model Kiro's list names as its default", () => + Effect.gen(function* () { + const signedIn = { + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 }, + }; + const listed = yield* check({ + ...signedIn, + "chat --list-models --format json": { + stdout: KIRO_MODEL_LIST.replace( + '"default_model":"auto"', + '"default_model":"claude-sonnet-4.5"', + ), + code: 0, + }, + }); + assert.deepEqual( + listed.snapshot.models.map((model) => [model.slug, model.name]), + [ + ["auto", "auto"], + ["default", "claude-sonnet-4.5"], + ["claude-haiku-4.5", "claude-haiku-4.5"], + ], + ); + assert.equal(kiroDefaultModelId(listed.snapshot.models), "claude-sonnet-4.5"); + // An unparsable list keeps the built-in entry, which falls back to `auto`. + const unlisted = yield* check({ + ...signedIn, + "chat --list-models --format json": { stdout: "not json\n", code: 0 }, + }); + assert.equal(unlisted.snapshot.models[0]?.name, "Kiro default"); + assert.equal(kiroDefaultModelId(unlisted.snapshot.models), "auto"); + }), + ); + + it.effect("lists Kiro default even when Kiro's model list leaves its default out", () => + Effect.gen(function* () { + const { snapshot } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 }, + "chat --list-models --format json": { + stdout: JSON.stringify({ models: [{ model_id: "claude-sonnet-4.5" }] }), + code: 0, + }, + }); + assert.deepEqual( + snapshot.models.map((model) => [model.slug, model.name, model.isDefault === true]), + [ + ["default", "Kiro default", true], + ["claude-sonnet-4.5", "claude-sonnet-4.5", false], + ], + ); + assert.equal(kiroDefaultModelId(snapshot.models), "auto"); + }), + ); + + // `--list-models` names no effort levels, so the picker's come from Kiro's ids. + const KIRO_EFFORT_MODEL_LIST = JSON.stringify({ + models: [ + "auto", + "claude-fable-5.1", + "claude-opus-5.5", + "claude-opus-4.6", + "kimi-k3", + "claude-sonnet-4.5", + ].map((id) => ({ + model_name: id, + model_id: id, + })), + default_model: "auto", + }); + + it.effect("offers each model only the reasoning levels Kiro takes for it", () => + Effect.gen(function* () { + const { snapshot } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 }, + "chat --list-models --format json": { stdout: KIRO_EFFORT_MODEL_LIST, code: 0 }, + }); + const reasoning = snapshot.models.map((model) => { + const descriptors = model.capabilities?.optionDescriptors ?? []; + const select = descriptors.find((descriptor) => descriptor.id === "reasoningEffort"); + return select?.type === "select" + ? { + slug: model.slug, + label: select.label, + levels: select.options.map((choice) => choice.id), + defaults: select.options.filter((choice) => choice.isDefault).map((c) => c.id), + currentValue: select.currentValue, + } + : { slug: model.slug, descriptors: descriptors.length }; + }); + assert.deepEqual(reasoning, [ + // Kiro default and models without effort get no Reasoning select. + { slug: "default", descriptors: 0 }, + { + slug: "claude-fable-5.1", + label: "Reasoning", + levels: ["low", "medium", "high", "xhigh", "max"], + defaults: ["high"], + currentValue: "high", + }, + { + slug: "claude-opus-5.5", + label: "Reasoning", + levels: ["low", "medium", "high", "xhigh", "max"], + defaults: ["medium"], + currentValue: "medium", + }, + // Opus 4.6 has no xhigh, and Kiro runs it at high by default. + { + slug: "claude-opus-4.6", + label: "Reasoning", + levels: ["low", "medium", "high", "max"], + defaults: ["high"], + currentValue: "high", + }, + // Kimi K3 skips medium and runs at max by default. + { + slug: "kimi-k3", + label: "Reasoning", + levels: ["low", "high", "max"], + defaults: ["max"], + currentValue: "max", + }, + { slug: "claude-sonnet-4.5", descriptors: 0 }, + ]); + }), + ); + + it.effect("gives every model, custom ones too, the agent picker Kiro's template lists", () => + Effect.gen(function* () { + const { snapshot } = yield* check( + { + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 }, + "chat --list-models --format json": { stdout: KIRO_MODEL_LIST, code: 0 }, + }, + { + template: { + configOptions: [ + { + type: "select", + id: "mode", + name: "Mode", + currentValue: "vibe", + options: [ + { value: "vibe", name: "Default" }, + { value: "reviewer", name: "reviewer" }, + ], + }, + ], + }, + settings: { ...settings, customModels: ["my-model"] }, + }, + ); + assert.deepEqual( + snapshot.models.map((model) => [ + model.slug, + model.capabilities?.optionDescriptors?.flatMap((descriptor) => + descriptor.type === "select" && descriptor.id === "mode" + ? descriptor.options.map((choice) => choice.id) + : [], + ), + ]), + [ + ["default", ["vibe", "reviewer"]], + ["claude-sonnet-4.5", ["vibe", "reviewer"]], + ["claude-haiku-4.5", ["vibe", "reviewer"]], + ["my-model", ["vibe", "reviewer"]], + ], + ); + }), + ); + + it("offers Kiro's agents from its config template, Default first", () => { + // `_kiro/config/template` from kiro-cli 2.27.0 with two user agents in + // `~/.kiro/agents`, trimmed to three bundled agents and the `mode` option. + const template = { + modes: { currentModeId: "vibe", availableModes: [] }, + configOptions: [ + { + type: "select", + id: "mode", + name: "Mode", + category: "mode", + currentValue: "vibe", + options: [ + { value: "vibe", name: "Default", description: "General coding assistance" }, + { value: "spec", name: "Spec", description: "Structured feature development" }, + { value: "plan", name: "Plan", description: "Plan-only mode" }, + { value: "global-probe", name: "global-probe", description: "Global probe agent" }, + { value: "planner", name: "planner", description: null }, + ], + }, + { + type: "select", + id: "autopilot", + name: "Autopilot", + currentValue: "on", + options: [{ value: "on", name: "Autopilot" }], + }, + ], + }; + const descriptor = kiroAgentDescriptor(template); + assert.deepEqual(descriptor, { + id: "mode", + label: "Agent", + type: "select", + currentValue: "vibe", + options: [ + { id: "vibe", label: "Default", description: "General coding assistance", isDefault: true }, + { id: "spec", label: "Spec", description: "Structured feature development" }, + { id: "plan", label: "Plan", description: "Plan-only mode" }, + { id: "global-probe", label: "global-probe", description: "Global probe agent" }, + { id: "planner", label: "planner" }, + ], + }); + // The default follows the agent Kiro starts sessions on. + const onSpec = kiroAgentDescriptor({ + configOptions: [{ ...template.configOptions[0], currentValue: "spec" }], + }); + assert.equal(onSpec?.type === "select" ? onSpec.currentValue : undefined, "spec"); + assert.deepEqual( + onSpec?.type === "select" ? onSpec.options.filter((choice) => choice.isDefault) : [], + [ + { + id: "spec", + label: "Spec", + description: "Structured feature development", + isDefault: true, + }, + ], + ); + // A template without agents to choose between gives no picker. + assert.isUndefined(kiroAgentDescriptor({ configOptions: [] })); + assert.isUndefined(kiroAgentDescriptor("not a template")); + }); + + it.effect("never lists models while signed out, where listing would start a browser login", () => + Effect.gen(function* () { + const { snapshot, invoked } = yield* check({ + "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 }, + "whoami --format json": { stdout: '{"account":null}\n', code: 1 }, + "chat --list-models --format json": { stdout: `${KIRO_MODEL_LIST}\n`, code: 0 }, + }); + assert.notInclude(invoked, "chat --list-models --format json"); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["default"], + ); + }), + ); +}); diff --git a/apps/server/src/provider/ProviderCredentialStore.ts b/apps/server/src/provider/ProviderCredentialStore.ts index 631b31bc3faa7..51557a3be0d90 100644 --- a/apps/server/src/provider/ProviderCredentialStore.ts +++ b/apps/server/src/provider/ProviderCredentialStore.ts @@ -1,4 +1,4 @@ -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 ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -8,10 +8,10 @@ export const make = Effect.fn("ProviderCredentialStore.make")(function* ( bindingId: string, ) { const secrets = yield* ServerSecretStore.ServerSecretStore; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; // Hash the tuple so arbitrary bindings cannot escape or exceed a filename. - const bindingHash = yield* crypto - .digest("SHA-256", new TextEncoder().encode(`${driver.length}:${driver}${bindingId}`)) + const bindingHash = yield* digest + .digest(Digest.sha256, new TextEncoder().encode(`${driver.length}:${driver}${bindingId}`)) .pipe(Effect.map(Hex.encode), Effect.orDie); const key = `provider-auth-${bindingHash}`; return { diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 8d73d697705fc..6398210599b61 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -1,6 +1,6 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { ProviderCredentialError } from "@t3tools/provider-core/server/errors"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -20,7 +20,7 @@ export const layer = Layer.effect( const serverSettings = yield* ServerSettings.ServerSettingsService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const secrets = yield* ServerSecretStore.ServerSecretStore; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const fileSystem = yield* FileSystem.FileSystem; return ProviderHost.ProviderHost.of({ paths: { @@ -68,7 +68,7 @@ export const layer = Layer.effect( ), })), Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Digest.Digest, digest), ); }), }); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index af3359ea7f27d..4bae35385d62b 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -5,6 +5,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; 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 { EnvironmentId, @@ -170,7 +171,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { endpoint: Effect.succeed("unused-private-pipe"), }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge( Layer.mock(CodexInstallation.CodexInstallation)({ managedDirectory: "unused-managed-installation", @@ -360,6 +361,24 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); + it.live("lists a blocked driver's instances as unavailable without running them", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex"); + const reason = "Example Corp's policy does not allow the Codex provider."; + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap: { [instanceId]: { driver: ProviderDriverKind.make("codex") } }, + blockedReason: (driver) => (driver === "codex" ? reason : undefined), + }); + + expect(yield* registry.getInstance(instanceId)).toBeUndefined(); + const [unavailable] = yield* registry.listUnavailable; + expect(unavailable?.instanceId).toBe(instanceId); + expect(unavailable?.availability).toBe("unavailable"); + expect(unavailable?.unavailableReason).toBe(reason); + }).pipe(Effect.provide(layerTest)), + ); + it.live("runs Codex and Claude readiness probes from configured tilde paths", () => Effect.gen(function* () { if (yield* HostProcess.isWindows) return; diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index a944f751a1d34..cc3ca60fcb829 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"; @@ -45,7 +47,7 @@ export class ProviderInstanceRegistry extends Context.Service< interface InstanceSecrets { readonly environment: ResolvedProviderInstanceEnvironment; - readonly config: Readonly>; + readonly config: ReadonlyArray; } interface LiveEntry { @@ -110,12 +112,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 }; }); @@ -129,6 +131,8 @@ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true; }; +type BlockedReason = (driver: ProviderDriverKind) => string | undefined; + // The enabled state `buildEntry` decodes, so a disabled instance never reads its secrets. const isEntryEnabled = Effect.fn("ProviderInstanceRegistry.isEntryEnabled")(function* ( driversById: ReadonlyMap>, @@ -143,6 +147,7 @@ const isEntryEnabled = Effect.fn("ProviderInstanceRegistry.isEntryEnabled")(func const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly driversById: ReadonlyMap>; + readonly blockedReason: BlockedReason; readonly parentScope: Scope.Scope; readonly instanceId: ProviderInstanceId; readonly rawInstanceId: string; @@ -155,6 +160,19 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* { const { driversById, parentScope, instanceId, rawInstanceId, entry, deferSecrets } = input; + const blocked = input.blockedReason(entry.driver); + if (blocked !== undefined) { + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: blocked, + }), + }; + } const driver = driversById.get(entry.driver); if (!driver) { return { @@ -208,7 +226,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly state: RegistryState; readonly driversById: ReadonlyMap>; + readonly blockedReason: BlockedReason; readonly parentScope: Scope.Scope; }): (( configMap: ProviderInstanceConfigMap, rebuild?: ReadonlySet, deferSecrets?: boolean, ) => Effect.Effect) => { - const { state, driversById, parentScope } = input; + const { state, driversById, blockedReason, parentScope } = input; return (configMap, rebuild = new Set(), deferSecrets = false) => Effect.gen(function* () { const previousEntries = yield* Ref.get(state.entries); @@ -351,6 +370,7 @@ const makeReconcile = (input: { const result = yield* buildEntry({ driversById, + blockedReason, parentScope, instanceId, rawInstanceId, @@ -402,6 +422,8 @@ export const makeProviderInstanceRegistry = Effect.fn( )(function* (input: { readonly drivers: ReadonlyArray>; readonly configMap: ProviderInstanceConfigMap; + /** A reason to show instead of running a driver, such as an administrator's policy. */ + readonly blockedReason?: BlockedReason | undefined; }): Effect.fn.Return< { readonly registry: ProviderInstanceRegistry["Service"]; @@ -428,7 +450,12 @@ export const makeProviderInstanceRegistry = Effect.fn( yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); const state: RegistryState = { entries, unavailable, changes }; - const reconcileWithR = makeReconcile({ state, driversById, parentScope }); + const reconcileWithR = makeReconcile({ + state, + driversById, + blockedReason: input.blockedReason ?? (() => undefined), + parentScope, + }); const configMapRef = yield* Ref.make(input.configMap); const semaphore = yield* Semaphore.make(1); const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] = @@ -535,6 +562,7 @@ export const makeProviderInstanceRegistry = Effect.fn( export const layer = (input: { readonly drivers: ReadonlyArray>; readonly configMap: ProviderInstanceConfigMap; + readonly blockedReason?: BlockedReason | undefined; }): Layer.Layer< ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index c8f2fb9b33e05..fbf7a5320c4a4 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -9,13 +9,14 @@ import { ServerSettings, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import type * as Crypto from "effect/Crypto"; +import type * 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 Path from "effect/Path"; import * as Stream from "effect/Stream"; +import * as ManagedProviders from "../policy/managedProviders.ts"; import * as Settings from "../serverSettings.ts"; import * as SecretResolver from "../secrets/SecretResolver.ts"; import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "./builtInDrivers.ts"; @@ -42,7 +43,7 @@ type ProviderInstanceRegistryHydrationEnv = | Path.Path | FileSystem.FileSystem // Secret provider reads. - | Crypto.Crypto + | SecureRandom.SecureRandom // The Claude runner counts its CLIs as running agents for port discovery. | PortScanner.PortDiscovery; @@ -165,13 +166,23 @@ export const layer: Layer.Layer< ? ({} as ProviderInstanceConfigMap) : deriveProviderInstanceConfigMap(initialSettings); + // Drivers launch from the host environment, minus launch overrides a policy ignores. + const hostEnvironment = ManagedProviders.providerHostEnvironment( + serverSettings.managedPolicy, + yield* HostProcess.Environment, + ); + // Instances of drivers the policy leaves out stay listed, as unavailable, so their threads + // explain why instead of losing their provider. const layerMutable = ProviderInstanceRegistry.layer({ drivers: BUILT_IN_DRIVERS, configMap: initialConfigMap, + blockedReason: (driver) => + ManagedProviders.disallowedDriverReason(serverSettings.managedPolicy, driver), }).pipe( Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer), Layer.provide(AcpRegistrySupport.layerFromHost), Layer.provide(ProviderHostLive.layer), + Layer.provide(Layer.succeed(HostProcess.Environment, hostEnvironment)), Layer.provide(SecretResolver.layer), ); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 5e1bc9e962b5f..93fec081ebb9c 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -4,6 +4,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert, expect } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; @@ -330,6 +331,7 @@ const awaitPersistedProvider = ( const layerTestNodeServices = Layer.mergeAll( ProviderLatestVersions.layer, NodeServices.layer, + NodeCrypto.layer, Layer.mock(CodexManagedTokenBroker.CodexManagedTokenBroker)({ endpoint: Effect.succeed("unused-private-pipe"), }), @@ -1066,6 +1068,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, @@ -1083,8 +1086,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, }, @@ -1105,6 +1110,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( @@ -1141,7 +1147,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))), ), ), ); @@ -1201,6 +1207,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, @@ -1218,8 +1225,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, }, @@ -1243,6 +1252,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 @@ -1277,7 +1287,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))), ), ), ); @@ -1476,6 +1486,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", () => @@ -1548,7 +1695,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)); @@ -1644,7 +1791,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)); @@ -1765,7 +1912,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)); @@ -1787,6 +1934,133 @@ it.layer( }), ); + 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"); @@ -1873,7 +2147,7 @@ it.layer( }), ), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -1904,6 +2178,167 @@ it.layer( }), ); + it.effect("streams the terminal update state when it lands during a slow cache write", () => + Effect.gen(function* () { + const claudeDriver = ProviderDriverKind.make("claude"); + const claudeInstanceId = ProviderInstanceId.make("claude"); + const initialProvider = { + instanceId: claudeInstanceId, + driver: claudeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2.1.300", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const verifyingProvider = { ...initialProvider, checkedAt: "2026-04-14T00:01:00.000Z" }; + const laterProvider = { ...initialProvider, checkedAt: "2026-04-14T00:02:00.000Z" }; + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: claudeInstanceId, + driverKind: claudeDriver, + continuationIdentity: { + driverKind: claudeDriver, + continuationKey: "claude:instance:claude", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: claudeDriver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(initialProvider), + streamChanges: Stream.fromPubSub(changes), + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === claudeInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const writeStarted = yield* Deferred.make(); + const releaseWrite = yield* Deferred.make(); + const gatedCachePath = yield* Ref.make(undefined); + const layerGatedFileSystem = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return { + ...fileSystem, + rename: (from, to) => + Ref.modify(gatedCachePath, (path) => + path === to ? [true, undefined] : [false, path], + ).pipe( + Effect.flatMap((gated) => + gated + ? Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseWrite)), + ) + : Effect.void, + ), + Effect.andThen(fileSystem.rename(from, to)), + ), + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeServices.layer)); + 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(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-update-state-race-", + }), + ), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + Layer.provideMerge(layerGatedFileSystem), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const running = { + status: "running", + startedAt: "2026-04-14T00:00:30.000Z", + finishedAt: null, + message: "Verifying the installed version", + output: null, + } as const; + const unchanged = { + ...running, + status: "unchanged", + finishedAt: "2026-04-14T00:01:30.000Z", + message: + "Update command completed, but T3 Code still detects an outdated provider version.", + } as const; + yield* registry.setProviderMaintenanceActionState({ + instanceId: claudeInstanceId, + action: "update", + state: running, + }); + const streamed = yield* registry.streamChanges.pipe( + Stream.takeUntil((providers) => + providers.some((provider) => provider.checkedAt === laterProvider.checkedAt), + ), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ); + + yield* Ref.set( + gatedCachePath, + yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: claudeInstanceId, + }), + ); + yield* PubSub.publish(changes, verifyingProvider); + yield* Deferred.await(writeStarted); + yield* registry.setProviderMaintenanceActionState({ + instanceId: claudeInstanceId, + action: "update", + state: unchanged, + }); + yield* Deferred.succeed(releaseWrite, undefined); + // The snapshot stream is consumed in order, so this lands after the slow write publishes. + yield* PubSub.publish(changes, laterProvider); + + const updateStateOf = (providers: ReadonlyArray | undefined) => + providers?.find((provider) => provider.instanceId === claudeInstanceId)?.updateState; + const beforeLater = (yield* Fiber.join(streamed)).slice(0, -1); + assert.deepStrictEqual(updateStateOf(yield* registry.getProviders), unchanged); + assert.deepStrictEqual( + updateStateOf(beforeLater.at(-1)), + unchanged, + "the last streamed providers before the next snapshot should carry the terminal update state", + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect( "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", () => @@ -2001,7 +2436,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)); @@ -2106,7 +2541,7 @@ it.layer( }), ), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ).pipe(Scope.provide(scope)); @@ -2199,7 +2634,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( @@ -2304,7 +2739,7 @@ it.layer( Layer.provideMerge( OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), ); const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 3234bfd61960a..76f2723cd9d4f 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, @@ -524,7 +535,8 @@ export const layer = Layer.effect( }); } if (options?.publish !== false) { - yield* PubSub.publish(changesPubSub, providers); + // A newer upsert may have committed and published during the cache write; never republish an older array. + yield* PubSub.publish(changesPubSub, yield* Ref.get(providersRef)); } } @@ -592,7 +604,9 @@ export const layer = Layer.effect( const refreshOneSource = Effect.fn("refreshOneSource")(function* ( providerSource: ProviderSnapshotSource, ) { + // Enrichment can publish a newer snapshot before refresh returns its pre-enrichment probe. return yield* providerSource.refresh.pipe( + Effect.andThen(providerSource.getSnapshot), Effect.flatMap((nextProvider) => correlateSnapshotWithSource(providerSource, nextProvider).pipe( Effect.flatMap(syncProvider), @@ -1025,6 +1039,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) => @@ -1036,6 +1078,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/TurnTokenUsage.test.ts b/apps/server/src/provider/TurnTokenUsage.test.ts index 06802143e73c1..fb14511211e36 100644 --- a/apps/server/src/provider/TurnTokenUsage.test.ts +++ b/apps/server/src/provider/TurnTokenUsage.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { normalizeClaudeTurnTokenUsage } from "./ClaudeTurnTokenUsage.ts"; +import { + claudeLedgerDelta, + claudeModelLedger, + normalizeClaudeTurnTokenUsage, +} from "./ClaudeTurnTokenUsage.ts"; import { accumulateCodexTurnTokenUsage, completeCodexTurnTokenUsage, @@ -209,6 +213,15 @@ describe("billable native turn usage", () => { }, ); + it("measures Claude ledger growth only from a known, nondecreasing baseline", () => { + const ledger = (output: number) => + claudeModelLedger({ "claude-sonnet-4-6": { inputTokens: 100, outputTokens: output } }); + expect(claudeLedgerDelta(ledger(10), ledger(25))).toEqual({ inputTokens: 0, outputTokens: 15 }); + expect(claudeLedgerDelta(ledger(25), ledger(10))).toBeUndefined(); + expect(claudeLedgerDelta(undefined, ledger(10))).toBeUndefined(); + expect(claudeModelLedger({})).toBeUndefined(); + }); + it("adds Claude steering segments to the final measured result once", () => { expect( normalizeClaudeTurnTokenUsage( diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 9e2da61be2af3..8957c12747a89 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -4,7 +4,9 @@ import * as NodeOS from "node:os"; import * as NodeURL from "node:url"; import * as NodeFS from "node:fs"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; @@ -91,7 +93,7 @@ describe("AcpSessionRuntime", () => { expect( (yield* runtime.getConfigOptions).find((option) => option.category === "model"), ).toMatchObject({ currentValue: "gpt-5.4" }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("publishes model changes returned by a config request and live notifications", () => @@ -105,7 +107,7 @@ describe("AcpSessionRuntime", () => { expect((yield* updates)[0]?.configOptions).toEqual(selected.configOptions); yield* runtime.request("_test/startup-metadata", {}); expect((yield* updates)[0]?.configOptions).toEqual(yield* runtime.getConfigOptions); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("waits for native cancellation and drains final updates before another prompt", () => @@ -190,7 +192,7 @@ describe("AcpSessionRuntime", () => { } expect(yield* Fiber.join(replacement)).toMatchObject({ stopReason: "end_turn" }); expect(promptRequests).toBe(2); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("reports an idle child exit and rejects later prompts", () => @@ -248,7 +250,7 @@ describe("AcpSessionRuntime", () => { ), ).toBe(event.error); expect(yield* runtime.notify("_test/exit", {}).pipe(Effect.flip)).toBe(event.error); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("retires a native runtime when its prompt caller is interrupted", () => @@ -288,7 +290,7 @@ describe("AcpSessionRuntime", () => { }) .pipe(Effect.flip), ).toBe(events[0]?.error); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("fails a pending request when the stderr handler rejects the runtime", () => @@ -310,7 +312,7 @@ describe("AcpSessionRuntime", () => { ); expect(events[0]?.error).toBe(failure); expect(yield* runtime.initialize().pipe(Effect.flip)).toBe(failure); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each([ @@ -358,7 +360,7 @@ describe("AcpSessionRuntime", () => { }, }); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("drains large stderr output and keeps auth-sized logging chunks", () => @@ -384,7 +386,7 @@ describe("AcpSessionRuntime", () => { expect(lengths.length).toBeGreaterThan(0); expect(Math.max(...lengths)).toBeGreaterThanOrEqual(16_384); expect(Math.max(...lengths)).toBeLessThanOrEqual(32_768); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("releases a queued event drain when its runtime scope closes", () => @@ -414,7 +416,7 @@ describe("AcpSessionRuntime", () => { _tag: "AcpTransportError", detail: "The ACP session runtime is closed.", }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("does not restore ambient variables to a sanitized child environment", () => @@ -445,7 +447,7 @@ describe("AcpSessionRuntime", () => { inherited: false, explicit: true, }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it("selects explicit or agent-managed authentication without choosing terminal auth", () => { @@ -491,7 +493,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -543,7 +545,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -587,7 +589,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -629,7 +631,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -686,7 +688,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -724,7 +726,7 @@ describe("AcpSessionRuntime", () => { const afterRestart = yield* collectFirstAssistantItemId; expect(afterRestart).not.toBe(beforeRestart); - }).pipe(Effect.provide(NodeServices.layer)); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); }); it.effect("drops session updates emitted for a child ACP session", () => @@ -767,7 +769,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -828,7 +830,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -874,7 +876,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -921,7 +923,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -966,6 +968,16 @@ describe("AcpSessionRuntime", () => { "logout", ]), ); + // Omitted roots clear an agent's stored ones, so every activation resends them. + for (const method of ["session/new", "session/fork", "session/resume"]) { + expect( + requestEvents.find((event) => event.method === method && event.status === "started") + ?.payload, + ).toMatchObject({ + additionalDirectories: ["/tmp/t3-attachments"], + _meta: { negotiated: "0.0.0" }, + }); + } }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -977,6 +989,10 @@ describe("AcpSessionRuntime", () => { T3_ACP_SESSION_LIFECYCLE: "1", }, }, + additionalDirectories: ["/tmp/t3-attachments"], + sessionMeta: (initializeResult) => ({ + negotiated: initializeResult.agentInfo?.version ?? "none", + }), cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, requestLogger: (event) => @@ -986,7 +1002,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); }); @@ -1035,7 +1051,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1063,7 +1079,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1094,7 +1110,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1131,7 +1147,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ); @@ -1180,7 +1196,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), ); }); @@ -1217,7 +1233,7 @@ describe("AcpSessionRuntime", () => { }), ), Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), ); }); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 8e0eed1371c5b..ea5efe230a234 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -5,6 +5,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ChatAttachment, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -15,7 +16,9 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { applyAntigravityAcpModelSelection, + antigravitySessionMeta, buildAntigravityPrompt, + isQualifiedAntigravityRelease, } from "./AntigravityAcpSupport.ts"; const modelConfig = { @@ -54,6 +57,39 @@ function makeModelRuntime( }; } +describe("Antigravity release gates", () => { + it.each([ + { version: "1.4.1", filter: false, native: false }, + { version: "1.4.2", filter: true, native: false }, + { version: "1.4.4", filter: true, native: false }, + { version: "1.4.5", filter: true, native: true }, + { version: "1.4.6", filter: false, native: false }, + { version: "1.3.0", filter: false, native: false }, + { version: undefined, filter: false, native: false }, + { version: "unknown", filter: false, native: false }, + ])( + "$version: workflow filter $filter, native reads and system prompt $native", + ({ version, filter, native }) => { + const initialize = { + protocolVersion: 1, + ...(version === undefined ? {} : { agentInfo: { name: "antigravity-acp", version } }), + }; + // Helpers send no append, so only the workflow filter can apply. + expect(antigravitySessionMeta(initialize)).toEqual( + filter ? { antigravity: { disabledTools: ["run_workflow"] } } : undefined, + ); + const antigravity = { + ...(filter ? { disabledTools: ["run_workflow"] } : {}), + ...(native ? { systemPrompt: { append: "" } } : {}), + }; + expect(antigravitySessionMeta(initialize, "")).toEqual( + filter || native ? { antigravity } : undefined, + ); + expect(isQualifiedAntigravityRelease(version)).toBe(native); + }, + ); +}); + describe("applyAntigravityAcpModelSelection", () => { it.effect("restores the saved model instead of the cold-resume default", () => Effect.gen(function* () { @@ -214,7 +250,9 @@ const makeAttachmentFixture = Effect.fn("AntigravityAcpSupportTest.makeAttachmen else yield* fs.writeFile(filePath, content); return { filePath, uri: (yield* path.toFileUrl(filePath)).href }; }); - return { fs, attachmentsDir, write }; + const resolve = (attachment: ChatAttachment) => + resolveAttachmentPath({ attachmentsDir, attachment }); + return { fs, attachmentsDir, resolve, write }; }, ); @@ -230,7 +268,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input: " Explain this image. ", attachments: [imageAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); @@ -251,7 +289,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input: undefined, attachments: [textAttachment], - attachmentsDir: selectedEnvironment.attachmentsDir, + resolveAttachmentPath: selectedEnvironment.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); @@ -282,7 +320,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input: "Inspect the pasted text only as needed.", attachments: [pastedText], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); @@ -309,7 +347,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input, attachments: [attachment, pdfAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe( Effect.provideService(FileSystem.FileSystem, { @@ -321,11 +359,100 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { ); expect(prompt).toEqual([ { type: "text", text: input }, - { type: "resource_link", uri: pdf.uri, name: "report.pdf", mimeType: "application/pdf" }, + { + type: "resource_link", + uri: pdf.uri, + name: "report.pdf", + mimeType: "application/pdf", + size: 50 * 1024 * 1024, + }, ]); }), ); + it.effect.each([ + { + case: "a Gemini model id", + model: { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + linked: true, + }, + { + case: "an opaque id named Gemini", + model: { id: "agy-model", name: "Gemini 4 Carbon" }, + linked: true, + }, + { case: "an unknown model", model: { id: "agy-custom", name: "Custom" }, linked: false }, + { case: "an unlisted opaque id", model: { id: "agy-custom", name: undefined }, linked: false }, + { case: "an unresolved model", model: undefined, linked: false }, + { + case: "a Claude model", + model: { id: "claude-opus-5-5", name: "Claude Opus 5.5" }, + linked: false, + }, + { + case: "an unqualified runtime", + agentVersion: "1.4.1", + model: { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + linked: false, + }, + { + case: "a Windows host", + platform: "win32" as const, + model: { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + linked: false, + }, + { + case: "a clip over 20 MiB", + model: { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + linked: false, + sizeBytes: 20 * 1024 * 1024 + 1, + }, + ])( + "links an MP4 for $case only when supported", + ({ agentVersion = "1.4.5", platform = "linux", model, linked, ...size }) => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const clip = { + ...textAttachment, + id: "clip", + name: "clip.mp4", + mimeType: "video/mp4", + sizeBytes: 6 * 1024 * 1024, + ...size, + }; + const upload = yield* fixture.write(clip, ""); + yield* fixture.fs.truncate(upload.filePath, clip.sizeBytes); + const prompt = yield* buildAntigravityPrompt({ + input: "What colors appear?", + attachments: [clip], + resolveAttachmentPath: fixture.resolve, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, + agentVersion, + currentModel: model, + }).pipe( + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + stream: () => Effect.die("Linked video is read by the agent").pipe(Stream.fromEffect), + }), + ); + expect(prompt).toEqual([ + { type: "text", text: "What colors appear?" }, + ...(linked + ? [ + { + type: "resource_link", + uri: upload.uri, + name: "clip.mp4", + mimeType: "video/mp4", + size: clip.sizeBytes, + }, + ] + : []), + ]); + }), + ); + it.effect.each([undefined, {}, { image: false, audio: false, embeddedContext: false }])( "keeps attachments path-only when native prompt capabilities are absent: %s", (promptCapabilities) => @@ -352,7 +479,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input, attachments: [imageAttachment, gifAttachment, pdfAttachment, textAttachment, audio], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities, }).pipe( Effect.provideService(FileSystem.FileSystem, { @@ -366,7 +493,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { yield* buildAntigravityPrompt({ input: "Continue", attachments: [], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities, }), ).toEqual([{ type: "text", text: "Continue" }]); @@ -387,7 +514,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input: "Transcribe this.", attachments: [audioAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); @@ -409,7 +536,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const prompt = yield* buildAntigravityPrompt({ input, attachments: [attachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities, }).pipe( Effect.provideService(FileSystem.FileSystem, { @@ -430,7 +557,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const error = yield* buildAntigravityPrompt({ input: "Read this attachment.", attachments: [imageAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ @@ -456,7 +583,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const input = { input: `Read ${first.filePath} and ${second.filePath}`, attachments: [pdfAttachment, secondAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }; const prompt = yield* buildAntigravityPrompt(input); @@ -467,12 +594,14 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { uri: first.uri, name: "report.pdf", mimeType: "application/pdf", + size: (50 * 1024 * 1024) / 2, }, { type: "resource_link", uri: second.uri, name: "second.pdf", mimeType: "application/pdf", + size: (50 * 1024 * 1024) / 2, }, ]); @@ -484,6 +613,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { uri: first.uri, name: "report.pdf", mimeType: "application/pdf", + size: (50 * 1024 * 1024) / 2, }, ]); }), @@ -498,7 +628,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const error = yield* buildAntigravityPrompt({ input: "Inspect both attachments.", attachments: [pdfAttachment, imageAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ @@ -517,7 +647,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const error = yield* buildAntigravityPrompt({ input: undefined, attachments: [pdfAttachment, textAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe( Effect.provideService(FileSystem.FileSystem, { @@ -551,7 +681,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const error = yield* buildAntigravityPrompt({ input: undefined, attachments: [textAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); @@ -569,7 +699,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const error = yield* buildAntigravityPrompt({ input: "Read this image.", attachments: [imageAttachment], - attachmentsDir: fixture.attachmentsDir, + resolveAttachmentPath: fixture.resolve, promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index a57fc08f9b7af..52fbd6c6070fb 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -6,7 +6,8 @@ import { type ProviderSendTurnInput, type RuntimeMode, } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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"; @@ -17,7 +18,6 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { makeAntigravityStderrHandler, makeAntigravityStdoutTransform, @@ -34,21 +34,84 @@ export interface AntigravityAcpRuntimeInput extends Omit< | "clientInfo" | "onStderr" | "resumeMethod" + | "sessionMeta" | "transformSessionUpdate" | "transformStdout" > { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly onAuthorizationUrl?: (url: string) => Effect.Effect; + /** Serve `fs/read_text_file` for runtimes whose native reader is not qualified. */ readonly clientFileReads?: boolean; + /** The installed version, known before `initialize`; it picks the file reader. */ + readonly runtimeVersion?: string | null; + /** Conversational sessions only; helpers and probes leave the system prompt untouched. */ + readonly sessionSystemPromptAppend?: string; readonly authMethod?: AntigravityAuthMethod; } +function parseAntigravityVersion(version: string | null | undefined) { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version?.trim() ?? ""); + return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compareVersions(left: ReadonlyArray, right: ReadonlyArray) { + for (let index = 0; index < 3; index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +/** + * 1.4.2 through 1.4.5 create or overwrite skills/system/run-workflow/SKILL.md in the workspace + * unless `run_workflow` is disabled. Older builds offer the tool and leave the file alone. + */ +function antigravityWritesRunWorkflowSkill(version: string | null | undefined): boolean { + const parsed = parseAntigravityVersion(version); + return ( + parsed !== undefined && + compareVersions(parsed, [1, 4, 2]) >= 0 && + compareVersions(parsed, [1, 4, 5]) <= 0 + ); +} + +/** + * Releases T3 qualified for native range reads and linked video. On 1.4.5 the client-read path + * sends a whole file to serve one line, and the native reader keeps diff edits. + */ +export function isQualifiedAntigravityRelease(version: string | null | undefined): boolean { + return version?.trim() === "1.4.5"; +} + +/** + * 1.4.5 appends `systemPrompt.append` to its system instructions on new, load and resume, and + * keeps none of it between processes, so every activation sends it again. + */ +export const antigravityAppendsSystemPrompt = ( + initializeResult: EffectAcpSchema.InitializeResponse, +) => isQualifiedAntigravityRelease(initializeResult.agentInfo?.version); + +export function antigravitySessionMeta( + initializeResult: EffectAcpSchema.InitializeResponse, + systemPromptAppend?: string, +): EffectAcpSchema.NewSessionRequest["_meta"] | undefined { + const antigravity = { + ...(antigravityWritesRunWorkflowSkill(initializeResult.agentInfo?.version) + ? { disabledTools: ["run_workflow"] } + : {}), + ...(systemPromptAppend !== undefined && antigravityAppendsSystemPrompt(initializeResult) + ? { systemPrompt: { append: systemPromptAppend } } + : {}), + }; + return Object.keys(antigravity).length === 0 ? undefined : { antigravity }; +} + export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(function* ( input: AntigravityAcpRuntimeInput, ): Effect.fn.Return< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Crypto.Crypto | Scope.Scope + SecureRandom.SecureRandom | Scope.Scope > { const context = yield* Layer.build( AcpSessionRuntime.layer({ @@ -59,9 +122,15 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")( cancelBehavior: "wait-for-prompt", // The agent offers non-Gemini models only to allowlisted clients such as Zed. clientInfo: { name: "zed", version: "0.0.0" }, + sessionMeta: (initializeResult) => + antigravitySessionMeta(initializeResult, input.sessionSystemPromptAppend), clientCapabilities: { // Advertising writes swaps the agent's diff edits for whole-file client_edit_file. - fs: { readTextFile: input.clientFileReads === true, writeTextFile: false }, + fs: { + readTextFile: + input.clientFileReads === true && !isQualifiedAntigravityRelease(input.runtimeVersion), + writeTextFile: false, + }, terminal: false, }, transformStdout: makeAntigravityStdoutTransform( @@ -92,6 +161,20 @@ export function antigravityPermissionMode(runtimeMode: RuntimeMode): string { } } +/** The runtime mode an Antigravity session mode grants; the inverse of `antigravityPermissionMode`. */ +export function antigravityRuntimeModeForSessionMode(modeId: string): RuntimeMode | undefined { + switch (modeId) { + case "yolo": + return "full-access"; + case "auto_edit": + return "auto-accept-edits"; + case "default": + return "approval-required"; + default: + return undefined; + } +} + export function antigravityModelOptions( configOptions: ReadonlyArray, ) { @@ -198,6 +281,18 @@ const AUDIO_MIME_TYPES = new Set([ "audio/webm", ]); const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024; +// The agent inlines linked video bytes into one model request, so keep it under the inline limit. +const ANTIGRAVITY_MAX_VIDEO_ATTACHMENT_BYTES = 20 * 1024 * 1024; + +/** + * Only Gemini takes video. The evidence is the session's resolved model, so an account default + * counts once the agent reports it; opaque ids such as `agy-model` need a Gemini display name. + */ +function antigravityModelAcceptsVideo( + model: { readonly id: string; readonly name: string | undefined } | undefined, +): boolean { + return model !== undefined && (/^gemini-/i.test(model.id) || /^gemini\b/i.test(model.name ?? "")); +} const TEXT_MIME_TYPES = new Set([ "application/json", "application/ld+json", @@ -263,8 +358,13 @@ const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: { readonly input: ProviderSendTurnInput["input"]; readonly attachments: ProviderSendTurnInput["attachments"]; - readonly attachmentsDir: string; + readonly resolveAttachmentPath: ( + attachment: NonNullable[number], + ) => string | null; readonly promptCapabilities: EffectAcpSchema.AgentCapabilities["promptCapabilities"]; + /** Linked video needs a runtime whose `resource_link` handling is qualified and a Gemini model. */ + readonly agentVersion?: string | undefined; + readonly currentModel?: { readonly id: string; readonly name: string | undefined } | undefined; }): Effect.fn.Return< ReadonlyArray, EffectAcpErrors.AcpError, @@ -272,6 +372,8 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + // The agent runs on this host. + const platform = yield* HostProcess.Platform; const blocks: Array = []; const text = input.input?.trim(); if (text) blocks.push({ type: "text", text }); @@ -287,6 +389,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); const pdf = attachment.type === "file" && mimeType === "application/pdf"; + const video = + attachment.type === "file" && + mimeType === "video/mp4" && + isQualifiedAntigravityRelease(input.agentVersion) && + // The agent turns a file URI into a path with unquote(urlparse(uri).path), which drops + // Windows drives and UNC hosts. Keep the saved path there until Google fixes it. + platform !== "win32" && + antigravityModelAcceptsVideo(input.currentModel); const textFile = attachment.type === "file" && (mimeType.startsWith("text/") || @@ -300,11 +410,8 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ? input.promptCapabilities?.audio !== true : pdf || textFile ? input.promptCapabilities?.embeddedContext !== true - : true))); - const attachmentPath = resolveAttachmentPath({ - attachmentsDir: input.attachmentsDir, - attachment, - }); + : !video))); + const attachmentPath = input.resolveAttachmentPath(attachment); if (!attachmentPath) { return yield* EffectAcpErrors.AcpRequestError.invalidParams( `Invalid attachment '${attachment.name}'.`, @@ -332,7 +439,9 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES : pdf ? MAX_TOTAL_ATTACHMENT_BYTES - : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; + : video + ? ANTIGRAVITY_MAX_VIDEO_ATTACHMENT_BYTES + : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; if ( attachment.type === "file" && (size > limit || totalBytes + size > MAX_TOTAL_ATTACHMENT_BYTES) @@ -351,8 +460,8 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi EffectAcpErrors.AcpRequestError.invalidParams(`Invalid attachment '${attachment.name}'.`), ), ); - if (pdf) { - blocks.push({ type: "resource_link", uri, name: attachment.name, mimeType }); + if (pdf || video) { + blocks.push({ type: "resource_link", uri, name: attachment.name, mimeType, size }); continue; } const bytes = yield* fileSystem.stream(attachmentPath, { bytesToRead: limit + 1 }).pipe( diff --git a/apps/server/src/provider/acp/AntigravityChildWork.test.ts b/apps/server/src/provider/acp/AntigravityChildWork.test.ts new file mode 100644 index 0000000000000..78927b09d74e3 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityChildWork.test.ts @@ -0,0 +1,344 @@ +// @effect-diagnostics nodeBuiltinImport:off -- fixtures name transcript files by URL. +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { + ANTIGRAVITY_SUBAGENT_BATCH_TITLE, + antigravitySubagentLaunches, + describeAntigravitySubagentLaunches, + readAntigravityChildWork, +} from "./AntigravityChildWork.ts"; +import { sanitizeAntigravityToolPayload } from "./AntigravityProtocol.ts"; +import { + autoApproved, + CHILD, + invokeStep, + LAUNCH, + makeProfile, + plannerStep, + ROOT, + transcriptLines, +} from "./AntigravityChildWork.testkit.ts"; + +const read = ( + profileDirectory: string, + nativeTaskId: string, + launch: { readonly title: string | null; readonly prompt: string } = { + title: null, + prompt: "Readme check", + }, + siblings: ReadonlyArray<{ + readonly nativeTaskId: string; + readonly title: string | null; + readonly prompt: string; + }> = [], +) => + readAntigravityChildWork({ + profileDirectory, + rootSessionId: ROOT, + nativeTaskId, + launch, + siblings, + }); + +it.layer(NodeServices.layer)("readAntigravityChildWork", (it) => { + it.effect("follows an auto-approved call id through its planner step to the record", () => + Effect.gen(function* () { + const { profile } = yield* makeProfile({ steps: autoApproved }); + const [child, ...rest] = yield* read(profile, `${ROOT}:1`); + expect(rest).toEqual([]); + expect(child).toMatchObject({ + conversationId: CHILD, + role: LAUNCH.role, + prompt: LAUNCH.prompt, + truncated: false, + }); + // The launch envelope repeats the prompt shown above, so the steps start with the child's work. + expect(child?.steps?.map((step) => step.text ?? step.toolCalls[0]?.name)).toEqual([ + "view_file", + "2: child read marker 4242", + "marker 4242 found", + ]); + expect(child?.steps?.[0]?.toolCalls[0]?.input).toBe('{"AbsolutePath":"/project/README.md"}'); + }), + ); + + it.effect("needs proof that the call id belongs to the parent's record", () => + Effect.gen(function* () { + const { profile } = yield* makeProfile({ + steps: (logUri) => [ + ...autoApproved(logUri), + [5, 15, plannerStep(["call-b", "call-c"])], + [ + 6, + 127, + invokeStep({ callId: "call-b", launches: [LAUNCH], children: [{ id: CHILD, logUri }] }), + ], + [ + 7, + 127, + invokeStep({ callId: "call-c", launches: [LAUNCH], children: [{ id: CHILD, logUri }] }), + ], + ], + }); + // Another session's id, a step that is not the planner call, and parallel calls that share one id. + expect(yield* read(profile, `00000000-0000-4000-8000-000000000000:1`)).toEqual([]); + expect(yield* read(profile, `${ROOT}:2`)).toEqual([]); + expect(yield* read(profile, `${ROOT}:5`)).toEqual([]); + }), + ); + + it.effect("matches a permission-prompt call by its launch text, only when unique", () => + Effect.gen(function* () { + const other = { role: "Test Runner", prompt: "Run the tests" }; + const long = { role: "Long Reader", prompt: `Read this:\n${"x".repeat(9_000)}` }; + // A role's leading byte order mark is part of its text. + const marked = { role: "\uFEFFMarked", prompt: "Check the mark" }; + const { profile } = yield* makeProfile({ + steps: (logUri) => [ + [ + 2, + 127, + invokeStep({ callId: "x", launches: [LAUNCH], children: [{ id: CHILD, logUri }] }), + ], + [ + 8, + 127, + invokeStep({ callId: "w", launches: [long], children: [{ id: CHILD, logUri }] }), + ], + [ + 10, + 127, + invokeStep({ callId: "v", launches: [marked], children: [{ id: CHILD, logUri }] }), + ], + [ + 4, + 127, + invokeStep({ callId: "y", launches: [other], children: [{ id: CHILD, logUri }] }), + ], + [ + 6, + 127, + invokeStep({ callId: "z", launches: [other], children: [{ id: CHILD, logUri }] }), + ], + ], + }); + const opaque = "35bea692fa95447fbdae6333fec5515f"; + const matched = yield* read(profile, opaque, describeAntigravitySubagentLaunches([LAUNCH])); + expect(matched.map((child) => child.role)).toEqual([LAUNCH.role]); + // T3 knows a second batch with this text that the agent has not recorded yet. + const twin = { nativeTaskId: "f00d", ...describeAntigravitySubagentLaunches([LAUNCH]) }; + expect( + yield* read(profile, opaque, describeAntigravitySubagentLaunches([LAUNCH]), [twin]), + ).toEqual([]); + // T3 stores the launch from the bounded tool payload, which keeps only a long prompt's tail. + const stored = antigravitySubagentLaunches( + sanitizeAntigravityToolPayload({ Subagents: [{ Role: long.role, Prompt: long.prompt }] }), + ); + const longMatch = yield* read(profile, opaque, describeAntigravitySubagentLaunches(stored!)); + expect(longMatch.map((child) => child.prompt)).toEqual([long.prompt]); + expect( + yield* read(profile, opaque, describeAntigravitySubagentLaunches([marked])), + ).toHaveLength(1); + expect(yield* read(profile, opaque, { title: "Marked", prompt: marked.prompt })).toEqual([]); + expect(yield* read(profile, opaque, describeAntigravitySubagentLaunches([other]))).toEqual( + [], + ); + expect(yield* read(profile, opaque, { title: null, prompt: "Readme check" })).toEqual([]); + }), + ); + + it.effect("leaves an auto-approved batch's record out of a launch-text match", () => + Effect.gen(function* () { + const { profile } = yield* makeProfile({ steps: autoApproved }); + const launch = describeAntigravitySubagentLaunches([LAUNCH]); + const opaque = "35bea692fa95447fbdae6333fec5515f"; + expect(yield* read(profile, opaque, launch)).toHaveLength(1); + // An earlier auto-approved batch stored no launch text, but its planner step claims the record. + const auto = { + nativeTaskId: `${ROOT}:1`, + title: ANTIGRAVITY_SUBAGENT_BATCH_TITLE, + prompt: "", + }; + expect(yield* read(profile, opaque, launch, [auto])).toEqual([]); + // A sibling that cannot be ruled out refuses the match: its planner step is unreadable, + // or it is a permission batch whose launch text is missing or cut. + for (const sibling of [ + { ...auto, nativeTaskId: `${ROOT}:99` }, + { nativeTaskId: "0ld", title: ANTIGRAVITY_SUBAGENT_BATCH_TITLE, prompt: "Running" }, + // Its stored launch was cut, so it may be this same launch. + { nativeTaskId: "cut", title: LAUNCH.role, prompt: "[Earlier output truncated]\n\nmarker" }, + { nativeTaskId: "cut", title: "[Earlier output truncated]\n\nReader", prompt: "x" }, + { nativeTaskId: "cut", title: null, prompt: "[Earlier output truncated]\n\nx" }, + ]) { + expect(yield* read(profile, opaque, launch, [sibling])).toEqual([]); + } + // A cut prompt under different roles is another launch. + const other = { + nativeTaskId: "cut", + title: "Test Runner", + prompt: "[Earlier output truncated]\n\nx", + }; + expect(yield* read(profile, opaque, launch, [other])).toHaveLength(1); + }), + ); + + it.effect("reads no transcript outside the child's own profile path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-outside-" }); + const secret = path.join(outside, "transcript.jsonl"); + yield* fs.writeFileString(secret, JSON.stringify(transcriptLines[3]) + "\n"); + const named = yield* makeProfile({ + steps: () => autoApproved(NodeURL.pathToFileURL(secret).href), + }); + const [namedChild] = yield* read(named.profile, `${ROOT}:1`); + expect(namedChild?.role).toBe(LAUNCH.role); + expect(namedChild?.steps).toBeNull(); + + const linked = yield* makeProfile({ steps: autoApproved }); + yield* fs.remove(linked.transcriptPath); + yield* fs.symlink(secret, linked.transcriptPath); + const [linkedChild] = yield* read(linked.profile, `${ROOT}:1`); + expect(linkedChild?.steps).toBeNull(); + }), + ); + + it.effect("reads only the tail of a large transcript", () => + Effect.gen(function* () { + const filler = JSON.stringify({ + step_index: 0, + type: "VIEW_FILE", + status: "DONE", + content: "x".repeat(10_000), + }); + const { profile } = yield* makeProfile({ + steps: autoApproved, + transcript: `${Array.from({ length: 40 }, () => filler).join("\n")}\n${JSON.stringify(transcriptLines[3])}\n`, + }); + const [child] = yield* read(profile, `${ROOT}:1`); + expect(child?.truncated).toBe(true); + expect(child?.steps?.length).toBeLessThan(40); + expect(child?.steps?.at(-1)?.text).toBe("marker 4242 found"); + expect(child?.steps?.[0]?.text?.length).toBeLessThanOrEqual(4_001); + }), + ); + + it.effect("refuses links between children and sessions inside the same profile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sibling = "b468c0e1d2f34a5b6c7d8e9f0a1b2c3d"; + // The record names this child's literal path, but the child directory links to a sibling. + const linked = yield* makeProfile({ steps: autoApproved }); + const brain = path.join(linked.profile, "antigravity-acp", "brain"); + yield* fs.rename(path.join(brain, CHILD), path.join(brain, sibling)); + yield* fs.symlink(path.join(brain, sibling), path.join(brain, CHILD)); + const [child] = yield* read(linked.profile, `${ROOT}:1`); + expect(child?.role).toBe(LAUNCH.role); + expect(child?.steps).toBeNull(); + + // This session's database links to another session's records. + const other = yield* makeProfile({ steps: autoApproved }); + const conversations = path.join(other.profile, "antigravity-acp", "conversations"); + const peer = path.join(conversations, "00000000-0000-4000-8000-000000000000.db"); + yield* fs.rename(path.join(conversations, `${ROOT}.db`), peer); + yield* fs.symlink(peer, path.join(conversations, `${ROOT}.db`)); + expect(yield* read(other.profile, `${ROOT}:1`)).toEqual([]); + }), + ); + + it.effect("declines a launch-text match it cannot prove unique within its scan bounds", () => + Effect.gen(function* () { + const record = (callId: string, logUri: string) => + invokeStep({ callId, launches: [LAUNCH], children: [{ id: CHILD, logUri }] }); + const other = { role: "Test Runner", prompt: "Run the tests" }; + const filler = (idx: number, logUri: string) => + [ + idx, + 127, + invokeStep({ callId: `f${idx}`, launches: [other], children: [{ id: CHILD, logUri }] }), + ] as const; + const opaque = "35bea692fa95447fbdae6333fec5515f"; + const launch = describeAntigravitySubagentLaunches([LAUNCH]); + const cases = [ + // More records than one open may scan. + (logUri: string) => [ + [1, 127, record("x", logUri)] as const, + ...Array.from({ length: 200 }, (_, index) => filler(index + 2, logUri)), + ], + // An oversized record could hold an identical launch. + (logUri: string) => [ + [1, 127, record("x", logUri)] as const, + [2, 127, new Uint8Array(1024 * 1024 + 1)] as const, + ], + // So could one this reader cannot decode. + (logUri: string) => [ + [1, 127, record("x", logUri)] as const, + [2, 127, Uint8Array.from([0xff, 0xff])] as const, + ], + ]; + for (const steps of cases) { + const { profile } = yield* makeProfile({ steps }); + expect(yield* read(profile, opaque, launch)).toEqual([]); + } + }), + ); + + it.effect("keeps the batch view for a batch over the child limit", () => + Effect.gen(function* () { + const { profile } = yield* makeProfile({ + steps: (logUri) => [ + [1, 15, plannerStep(["call-a"])], + [ + 2, + 127, + invokeStep({ + callId: "call-a", + launches: Array.from({ length: 17 }, () => LAUNCH), + children: Array.from({ length: 17 }, () => ({ id: CHILD, logUri })), + }), + ], + ], + }); + expect(yield* read(profile, `${ROOT}:1`)).toEqual([]); + }), + ); + + it.effect("shares one transcript budget across the batch", () => + Effect.gen(function* () { + const filler = JSON.stringify({ + step_index: 0, + type: "VIEW_FILE", + status: "DONE", + content: "x".repeat(1_000), + }); + const transcript = `${Array.from({ length: 300 }, () => filler).join("\n")}\n`; + const { profile } = yield* makeProfile({ + transcript, + steps: (logUri) => [ + [1, 15, plannerStep(["call-a"])], + [ + 2, + 127, + invokeStep({ + callId: "call-a", + launches: Array.from({ length: 16 }, () => LAUNCH), + children: Array.from({ length: 16 }, () => ({ id: CHILD, logUri })), + }), + ], + ], + }); + const children = yield* read(profile, `${ROOT}:1`); + expect(children).toHaveLength(16); + expect(children.every((child) => child.truncated)).toBe(true); + expect(JSON.stringify(children).length).toBeLessThan(600 * 1024); + }), + ); +}); diff --git a/apps/server/src/provider/acp/AntigravityChildWork.testkit.ts b/apps/server/src/provider/acp/AntigravityChildWork.testkit.ts new file mode 100644 index 0000000000000..82a2befbf6698 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityChildWork.testkit.ts @@ -0,0 +1,111 @@ +// @effect-diagnostics nodeBuiltinImport:off -- fixtures write a native session database. +import * as NodeSqlite from "node:sqlite"; +import * as NodeURL from "node:url"; + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +export const ROOT = "7d40e54d-5977-4239-940b-246cdd81a12b"; +export const CHILD = "a357bf2720914589dd1ec49f7a816115"; +export const LAUNCH = { role: "Readme Reader", prompt: "Read README.md and report the marker" }; + +// Mirrors the 1.4.5 runtime: Step{5: metadata{4: tool call{1 id, 2 name}}, 20: planner{7: calls}, +// 143: invoke_subagent{9: specs{2 role, 3 prompt}, 10: results{1 conversation, 2 log uri}}}. +const varint = (value: number): number[] => { + const bytes: number[] = []; + while (value >= 0x80) { + bytes.push((value & 0x7f) | 0x80); + value = Math.floor(value / 0x80); + } + return [...bytes, value]; +}; +const field = (number: number, value: string | Uint8Array) => { + const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value; + return [...varint(number * 8 + 2), ...varint(bytes.length), ...bytes]; +}; +const proto = (...parts: number[][]) => Uint8Array.from(parts.flat()); +const toolCall = (id: string) => proto(field(1, id), field(2, "invoke_subagent")); + +export const invokeStep = (input: { + readonly callId: string; + readonly launches: ReadonlyArray; + readonly children: ReadonlyArray<{ readonly id: string; readonly logUri: string }>; +}) => + proto( + [...varint(1 * 8), ...varint(127)], + field(5, proto(field(4, toolCall(input.callId)))), + field( + 143, + proto( + ...input.launches.map((launch) => + field(9, proto(field(1, "research"), field(2, launch.role), field(3, launch.prompt))), + ), + ...input.children.map((child) => + field(10, proto(field(1, child.id), field(2, child.logUri))), + ), + ), + ), + ); +export const plannerStep = (callIds: ReadonlyArray) => + proto(field(20, proto(...callIds.map((id) => field(7, toolCall(id)))))); + +export const transcriptLines = [ + // Trimmed from 1.4.5: the launch envelope around the prompt the parent record already holds. + { + step_index: 0, + type: "SYSTEM_MESSAGE", + status: "DONE", + content: `\n${LAUNCH.prompt}\n`, + }, + { + step_index: 1, + type: "PLANNER_RESPONSE", + status: "DONE", + // The runtime stores each argument value as JSON text. + tool_calls: [{ name: "view_file", args: { AbsolutePath: '"/project/README.md"' } }], + }, + { step_index: 2, type: "VIEW_FILE", status: "DONE", content: "2: child read marker 4242" }, + { step_index: 3, type: "PLANNER_RESPONSE", status: "DONE", content: "marker 4242 found" }, +]; + +export const makeProfile = Effect.fn("makeProfile")(function* (input: { + /** Defaults to a scoped temp directory. */ + readonly profile?: string; + readonly steps: (logUri: string) => ReadonlyArray; + readonly transcript?: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profile = input.profile ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-child-" })); + const acp = path.join(profile, "antigravity-acp"); + const logs = path.join(acp, "brain", CHILD, ".system_generated", "logs"); + yield* fs.makeDirectory(path.join(acp, "conversations"), { recursive: true }); + yield* fs.makeDirectory(logs, { recursive: true }); + const transcriptPath = path.join(logs, "transcript.jsonl"); + yield* fs.writeFileString( + transcriptPath, + input.transcript ?? transcriptLines.map((line) => JSON.stringify(line)).join("\n") + "\n", + ); + const logUri = NodeURL.pathToFileURL(transcriptPath).href; + const database = new NodeSqlite.DatabaseSync(path.join(acp, "conversations", `${ROOT}.db`)); + database.exec( + "CREATE TABLE steps (idx integer PRIMARY KEY, step_type integer NOT NULL DEFAULT 0, step_format integer NOT NULL DEFAULT 0, step_payload blob)", + ); + const insert = database.prepare( + "INSERT INTO steps (idx, step_type, step_payload) VALUES (?, ?, ?)", + ); + for (const [idx, type, payload] of input.steps(logUri)) insert.run(idx, type, payload); + database.close(); + return { profile, transcriptPath, logUri }; +}); + +export const autoApproved = (logUri: string) => + [ + [1, 15, plannerStep(["call-a"])], + [ + 2, + 127, + invokeStep({ callId: "call-a", launches: [LAUNCH], children: [{ id: CHILD, logUri }] }), + ], + ] as const; diff --git a/apps/server/src/provider/acp/AntigravityChildWork.ts b/apps/server/src/provider/acp/AntigravityChildWork.ts new file mode 100644 index 0000000000000..ea12e0e2b20de --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityChildWork.ts @@ -0,0 +1,478 @@ +// @effect-diagnostics nodeBuiltinImport:off -- node:sqlite reads the native session database read-only. +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeURL from "node:url"; + +import type { + OrchestrationV2NativeChildStep, + OrchestrationV2NativeChildWork, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +import { antigravityManagedPath, antigravityProfileRoot } from "./AntigravityProfilePaths.ts"; +import { boundAntigravityToolText, isCutAntigravityToolText } from "./AntigravityProtocol.ts"; + +// Field numbers from the runtime's trajectory protos (exa.cortex_pb). +const STEP_METADATA = 5; +const METADATA_TOOL_CALL = 4; +const STEP_PLANNER_RESPONSE = 20; +const PLANNER_TOOL_CALLS = 7; +const STEP_INVOKE_SUBAGENT = 143; +const INVOKE_SPECS = 9; +const INVOKE_RESULTS = 10; +const INVOKE_SUBAGENT_STEP_TYPE = 127; + +// One open reads at most this much; past any bound the batch view stays as it was. +const MAX_STEP_BYTES = 1024 * 1024; +const MAX_INVOKE_ROWS = 200; +const MAX_SCAN_BYTES = 8 * 1024 * 1024; +const MAX_CHILDREN = 16; +const TRANSCRIPT_BYTE_CAP = 256 * 1024; +const BATCH_TRANSCRIPT_BYTES = 512 * 1024; +const STEP_TEXT_CAP = 4_000; +const TOOL_INPUT_CAP = 1_000; + +/** T3's title for a batch whose launch text it never saw. */ +export const ANTIGRAVITY_SUBAGENT_BATCH_TITLE = "Antigravity subagent batch"; + +interface AntigravityChildLaunch { + readonly role: string; + readonly prompt: string; +} + +const decodeLaunchInput = Schema.decodeUnknownOption( + Schema.Struct({ + Subagents: Schema.NonEmptyArray(Schema.Struct({ Role: Schema.String, Prompt: Schema.String })), + }), +); + +/** `invoke_subagent` arguments, from the permission prompt or the persisted record. */ +export function antigravitySubagentLaunches( + input: unknown, +): ReadonlyArray | undefined { + return Option.getOrUndefined( + Option.map(decodeLaunchInput(input), (decoded) => + decoded.Subagents.map((entry) => ({ role: entry.Role, prompt: entry.Prompt })), + ), + ); +} + +/** Batch title and prompt; reads match permission-prompt tool call ids against this exact text. */ +export function describeAntigravitySubagentLaunches( + launches: ReadonlyArray, +) { + const [only] = launches; + return launches.length === 1 && only !== undefined + ? { title: only.role, prompt: only.prompt } + : { + title: launches.map((launch) => launch.role).join(", "), + prompt: launches.map((launch) => `${launch.role}\n${launch.prompt}`).join("\n\n"), + }; +} + +/** Length-delimited fields only; varints and fixed values are skipped. */ +function protoFields(bytes: Uint8Array): ReadonlyArray { + const fields: Array = []; + let offset = 0; + const varint = () => { + let value = 0; + for (let shift = 0; shift < 64; shift += 7) { + const byte = bytes[offset++]; + if (byte === undefined) throw new Error("Truncated varint"); + value += (byte & 0x7f) * 2 ** shift; + if (byte < 0x80) return value; + } + throw new Error("Overlong varint"); + }; + while (offset < bytes.length) { + const key = varint(); + const wire = key % 8; + const field = Math.floor(key / 8); + if (wire === 0) varint(); + else if (wire === 1) offset += 8; + else if (wire === 5) offset += 4; + else if (wire === 2) { + const length = varint(); + if (offset + length > bytes.length) throw new Error("Truncated field"); + fields.push([field, bytes.subarray(offset, offset + length)]); + offset += length; + } else throw new Error("Unsupported wire type"); + } + if (offset > bytes.length) throw new Error("Truncated field"); + return fields; +} + +// Keep a leading byte order mark: T3 stores launch text with it. +const textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const messages = (bytes: Uint8Array, field: number) => + protoFields(bytes).flatMap(([number, value]) => (number === field ? [value] : [])); +const message = (bytes: Uint8Array | undefined, field: number) => + bytes === undefined ? undefined : messages(bytes, field)[0]; +const text = (bytes: Uint8Array | undefined, field: number) => { + const value = message(bytes, field); + return value === undefined ? undefined : textDecoder.decode(value); +}; + +interface InvokeRecord { + readonly toolCallId: string | undefined; + readonly launches: ReadonlyArray; + readonly children: ReadonlyArray<{ readonly conversationId: string; readonly logUri: string }>; +} + +const NATIVE_ID = /^[A-Za-z0-9-]{1,64}$/; + +/** Undefined for steps this reader cannot decode, so they never count as proof. */ +const safely = + (decode: (payload: Uint8Array) => A) => + (payload: Uint8Array): A | undefined => { + try { + return decode(payload); + } catch { + return undefined; + } + }; + +const decodeInvokeRecord = safely((payload): InvokeRecord | undefined => { + const invoke = message(payload, STEP_INVOKE_SUBAGENT); + if (invoke === undefined) return undefined; + const children = messages(invoke, INVOKE_RESULTS).map((result) => ({ + conversationId: text(result, 1) ?? "", + logUri: text(result, 2) ?? "", + })); + if (!children.every((child) => NATIVE_ID.test(child.conversationId))) return undefined; + return { + toolCallId: text(message(message(payload, STEP_METADATA), METADATA_TOOL_CALL), 1), + launches: messages(invoke, INVOKE_SPECS).map((spec) => ({ + role: text(spec, 2) ?? "", + prompt: text(spec, 3) ?? "", + })), + children, + }; +}); + +const plannerInvokeCallIds = safely((payload): ReadonlyArray => { + const planner = message(payload, STEP_PLANNER_RESPONSE); + if (planner === undefined) return []; + return messages(planner, PLANNER_TOOL_CALLS).flatMap((call) => { + const id = text(call, 1); + return text(call, 2) === "invoke_subagent" && id !== undefined ? [id] : []; + }); +}); + +const payloadOf = (row: unknown) => + row !== null && + typeof row === "object" && + "step_payload" in row && + row.step_payload instanceof Uint8Array + ? row.step_payload + : undefined; + +interface BatchLaunch { + readonly title: string | null; + readonly prompt: string; +} + +const plannerCallIdsAt = (database: NodeSqlite.DatabaseSync, plannerIdx: number) => { + const planner = payloadOf( + database + .prepare( + "SELECT step_payload FROM steps WHERE idx = ? AND step_format = 0 AND length(step_payload) <= ?", + ) + .get(plannerIdx, MAX_STEP_BYTES), + ); + return planner === undefined ? undefined : plannerInvokeCallIds(planner); +}; + +/** + * Auto-approved calls carry `:`; the record with that planner call's id + * is the proof. Permission prompts carry a random id, so the launch text must match one record + * no auto-approved sibling claims, and no sibling may share the text. + */ +function findInvokeRecord( + database: NodeSqlite.DatabaseSync, + input: { + readonly rootSessionId: string; + readonly nativeTaskId: string; + readonly launch: BatchLaunch; + readonly siblings: ReadonlyArray; + }, +): InvokeRecord | undefined { + const plannerIdxOf = (nativeTaskId: string) => { + const stepId = /^(.+):(\d+)$/.exec(nativeTaskId); + return stepId === null ? undefined : stepId[1] === input.rootSessionId ? Number(stepId[2]) : -1; + }; + const plannerIdx = plannerIdxOf(input.nativeTaskId); + if (plannerIdx !== undefined) { + if (plannerIdx < 0) return undefined; + const callIds = plannerCallIdsAt(database, plannerIdx) ?? []; + const [callId] = callIds; + if (callIds.length !== 1) return undefined; + // The record follows the planner step that issued the call. + let rows = 0; + let bytes = 0; + for (const row of database + .prepare( + // SQLite returns only in-bound payloads, so an oversized row never reaches this process. + "SELECT length(step_payload) AS size, CASE WHEN length(step_payload) <= ? THEN step_payload END AS step_payload FROM steps WHERE step_type = ? AND step_format = 0 AND idx > ? ORDER BY idx", + ) + .iterate(MAX_STEP_BYTES, INVOKE_SUBAGENT_STEP_TYPE, plannerIdx)) { + const size = row !== null && typeof row === "object" && "size" in row ? Number(row.size) : 0; + rows += 1; + bytes += size; + if (rows > MAX_INVOKE_ROWS || size > MAX_STEP_BYTES || bytes > MAX_SCAN_BYTES) + return undefined; + const payload = payloadOf(row); + const record = payload === undefined ? undefined : decodeInvokeRecord(payload); + if (record !== undefined && record.toolCallId === callId) return complete(record); + } + return undefined; + } + const totals = database + .prepare( + "SELECT count(*) AS rows, coalesce(sum(length(step_payload)), 0) AS bytes, coalesce(max(length(step_payload)), 0) AS largest, sum(step_format != 0) AS other FROM steps WHERE step_type = ?", + ) + .get(INVOKE_SUBAGENT_STEP_TYPE); + const total = (key: string) => + totals !== undefined && key in totals ? Number(totals[key as keyof typeof totals] ?? 0) : 0; + const sameText = (launch: BatchLaunch) => + launch.title === input.launch.title && launch.prompt === input.launch.prompt; + if ( + input.siblings.some(sameText) || + total("rows") > MAX_INVOKE_ROWS || + total("bytes") > MAX_SCAN_BYTES || + total("largest") > MAX_STEP_BYTES || + total("other") > 0 + ) { + return undefined; + } + const claimed = new Set(); + for (const sibling of input.siblings) { + const idx = plannerIdxOf(sibling.nativeTaskId); + // A sibling whose launch text is missing, or cut under the same roles, or whose planner step + // is unreadable, cannot be ruled out. + if (idx === undefined) { + if ( + sibling.title === ANTIGRAVITY_SUBAGENT_BATCH_TITLE || + (sibling.title !== null && isCutAntigravityToolText(sibling.title)) || + (isCutAntigravityToolText(sibling.prompt) && + (sibling.title === null || sibling.title === input.launch.title)) + ) + return undefined; + continue; + } + if (idx < 0) continue; + const callIds = plannerCallIdsAt(database, idx); + if (callIds === undefined || callIds.length === 0) return undefined; + for (const callId of callIds) claimed.add(callId); + } + const matches: Array = []; + for (const row of database + .prepare("SELECT step_payload FROM steps WHERE step_type = ? ORDER BY idx") + .iterate(INVOKE_SUBAGENT_STEP_TYPE)) { + const payload = payloadOf(row); + const record = payload === undefined ? undefined : decodeInvokeRecord(payload); + // An unreadable record could be an identical launch, so uniqueness is unproven. + if (record === undefined) return undefined; + if (record.toolCallId !== undefined && claimed.has(record.toolCallId)) continue; + // T3 stored the launch from the bounded tool payload, so bound the record the same way. + const described = describeAntigravitySubagentLaunches( + record.launches.map((launch) => ({ + role: boundAntigravityToolText(launch.role), + prompt: boundAntigravityToolText(launch.prompt), + })), + ); + if (sameText(described)) { + matches.push(record); + } + } + const [match] = matches; + return matches.length === 1 && match !== undefined ? complete(match) : undefined; +} + +const complete = (record: InvokeRecord) => + record.launches.length === record.children.length && record.children.length <= MAX_CHILDREN + ? record + : undefined; + +const TranscriptLine = Schema.Struct({ + step_index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + type: Schema.String, + status: Schema.optional(Schema.String), + content: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + tool_calls: Schema.optional( + Schema.Array(Schema.Struct({ name: Schema.String, args: Schema.optional(Schema.Unknown) })), + ), +}); +const decodeTranscriptLine = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptLine)); + +// The runtime records each tool argument value as JSON text. +const decodeArgument = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +const toolArguments = (args: unknown) => + Predicate.isObject(args) + ? Object.fromEntries( + Object.entries(args).map(([key, value]) => [ + key, + typeof value === "string" ? Option.getOrElse(decodeArgument(value), () => value) : value, + ]), + ) + : (args ?? {}); + +const capText = (value: string, limit: number) => + value.length <= limit ? value : `${value.slice(0, limit)}…`; + +/** Skips the runtime's opening envelope around the launch prompt the view already shows. */ +function transcriptSteps( + contents: string, + prompt: string, +): ReadonlyArray { + const launchPrompt = prompt.trim(); + return contents.split("\n").flatMap((line) => + Option.match(decodeTranscriptLine(line), { + onNone: () => [], + onSome: (step) => + step.step_index === 0 && + step.type === "SYSTEM_MESSAGE" && + launchPrompt !== "" && + step.content?.includes(launchPrompt) === true + ? [] + : [ + { + index: step.step_index, + type: step.type, + status: step.status ?? null, + text: + step.content !== undefined || step.error !== undefined + ? capText(step.content ?? step.error ?? "", STEP_TEXT_CAP) + : null, + toolCalls: (step.tool_calls ?? []).map((call) => ({ + name: call.name, + input: capText(JSON.stringify(toolArguments(call.args)), TOOL_INPUT_CAP), + })), + }, + ], + }), + ); +} + +/** Reads the tail of the transcript at the named child's own unlinked profile path. */ +async function readChildTranscript( + profileRoot: string, + child: InvokeRecord["children"][number], + prompt: string, + byteCap: number, +) { + const expected = ( + await antigravityManagedPath( + profileRoot, + [ + "antigravity-acp", + "brain", + child.conversationId, + ".system_generated", + "logs", + "transcript.jsonl", + ], + "file", + ) + )?.path; + if (expected === undefined) return undefined; + let named: string; + try { + named = NodeURL.fileURLToPath(child.logUri); + } catch { + return undefined; + } + if ((await NodeFSP.realpath(named).catch(() => undefined)) !== expected) return undefined; + const handle = await NodeFSP.open( + expected, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NOFOLLOW ?? 0), + ); + try { + const stat = await handle.stat(); + if (!stat.isFile()) return undefined; + const length = Math.min(stat.size, byteCap); + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, stat.size - length); + const truncated = stat.size > length; + let contents = buffer.subarray(0, bytesRead).toString("utf8"); + // A tail read starts mid-line. + if (truncated) contents = contents.slice(contents.indexOf("\n") + 1); + return { steps: transcriptSteps(contents, prompt), truncated }; + } finally { + await handle.close(); + } +} + +class AntigravityChildWorkReadError extends Schema.TaggedError()( + "AntigravityChildWorkReadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not read Antigravity subagent records."; + } +} + +/** Children a subagent tool call launched, proven by the parent's own session record. */ +export const readAntigravityChildWork = Effect.fn("readAntigravityChildWork")(function* (input: { + readonly profileDirectory: string; + readonly rootSessionId: string; + readonly nativeTaskId: string; + readonly launch: BatchLaunch; + /** The thread's other Antigravity batches from this instance. */ + readonly siblings: ReadonlyArray; +}) { + if (!NATIVE_ID.test(input.rootSessionId)) return []; + return yield* Effect.tryPromise({ + try: async (): Promise> => { + const profileRoot = await antigravityProfileRoot(input.profileDirectory); + const databasePath = + profileRoot === undefined + ? undefined + : ( + await antigravityManagedPath( + profileRoot, + ["antigravity-acp", "conversations", `${input.rootSessionId}.db`], + "file", + ) + )?.path; + if (profileRoot === undefined || databasePath === undefined) return []; + const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + let record: InvokeRecord | undefined; + try { + record = findInvokeRecord(database, input); + } finally { + database.close(); + } + if (record === undefined) return []; + const { launches, children } = record; + // One budget for the whole batch, so the response stays small however many children ran. + const byteCap = Math.min( + TRANSCRIPT_BYTE_CAP, + Math.floor(BATCH_TRANSCRIPT_BYTES / Math.max(1, children.length)), + ); + return Promise.all( + children.map(async (child, index) => { + const transcript = await readChildTranscript( + profileRoot, + child, + launches[index]?.prompt ?? "", + byteCap, + ).catch(() => undefined); + return { + conversationId: child.conversationId, + role: launches[index]?.role ?? "", + prompt: launches[index]?.prompt ?? "", + steps: transcript?.steps ?? null, + truncated: transcript?.truncated ?? false, + }; + }), + ); + }, + catch: (cause) => new AntigravityChildWorkReadError({ cause }), + }); +}); diff --git a/apps/server/src/provider/acp/AntigravityEditLocation.test.ts b/apps/server/src/provider/acp/AntigravityEditLocation.test.ts new file mode 100644 index 0000000000000..23f04f011fd25 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityEditLocation.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ANTIGRAVITY_EDIT_DIFF_OMITTED, antigravityEditPatch } from "./AntigravityEditLocation.ts"; + +const path = "/workspace/app.ts"; +const file = Array.from({ length: 20 }, (_, i) => + i === 9 + ? "const target = 10;" + : i === 14 + ? "const other = 15;" + : `const line${i + 1} = ${i + 1};`, +).join("\n"); +const replace = { TargetContent: "const target = 10;", ReplacementContent: "const target = 100;" }; +const header = `--- ${path}\n+++ ${path}\n`; + +describe("antigravityEditPatch", () => { + it("finds the target inside the announced search range, in either argument casing", () => { + const expected = `${header}@@ -10,1 +10,1 @@\n-const target = 10;\n+const target = 100;\n`; + const readPreEditFile = () => file; + for (const rawInput of [ + { ...replace, StartLine: 5, EndLine: 20 }, + { + target_content: replace.TargetContent, + replacement_content: replace.ReplacementContent, + start_line: 5, + end_line: 20, + }, + { + replacement_chunks: [ + { + target_content: replace.TargetContent, + replacement_content: replace.ReplacementContent, + start_line: 5, + end_line: 20, + }, + ], + }, + // Like the runtime, a present PascalCase value wins, and a 0 line means unspecified. + { ...replace, target_content: "const other = 15;", StartLine: 5, EndLine: 0 }, + ]) { + expect(antigravityEditPatch({ path, rawInput, readPreEditFile })).toBe(expected); + } + for (const rawInput of [replace, { ReplacementChunks: [replace] }]) { + expect( + antigravityEditPatch({ path, rawInput, locations: [{ path, line: 5 }], readPreEditFile }), + ).toBe(expected); + } + // A hunk replaces whole lines, so a match inside a line is not a placement. + expect( + antigravityEditPatch({ + path, + rawInput: { TargetContent: "10", ReplacementContent: "100" }, + readPreEditFile, + }), + ).toMatch(/ snippet, line unknown$/m); + // The range start is not the target's line: without the file, or outside the range, it stays unplaced. + const snippet = `${header}@@ -1,1 +1,1 @@ snippet, line unknown\n-const target = 10;\n+const target = 100;\n`; + expect( + antigravityEditPatch({ path, rawInput: { ...replace, StartLine: 5, EndLine: 20 } }), + ).toBe(snippet); + expect( + antigravityEditPatch({ + path, + rawInput: { ...replace, StartLine: 11, EndLine: 20 }, + readPreEditFile, + }), + ).toBe(snippet); + }); + + it("finds an unannounced snippet in the pre-edit file", () => { + const expected = `${header}@@ -10,1 +10,1 @@\n-const target = 10;\n+const target = 100;\n`; + expect(antigravityEditPatch({ path, rawInput: replace, readPreEditFile: () => file })).toBe( + expected, + ); + const crlf = file.replaceAll("\n", "\r\n"); + expect(antigravityEditPatch({ path, rawInput: replace, readPreEditFile: () => crlf })).toBe( + expected, + ); + // A target written with CRLF previews the same lines, without carriage returns. + expect( + antigravityEditPatch({ + path, + rawInput: { + TargetContent: "const target = 10;\r\n", + ReplacementContent: "const target = 100;\r\n", + }, + readPreEditFile: () => crlf, + }), + ).toBe(expected); + // A change to the line ending alone has no LF hunk to place. + const endingOnly = { + TargetContent: "const target = 10;\r\n", + ReplacementContent: "const target = 10;\n", + }; + for (const readPreEditFile of [() => crlf, undefined]) { + expect(antigravityEditPatch({ path, rawInput: endingOnly, readPreEditFile })).toBe( + ANTIGRAVITY_EDIT_DIFF_OMITTED, + ); + } + // A multi-line target written with LF still finds its lines in a CRLF file. + expect( + antigravityEditPatch({ + path, + rawInput: { + TargetContent: "const line9 = 9;\nconst target = 10;", + ReplacementContent: "const line9 = 9;\nconst target = 100;", + }, + readPreEditFile: () => crlf, + }), + ).toMatch(/^@@ -9,2 \+9,2 @@$/m); + }); + + it("omits the preview when a chunk adds or removes a line break at its end", () => { + // Whether that splits or joins lines depends on what follows the match. + for (const [TargetContent, ReplacementContent] of [ + ["const line3 = 3;", "x\n"], + ["const line3 = 3;\n", "x"], + ["const line20 = 20;", "x\n"], + // Clearing the last line of a file with no final newline removes the line. + ["const line20 = 20;", ""], + ]) { + expect( + antigravityEditPatch({ + path, + rawInput: { TargetContent, ReplacementContent }, + readPreEditFile: () => file, + }), + ).toBe(ANTIGRAVITY_EDIT_DIFF_OMITTED); + } + }); + + it("labels repeated or unmatched snippets instead of placing them at line 1", () => { + const snippet = `${header}@@ -1,1 +1,1 @@ snippet, line unknown\n-const target = 10;\n+const target = 100;\n`; + for (const readPreEditFile of [ + () => `${file}\n${file}`, + () => "unrelated\n", + () => undefined, + undefined, + ]) { + expect(antigravityEditPatch({ path, rawInput: replace, readPreEditFile })).toBe(snippet); + } + }); + + it("places each replacement chunk and shifts later chunks by earlier line changes", () => { + const patch = antigravityEditPatch({ + path, + rawInput: { + ReplacementChunks: [ + { + TargetContent: "const other = 15;\n", + ReplacementContent: "const other = 150;\n", + StartLine: 15, + EndLine: 15, + }, + { + TargetContent: "const target = 10;\n", + ReplacementContent: "const target = 100;\nconst added = 1;\n", + StartLine: 10, + EndLine: 10, + }, + ], + }, + readPreEditFile: () => file, + }); + expect(patch).toBe( + [ + header.trimEnd(), + "@@ -10,1 +10,2 @@", + "-const target = 10;", + "+const target = 100;", + "+const added = 1;", + "@@ -15,1 +16,1 @@", + "-const other = 15;", + "+const other = 150;", + "", + ].join("\n"), + ); + }); + + it("omits the preview when only some chunks can be placed", () => { + const patch = antigravityEditPatch({ + path, + rawInput: { + ReplacementChunks: [ + { + TargetContent: "const target = 10;\n", + ReplacementContent: "const target = 100;\n", + StartLine: 10, + EndLine: 10, + }, + { TargetContent: "missing", ReplacementContent: "still missing" }, + ], + }, + readPreEditFile: () => file, + }); + expect(patch).toBe(ANTIGRAVITY_EDIT_DIFF_OMITTED); + }); + + it("leaves whole-file writes and unknown inputs to the generic diff", () => { + expect(antigravityEditPatch({ path, rawInput: { CodeContent: "x" } })).toBeUndefined(); + expect(antigravityEditPatch({ path, rawInput: undefined })).toBeUndefined(); + }); + + it("omits the preview once an edit exceeds the shared diff budget", () => { + const lines = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => `${prefix} ${index} ${"x".repeat(8)}`).join("\n"); + // A 7,000-line wholesale replacement, as one chunk. + expect( + antigravityEditPatch({ + path, + rawInput: { + TargetContent: lines("old", 7_000), + ReplacementContent: lines("new", 7_000), + StartLine: 1, + }, + }), + ).toBe(ANTIGRAVITY_EDIT_DIFF_OMITTED); + // Chunks that each fit, but not together. + const chunk = (start: number) => ({ + TargetContent: lines(`old-${start}`, 300), + ReplacementContent: lines(`new-${start}`, 300), + StartLine: start, + }); + expect( + antigravityEditPatch({ + path, + rawInput: { ReplacementChunks: [chunk(1), chunk(1_000)] }, + }), + ).toBe(ANTIGRAVITY_EDIT_DIFF_OMITTED); + // Text over the size limit is not diffed at all. + expect( + antigravityEditPatch({ + path, + rawInput: { + TargetContent: "a".repeat(200_000), + ReplacementContent: "b".repeat(100_000), + StartLine: 1, + }, + }), + ).toBe(ANTIGRAVITY_EDIT_DIFF_OMITTED); + expect(antigravityEditPatch({ path, rawInput: { ReplacementChunks: [chunk(1)] } })).toMatch( + /^@@ -1,300 \+1,300 @@/m, + ); + }); +}); diff --git a/apps/server/src/provider/acp/AntigravityEditLocation.ts b/apps/server/src/provider/acp/AntigravityEditLocation.ts new file mode 100644 index 0000000000000..ff16dd014c97a --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityEditLocation.ts @@ -0,0 +1,226 @@ +// @effect-diagnostics nodeBuiltinImport:off - the tool-call normalizer is synchronous +import * as NodeFS from "node:fs"; + +import { PositiveInt } from "@t3tools/contracts"; +import { FILE_HEADERS_ONLY, formatPatch, structuredPatch, type StructuredPatchHunk } from "diff"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const PRE_EDIT_FILE_LIMIT = 1024 * 1024; +// The live preview runs synchronously. One budget covers every chunk in a call: the generic +// ACP formatter's edit-distance limit, plus a size limit that skips diffing entirely. +const EDIT_DIFF_BUDGET = 1_000; +const EDIT_TEXT_BUDGET = 256 * 1024; + +/** The edit is too large to preview cheaply; the turn's checkpoint diff still shows it. */ +export const ANTIGRAVITY_EDIT_DIFF_OMITTED = Symbol("AntigravityEditDiffOmitted"); +const SNIPPET_LABEL = " snippet, line unknown"; + +/** True when no chunk could be placed; a patch never mixes placed and unplaced chunks. */ +export function isAntigravitySnippetPatch(patch: string): boolean { + return /^@@ .* @@.*$/m.exec(patch)?.[0].endsWith(SNIPPET_LABEL) === true; +} + +// The runtime reads a missing, 0 or invalid line as unspecified. +const Line = Schema.optional(Schema.Unknown); +const decodeLine = Schema.decodeUnknownOption(PositiveInt); +const lineOf = (value: unknown) => Option.getOrUndefined(decodeLine(value)); +const ReplacementChunk = Schema.Struct({ + TargetContent: Schema.String, + ReplacementContent: Schema.String, + StartLine: Line, + EndLine: Line, +}); +// replace_file_content sends one chunk at the top level; multi_replace_file_content sends a list. +const decodeEditInput = Schema.decodeUnknownOption( + Schema.Union([ + Schema.Struct({ ReplacementChunks: Schema.NonEmptyArray(ReplacementChunk) }), + ReplacementChunk, + ]), +); +const decodeLocations = Schema.decodeUnknownOption(Schema.Array(Schema.Struct({ line: Line }))); +// The runtime accepts edit arguments in either casing. +const PASCAL_KEYS: Readonly> = { + replacement_chunks: "ReplacementChunks", + target_content: "TargetContent", + replacement_content: "ReplacementContent", + start_line: "StartLine", + end_line: "EndLine", +}; +const pascalKeys = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(pascalKeys); + if (typeof value !== "object" || value === null) return value; + const fields: Record = value as Record; + const result: Record = {}; + for (const [key, entry] of Object.entries(fields)) { + const pascal = PASCAL_KEYS[key]; + // Like the runtime, a present PascalCase value wins over its snake_case alias. + if (pascal === undefined) result[key] ??= pascalKeys(entry); + else if (fields[pascal] == null) result[pascal] = pascalKeys(entry); + } + return result; +}; + +/** Reads a small file for snippet matching; edits to large or unreadable files stay unlocated. */ +export function readAntigravityPreEditFile(path: string): string | undefined { + try { + const stat = NodeFS.statSync(path); + return stat.isFile() && stat.size <= PRE_EDIT_FILE_LIMIT + ? NodeFS.readFileSync(path, "utf8") + : undefined; + } catch { + return undefined; + } +} + +/** The line of the snippet's only match starting within `from`..`to`, if it spans whole lines. */ +function uniqueStartLine( + text: string | undefined, + snippet: string, + from = 1, + to = Number.POSITIVE_INFINITY, +): number | undefined { + if (text === undefined || snippet === "") return undefined; + let found: { readonly line: number; readonly whole: boolean } | undefined; + let line = 1; + let lineStart = 0; + let nextBreak = text.indexOf("\n"); + for (let index = text.indexOf(snippet); index !== -1; index = text.indexOf(snippet, index + 1)) { + while (nextBreak !== -1 && nextBreak < index) { + line++; + lineStart = nextBreak + 1; + nextBreak = text.indexOf("\n", lineStart); + } + if (line > to) break; + if (line < from) continue; + if (found !== undefined) return undefined; + const end = index + snippet.length; + // A hunk replaces whole lines. + const whole = + index === lineStart && (end === text.length || snippet.endsWith("\n") || text[end] === "\n"); + found = { line, whole }; + } + return found?.whole === true ? found.line : undefined; +} + +function snippetHunks( + oldText: string, + newText: string, + maxEditLength: number, +): StructuredPatchHunk[] | undefined { + const before = oldText.replaceAll("\r\n", "\n"); + const after = newText.replaceAll("\r\n", "\n"); + // A target without its newline keeps the line's own newline, on both sides. + const eol = before.endsWith("\n") ? "" : "\n"; + return structuredPatch("", "", before + eol, after + eol, undefined, undefined, { + maxEditLength, + })?.hunks; +} + +const changedLines = (hunks: ReadonlyArray) => + hunks.reduce( + (total, hunk) => + total + hunk.lines.filter((line) => line.startsWith("+") || line.startsWith("-")).length, + 0, + ); + +/** + * Antigravity reports replacements as bare old/new snippets. `StartLine` only starts the + * runtime's search range, so a chunk is placed at its unique match in the pre-edit file within + * that range. All-unplaced chunks get hunks labeled as snippets; a mix is omitted. + */ +export function antigravityEditPatch(input: { + readonly path: string; + readonly rawInput: unknown; + readonly locations?: unknown; + /** Pass only while the edit is pending, when the file still holds the old text. */ + readonly readPreEditFile?: ((path: string) => string | undefined) | undefined; +}): string | typeof ANTIGRAVITY_EDIT_DIFF_OMITTED | undefined { + const edit = Option.getOrUndefined(decodeEditInput(pascalKeys(input.rawInput))); + if (edit === undefined) return undefined; + const announced = lineOf(Option.getOrUndefined(decodeLocations(input.locations))?.[0]?.line); + const listed = "ReplacementChunks" in edit ? edit.ReplacementChunks : [edit]; + const chunks = listed.map((chunk) => ({ + ...chunk, + StartLine: lineOf(chunk.StartLine) ?? (listed.length === 1 ? announced : undefined), + EndLine: lineOf(chunk.EndLine), + })); + let file: string | undefined; + let fileRead = false; + const textSize = chunks.reduce( + (total, chunk) => total + chunk.TargetContent.length + chunk.ReplacementContent.length, + 0, + ); + if (textSize > EDIT_TEXT_BUDGET) return ANTIGRAVITY_EDIT_DIFF_OMITTED; + let budget = EDIT_DIFF_BUDGET; + const placed: Array<{ readonly start: number; readonly hunks: StructuredPatchHunk[] }> = []; + const snippets: StructuredPatchHunk[] = []; + for (const chunk of chunks) { + // Whether a changed final line break, or a cleared line, splits, joins or removes lines + // depends on what follows the match. Only a whole-line delete is certain. + const targetEndsLine = chunk.TargetContent.endsWith("\n"); + if ( + chunk.ReplacementContent === "" + ? !targetEndsLine + : targetEndsLine !== chunk.ReplacementContent.endsWith("\n") + ) + return ANTIGRAVITY_EDIT_DIFF_OMITTED; + const hunks = snippetHunks(chunk.TargetContent, chunk.ReplacementContent, budget); + // A change to the line ending alone leaves no LF hunk to show. + if ( + hunks === undefined || + (hunks.length === 0 && chunk.TargetContent !== chunk.ReplacementContent) + ) + return ANTIGRAVITY_EDIT_DIFF_OMITTED; + budget -= changedLines(hunks); + if (budget < 0) return ANTIGRAVITY_EDIT_DIFF_OMITTED; + let start: number | undefined; + if (input.readPreEditFile !== undefined) { + // Line numbers do not depend on CRLF, so search with LF on both sides. + if (!fileRead) file = input.readPreEditFile(input.path)?.replaceAll("\r\n", "\n"); + fileRead = true; + start = uniqueStartLine( + file, + chunk.TargetContent.replaceAll("\r\n", "\n"), + chunk.StartLine, + chunk.EndLine, + ); + } + if (start === undefined) snippets.push(...hunks); + else placed.push({ start, hunks }); + } + // A placed hunk beside an unplaced one would still number the unplaced one from line 1. + if (placed.length > 0 && snippets.length > 0) return ANTIGRAVITY_EDIT_DIFF_OMITTED; + let shift = 0; + const placedHunks = placed + .toSorted((left, right) => left.start - right.start) + .flatMap(({ start, hunks }) => { + const shifted = hunks.map((hunk) => ({ + ...hunk, + oldStart: hunk.oldStart + start - 1, + newStart: hunk.newStart + start - 1 + shift, + })); + for (const hunk of hunks) shift += hunk.newLines - hunk.oldLines; + return shifted; + }); + const section = (hunks: StructuredPatchHunk[]) => + hunks.length === 0 + ? "" + : formatPatch( + { + oldFileName: input.path, + newFileName: input.path, + oldHeader: undefined, + newHeader: undefined, + hunks, + }, + FILE_HEADERS_ONLY, + ); + const patch = [ + section(placedHunks), + section(snippets).replace(/^@@ .* @@$/gm, `$&${SNIPPET_LABEL}`), + ] + .filter(Boolean) + .join("\n"); + return patch || undefined; +} diff --git a/apps/server/src/provider/acp/AntigravityProfilePaths.ts b/apps/server/src/provider/acp/AntigravityProfilePaths.ts new file mode 100644 index 0000000000000..eb5ef1fcac292 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityProfilePaths.ts @@ -0,0 +1,37 @@ +// @effect-diagnostics nodeBuiltinImport:off -- lstat proves no managed component is a link. +import type * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +/** + * Proves a native runtime entry is at its literal path below the canonical profile: every + * component exists with the expected type and none is a link. A link could point at another + * child's transcript or another session's database under the same profile. + */ +export async function antigravityManagedPath( + profileRoot: string, + segments: ReadonlyArray, + kind: "file" | "directory", +): Promise<{ readonly path: string; readonly info: NodeFS.Stats } | undefined> { + let current = profileRoot; + let info: NodeFS.Stats | undefined; + for (const [index, segment] of segments.entries()) { + current = NodePath.join(current, segment); + info = await NodeFSP.lstat(current).catch(() => undefined); + const expectFile = kind === "file" && index === segments.length - 1; + if ( + info === undefined || + info.isSymbolicLink() || + (expectFile ? !info.isFile() : !info.isDirectory()) + ) { + return undefined; + } + } + if (info === undefined) return undefined; + return (await NodeFSP.realpath(current).catch(() => undefined)) === current + ? { path: current, info } + : undefined; +} + +export const antigravityProfileRoot = (profileDirectory: string) => + NodeFSP.realpath(profileDirectory).catch(() => undefined); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 909de58cb7557..a2d7c86f4c2d3 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -19,6 +19,7 @@ import { mergeToolCallState, parseSessionUpdateEvent, } from "@t3tools/provider-acp/server/runtimeModel"; +import { acpToolCallDiffPatch } from "@t3tools/provider-acp/server/adapter"; const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification); @@ -291,6 +292,135 @@ describe("Antigravity tool results", () => { expect(commandCompleted.update.rawOutput.formatted_output).toBe("after\n"); }); + it("keeps the captured edit location through approval and completion", () => { + const path = "/workspace/app.ts"; + const replace = { + TargetContent: "const target = 10;", + ReplacementContent: "const target = 100;", + TargetFile: path, + }; + const frame = (rawInput: Record, line?: number) => + ({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "edit-1", + title: "Run edit_file?", + kind: "edit", + status: "pending", + content: [ + { + type: "diff", + path, + oldText: replace.TargetContent, + newText: replace.ReplacementContent, + }, + ], + locations: [line === undefined ? { path } : { path, line }], + rawInput, + }, + }) satisfies EffectAcpSchema.SessionNotification; + const running = parseToolUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "edit-1", + kind: "edit", + status: "in_progress", + title: "Running edit_file", + locations: [{ path }], + rawInput: { file_path: path }, + }, + }).toolCall; + const completed = parseToolUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "edit-1", + status: "completed", + rawOutput: "Tool execution", + }, + }).toolCall; + const preEdit = Array.from({ length: 12 }, (_, i) => + i === 9 ? replace.TargetContent : `line ${i + 1}`, + ).join("\n"); + const postEdit = preEdit.replace(replace.TargetContent, replace.ReplacementContent); + for (const [pending, readFile] of [ + [frame({ ...replace, StartLine: 5, EndLine: 12 }, 5), () => preEdit], + [frame(replace), () => preEdit], + ] as const) { + let toolCall = normalizeAntigravityToolCall(parseToolUpdate(pending).toolCall, readFile); + for (const next of [running, completed]) { + toolCall = normalizeAntigravityToolCall(mergeToolCallState(toolCall, next), () => postEdit); + } + expect(toolCall.status).toBe("completed"); + expect(acpToolCallDiffPatch(toolCall.data.content)).toBe( + `--- ${path}\n+++ ${path}\n@@ -10,1 +10,1 @@\n-const target = 10;\n+const target = 100;\n`, + ); + } + // Full-access edits arrive path-only; no hunk is offered instead of the tool's status text. + let pathOnly = normalizeAntigravityToolCall( + parseToolUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "edit-2", + kind: "edit", + status: "in_progress", + title: "Running edit_file", + locations: [{ path }], + rawInput: { file_path: path }, + }, + }).toolCall, + ); + pathOnly = normalizeAntigravityToolCall(mergeToolCallState(pathOnly, completed)); + expect(pathOnly.data.content).toEqual([{ type: "diff", patch: { format: "omitted" } }]); + expect(acpToolCallDiffPatch(pathOnly.data.content)).toBeUndefined(); + // A captured patch that a later update's shared text budget cut or dropped is omitted. + for (const patch of [{ text: "[Earlier output truncated]\n\n@@ -80,1 +80,1 @@" }, {}]) { + const kept = normalizeAntigravityToolCall({ + toolCallId: "edit-cut", + kind: "edit", + status: "completed", + data: { content: [{ type: "diff", path, patch }] }, + }); + expect(kept.data.content).toEqual([{ type: "diff", path, patch: { format: "omitted" } }]); + } + // A whole-file write whose old text was cut to its tail must not show the cut as deletions. + const overwrite = normalizeAntigravityToolCall({ + toolCallId: "edit-overwrite", + kind: "edit", + status: "completed", + data: { + rawInput: { TargetFile: path, CodeContent: "one line\n" }, + content: [ + { + type: "diff", + path, + oldText: "[Earlier output truncated]\n\nrow 999\n", + newText: "one line\n", + }, + ], + }, + }); + expect(acpToolCallDiffPatch(overwrite.data.content)).toBeUndefined(); + // A patch too long to keep, or input cut to its tail, must not fall back to line-1 snippets. + for (const width of [60, 120]) { + const block = (value: string) => + Array.from({ length: 120 }, (_, i) => `${value} ${i} `.padEnd(width, "x")).join("\n"); + const long = { + TargetContent: block("old"), + ReplacementContent: block("new"), + TargetFile: path, + }; + const toolCall = normalizeAntigravityToolCall( + parseToolUpdate(frame({ ...long, StartLine: 80, EndLine: 199 }, 80)).toolCall, + ); + expect(toolCall.data.content).toMatchObject([{ patch: { format: "omitted" } }]); + expect(acpToolCallDiffPatch(toolCall.data.content)).toBeUndefined(); + } + }); + it.each([ { CommandLine: "pwd", Cwd: "/one" }, { CommandLine: "pwd", WorkingDirectory: "/one" }, diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index a91d45164caaf..5b74756a1de11 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -12,6 +12,13 @@ import * as AcpWireSchema from "effect-acp/schema-v1"; import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; +import { + ANTIGRAVITY_EDIT_DIFF_OMITTED, + antigravityEditPatch, + isAntigravitySnippetPatch, + readAntigravityPreEditFile, +} from "./AntigravityEditLocation.ts"; + const TOOL_TEXT_LIMIT = 8_000; const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; const QUESTION_LABEL_LIMIT = 512; @@ -163,6 +170,12 @@ function boundText(text: string, limit = TOOL_TEXT_LIMIT): string { : copyBoundedText(`${TOOL_TEXT_TRUNCATED}${text.slice(-limit)}`); } +/** Keeps the tail of long tool text, as every bounded tool payload does. */ +export { boundText as boundAntigravityToolText }; + +/** True when bounding cut some of this text. */ +export const isCutAntigravityToolText = (text: string) => text.includes(TOOL_TEXT_TRUNCATED); + interface ToolPayloadBudget { nodes: number; text: number; @@ -350,7 +363,58 @@ function localImagePath(imagePath: string | undefined): string | undefined { return /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path) ? undefined : path; } -export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToolCallState { +function hasTruncatedText(value: unknown): boolean { + if (typeof value === "string") return value.startsWith(TOOL_TEXT_TRUNCATED); + return Predicate.isObject(value) && Object.values(value).some(hasTruncatedText); +} + +/** The ACP adapter renders a diff's `patch.text` ahead of its bare old/new snippets. */ +function withAntigravityEditPatches( + content: ReadonlyArray, + data: Record, + readPreEditFile: ((path: string) => string | undefined) | undefined, +) { + return content.map((entry) => { + if (!Predicate.isObject(entry) || entry.type !== "diff" || typeof entry.path !== "string") + return entry; + // Keep the first patch: once the edit runs, the file no longer holds the old text. A later + // update's shared text budget can cut or drop it, and a cut patch would misplace the hunk. + if (entry.patch !== undefined) { + const patch: unknown = entry.patch; + const cut = + Predicate.isObject(patch) && + patch.format !== "omitted" && + (typeof patch.text !== "string" || patch.text.startsWith(TOOL_TEXT_TRUNCATED)); + return cut ? { ...entry, patch: { format: "omitted" } } : entry; + } + // Bounding keeps only the tail of long text, so a cut snippet cannot be located and a cut + // old or new text would render the cut as file content. + if ([data.rawInput, entry.oldText, entry.newText].some(hasTruncatedText)) + return { ...entry, patch: { format: "omitted" } }; + const text = antigravityEditPatch({ + path: entry.path, + rawInput: data.rawInput, + locations: data.locations, + readPreEditFile, + }); + // A truncated patch would no longer parse, and bare snippets would render at line 1. + if ( + text === ANTIGRAVITY_EDIT_DIFF_OMITTED || + (text !== undefined && text.length > TOOL_TEXT_LIMIT) + ) + return { ...entry, patch: { format: "omitted" } }; + if (text === undefined) return entry; + return { + ...entry, + patch: isAntigravitySnippetPatch(text) ? { format: "snippet", text } : { text }, + }; + }); +} + +export function normalizeAntigravityToolCall( + toolCall: AcpToolCallState, + readPreEditFile = readAntigravityPreEditFile, +): AcpToolCallState { const input = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawInput)); const output = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawOutput)); const nativeCommand = @@ -382,6 +446,17 @@ export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToo if (command !== undefined) data.command = command; if (cwd !== undefined) data.cwd = cwd; if (imagePath !== undefined) data.imagePath = imagePath; + if (kind === "edit") { + const content = Array.isArray(data.content) ? data.content : []; + // Full-access edits report only a path; without a hunk the checkpoint diff is the review. + data.content = content.some((entry) => Predicate.isObject(entry) && entry.type === "diff") + ? withAntigravityEditPatches( + content, + data, + toolCall.status === "pending" ? readPreEditFile : undefined, + ) + : [...content, { type: "diff", patch: { format: "omitted" } }]; + } if (kind === "execute") { data.item = { ...(Predicate.isObject(data.item) ? data.item : {}), diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.test.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.test.ts new file mode 100644 index 0000000000000..795e80b861e84 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.test.ts @@ -0,0 +1,64 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { retireAntigravityRuntimeTempDirs } from "./AntigravitySessionFiles.ts"; + +it.layer(NodeServices.layer)("retireAntigravityRuntimeTempDirs", (it) => { + it.effect("moves leftovers aside at once and deletes them, plus older retirees, later", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped(); + const runtimeTemp = path.join(parent, "abc123"); + const orphan = path.join(runtimeTemp, "run-orphan", "_MEI123"); + yield* fs.makeDirectory(orphan, { recursive: true }); + yield* fs.writeFileString(path.join(orphan, "payload.bin"), "stale"); + yield* fs.makeDirectory(path.join(parent, "abc123.old-unfinished", "runtime"), { + recursive: true, + }); + yield* fs.makeDirectory(path.join(parent, "other-instance")); + + const removeRetired = yield* retireAntigravityRuntimeTempDirs(runtimeTemp); + expect(yield* fs.exists(runtimeTemp)).toBe(false); + const retired = (yield* fs.readDirectory(parent)).filter((entry) => + entry.startsWith("abc123.old-"), + ); + expect(retired).toHaveLength(2); + + yield* removeRetired; + expect((yield* fs.readDirectory(parent)).toSorted()).toEqual(["other-instance"]); + }).pipe(Effect.scoped), + ); + + it.effect( + "deletes leftovers inline when they cannot be moved aside, such as on a full disk", + () => + Effect.gen(function* () { + const removed: Array = []; + const fs = FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + makeTempDirectory: () => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "makeTempDirectory", + description: "ENOSPC", + }), + ), + remove: (path) => Effect.sync(() => void removed.push(path)), + readDirectory: () => Effect.succeed([]), + }); + + const removeRetired = yield* retireAntigravityRuntimeTempDirs( + "/state/antigravity-tmp/abc123", + ).pipe(Effect.provideService(FileSystem.FileSystem, fs)); + yield* removeRetired; + expect(removed).toEqual(["/state/antigravity-tmp/abc123"]); + }), + ); +}); diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.ts index 7a25c8323174d..819d79a05ed12 100644 --- a/apps/server/src/provider/acp/AntigravitySessionFiles.ts +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.ts @@ -42,13 +42,35 @@ export const removeAntigravitySessionFiles = Effect.fn("removeAntigravitySession Effect.catch(() => Effect.logWarning("Could not remove temporary Antigravity session files.")), ); -/** Sweep only private runtimes before launch; Windows does not protect live extracted data files from deletion. */ -export const removeAntigravityRuntimeTempDirs = Effect.fn("removeAntigravityRuntimeTempDirs")( +const warnRuntimeTempLeftovers = Effect.logWarning( + "Could not remove leftover Antigravity runtime temp files.", +); + +/** + * Sweep only private runtimes before launch; Windows does not protect live extracted data files from deletion. + * Moves leftovers aside and returns their slow deletion (seconds on Windows) to run off the startup path. + */ +export const retireAntigravityRuntimeTempDirs = Effect.fn("retireAntigravityRuntimeTempDirs")( function* (tempDirectory: string) { const fs = yield* FileSystem.FileSystem; - yield* fs.remove(tempDirectory, { recursive: true, force: true }); + const path = yield* Path.Path; + const parent = path.dirname(tempDirectory); + const prefix = `${path.basename(tempDirectory)}.old-`; + if (yield* fs.exists(tempDirectory)) { + yield* fs.makeTempDirectory({ directory: parent, prefix }).pipe( + Effect.flatMap((retired) => fs.rename(tempDirectory, path.join(retired, "runtime"))), + Effect.catch(() => fs.remove(tempDirectory, { recursive: true, force: true })), + ); + } + const entries = yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => [])); + return Effect.forEach( + entries.filter((entry) => entry.startsWith(prefix)), + (entry) => + fs + .remove(path.join(parent, entry), { recursive: true, force: true }) + .pipe(Effect.catch(() => warnRuntimeTempLeftovers)), + { discard: true }, + ); }, - Effect.catch(() => - Effect.logWarning("Could not remove leftover Antigravity runtime temp files."), - ), + Effect.catch(() => warnRuntimeTempLeftovers.pipe(Effect.as(Effect.void))), ); diff --git a/apps/server/src/provider/acp/AntigravityTurnOutcome.test.ts b/apps/server/src/provider/acp/AntigravityTurnOutcome.test.ts new file mode 100644 index 0000000000000..d3ca431c50794 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityTurnOutcome.test.ts @@ -0,0 +1,195 @@ +import * as NodeSqlite from "node:sqlite"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { watchAntigravityPromptOutcome } from "./AntigravityTurnOutcome.ts"; + +const USER_INPUT = 14; +const PLANNER_RESPONSE = 15; +const TOOL = 8; +const ERROR_MESSAGE = 17; + +// Error-step payloads trimmed from Antigravity 1.4.5 against a local Gemini endpoint. +const errorPayload = (error: string) => + Buffer.concat([ + Buffer.from([0x0a, 0x24]), + Buffer.from( + `Agent execution terminated due to error.\u0012agent executor error: ${error}: ${error}\n(1) attached stack trace\n -- stack trace:`, + ), + ]); +// An unparsable reset timestamp must not hide the reset delay. +const QUOTA_ERROR = errorPayload( + "Error 429, Message: Quota exhausted for test model., Status: RESOURCE_EXHAUSTED, Details: [map[@type:type.googleapis.com/google.rpc.ErrorInfo domain:googleapis.com metadata:map[quotaResetDelay:3600s quotaResetTimeStamp:soon] reason:QUOTA_EXHAUSTED]]", +); +const INVALID_ARGUMENT_ERROR = errorPayload( + "Error 400, Message: Request contains an invalid argument., Status: INVALID_ARGUMENT, Details: []", +); +// The model's reply quoting the agent's quota notice is an ordinary planner response. +const QUOTED_NOTICE = Buffer.from( + "Usage Limit Reached\n\nYou have reached your current quota for this period. Your limit will reset in 1 hour.", +); + +describe("watchAntigravityPromptOutcome", () => { + it.effect("fails only turns whose own steps end in an upstream error", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profileDirectory = yield* fs.makeTempDirectoryScoped(); + const sessionId = "e6aba774-5472-4277-b6ee-27b2e05570a7"; + const conversations = path.join(profileDirectory, "antigravity-acp", "conversations"); + yield* fs.makeDirectory(conversations, { recursive: true }); + let nextIndex = 0; + const appendSteps = (steps: ReadonlyArray) => { + const database = new NodeSqlite.DatabaseSync(path.join(conversations, `${sessionId}.db`)); + try { + database.exec( + "CREATE TABLE IF NOT EXISTS steps (idx INTEGER PRIMARY KEY, step_type INTEGER, step_payload BLOB)", + ); + const insert = database.prepare("INSERT INTO steps VALUES (?, ?, ?)"); + for (const [type, payload] of steps) insert.run(nextIndex++, type, payload); + } finally { + database.close(); + } + }; + const turn = ( + steps: ReadonlyArray, + result: EffectAcpSchema.PromptResponse = { stopReason: "end_turn" }, + ) => + Effect.gen(function* () { + const check = yield* watchAntigravityPromptOutcome(profileDirectory, sessionId); + appendSteps(steps); + return yield* check(result); + }); + const input = Buffer.from("Run the fixture."); + const reply = Buffer.from("FIXTURE_OK"); + + // The first prompt creates the session log. + assert.isUndefined( + yield* turn([ + [USER_INPUT, input], + [PLANNER_RESPONSE, reply], + ]), + ); + assert.deepEqual( + yield* turn([ + [USER_INPUT, input], + [ERROR_MESSAGE, QUOTA_ERROR], + ]), + { + class: "usage_limit", + message: "You have reached your Antigravity quota for this period.", + code: "RESOURCE_EXHAUSTED", + retryable: true, + resetAt: "1970-01-01T01:00:00.000Z", + }, + ); + // The earlier error step is outside this turn. + assert.isUndefined( + yield* turn( + [ + [USER_INPUT, input], + [PLANNER_RESPONSE, QUOTED_NOTICE], + ], + { + stopReason: "end_turn", + usage: { inputTokens: 42, outputTokens: 3, totalTokens: 54 }, + }, + ), + ); + assert.deepEqual( + yield* turn([ + [USER_INPUT, input], + [ERROR_MESSAGE, INVALID_ARGUMENT_ERROR], + ]), + { + class: "provider_error", + message: "Request contains an invalid argument.", + code: "INVALID_ARGUMENT", + retryable: false, + }, + ); + // A completed tool keeps its usage, and the turn still fails. + assert.equal( + (yield* turn( + [ + [USER_INPUT, input], + [PLANNER_RESPONSE, reply], + [TOOL, reply], + [ERROR_MESSAGE, QUOTA_ERROR], + ], + { + stopReason: "end_turn", + usage: { inputTokens: 40, outputTokens: 5, totalTokens: 45 }, + }, + ))?.class, + "usage_limit", + ); + assert.isUndefined( + yield* turn([ + [USER_INPUT, input], + [PLANNER_RESPONSE, reply], + ]), + ); + // The agent maps the SDK's QUOTA_EXHAUSTED stop reason to `refusal`. + assert.deepEqual(yield* turn([], { stopReason: "refusal" }), { + class: "usage_limit", + message: "You have reached your Antigravity quota for this period.", + code: "QUOTA_EXHAUSTED", + retryable: true, + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reads only this session's own unlinked log, and keeps a typed refusal", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profileDirectory = yield* fs.makeTempDirectoryScoped(); + const conversations = path.join(profileDirectory, "antigravity-acp", "conversations"); + yield* fs.makeDirectory(conversations, { recursive: true }); + const own = "e6aba774-5472-4277-b6ee-27b2e05570a7"; + const peer = "0d9c2b4e-1111-4222-8333-944455556666"; + const append = (sessionId: string, type: number, payload: Uint8Array) => { + const database = new NodeSqlite.DatabaseSync(path.join(conversations, `${sessionId}.db`)); + try { + database.exec( + "CREATE TABLE IF NOT EXISTS steps (idx INTEGER PRIMARY KEY, step_type INTEGER, step_payload BLOB)", + ); + database + .prepare("INSERT INTO steps (step_type, step_payload) VALUES (?, ?)") + .run(type, payload); + } finally { + database.close(); + } + }; + + // own.db links to a peer session; the peer's new error step is not this turn's. + append(peer, USER_INPUT, Buffer.from("peer")); + yield* fs.symlink( + path.join(conversations, `${peer}.db`), + path.join(conversations, `${own}.db`), + ); + const linked = yield* watchAntigravityPromptOutcome(profileDirectory, own); + append(peer, ERROR_MESSAGE, QUOTA_ERROR); + assert.isUndefined(yield* linked({ stopReason: "end_turn" })); + assert.equal((yield* linked({ stopReason: "refusal" }))?.class, "usage_limit"); + + // An unreadable log gives no evidence, but the agent's typed refusal still fails the turn. + yield* fs.remove(path.join(conversations, `${own}.db`)); + yield* fs.writeFileString(path.join(conversations, `${own}.db`), "not a database"); + const corrupt = yield* watchAntigravityPromptOutcome(profileDirectory, own); + assert.isUndefined(yield* corrupt({ stopReason: "end_turn" })); + assert.deepEqual(yield* corrupt({ stopReason: "refusal" }), { + class: "usage_limit", + message: "You have reached your Antigravity quota for this period.", + code: "QUOTA_EXHAUSTED", + retryable: true, + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/acp/AntigravityTurnOutcome.ts b/apps/server/src/provider/acp/AntigravityTurnOutcome.ts new file mode 100644 index 0000000000000..874c28eeb1537 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityTurnOutcome.ts @@ -0,0 +1,194 @@ +// @effect-diagnostics nodeBuiltinImport:off -- node:sqlite and lstat read the native session log. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; + +import type { OrchestrationV2ProviderFailure } from "@t3tools/contracts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { antigravityManagedPath, antigravityProfileRoot } from "./AntigravityProfilePaths.ts"; + +// CORTEX_STEP_TYPE_ERROR_MESSAGE in the native trajectory. +const ERROR_STEP_TYPE = 17; +// The harness error text leads the payload; a Go stack trace follows it. +const MAX_ERROR_PAYLOAD_BYTES = 16_384; +// The Go harness's rendering of a Google API error, as the agent's api_errors.py reads it. +const API_ERROR = /Error (\d{3}), Message: (.+?), Status: ([A-Z_]+)/s; +const QUOTA_EXHAUSTED = /\breason:QUOTA_EXHAUSTED\b/; +const RESET_TIMESTAMP = /\bquotaResetTimeStamp:([^\s\]]+)/; +const RESET_DELAY = /\bquotaResetDelay:((?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|h|m|s))+)(?=[\s\]])/; +const GO_DURATION_PART = /(\d+(?:\.\d+)?)(ns|us|µs|μs|ms|h|m|s)/g; +const GO_DURATION_UNIT_MS: Record = { + ns: 1e-6, + us: 1e-3, + µs: 1e-3, + μs: 1e-3, + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, +}; +const NATIVE_SESSION_ID = /^[\w-]+$/; + +interface LastStep { + readonly type: number; + readonly payload: Uint8Array | null; +} + +const readSteps = (file: string, read: (database: NodeSqlite.DatabaseSync) => A) => + Effect.try(() => { + const database = new NodeSqlite.DatabaseSync(file, { readOnly: true }); + try { + database.exec("PRAGMA busy_timeout = 100"); + const hasSteps = + database + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'steps'") + .get() !== undefined; + return hasSteps ? read(database) : undefined; + } finally { + database.close(); + } + }); + +const lastStepIndex = (database: NodeSqlite.DatabaseSync) => { + const index = database.prepare("SELECT max(idx) AS idx FROM steps").get()?.idx; + return typeof index === "number" ? index : -1; +}; + +const lastStepAfter = + (index: number) => + (database: NodeSqlite.DatabaseSync): LastStep | undefined => { + const row = database + .prepare( + `SELECT step_type AS type, substr(step_payload, 1, ?) AS payload + FROM steps WHERE idx > ? ORDER BY idx DESC LIMIT 1`, + ) + .get(MAX_ERROR_PAYLOAD_BYTES, index); + if (typeof row?.type !== "number") return undefined; + return { type: row.type, payload: row.payload instanceof Uint8Array ? row.payload : null }; + }; + +function resetAtFrom(text: string, now: DateTime.Utc): string | undefined { + // makeProviderFailure drops a timestamp that does not parse, so fall back to the delay. + const timestamp = RESET_TIMESTAMP.exec(text)?.[1]; + if (timestamp !== undefined && Option.isSome(DateTime.make(timestamp))) return timestamp; + const delay = RESET_DELAY.exec(text)?.[1]; + if (delay === undefined) return undefined; + let delayMs = 0; + for (const [, amount, unit] of delay.matchAll(GO_DURATION_PART)) { + delayMs += Number(amount) * (GO_DURATION_UNIT_MS[unit!] ?? 0); + } + return delayMs > 0 ? DateTime.formatIso(DateTime.add(now, { milliseconds: delayMs })) : undefined; +} + +function turnFailure( + stopReason: EffectAcpSchema.StopReason, + step: LastStep | undefined, + now: DateTime.Utc, +): OrchestrationV2ProviderFailure | undefined { + const errorText = + step?.type === ERROR_STEP_TYPE + ? new TextDecoder().decode(step.payload ?? new Uint8Array()) + : undefined; + // The agent maps only the SDK's QUOTA_EXHAUSTED stop reason to `refusal`. + if (errorText === undefined && stopReason !== "refusal") return undefined; + const text = errorText ?? ""; + const apiError = API_ERROR.exec(text); + if (stopReason === "refusal" || QUOTA_EXHAUSTED.test(text)) { + return makeProviderFailure({ + class: "usage_limit", + message: "You have reached your Antigravity quota for this period.", + code: apiError?.[3] ?? "QUOTA_EXHAUSTED", + retryable: true, + resetAt: resetAtFrom(text, now) ?? null, + }); + } + if (apiError === null) { + return makeProviderFailure({ + class: "provider_error", + message: "Antigravity stopped this turn because of an error.", + }); + } + const [, httpStatus, message, status] = apiError; + const rateLimited = httpStatus === "429" || status === "RESOURCE_EXHAUSTED"; + return makeProviderFailure({ + class: rateLimited ? "usage_limit" : "provider_error", + message: message!.trim(), + code: status ?? null, + retryable: rateLimited || Number(httpStatus) >= 500, + }); +} + +const conversationSegments = ["antigravity-acp", "conversations"]; + +/** + * Antigravity reports an upstream API failure as a chat message and `end_turn`. Its + * session log ends that turn with an error step, so the check reads only steps the + * current prompt added, read-only, from this session's own unlinked database. Without + * that evidence a typed `refusal` still fails the turn. + */ +export const watchAntigravityPromptOutcome = Effect.fn("AntigravityTurnOutcome.watch")(function* ( + profileDirectory: string, + sessionId: string, +) { + const typedOnly = (result: EffectAcpSchema.PromptResponse) => + DateTime.now.pipe(Effect.map((now) => turnFailure(result.stopReason, undefined, now))); + if (!NATIVE_SESSION_ID.test(sessionId)) return typedOnly; + const segments = [...conversationSegments, `${sessionId}.db`]; + const before = yield* Effect.tryPromise(async () => { + const root = await antigravityProfileRoot(profileDirectory); + if (root === undefined) return undefined; + if ((await antigravityManagedPath(root, conversationSegments, "directory")) === undefined) { + return undefined; + } + const database = await antigravityManagedPath(root, segments, "file"); + // A path that exists but fails the proof is a link or other foreign entry. + if (database === undefined) { + const present = await NodeFSP.lstat(NodePath.join(root, ...segments)).then( + () => true, + () => false, + ); + return present ? undefined : { root, identity: undefined, startIndex: -1 }; + } + return { root, identity: database.info, startIndex: undefined }; + }).pipe(Effect.orElseSucceed(() => undefined)); + if (before === undefined) { + yield* Effect.logWarning("The Antigravity session log is not this session's own file."); + return typedOnly; + } + const startIndex = + before.startIndex ?? + (yield* readSteps(NodePath.join(before.root, ...segments), lastStepIndex).pipe( + Effect.map((index) => index ?? -1), + Effect.orElseSucceed(() => undefined), + )); + if (startIndex === undefined) { + yield* Effect.logWarning("Could not read the Antigravity session log before the prompt."); + return typedOnly; + } + return Effect.fn("AntigravityTurnOutcome.check")(function* ( + result: EffectAcpSchema.PromptResponse, + ) { + // The same file as before the prompt, or one this prompt created, still unlinked. + const after = yield* Effect.tryPromise(() => + antigravityManagedPath(before.root, segments, "file"), + ).pipe(Effect.orElseSucceed(() => undefined)); + const sameFile = + after !== undefined && + (before.identity === undefined || + (after.info.dev === before.identity.dev && after.info.ino === before.identity.ino)); + const step = sameFile + ? yield* readSteps(after.path, lastStepAfter(startIndex)).pipe( + Effect.tapError(() => + Effect.logWarning("Could not read the Antigravity session log after the prompt."), + ), + Effect.orElseSucceed(() => undefined), + ) + : undefined; + return turnFailure(result.stopReason, step, yield* DateTime.now); + }); +}); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 07830f90d0567..1719d3ca6bd19 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 5129, - "codexAdditionalContextBytes": 6859, - "mcpToolListJsonBytes": 46109 + "claudeSystemAppendBytes": 5153, + "codexAdditionalContextBytes": 6883, + "mcpToolListJsonBytes": 47538 } diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 4a386b100f055..2b2ae029e5b0e 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -4,7 +4,7 @@ import * as NodeFSP from "node:fs/promises"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; -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"; @@ -178,9 +178,9 @@ export interface AntigravityInstanceDirectories { export const resolveAntigravityInstanceDirectories = Effect.fn( "resolveAntigravityInstanceDirectories", )(function* (stateDir: string, instanceId: ProviderInstanceId) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const path = yield* Path.Path; - const key = Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(instanceId))); + const key = Hex.encode(yield* digest.digest(Digest.sha256, new TextEncoder().encode(instanceId))); return { profile: path.join(stateDir, "providers", "antigravity", key), runtimeTemp: path.join(stateDir, "antigravity-tmp", key.slice(0, 12)), diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index 17173760d5e3d..255a92761994a 100644 --- a/apps/server/src/provider/antigravityRelease.ts +++ b/apps/server/src/provider/antigravityRelease.ts @@ -1,5 +1,6 @@ -// 1.4.2 through 1.4.4 write skills/system/run-workflow/SKILL.md into every session workspace. -const ANTIGRAVITY_RELEASE_VERSION = "1.4.1"; +// Hashes and sizes match the official ACP registry release. Like 1.4.2 through 1.4.4, it writes a +// run-workflow skill into the workspace unless T3 disables the tool (antigravitySessionMeta). +const ANTIGRAVITY_RELEASE_VERSION = "1.4.5"; export interface AntigravityReleaseAsset { readonly version: string; @@ -21,66 +22,66 @@ const releaseAssets = new Map([ "darwin-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.4.1-darwin-arm64.zip", - sha256: "f9bdd68291ae78c38d2141e63bc86b84e6cc7b9f60f71652c68435a7943deb17", - archiveBytes: 112_737_434, - executable: { name: "agy_acp_server.par", bytes: 280_996_576 }, - harness: { name: "localharness_external", bytes: 120_246_928 }, + url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.4.5-darwin-arm64.zip", + sha256: "886fff7767d08994ca39eb02c3d01a7532c7325a94aa33aaf52d52068733284f", + archiveBytes: 97_923_855, + executable: { name: "agy_acp_server.par", bytes: 178_915_920 }, + harness: { name: "localharness_external", bytes: 122_957_936 }, }, ], [ "darwin-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.4.1-darwin-x86_64.zip", - sha256: "27e31f096874704f14167b07ef9e84cc77134615ee41d024f7b1c957add85307", - archiveBytes: 118_349_036, - executable: { name: "agy_acp_server.par", bytes: 285_186_624 }, - harness: { name: "localharness_external", bytes: 124_910_048 }, + url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.4.5-darwin-x86_64.zip", + sha256: "b39f3573c03b9acb9df028853f647527f1f3133f32bd77e78b796e48a205114a", + archiveBytes: 103_577_295, + executable: { name: "agy_acp_server.par", bytes: 183_127_600 }, + harness: { name: "localharness_external", bytes: 127_612_208 }, }, ], [ "linux-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.4.1-linux-x86_64.zip", - sha256: "2e37012ceb2a5c7fc7309b6e6596234fb637ee3c62a5199f65d4cbe777871384", - archiveBytes: 336_170_339, - executable: { name: "agy_acp_server.par", bytes: 930_826_753 }, - harness: { name: "localharness_external", bytes: 131_187_512 }, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.4.5-linux-x86_64.zip", + sha256: "923923a0d7dc8073f66cc6f78647a3107e3a7ee2b7e1a24df8b3927b5188013b", + archiveBytes: 321_783_181, + executable: { name: "agy_acp_server.par", bytes: 826_226_766 }, + harness: { name: "localharness_external", bytes: 133_926_136 }, }, ], [ "linux-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.4.1-linux-arm64.zip", - sha256: "609336a1c97545487e3ddd55dbd153dc0f40f4e6534486cc1c5f4c0ebb85c2da", - archiveBytes: 321_922_507, - executable: { name: "agy_acp_server.par", bytes: 933_416_796 }, - harness: { name: "localharness_external", bytes: 124_895_000 }, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.4.5-linux-arm64.zip", + sha256: "7d64246633ae094f67e3bbcd2d679e89048f91346d5339313336e7483f40f056", + archiveBytes: 307_829_008, + executable: { name: "agy_acp_server.par", bytes: 831_194_025 }, + harness: { name: "localharness_external", bytes: 127_512_832 }, }, ], [ "win32-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.4.1-windows-x86_64.zip", - sha256: "5f5b7d0328df17c128b2be338d4567b0d24e56ace8196e88794fec37d1372554", - archiveBytes: 129_599_308, - executable: { name: "agy_acp_server.exe", bytes: 86_098_784 }, - harness: { name: "localharness_external.exe", bytes: 146_036_888 }, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.4.5-windows-x86_64.zip", + sha256: "d24f4c73c9f96d4f44384e281c855a56c72d8eaa9c5b0fad7ccb1635c6b63f50", + archiveBytes: 100_423_655, + executable: { name: "agy_acp_server.exe", bytes: 55_839_864 }, + harness: { name: "localharness_external.exe", bytes: 147_843_736 }, }, ], [ "win32-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.4.1-windows-arm64.zip", - sha256: "24c52bbc12aae20f55eb53d373669412d95a9c44fd59be4ea4e355918b91fbf7", - archiveBytes: 132_507_344, - executable: { name: "agy_acp_server.exe", bytes: 93_197_744 }, - harness: { name: "localharness_external.exe", bytes: 136_970_904 }, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.4.5-windows-arm64.zip", + sha256: "ab0a9aac8349e08ef289be6d16da8f89e3abe60eba5158032ef78c4a1220261c", + archiveBytes: 102_703_121, + executable: { name: "agy_acp_server.exe", bytes: 62_293_488 }, + harness: { name: "localharness_external.exe", bytes: 139_322_008 }, }, ], ]); diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 3eb366f0dba3a..612ea11efcefa 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -1,4 +1,6 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; import { describe, expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -195,7 +197,7 @@ describe("Antigravity subscription limits", () => { }); expect(byId.get("gemini-5h")?.resetsAt).toBe("2026-10-21T13:29:53.000Z"); expect(byId.get("gemini-weekly")?.resetsAt).toBe("2026-10-28T08:29:53.000Z"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: measure the tolerance from the summary request, after auth. @@ -216,7 +218,7 @@ describe("Antigravity subscription limits", () => { }, }); expect((yield* probe).windows[0]?.resetsAt).toBeUndefined(); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: keep descriptions, measured resets, and spent buckets. @@ -268,7 +270,7 @@ describe("Antigravity subscription limits", () => { { id: "spent", resetsAt: "2026-10-21T15:16:04.000Z" }, { id: "distant", resetsAt: "2026-10-28T12:16:04.000Z" }, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: the 60-second tolerance applies to both known window lengths. @@ -325,7 +327,7 @@ describe("Antigravity subscription limits", () => { { id: "weekly-before", resetsAt: "2026-10-28T10:15:03.000Z" }, { id: "weekly-edge", resetsAt: undefined }, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: the existing endpoint merge keeps the most-used reading. @@ -386,7 +388,7 @@ describe("Antigravity subscription limits", () => { resetsAt: "2026-10-27T10:16:04.000Z", }, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: model quotas have no evidence for the summary-only rule. @@ -414,7 +416,7 @@ describe("Antigravity subscription limits", () => { { id: "gemini-weekly", resetsAt: "2026-10-28T10:16:04.000Z", windowDurationMins: 10080 }, { id: "gemini-quota", resetsAt: "2026-10-21T15:16:04.000Z", windowDurationMins: undefined }, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("reports separate Gemini and shared Claude/GPT five-hour and weekly limits", () => @@ -441,7 +443,7 @@ describe("Antigravity subscription limits", () => { expect( requests.filter((request) => request.url.endsWith(":retrieveUserQuotaSummary")), ).toHaveLength(4); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("merges quota groups that only one endpoint reports", () => @@ -473,7 +475,7 @@ describe("Antigravity subscription limits", () => { expect(byId.get("gemini-weekly")?.label).toBe("Gemini · Weekly"); expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(70); expect(byId.get("3p-weekly")?.usedPercent).toBe(100); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("labels Argon's weekly quota apart from the default Gemini weekly quota", () => @@ -495,7 +497,7 @@ describe("Antigravity subscription limits", () => { const byId = new Map(limits.windows.map((window) => [window.id, window])); expect(byId.get("gemini-weekly")?.label).toBe("Gemini · Weekly"); expect(byId.get("gemini-argon-wtu-weekly")?.label).toBe("Gemini 4 Argon · Weekly"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("keeps one endpoint's quotas when the other fails", () => @@ -506,7 +508,7 @@ describe("Antigravity subscription limits", () => { const limits = yield* probe; expect(limits.unavailable).toBeUndefined(); expect(limits.windows).toHaveLength(4); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("re-reads changed credentials and clears cached access after sign-out", () => @@ -525,7 +527,7 @@ describe("Antigravity subscription limits", () => { yield* clear; yield* probe; expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(4); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("names one Google account the same way from every sign-in", () => @@ -539,7 +541,7 @@ describe("Antigravity subscription limits", () => { expect(machineB.credentialFingerprint).toBe(machineA.credentialFingerprint); expect(otherAccount.credentialFingerprint).not.toBe(machineA.credentialFingerprint); expect(JSON.stringify(machineA)).not.toMatch(/google-user-1|someone@example.com/); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("falls back to model quotas without inventing a window duration", () => @@ -568,7 +570,7 @@ describe("Antigravity subscription limits", () => { (window) => window.kind === "other" && window.windowDurationMins === undefined, ), ).toBe(true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("skips disabled and missing fractions and clamps valid percentages", () => @@ -591,7 +593,7 @@ describe("Antigravity subscription limits", () => { windowDurationMins: 300, }, ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("returns a safe failure when OAuth refresh fails", () => @@ -601,7 +603,7 @@ describe("Antigravity subscription limits", () => { expect(limits.windows).toEqual([]); expect(limits.unavailable?.reason).toBe("probeFailed"); expect(JSON.stringify(limits)).not.toMatch(/client-secret|refresh-a|access-token/); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("does not make network requests for API keys or missing sign-ins", () => @@ -611,6 +613,6 @@ describe("Antigravity subscription limits", () => { expect((yield* probe).unavailable?.reason).toBe("unsupported"); expect(requests).toEqual([]); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index f73fddb305962..5445c01432a2f 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -1,7 +1,7 @@ import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; -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 FileSystem from "effect/FileSystem"; @@ -188,7 +188,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); - const crypto = yield* Crypto.Crypto; + const crypto = yield* Digest.Digest; const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; const tokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json"); @@ -199,7 +199,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( const sha256 = (text: string) => crypto - .digest("SHA-256", new TextEncoder().encode(text)) + .digest(Digest.sha256, new TextEncoder().encode(text)) .pipe( Effect.map((digest) => Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""), diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 76819ce997efe..342a6780e9ad0 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -15,6 +15,7 @@ import type { AnyProviderDriver, ProviderUsageReaderEnv, } from "@t3tools/provider-core/server/driver"; +import { KiroDriver, type KiroDriverEnv } from "@t3tools/provider-kiro/server"; export type BuiltInDriversEnv = | AcpRegistryDriverEnv @@ -24,6 +25,7 @@ export type BuiltInDriversEnv = | CursorDriverEnv | FxDriverEnv | GrokDriverEnv + | KiroDriverEnv | OpenCodeDriverEnv | PiDriverEnv | MuseDriverEnv; @@ -36,6 +38,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray=0.0.42", - "recommendedRange": "=1.4.1", + "recommendedRange": "=1.4.5 || =1.4.1", "ranges": [ + { "range": "=1.4.5", "status": "supported" }, { "range": "=1.4.1", "status": "supported" }, { "range": "=1.3.0", "status": "supported" }, { "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/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 8575a73230f85..928acfb48072f 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -133,18 +133,24 @@ describe("provider compatibility", () => { } }); - it("supports the managed Antigravity runtime in the bundled manifest", () => { - const policies = decodeCompatibilityPolicies(bundledManifest.compatibility); - const release = resolveAntigravityReleaseAsset("darwin", "arm64"); - assert.strictEqual( - resolveProviderCompatibility( - policies, - ProviderDriverKind.make("antigravity"), - `agy_acp_server_${release?.version}`, - )?.status, - "supported", - ); - }); + it.each([ + ["darwin", "arm64"], + ["win32", "x64"], + ] as const)( + "supports the managed Antigravity runtime for %s-%s in the bundled manifest", + (platform, arch) => { + const policies = decodeCompatibilityPolicies(bundledManifest.compatibility); + const release = resolveAntigravityReleaseAsset(platform, arch); + assert.strictEqual( + resolveProviderCompatibility( + policies, + ProviderDriverKind.make("antigravity"), + `agy_acp_server_${release?.version}`, + )?.status, + "supported", + ); + }, + ); it("classifies boundaries and treats unlisted versions and release tags as unknown", () => { for (const [version, expected] of [ diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 9b7d1ffb35d41..77a44c7fa29a5 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -15,6 +16,7 @@ import * as Ref from "effect/Ref"; 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 { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -209,6 +211,7 @@ function makeRegistry( ); }, refreshWorkspaceSnapshot: () => Ref.get(providersRef), + invalidateModelCaches: () => Effect.void, getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, @@ -755,6 +758,41 @@ describe("providerMaintenanceRunner", () => { ), ); + // A native updater such as `claude update` downloads a ~300 MB binary and + // prints nothing while it does. Killed mid-download, it leaves a truncated + // binary behind that later updates mistake for an install. + it.effect.each([ + { runFor: "6 minutes", status: "succeeded", message: "Provider updated." }, + { runFor: "31 minutes", status: "failed", message: "Update timed out after 30 minutes." }, + ] as const)("lets a slow installer run for $runFor", ({ runFor, status, message }) => { + const started = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseNativeCliProvider); + const updater = yield* makeTestRunner(registry); + + const update = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkScoped); + yield* Deferred.await(started); + yield* TestClock.adjust(runFor); + const result = yield* Fiber.join(update); + + assert.strictEqual(result.providers[0]?.updateState?.status, status); + assert.strictEqual(result.providers[0]?.updateState?.message, message); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("1.0.0"), + layerMockSpawner(() => ({ + exitCode: Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.sleep(runFor)), + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + })), + ), + ), + ); + }); + it.effect("prevents concurrent updates for the same provider", () => { const startedLatch: { resolve: () => void } = { resolve: () => {} }; const releaseLatch: { resolve: () => void } = { resolve: () => {} }; @@ -856,17 +894,31 @@ describe("providerMaintenanceRunner", () => { ), ); - it.effect("resolves npm to a .cmd shim and routes through the shell on win32", () => { + it.effect("resolves npm to a .cmd shim from the provider's PATH on win32", () => { const captured: Array<{ readonly command: string; readonly args: ReadonlyArray; readonly shell: boolean | string | undefined; + readonly env: NodeJS.ProcessEnv | undefined; }> = []; return Effect.gen(function* () { const { registry } = yield* makeRegistry(baseProvider, { installedVersionAfterUpdate: "1.0.0", }); - const runner = yield* makeTestRunner(registry); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + env: { PATH: "C:\\fake\\npm" }, + }), + ), + }); const result = yield* runner.updateProvider(CODEX_DRIVER); @@ -879,17 +931,21 @@ describe("providerMaintenanceRunner", () => { ); assert.deepEqual(call.args, []); assert.strictEqual(call.shell, true); + assert.deepStrictEqual(call.env, { PATH: "C:\\fake\\npm" }); assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); }).pipe( Effect.provide( Layer.mergeAll( Layer.succeed(HostProcess.Platform, "win32"), + // Only the provider's PATH has npm. Layer.succeed(HostProcess.Environment, { - PATH: "C:\\fake\\npm", + PATH: "C:\\Windows\\System32", PATHEXT: ".COM;.EXE;.BAT;.CMD", }), - Layer.succeed(SpawnExecutableResolution, (command) => - command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined, + Layer.succeed(SpawnExecutableResolution, (command, _platform, env) => + command === "npm" && env.PATH === "C:\\fake\\npm" + ? "C:\\fake\\npm\\npm.cmd" + : undefined, ), layerLatestVersionHttpClient("1.0.0"), Layer.succeed( @@ -898,12 +954,16 @@ describe("providerMaintenanceRunner", () => { const childProcess = command as unknown as { readonly command: string; readonly args: ReadonlyArray; - readonly options: { readonly shell?: boolean | string | undefined }; + readonly options: { + readonly shell?: boolean | string | undefined; + readonly env?: NodeJS.ProcessEnv | undefined; + }; }; captured.push({ command: childProcess.command, args: childProcess.args, shell: childProcess.options.shell, + env: childProcess.options.env, }); return Effect.succeed(mockHandle({ stdout: "updated" })); }), diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index c2347da003300..ee7709e710eda 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -41,7 +41,11 @@ import type { ProviderMaintenanceCapabilities } from "@t3tools/provider-core/ser import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); -const UPDATE_TIMEOUT_MS = 5 * 60_000; +// Native updaters (`claude update`) download a ~300 MB binary without printing +// progress, and killing one mid-download leaves a truncated binary that the +// next run treats as installed. Allow a slow connection to finish. +const UPDATE_TIMEOUT_MINUTES = 30; +const UPDATE_TIMEOUT_MS = UPDATE_TIMEOUT_MINUTES * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; // Sample installer output because every progress update resends the provider list to all clients. const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); @@ -98,13 +102,14 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { + const env = input.env ? { env: input.env, extendEnv: true } : {}; // Windows batch shims require shell dispatch through resolveSpawnCommand. - const resolved = yield* resolveSpawnCommand(input.command, input.args); + const resolved = yield* resolveSpawnCommand(input.command, input.args, env); const child = yield* input.spawner .spawn( ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell, - ...(input.env ? { env: input.env, extendEnv: true } : {}), + ...env, }), ) .pipe( @@ -259,7 +264,7 @@ function lastOutputLine(result: ProviderMaintenanceCommandResult): string | null function failureMessage(result: ProviderMaintenanceCommandResult): string { if (result.timedOut) { - return "Update timed out."; + return `Update timed out after ${UPDATE_TIMEOUT_MINUTES} minutes.`; } const summary = result.exitCode !== null && result.exitCode !== 0 diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 85b76eb3fc63d..66e7d64d70a64 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -35,6 +35,7 @@ const BUILT_IN_DRIVER_ORDER: ReadonlyArray = [ "opencode", "antigravity", "fx", + "kiro", ]; const driverRank = (driver: string): number => { diff --git a/apps/server/src/provider/resetCreditCoordinator.ts b/apps/server/src/provider/resetCreditCoordinator.ts index 189c43b54f7f6..153728bc8ae43 100644 --- a/apps/server/src/provider/resetCreditCoordinator.ts +++ b/apps/server/src/provider/resetCreditCoordinator.ts @@ -1,7 +1,7 @@ /** Instances sharing a login directory share credits; serialize redemptions on that directory. */ import type { ProviderConsumeResetCreditOutcome } 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 type * as PlatformError from "effect/PlatformError"; @@ -27,7 +27,7 @@ export class ResetCreditCoordinator extends Context.Service< /** @public */ export const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const statesRef = yield* Ref.make>(new Map()); // Create account locks atomically so concurrent first redemptions cannot use separate locks. @@ -54,7 +54,7 @@ export const make = Effect.gen(function* () { return yield* state.lock.withPermits(1)( Effect.gen(function* () { const existing = yield* Ref.get(state.pendingKey); - const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4()); + const idempotencyKey = existing ?? (yield* random.uuidV4()); yield* Ref.set(state.pendingKey, idempotencyKey); const outcome = yield* consume(idempotencyKey).pipe( Effect.tapError((error) => @@ -78,13 +78,10 @@ export const layerTest = Layer.effect( let counter = 0; return yield* make.pipe( Effect.provideService( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => { - counter += 1; - return new Uint8Array(size).fill(counter); - }, - digest: (_algorithm, data) => Effect.succeed(data), + SecureRandom.SecureRandom, + SecureRandom.make((size) => { + counter += 1; + return new Uint8Array(size).fill(counter); }), ), ); 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 a4d944a457286..4f567142fbed5 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"; @@ -221,7 +222,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; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 004e883784499..0f068df5ef829 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"; @@ -300,7 +301,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)), ), ), ), diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts index a47743b34263a..92704c51102fe 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts @@ -152,6 +152,48 @@ describe("normalizeReviewGuide", () => { expect(result.stops).toHaveLength(1); }); + it("merges excerpts that a later excerpt bridges into one", () => { + const result = normalizeReviewGuide( + guide({ + stops: [ + stop({ + excerpts: [ + excerpt("src/a.ts", 40, 41), + { ...excerpt("src/a.ts", 50, 51), caption: "Second" }, + excerpt("src/a.ts", 41, 50), + ], + }), + ], + }), + change, + ); + expect(result.stops[0]?.excerpts).toEqual([ + { ...excerpt("src/a.ts", 40, 51), caption: "Second" }, + ]); + + const tailNote = { path: "src/a.ts", line: 40, title: "Tail", body: "Keep this note." }; + const wide = normalizeReviewGuide( + guide({ + stops: [ + stop({ + excerpts: [ + excerpt("src/a.ts", 1, 25), + { ...excerpt("src/a.ts", 30, 45), caption: "Second" }, + excerpt("src/a.ts", 24, 32), + ], + notes: [tailNote], + }), + ], + }), + parseReviewGuideChange( + "src/a.ts\0", + "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -0,0 +1,60 @@\n+added", + ), + ); + expect(wide.stops[0]?.excerpts).toEqual([{ ...excerpt("src/a.ts", 1, 25), caption: "Second" }]); + expect(wide.stops[0]?.notes).toEqual([]); + }); + it("drops notes outside the stop's remaining excerpts", () => { const result = normalizeReviewGuide( guide({ diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts index 184908e4bd7f2..82edfc5fb8159 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts @@ -87,26 +87,29 @@ function clampExcerpt( function mergeExcerpts( excerpts: ReadonlyArray, ): Array { - const merged: Array = []; + let merged: Array = []; for (const excerpt of excerpts) { - const index = merged.findIndex( - (other) => - other.path === excerpt.path && - other.side === excerpt.side && - other.start <= excerpt.end + 1 && - excerpt.start <= other.end + 1, - ); - const other = merged[index]; - if (other === undefined) { + const touches = (other: PullRequestReviewGuideExcerpt) => + other.path === excerpt.path && + other.side === excerpt.side && + other.start <= excerpt.end + 1 && + excerpt.start <= other.end + 1; + // An excerpt can bridge several earlier ones; fold them all into the first. + const group = merged.filter(touches); + const first = group[0]; + if (first === undefined) { merged.push(excerpt); continue; } - merged[index] = { - ...other, - start: Math.min(other.start, excerpt.start), - end: Math.max(other.end, excerpt.end), - caption: other.caption || excerpt.caption, + const combined = { + ...first, + start: Math.min(excerpt.start, ...group.map((other) => other.start)), + end: Math.max(excerpt.end, ...group.map((other) => other.end)), + caption: group.find((other) => other.caption)?.caption ?? excerpt.caption, }; + merged = merged.flatMap((other) => + other === first ? [combined] : touches(other) ? [] : [other], + ); } return merged.map((excerpt) => ({ ...excerpt, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 4bbaa85fc3b16..87838fd5a51ed 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -190,6 +190,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), listActiveSubagentThreadIds: unused, + listAppOwnedChildThreadIds: unused, countThreads: unused, listActiveShellIndex: () => Effect.succeed([]), listOlderSettledThreads: unused, @@ -482,6 +483,113 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("publishes a cleared thread's tombstone once while it stays cleared", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", null], + ); + + for (let i = 0; i < 3; i++) { + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + } + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", null], + ); + }), + ); + + it.effect("does not alert an old completion after publishing its thread's tombstone", () => + Effect.gen(function* () { + const oldCompleted = shell({ + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T11:59:00.000Z"), + }); + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe(NOW))); + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* Ref.set(currentShell, { + ...oldCompleted, + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(NOW), + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state), + [null], + ); + + yield* Ref.set(currentShell, oldCompleted); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state), + [null], + ); + }), + ); + + it.effect("alerts a completion settled during its hold after an earlier tombstone", () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe(NOW))); + const { relay, currentShell, publications } = yield* makeTestRelay(); + const settledAt = DateTime.makeUnsafe(NOW); + yield* Ref.set(currentShell, { + ...shell({ status: "completed" }), + settledOverride: "settled", + settledAt, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + + const completed = shell({ + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:00:06.000Z"), + }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + [null, "completed", null], + ); + }), + ); + it.effect("drops a held completion when a queued continuation hides the thread", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index f3c7b893afa83..6a235911d4f91 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -23,7 +23,7 @@ import { } from "@t3tools/shared/relayJwt"; 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 Fiber from "effect/Fiber"; @@ -113,6 +113,7 @@ function shouldPublishAgentAwarenessEvent( case "thread.unpinned": case "thread.pin-reordered": case "thread.active-reordered": + case "thread.tags-updated": case "thread.visited": case "thread.marked-unread": case "thread.runtime-mode-updated": @@ -164,6 +165,17 @@ function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): s return JSON.stringify(meaningfulState); } +const CLEARED_PUBLISH_IDENTITY = agentAwarenessPublishIdentity(null); + +// A published tombstone stays recorded so repeats are skipped, but it is not live state. +function hasLivePublishedState( + publishedStateByThread: ReadonlyMap, + threadId: ThreadId, +): boolean { + const identity = publishedStateByThread.get(threadId); + return identity !== undefined && identity !== CLEARED_PUBLISH_IDENTITY; +} + function resolveAgentActivityPublishingStartupState(input: { readonly relayConfigured: boolean; readonly publishEnabled: boolean; @@ -365,7 +377,7 @@ export const make = Effect.gen(function* () { const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const threads = yield* ThreadManagement.ThreadManagementService; const projects = yield* ProjectService.ProjectService; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const scope = yield* Effect.scope; const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); const startedAt = (yield* DateTime.now).epochMilliseconds; @@ -478,7 +490,7 @@ export const make = Effect.gen(function* () { environmentId, threadId, state: input.state, - jti: yield* crypto.randomUUIDv4(), + jti: yield* random.uuidV4(), }); yield* Effect.logInfo("publishing agent activity for thread", { @@ -516,7 +528,7 @@ export const make = Effect.gen(function* () { if ( threadShell !== null && !isListedThreadLineage(threadShell.lineage) && - !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) + !hasLivePublishedState(yield* Ref.get(publishedStateByThreadRef), threadId) ) { // Subagents and side chats have no relay rows to clear; per-event tombstones would needlessly redeliver user aggregates. return; @@ -537,11 +549,15 @@ export const make = Effect.gen(function* () { const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); if ( (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && - !publishedStateByThread.has(threadId) + !hasLivePublishedState(publishedStateByThread, threadId) ) { if (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, startedAt)) return; } - if (publishedStateByThread.get(threadId) === publishIdentity) { + if ( + publishedStateByThread.get(threadId) === publishIdentity && + // A held completion settled after an earlier clear still has to alert. + !(snapshot.state === null && heldCompletions.has(threadId)) + ) { // Clear moot deadlines so later transient nulls cannot skip their deferral window. publishConfirmDeadlines.delete(threadId); heldCompletions.delete(threadId); @@ -556,10 +572,7 @@ export const make = Effect.gen(function* () { // Confirm tombstones over live state to avoid removing active cards. // Confirm completion: a run can end moments before its continuation starts, and the relay // alerts on every move into completed. - const requiresConfirmation = - (snapshot.state === null && - publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || - snapshot.state?.phase === "completed"; + const requiresConfirmation = snapshot.state === null || snapshot.state?.phase === "completed"; if (requiresConfirmation) { if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; @@ -639,15 +652,9 @@ export const make = Effect.gen(function* () { }).pipe(withRelayClientTracing); publishConfirmDeadlines.delete(threadId); heldCompletions.delete(threadId); - yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { - const nextPublishedStates = new Map(publishedStates); - if (snapshot.state === null) { - nextPublishedStates.delete(threadId); - } else { - nextPublishedStates.set(threadId, publishIdentity); - } - return nextPublishedStates; - }); + yield* Ref.update(publishedStateByThreadRef, (publishedStates) => + new Map(publishedStates).set(threadId, publishIdentity), + ); }); const processThreadPublish = Effect.fnUntraced(function* (threadId: ThreadId) { 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/resourceTelemetry/DesktopTelemetryReceiver.test.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts index 616643c6e1e9e..fe014b82e2dd5 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts @@ -1,12 +1,195 @@ // @effect-diagnostics nodeBuiltinImport:off import { it } from "@effect/vitest"; +import * as NodeTls from "node:tls"; +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as Cause from "effect/Cause"; +import * as FileSystem from "effect/FileSystem"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { describe, expect } from "vite-plus/test"; -import { requireDesktopTelemetryWriteProgress } from "./DesktopTelemetryReceiver.ts"; +import { + makeApplyDesktopShellEnvironmentSnapshot, + requireDesktopTelemetryWriteProgress, +} from "./DesktopTelemetryReceiver.ts"; describe("DesktopTelemetryReceiver", () => { + it.live( + "preserves system and bundled roots when the first snapshot withdraws overlapping extras", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const directory = yield* fs.makeTempDirectoryScoped(); + const certificatePath = path.join(directory, "startup-extra-ca.pem"); + yield* fs.writeFileString( + certificatePath, + [...NodeTls.getCACertificates("system"), ...NodeTls.rootCertificates].join("\n"), + ); + const child = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + [ + "--use-bundled-ca", + "--use-system-ca", + "--input-type=module", + "--eval", + ` +import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; +import * as NodeTls from "node:tls"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { makeApplyDesktopShellEnvironmentSnapshot } from ${JSON.stringify(new URL("./DesktopTelemetryReceiver.ts", import.meta.url).href)}; +const fingerprints = (certificates) => [...new Set(certificates.map((pem) => new NodeCrypto.X509Certificate(pem).fingerprint256))].sort(); +const expected = fingerprints([...NodeTls.getCACertificates("bundled"), ...NodeTls.getCACertificates("system")]); +const applySnapshot = makeApplyDesktopShellEnvironmentSnapshot(process.env, process.platform); +await Effect.runPromise(applySnapshot({ + version: 1, + type: "shellEnvironment", + platform: process.platform, + discovered: true, + environment: { NODE_EXTRA_CA_CERTS: null }, +}).pipe(Effect.provide(NodeServices.layer))); +NodeAssert.equal(process.env.NODE_EXTRA_CA_CERTS, undefined); +NodeAssert.deepEqual(fingerprints(NodeTls.getCACertificates("default")), expected); +`, + ], + { + cwd: path.resolve(import.meta.dirname, "../.."), + env: { NODE_OPTIONS: "", NODE_EXTRA_CA_CERTS: certificatePath }, + }, + ), + ); + const result = yield* Effect.all( + { + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: child.exitCode.pipe(Effect.map(Number)), + }, + { concurrency: "unbounded" }, + ); + expect(result.exitCode, result.stderr).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("updates live TLS trust when shell certificates arrive after startup", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const certificatePath = path.join(directory, "extra-ca.pem"); + const original = NodeTls.getCACertificates("default"); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeTls.setDefaultCACertificates(original)), + ); + const baseline = NodeTls.rootCertificates[0]!; + const extra = NodeTls.rootCertificates[1]!; + const replacement = NodeTls.rootCertificates[2]!; + NodeTls.setDefaultCACertificates([baseline]); + yield* fs.writeFileString(certificatePath, `${extra}\n${replacement}`); + const environment: NodeJS.ProcessEnv = { T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1" }; + const applySnapshot = makeApplyDesktopShellEnvironmentSnapshot(environment, "darwin"); + const message = { + version: 1, + type: "shellEnvironment", + platform: "darwin", + discovered: true, + environment: { NODE_EXTRA_CA_CERTS: certificatePath }, + } as const; + const fingerprints = (certificates: ReadonlyArray) => + certificates.map((pem) => new NodeCrypto.X509Certificate(pem).fingerprint256).sort(); + yield* applySnapshot({ ...message, platform: "win32" }); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual(fingerprints([baseline])); + expect(environment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING).toBe("1"); + yield* applySnapshot(message); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual( + fingerprints([baseline, extra, replacement]), + ); + expect(environment.NODE_EXTRA_CA_CERTS).toBe(certificatePath); + expect(environment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING).toBeUndefined(); + + yield* fs.writeFileString(certificatePath, replacement); + yield* applySnapshot(message); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual( + fingerprints([baseline, replacement]), + ); + + yield* fs.writeFileString( + certificatePath, + "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----", + ); + yield* applySnapshot(message); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual( + fingerprints([baseline, replacement]), + ); + yield* fs.remove(certificatePath); + const warnings: Array> = []; + yield* applySnapshot(message).pipe( + Effect.provide( + Logger.layer([Logger.make((entry) => warnings.push(entry))], { + mergeWithExisting: false, + }), + ), + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]!.message).toEqual(["Failed to update desktop shell TLS certificates."]); + expect(Cause.squash(warnings[0]!.cause)).toMatchObject({ _tag: "PlatformError" }); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual( + fingerprints([baseline, replacement]), + ); + + yield* applySnapshot({ ...message, environment: { NODE_EXTRA_CA_CERTS: null } }); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual(fingerprints([baseline])); + expect(environment.NODE_EXTRA_CA_CERTS).toBeUndefined(); + + NodeTls.setDefaultCACertificates([]); + yield* fs.writeFileString(certificatePath, extra); + const applyOpaqueTrust = makeApplyDesktopShellEnvironmentSnapshot(environment, "darwin"); + yield* applyOpaqueTrust(message); + expect(NodeTls.getCACertificates("default")).toEqual([]); + expect(environment.NODE_EXTRA_CA_CERTS).toBe(certificatePath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect.each([ + { platform: "darwin", discovered: true, applied: true, pending: false }, + { platform: "win32", discovered: true, applied: false, pending: true }, + { platform: "darwin", discovered: false, applied: true, pending: true }, + ])( + "applies platform=$platform discovered=$discovered with pending=$pending", + ({ platform, discovered, applied, pending }) => + Effect.gen(function* () { + const environment: NodeJS.ProcessEnv = { + PATH: "/old/bin", + JAVA_HOME: "/old/java", + UNRELATED: "keep", + T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1", + }; + yield* makeApplyDesktopShellEnvironmentSnapshot( + environment, + "darwin", + )({ + version: 1, + type: "shellEnvironment", + platform, + discovered, + environment: { PATH: "/fresh/bin", JAVA_HOME: null }, + }); + expect(environment).toEqual({ + PATH: applied ? "/fresh/bin" : "/old/bin", + ...(applied ? {} : { JAVA_HOME: "/old/java" }), + UNRELATED: "keep", + ...(pending ? { T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1" } : {}), + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("models a zero-byte control write as a stalled descriptor", () => Effect.gen(function* () { const error = yield* requireDesktopTelemetryWriteProgress(7, 42, 0).pipe(Effect.flip); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 70dc2e8d750ef..7d9de87ecfd98 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -1,22 +1,28 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodeNet from "node:net"; +import * as NodeTls from "node:tls"; import * as NodeStream from "@effect/platform-node/NodeStream"; import { DesktopHostTelemetryMessage, type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue, type DesktopHostTelemetrySnapshot, + type DesktopShellEnvironmentSnapshot, DesktopTelemetryControlMessage, type DesktopUpdateChannel, type DesktopUpdateStatusReport, type ResourceTelemetrySourceStatus, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { applyShellEnvironment } from "@t3tools/shared/shell"; +import * as Cause from "effect/Cause"; 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 FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -202,6 +208,70 @@ export class DesktopTelemetryReceiver extends Context.Service< } >()("t3/resourceTelemetry/DesktopTelemetryReceiver") {} +class DesktopShellCertificatesUpdateFailed extends Schema.TaggedError()( + "DesktopShellCertificatesUpdateFailed", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to update desktop shell TLS certificates."; + } +} + +export function makeApplyDesktopShellEnvironmentSnapshot( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +) { + let baseCertificates: Array | undefined; + return Effect.fn("resourceTelemetry.desktopTelemetryReceiver.applyShellEnvironment")(function* ( + message: DesktopShellEnvironmentSnapshot, + ) { + if (message.platform !== platform) return; + // Node's first default-CA enumeration consults process.env, so capture it before the patch. + if (message.environment.NODE_EXTRA_CA_CERTS !== undefined && baseCertificates === undefined) { + // Remove only the startup extras, preserving duplicates from bundled or system trust. + const extras = [...NodeTls.getCACertificates("extra")]; + baseCertificates = NodeTls.getCACertificates("default").filter((certificate) => { + const index = extras.indexOf(certificate); + if (index === -1) return true; + extras.splice(index, 1); + return false; + }); + } + applyShellEnvironment(environment, message.environment, platform); + if (message.environment.NODE_EXTRA_CA_CERTS !== undefined) { + yield* Effect.gen(function* () { + const certificates = baseCertificates; + // --use-openssl-ca roots are not enumerable; replacing the list would discard them. + if (certificates === undefined || certificates.length === 0) { + yield* Effect.logWarning( + "Preserving non-enumerable TLS trust. Restart to apply updated desktop shell certificates.", + ); + return; + } + const fs = yield* FileSystem.FileSystem; + const file = message.environment.NODE_EXTRA_CA_CERTS; + const bundle = file ? yield* fs.readFileString(file) : undefined; + yield* Effect.try({ + try: () => { + const extras = + bundle?.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? []; + if (bundle?.trim() && extras.length === 0) { + throw new Error("No PEM certificates found."); + } + NodeTls.setDefaultCACertificates([...certificates, ...extras]); + }, + catch: (cause) => new DesktopShellCertificatesUpdateFailed({ cause }), + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to update desktop shell TLS certificates.", Cause.fail(cause)), + ), + ); + } + if (message.discovered) delete environment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING; + }); +} + const decodeMessage = Schema.decodeUnknownEffect(DesktopHostTelemetryMessage); const encodeControlMessage = Schema.encodeEffect( Schema.fromJsonString(DesktopTelemetryControlMessage), @@ -328,6 +398,9 @@ export function requireDesktopTelemetryWriteProgress( /** @public */ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () { const config = yield* ServerConfig.ServerConfig; + const environment = yield* HostProcess.Environment; + const platform = yield* HostProcess.Platform; + const applyShellSnapshot = makeApplyDesktopShellEnvironmentSnapshot(environment, platform); const serverSettings = yield* ServerSettings.ServerSettingsService; const latest = yield* Ref.make(Option.none()); const receiverStartedAt = yield* DateTime.now; @@ -523,6 +596,10 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") ); } + if (message.type === "shellEnvironment") { + return recordContact.pipe(Effect.andThen(applyShellSnapshot(message))); + } + if (message.type === "desktopUpdateStatus") { return recordContact.pipe( Effect.andThen(Ref.set(latestUpdateReport, Option.some(message))), diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 0d9e442c4546a..741cd00285887 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -16,7 +16,7 @@ import { } from "@t3tools/contracts"; 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 Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -365,7 +365,7 @@ function canCommandNativeTelemetrySidecar( export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () { const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const config = yield* ServerConfig.ServerConfig; const initializedAt = yield* DateTime.now; const state = yield* Ref.make(initialState); @@ -864,7 +864,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), }); } - const requestId = yield* crypto.randomUUIDv4().pipe( + const requestId = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new NativeTelemetryCommandFailed({ @@ -923,7 +923,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu }); } - const requestId = yield* crypto.randomUUIDv4().pipe( + const requestId = yield* random.uuidV4().pipe( Effect.mapError( (cause) => new NativeTelemetryCommandFailed({ @@ -978,8 +978,8 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu }); } - const requestId = yield* crypto - .randomUUIDv4() + const requestId = yield* random + .uuidV4() .pipe( Effect.mapError( (cause) => new NativeTelemetryCommandFailed({ operation: "createRequestId", cause }), diff --git a/apps/server/src/scheduledTasks/Schedule.test.ts b/apps/server/src/scheduledTasks/Schedule.test.ts index f5973969747f7..0941c7392ff94 100644 --- a/apps/server/src/scheduledTasks/Schedule.test.ts +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -27,6 +27,12 @@ describe("scheduled task schedule calculation", () => { }); it("compares schedules structurally", () => { + expect( + isSameSchedule( + { type: "once", runAt: "2026-11-01T03:00:00-05:00" }, + { type: "once", runAt: "2026-11-01T08:00:00.000Z" }, + ), + ).toBe(true); expect( isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 60_000 }), ).toBe(true); diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index bac32bb913146..f8fb648aaaaa9 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -1,5 +1,6 @@ import { MIN_SCHEDULED_TASK_INTERVAL_MS, type ScheduledTaskSchedule } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; const MINUTE_MS = 60_000; @@ -14,6 +15,13 @@ export function nextScheduledRunAt( from: DateTime.DateTime, ): DateTime.DateTime | null { if (schedule.type === "webhook") return null; + if (schedule.type === "once") { + const runAt = DateTime.make(schedule.runAt); + return Option.isSome(runAt) && + DateTime.toEpochMillis(runAt.value) > DateTime.toEpochMillis(from) + ? runAt.value + : null; + } if (schedule.type === "interval") { // Intervals advance elapsed time, including across daylight-saving changes. return DateTime.addDuration(from, Math.max(schedule.everyMs, MIN_SCHEDULED_TASK_INTERVAL_MS)); @@ -48,6 +56,7 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul return b.type === "interval" && a.everyMs === b.everyMs; } if (a.type === "webhook") return b.type === "webhook"; + if (a.type === "once") return b.type === "once" && Date.parse(a.runAt) === Date.parse(b.runAt); if (b.type !== "fixed_time") return false; // Compare parsed times so padded-hour edits do not reschedule pending runs. const aTime = parseTimeOfDay(a.timeOfDay); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts index 4a1a58505c5cb..ecc761d1d0b47 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts @@ -4,6 +4,7 @@ import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; import { afterEach, vi } from "vite-plus/test"; @@ -15,12 +16,14 @@ import * as Scheduler from "../scheduling/Scheduler.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import { ServerActivation } from "../serverActivation.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; +import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; afterEach(() => vi.unstubAllEnvs()); const dependencies = Layer.mergeAll( NodeCrypto.layer, Scheduler.layer, + ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({ launch: () => Effect.die("synthetic dispatch failure"), }), @@ -129,3 +132,185 @@ it.effect.each([ Effect.provide(SqlitePersistence.layerMemory), ), ); + +// Each launch takes two minutes, so a manual run can start before the run time and end after it. +const slowLaunchLayer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.merge( + dependencies, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => TestClock.adjust("2 minutes").pipe(Effect.andThen(Effect.die("synthetic"))), + }), + ), + ), +); + +it.effect("runs a one-time schedule once and rejects past times", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00.000Z")); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const decoded = yield* decodeInput({ + ...input, + schedule: { type: "once", runAt: "2026-11-01T03:00:00-05:00" }, + }); + const created = yield* service.upsert(decoded); + expect(created.task.schedule).toEqual({ type: "once", runAt: "2026-11-01T08:00:00.000Z" }); + expect(created.task.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + + // A manual run that starts before the time keeps the scheduled run, even if it ends after. + const early = yield* service.runNow({ id: created.task.id }); + expect(early.task.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + + const due = yield* service.runNow({ id: created.task.id }); + expect(due.task.nextRunAt).toBeNull(); + expect((yield* service.list()).tasks[0]?.nextRunAt).toBeNull(); + + yield* service.setEnabled({ id: created.task.id, enabled: false }); + const resumed = yield* service + .setEnabled({ id: created.task.id, enabled: true }) + .pipe(Effect.flip); + expect(resumed.message).toBe("The run time has passed. Pick a time in the future."); + + const past = yield* service + .upsert({ ...decoded, commandId: undefined, title: "Late" }) + .pipe(Effect.flip); + expect(past.message).toBe("The run time has passed. Pick a time in the future."); + }).pipe(Effect.provide(slowLaunchLayer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +const ONCE = { type: "once", runAt: "2026-11-01T08:00:00Z" } as const; +/** A next run that only the simulated concurrent write produces. */ +const MOVED = "2030-01-01T00:00:00.000Z"; + +const onceInput = { + ...input, + commandId: "once-race", + schedule: { type: "once", runAt: "2026-11-01T08:00:00Z" }, +}; + +it.effect.each([ + { change: "a run completes", saved: ONCE, edited: ONCE, pause: false, expected: MOVED }, + { + change: "a run completes during a padded-time edit", + saved: { type: "fixed_time", timeOfDay: "9:00" }, + edited: { type: "fixed_time", timeOfDay: "09:00" }, + pause: false, + expected: MOVED, + }, + { + change: "another client pauses the task", + saved: ONCE, + edited: ONCE, + pause: true, + expected: "2026-11-01T08:00:00.000Z", + }, +] as const)( + "a title save keeps the stored next run only while nothing else changed: $change", + ({ saved, edited, pause, expected }) => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:00:00.000Z")); + // The project check runs between the save's read and write, so it stands in for + // a concurrent write in that gap. + const writeMidSave = Layer.unwrap( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => + sql`UPDATE scheduled_tasks SET next_run_at = ${MOVED}, enabled = ${pause ? 0 : 1}`.pipe( + Effect.as(false), + Effect.orDie, + ), + }); + }), + ); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide(Layer.merge(dependencies, writeMidSave)), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const worktree = { type: "worktree", baseRef: "main" }; + yield* service.upsert( + yield* decodeInput({ + ...onceInput, + schedule: saved, + workspaceStrategy: { type: "root" }, + }), + ); + const renamed = yield* service.upsert( + yield* decodeInput({ + ...onceInput, + schedule: edited, + workspaceStrategy: worktree, + title: "Renamed", + }), + ); + expect(renamed.task.enabled).toBe(true); + expect(renamed.task.nextRunAt).toBe(expected); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect("keeps a one-time run after a restart interrupts an earlier manual run", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00.000Z")); + const sql = yield* SqlClient.SqlClient; + const created = yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + return yield* service.upsert(yield* decodeInput(onceInput)); + }).pipe(Effect.provide(serviceLayer)); + yield* sql`UPDATE scheduled_tasks SET last_run_status = 'running', last_run_at = '2026-11-01T07:59:00.000Z'`; + + yield* TestClock.setTime(Date.parse("2026-11-01T08:05:00.000Z")); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const [task] = (yield* service.list()).tasks; + expect(task?.id).toBe(created.task.id); + expect(task?.lastRunStatus).toBe("failed"); + expect(task?.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + }).pipe(Effect.provide(serviceLayer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect("reports the stored task when the row was replaced during the run", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:00:00.000Z")); + // The launch stands in for a delete and same-id recreate while the run is out. + const replaceRowDuringLaunch = Layer.unwrap( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => + sql`UPDATE scheduled_tasks SET last_run_status = 'never', last_run_at = NULL, run_count = 0`.pipe( + Effect.orDie, + Effect.andThen(Effect.die("synthetic dispatch failure")), + ), + }); + }), + ); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide(Layer.merge(dependencies, replaceRowDuringLaunch)), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const created = yield* service.upsert(yield* decodeInput(onceInput)); + const ran = yield* service.runNow({ id: created.task.id }); + expect(ran.task.runCount).toBe(0); + expect(ran.task.lastRunStatus).toBe("never"); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 1070b49a0dcf2..a6adcdaba9108 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -15,6 +15,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; +import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); @@ -23,6 +24,7 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id const layerDependencies = Layer.mergeAll( NodeCrypto.layer, Scheduler.layer, + ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", @@ -70,6 +72,7 @@ it.effect("preserves a due run when a save only pads the scheduled hour", () => const layerDependencies = Layer.mergeAll( NodeCrypto.layer, Scheduler.layer, + ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", @@ -124,6 +127,7 @@ it.effect("keeps the task list order when a toggle or edit rewrites updated_at", const layerDependencies = Layer.mergeAll( NodeCrypto.layer, Scheduler.layer, + ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", @@ -178,6 +182,7 @@ it.effect( const layerDependencies = Layer.mergeAll( NodeCrypto.layer, Scheduler.layer, + ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({ launch: (input) => Deferred.succeed(launched, input.title).pipe( diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 82d165972750f..b37932dc1c9eb 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -2,7 +2,7 @@ import * as Scheduler from "../scheduling/Scheduler.ts"; import * as NodeUtil from "node:util"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { assert, it } from "@effect/vitest"; +import { assert, it, vi } from "@effect/vitest"; import { ProjectId, ProviderInstanceId, @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as SqlClient from "effect/sql/SqlClient"; @@ -29,6 +30,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; +import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; const isScheduledTaskError = Schema.is(ScheduledTaskError); @@ -189,6 +191,7 @@ it.effect( Layer.provideMerge( ScheduledTaskService.layer, Layer.mergeAll( + ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({ launch: () => Ref.updateAndGet(dispatched, (n) => n + 1).pipe( @@ -282,6 +285,7 @@ it.effect( Layer.provideMerge( ScheduledTaskService.layer, Layer.mergeAll( + ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({ launch: () => Ref.updateAndGet(dispatched, (n) => n + 1).pipe( @@ -346,6 +350,7 @@ it.effect("thread cleanup notifies subscribers and a finishing run cannot recrea const layer = ScheduledTaskService.layer.pipe( Layer.provide( Layer.mergeAll( + ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), Layer.mock(ThreadManagementService.ThreadManagementService)({ sendToThread: () => @@ -413,6 +418,7 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", () const layer = ScheduledTaskService.layer.pipe( Layer.provide( Layer.mergeAll( + ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), Layer.mock(ThreadManagementService.ThreadManagementService)({ sendToThread: (input) => @@ -476,3 +482,137 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", () }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("refuses saving an enabled task above the policy ceiling", () => + Effect.gen(function* () { + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + ManagedPolicy.layerTest({ + schemaVersion: 1, + organization: "Example Corp", + limits: { maxRuntimeMode: "auto-accept-edits" }, + }), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Scheduler.layer, + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const save = (enabled: boolean) => + service.upsert({ + id: ScheduledTaskId.make("task:above-ceiling"), + title: "Nightly", + prompt: "Check progress", + enabled, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project:test"), + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + const refused = yield* save(true).pipe(Effect.flip); + assert.equal(refused.message, "Example Corp limits threads to Auto-accept edits."); + assert.lengthOf((yield* service.list()).tasks, 0); + // A disabled task does not run, so it can still be saved. + yield* save(false); + assert.lengthOf((yield* service.list()).tasks, 1); + // Nor can it be switched back on while its mode is above the ceiling. + const reenabled = yield* service + .setEnabled({ id: ScheduledTaskId.make("task:above-ceiling"), enabled: true }) + .pipe(Effect.flip); + assert.equal(reenabled.message, "Example Corp limits threads to Auto-accept edits."); + // A task bound to a thread runs in that thread's mode, so its own is not checked. + yield* service.upsert({ + id: ScheduledTaskId.make("task:bound"), + title: "Follow up", + prompt: "Check progress", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project:test"), + threadId: ThreadId.make("thread:bound"), + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + +it.effect("a run interrupted right after it reserves the task does not block the next run", () => + Effect.gen(function* () { + const launches = yield* Ref.make(0); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + ManagedPolicy.layerTest(), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => + Ref.update(launches, (n) => n + 1).pipe( + Effect.as({ threadId: ThreadId.make("thread:launched"), resumed: false } as never), + ), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Layer.succeed(Scheduler.Scheduler, { register: () => Effect.void }), + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const { task } = yield* service.upsert({ + id: ScheduledTaskId.make("task:interrupted-reservation"), + title: "Check in", + prompt: "Check progress", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project:test"), + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + + // The reservation is the first Set.add of the task id; interrupt the run right there. + const add = Set.prototype.add; + let interrupted = false; + const first = yield* Effect.gen(function* () { + using _reserve = vi.spyOn(Set.prototype, "add").mockImplementation(function ( + this: Set, + value: unknown, + ) { + const result = add.call(this, value); + if (value === task.id && !interrupted) { + interrupted = true; + Fiber.getCurrent()?.interruptUnsafe(); + } + return result; + }); + return yield* service + .runNow({ id: task.id }) + .pipe(Effect.forkChild, Effect.flatMap(Fiber.await)); + }); + assert.isTrue(interrupted); + assert.isTrue(Exit.hasInterrupts(first)); + + const second = yield* service.runNow({ id: task.id }); + assert.equal(second.threadId, ThreadId.make("thread:launched")); + assert.equal(yield* Ref.get(launches), 1); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index c41f0a952ab7c..afeb73de3df96 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -23,10 +23,12 @@ import { type ScheduledTaskRunNowResult, type ScheduledTaskSetEnabledInput, type ScheduledTaskUpsertInput, + managedPolicyDeniedMessage, + managedRuntimeModeDenial, } from "@t3tools/contracts"; 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 Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -49,6 +51,7 @@ import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as Metrics from "../observability/Metrics.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import * as DueHint from "../scheduling/DueHint.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; @@ -63,6 +66,7 @@ import { acceptImmediateWebhookDedupe, releaseWebhookReplay, decodePendingTask, + encodePendingTask, decodePendingRequest, dedupeError, } from "./webhookDedupe.ts"; @@ -145,6 +149,7 @@ export type WebhookDeliveryOutcome = | "expired" | "filtered" | "not_found" + | "blocked_by_policy" | "error"; export type WebhookTriggerResult = @@ -156,6 +161,7 @@ export type WebhookTriggerResult = | { readonly _tag: "not_found" } | { readonly _tag: "rejected_signature" } | { readonly _tag: "disabled" } + | { readonly _tag: "blocked_by_policy" } | { readonly _tag: "rate_limited"; readonly outcome: "rate_limited" | "queue_full"; @@ -298,6 +304,35 @@ function nextRunAt( } } +/** + * Next run after an interrupted run. A one-time task counts from the run's start + * (`lastRunAt`), so a manual run that began before its time does not use it up. + */ +function nextRunAfterRun( + task: Pick, + now: DateTime.DateTime, +): string | null { + const startedAt = + task.schedule.type === "once" && task.lastRunAt !== null + ? DateTime.make(task.lastRunAt) + : Option.none(); + return nextRunAt( + task, + Option.getOrElse(startedAt, () => now), + ); +} + +/** An enabled one-time task with a past time would never run, so saving it is an error. */ +function requireFutureOnce( + task: Pick, + now: DateTime.DateTime, + taskId: ScheduledTaskId, +) { + return task.enabled && task.schedule.type === "once" && nextRunAt(task, now) === null + ? taskError("The run time has passed. Pick a time in the future.", { taskId }) + : Effect.void; +} + function errorMessage(error: unknown): string { if (Cause.isCause(error)) return Cause.pretty(error); if (error instanceof Error) return error.message; @@ -401,13 +436,24 @@ 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 threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; const secretRequests = yield* SecretRequests.SecretRequests; const scheduler = yield* Scheduler.Scheduler; const readWebhookOrigin = yield* ScheduledTaskWebhookOrigin; + const managedPolicy = yield* ManagedPolicy.ManagedPolicy; + const webhookPolicyOrganization = ManagedPolicy.deniedBy(managedPolicy, "webhookTasks"); + // Existing webhook tasks stay stored but report why they do not run. + const webhookPolicyBlock = + webhookPolicyOrganization === undefined + ? undefined + : managedPolicyDeniedMessage(webhookPolicyOrganization, "webhook tasks"); + const withPolicy = (task: ScheduledTask): ScheduledTask => + webhookPolicyBlock !== undefined && task.schedule.type === "webhook" + ? { ...task, blockedByPolicy: webhookPolicyBlock } + : task; const webhookPermits = yield* Ref.make>( new Map(), ); @@ -469,7 +515,11 @@ export const layer = Layer.effect( const listRows = Effect.fn("ScheduledTaskService.listRows")(function* () { const rows = yield* selectAllRows(); const origin = yield* readWebhookOrigin; - return yield* Effect.forEach(rows, (row) => decodeRow(row, origin), { concurrency: 1 }); + return yield* Effect.forEach( + rows, + (row) => decodeRow(row, origin).pipe(Effect.map(withPolicy)), + { concurrency: 1 }, + ); }); const getRows = (id: ScheduledTaskId) => @@ -509,7 +559,7 @@ export const layer = Layer.effect( ); const row = rows[0]; if (row === undefined) return null; - return yield* decodeRow(row, yield* readWebhookOrigin); + return withPolicy(yield* decodeRow(row, yield* readWebhookOrigin)); }); const findWebhookCredentials = (id: ScheduledTaskId) => @@ -524,7 +574,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 })), ); @@ -542,6 +592,8 @@ export const layer = Layer.effect( const saveTask = ( task: ScheduledTask, requireExisting: boolean, + /** The schedule this save read, when it left the schedule unchanged. */ + keptSchedule: ScheduledTask["schedule"] | null, webhook: { readonly token: string | null; readonly secret: string | null; @@ -612,7 +664,18 @@ export const layer = Layer.effect( interaction_mode = excluded.interaction_mode, creation_source = excluded.creation_source, updated_at = excluded.updated_at, - next_run_at = excluded.next_run_at, + -- Keep a next_run_at that a finishing run moved during this save, unless + -- another edit changed the schedule or enabled state meanwhile. + next_run_at = CASE + WHEN ${keptSchedule === null ? 0 : 1} = 1 + AND scheduled_tasks.schedule_json IN ( + excluded.schedule_json, + ${keptSchedule === null ? null : JSON.stringify(keptSchedule)} + ) + AND scheduled_tasks.enabled = excluded.enabled + THEN scheduled_tasks.next_run_at + ELSE excluded.next_run_at + END, -- Only rotate changes a live token, so a save racing a rotation -- cannot bring the old URL back. webhook_token = CASE @@ -685,7 +748,9 @@ export const layer = Layer.effect( WHERE task_id = ${input.id} AND last_run_status = 'running' AND last_run_at = ${input.startedAtIso} + RETURNING task_id `.pipe( + Effect.map((rows) => rows.length > 0), Effect.mapError((cause) => taskError("Could not record schedule task run.", { taskId: input.id, cause }), ), @@ -695,7 +760,12 @@ export const layer = Layer.effect( // runDueTasks skips 'running' rows forever, and the dispatch may already have gone out, // so next_run_at must advance. Pending webhook attempts count only with their completion. const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( - function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { + function* ( + task: ScheduledTask, + startedAtIso: string, + message: string, + pendingDeliveryId?: string, + ) { const now = yield* localNow; const reread = yield* Effect.result(findTask(task.id)); if (Result.isSuccess(reread) && reread.success === null) return; @@ -704,7 +774,7 @@ export const layer = Layer.effect( UPDATE scheduled_tasks SET last_run_status = 'failed', last_run_error = ${message}, - next_run_at = ${nextRunAt(source, now)}, + next_run_at = ${nextRunAfterRun({ ...source, lastRunAt: startedAtIso }, now)}, updated_at = ${iso(now)}, run_count = run_count + CASE WHEN EXISTS ( SELECT 1 FROM scheduled_task_webhook_pending @@ -714,7 +784,7 @@ export const layer = Layer.effect( `; yield* notifyChanged; }, - (effect, task, _message, _pendingDeliveryId) => + (effect, task, _startedAtIso, _message, _pendingDeliveryId) => effect.pipe( Effect.catch((cause) => Effect.logWarning("Could not release stuck schedule task run", { @@ -734,23 +804,10 @@ export const layer = Layer.effect( readonly dispatched: Ref.Ref; }, ) { - const reserved = yield* Ref.modify(activeRuns, (active) => { - if (active.has(task.id)) return [false, active] as const; - const next = new Set(active); - next.add(task.id); - return [true, next] as const; - }); - if (!reserved) { - if (trigger !== "scheduled") { - return yield* taskError("Schedule task is already running.", { taskId: task.id }); - } - return { task, threadId: null }; - } - - return yield* Effect.gen(function* () { - const startedAt = yield* localNow; - const startedAtIso = iso(startedAt); - + // Read before reserving: nothing between the reservation and its release may fail. + const startedAt = yield* localNow; + const startedAtIso = iso(startedAt); + const run = Effect.gen(function* () { // Re-read stale poll snapshots so deleted, paused or postponed tasks cannot fire. const active = yield* findTask(task.id); if (active === null) { @@ -762,7 +819,7 @@ export const layer = Layer.effect( if (trigger !== "scheduled") { return yield* taskError("Schedule task not found.", { taskId: task.id }); } - return { task, threadId: null }; + return { task, threadId: null, dispatchFailed: false }; } if ( webhook !== undefined && @@ -770,7 +827,11 @@ export const layer = Layer.effect( NodeSqliteClient.readOnly, )).length > 0 ) - return { task: { ...active, lastRunStatus: "succeeded" as const }, threadId: null }; + return { + task: { ...active, lastRunStatus: "succeeded" as const }, + threadId: null, + dispatchFailed: false, + }; const parsedNextRunAt = active.nextRunAt === null ? Option.none() : DateTime.make(active.nextRunAt); if ( @@ -779,7 +840,7 @@ export const layer = Layer.effect( Option.isNone(parsedNextRunAt) || DateTime.toEpochMillis(parsedNextRunAt.value) > DateTime.toEpochMillis(startedAt)) ) { - return { task: active, threadId: null }; + return { task: active, threadId: null, dispatchFailed: false }; } // Reject queued deliveries after pause, same-id recreation or trigger changes. if (webhook !== undefined) { @@ -788,9 +849,11 @@ export const layer = Layer.effect( ? "The task was replaced before this delivery ran." : active.schedule.type !== "webhook" ? "The task's trigger changed before this delivery ran." - : !active.enabled - ? "The task was paused before this delivery ran." - : null; + : webhookPolicyBlock !== undefined + ? webhookPolicyBlock + : !active.enabled + ? "The task was paused before this delivery ran." + : null; if (reason !== null) return yield* new WebhookDeliverySkipped({ reason }); } @@ -818,7 +881,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 @@ -895,18 +957,28 @@ export const layer = Layer.effect( const lastRunStatus = runSucceeded ? ("succeeded" as const) : ("failed" as const); const lastRunError = runSucceeded ? null : errorMessage(result.cause); const threadId = runSucceeded ? result.value : null; - const current = yield* findTask(task.id); - const scheduleSource = current ?? task; - const completed: ScheduledTask = { - ...scheduleSource, - updatedAt: iso(completedAt), - lastRunAt: startedAtIso, - nextRunAt: nextRunAt(scheduleSource, completedAt), - lastRunStatus, - lastRunError, - runCount: scheduleSource.runCount + 1, - }; - if (current !== null) { + // One transaction, so an edit cannot land between reading the schedule and + // writing the next run computed from it. The origin is read first: it reads + // files, and the transaction holds SQLite's writer lock. + const origin = yield* readWebhookOrigin; + const record = Effect.gen(function* () { + const [row] = yield* getRows(task.id); + const current = row === undefined ? null : yield* decodeRow(row, origin); + const scheduleSource = current ?? task; + const completed: ScheduledTask = { + ...scheduleSource, + updatedAt: iso(completedAt), + lastRunAt: startedAtIso, + // A manual run that starts before a one-time run must not use it up. + nextRunAt: nextRunAt( + scheduleSource, + scheduleSource.schedule.type === "once" ? startedAt : completedAt, + ), + lastRunStatus, + lastRunError, + runCount: scheduleSource.runCount + 1, + }; + if (current === null) return { recorded: completed, written: false }; // startedAtIso in the guard ensures this writes only to the row this // run marked as running — a task deleted mid-run and recreated with // the same id (idempotent commandId replay) must not be stamped. @@ -918,40 +990,68 @@ export const layer = Layer.effect( error: lastRunError, startedAtIso, }); - if (webhook === undefined) yield* completion; - else - yield* sql - .withTransaction( - Effect.gen(function* () { - const pending = - yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; - if (pending.length === 0) yield* completion; - else { - const counted = - yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} - WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; - if (counted.length > 0) yield* completion; - } - }), - ) - .pipe( - Effect.mapError((cause) => - taskError("Could not record webhook completion.", { taskId: task.id, cause }), - ), - ); - yield* notifyChanged; - } - return { task: completed, threadId }; + let written = false; + if (webhook === undefined) written = yield* completion; + else { + const pending = + yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; + if (pending.length === 0) written = yield* completion; + else { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} + WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; + if (counted.length > 0) written = yield* completion; + } + } + // A row recreated mid-run is not this run's, so report what is stored. + return { recorded: written ? completed : current, written }; + }); + const { recorded, written } = yield* sql + .withTransaction(record) + .pipe( + Effect.mapError((cause) => + isScheduledTaskError(cause) + ? cause + : taskError( + webhook === undefined + ? "Could not record schedule task run." + : "Could not record webhook completion.", + { taskId: task.id, cause }, + ), + ), + ); + if (written) yield* notifyChanged; + // The stored row can belong to a replacement, so report this dispatch separately. + return { task: recorded, threadId, dispatchFailed: !runSucceeded }; }).pipe( - Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause), webhook?.deliveryId)), - Effect.ensuring( - Ref.update(activeRuns, (active) => { - const next = new Set(active); - next.delete(task.id); - return next; - }), + Effect.onError((cause) => + releaseStuckRun(task, startedAtIso, errorMessage(cause), webhook?.deliveryId), ), ); + + // Reserve and release as one bracket so an interrupt cannot leave the task marked busy. + return yield* Effect.acquireUseRelease( + Ref.modify(activeRuns, (active) => { + if (active.has(task.id)) return [false, active] as const; + const next = new Set(active); + next.add(task.id); + return [true, next] as const; + }), + (reserved) => + reserved + ? run + : trigger !== "scheduled" + ? Effect.fail(taskError("Schedule task is already running.", { taskId: task.id })) + : Effect.succeed({ task, threadId: null, dispatchFailed: false }), + (reserved) => + reserved + ? Ref.update(activeRuns, (active) => { + const next = new Set(active); + next.delete(task.id); + return next; + }) + : Effect.void, + ); }); const rescheduleMissedRun = Effect.fn("ScheduledTaskService.rescheduleMissedRun")(function* ( @@ -1039,7 +1139,7 @@ export const layer = Layer.effect( UPDATE scheduled_tasks SET last_run_status = 'failed', last_run_error = 'Run was interrupted by a server restart.', - next_run_at = ${nextRunAt(decoded.success, now)}, + next_run_at = ${nextRunAfterRun(decoded.success, now)}, updated_at = ${iso(now)}, run_count = run_count + CASE WHEN EXISTS ( SELECT 1 FROM scheduled_task_webhook_pending @@ -1099,11 +1199,28 @@ export const layer = Layer.effect( const upsert: ScheduledTaskService["Service"]["upsert"] = Effect.fn( "ScheduledTaskService.upsert", )(function* (input) { + // A webhook task the policy blocks can still be edited while it is off. + if (input.enabled && input.schedule.type === "webhook" && webhookPolicyBlock !== undefined) { + return yield* taskError( + webhookPolicyBlock, + input.id === undefined ? {} : { taskId: input.id }, + ); + } + // Refused on save rather than at run time; a disabled task can still be saved, so one + // stored above the ceiling can be turned off before its mode is lowered. A task bound to a + // thread runs in that thread's mode, which the orchestrator checks instead. + const modeDenial = + input.enabled && input.threadId == null + ? managedRuntimeModeDenial(managedPolicy.summary, input.runtimeMode) + : undefined; + if (modeDenial !== undefined) { + return yield* taskError(modeDenial, input.id === undefined ? {} : { taskId: input.id }); + } 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 }), @@ -1150,7 +1267,9 @@ export const layer = Layer.effect( : undefined), ), } - : input.schedule; + : input.schedule.type === "once" + ? { type: "once", runAt: iso(DateTime.makeUnsafe(input.schedule.runAt)) } + : input.schedule; const webhook = input.schedule.type === "webhook" ? yield* Effect.gen(function* () { @@ -1196,6 +1315,8 @@ export const layer = Layer.effect( existingTask !== null && existingTask.enabled === input.enabled && isSameSchedule(existingTask.schedule, schedule); + if (!scheduleUnchanged) + yield* requireFutureOnce({ enabled: input.enabled, schedule }, now, id); const task: ScheduledTask = { id, title: input.title, @@ -1220,7 +1341,12 @@ export const layer = Layer.effect( lastRunError: existingTask?.lastRunError ?? null, runCount: existingTask?.runCount ?? 0, }; - yield* saveTask(task, input.requireExisting === true, webhook); + yield* saveTask( + task, + input.requireExisting === true, + scheduleUnchanged ? existingTask.schedule : null, + webhook, + ); yield* notifyChanged; return { task: (yield* findTask(id)) ?? task }; }); @@ -1230,7 +1356,26 @@ export const layer = Layer.effect( )(function* (input) { const existing = yield* loadTask(input.id); if (existing.enabled === input.enabled) return { task: existing }; + if ( + input.enabled && + existing.schedule.type === "webhook" && + webhookPolicyBlock !== undefined + ) { + return yield* taskError(webhookPolicyBlock, { taskId: input.id }); + } + const modeDenial = + input.enabled && existing.threadId === null + ? managedRuntimeModeDenial(managedPolicy.summary, existing.runtimeMode) + : undefined; + if (modeDenial !== undefined) { + return yield* taskError(modeDenial, { taskId: input.id }); + } const now = yield* localNow; + yield* requireFutureOnce( + { enabled: input.enabled, schedule: existing.schedule }, + now, + input.id, + ); const next = nextRunAt({ enabled: input.enabled, schedule: existing.schedule }, now); // RETURNING so a task deleted between the load and this UPDATE is a // visible not-found error, not a false success. @@ -1668,8 +1813,8 @@ export const layer = Layer.effect( ]); const dispatched = yield* Ref.make(false); return yield* runTask(task, "webhook", { deliveryId, prompt, dispatched }).pipe( - Effect.flatMap(({ task: completed }) => - completed.lastRunStatus === "failed" + Effect.flatMap(({ dispatchFailed }) => + dispatchFailed ? runOutcome("failed").pipe( Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), Effect.andThen(releaseFailedDelivery(deliveryId)), @@ -1759,6 +1904,12 @@ export const layer = Layer.effect( // triggerWebhook owns the operation span and its task-id annotation. const triggerWebhookUnobserved = Effect.fnUntraced(function* (request: WebhookTriggerRequest) { + // Refused before any lookup or log, so a blocked hook neither runs nor records a delivery. + if (webhookPolicyBlock !== undefined) { + return yield* observeDelivery(request, "blocked_by_policy", undefined, undefined).pipe( + Effect.as({ _tag: "blocked_by_policy" as const }), + ); + } const taskId = decodeTaskId(request.hookId); const notFound = observeDelivery(request, "not_found", undefined, undefined).pipe( Effect.as({ _tag: "not_found" as const }), @@ -1799,8 +1950,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 })), )}` @@ -2029,7 +2180,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 })), ); @@ -2155,83 +2306,100 @@ export const layer = Layer.effect( } yield* Effect.forEach( tasks, - ({ task_id: taskId }) => - Effect.gen(function* () { - const reserved = yield* Ref.modify(pendingInFlight, (active) => - active.has(taskId) - ? ([false, active] as const) - : ([true, new Set(active).add(taskId)] as const), - ); - if (!reserved) return; - const permit = yield* webhookPermit(taskId); - // One worker per task waits on its permit, then drains persisted rows in order. - // Other tasks remain independent and ticks never reload an active task's payloads. - const dispatch = Effect.gen(function* () { - while (true) { - const [entry] = yield* sql<{ - delivery_id: string; - task_json: string; - request_json: string; - state: string; - }>` + ({ task_id: taskId }) => { + // One worker per task waits on its permit, then drains persisted rows in order. + // Other tasks remain independent and ticks never reload an active task's payloads. + const dispatch = Effect.gen(function* () { + while (true) { + const [entry] = yield* sql<{ + delivery_id: string; + task_json: string; + request_json: string; + state: string; + }>` SELECT delivery_id, task_json, request_json, state FROM scheduled_task_webhook_pending WHERE task_id = ${taskId} AND state IN ('dispatching', 'running', 'completed', 'failed') ORDER BY due_at, rowid LIMIT 1 `.pipe(NodeSqliteClient.readOnly); - if (entry === undefined) return; - const remove = sql`DELETE FROM scheduled_task_webhook_pending WHERE delivery_id = ${entry.delivery_id}`; - if (entry.state === "completed" || entry.state === "failed") { - if (entry.state === "failed") { - yield* markDeliveryFailed(entry.delivery_id, "The run failed to start."); - yield* releaseFailedDelivery(entry.delivery_id); - } - yield* remove; - continue; - } - const prepared = yield* Effect.gen(function* () { - const task = yield* decodePendingTask(entry.task_json); - 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) }; - }).pipe(Effect.exit); - if (Exit.isFailure(prepared)) { - if (Cause.hasInterruptsOnly(prepared.cause)) - return yield* Effect.failCause(prepared.cause); - yield* markDeliveryFailed( - entry.delivery_id, - "The pending webhook payload could not be prepared.", - ); - yield* releaseFailedDelivery(entry.delivery_id); - yield* remove; - continue; - } - const { task, rendered } = prepared.value; - if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { - yield* markDeliveryFailed(entry.delivery_id, PROMPT_TOO_LONG); + if (entry === undefined) return; + const remove = sql`DELETE FROM scheduled_task_webhook_pending WHERE delivery_id = ${entry.delivery_id}`; + if (entry.state === "completed" || entry.state === "failed") { + if (entry.state === "failed") { + yield* markDeliveryFailed(entry.delivery_id, "The run failed to start."); yield* releaseFailedDelivery(entry.delivery_id); - } else { - 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; } yield* remove; + continue; } - }); - yield* dispatch.pipe( - permit.withPermits(1), - Effect.catch((cause) => - Effect.logWarning("Pending webhook dispatch failed", { cause }), - ), - Effect.ensuring( - Ref.update(pendingInFlight, (active) => { - const next = new Set(active); - next.delete(taskId); - return next; - }), - ), - Effect.withSpan("ScheduledTaskService.dispatchPendingWebhook", { root: true }), - Effect.forkIn(serviceScope), - ); - }), + const prepared = yield* Effect.gen(function* () { + // 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* 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)) + return yield* Effect.failCause(prepared.cause); + yield* markDeliveryFailed( + entry.delivery_id, + "The pending webhook payload could not be prepared.", + ); + yield* releaseFailedDelivery(entry.delivery_id); + yield* remove; + continue; + } + 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; + } + yield* remove; + } + }); + // The worker owns its reservation, so an interrupt anywhere still releases it. + return Effect.acquireUseRelease( + Ref.modify(pendingInFlight, (active) => + active.has(taskId) + ? ([false, active] as const) + : ([true, new Set(active).add(taskId)] as const), + ), + (reserved) => + reserved + ? webhookPermit(taskId).pipe( + Effect.flatMap((permit) => dispatch.pipe(permit.withPermits(1))), + Effect.catch((cause) => + Effect.logWarning("Pending webhook dispatch failed", { cause }), + ), + Effect.withSpan("ScheduledTaskService.dispatchPendingWebhook", { + root: true, + }), + ) + : Effect.void, + (reserved) => + reserved + ? Ref.update(pendingInFlight, (active) => { + const next = new Set(active); + next.delete(taskId); + return next; + }) + : Effect.void, + ).pipe(Effect.forkIn(serviceScope)); + }, { discard: true }, ); }); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index cdd4e60e62d76..6303b38c05ce6 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -4,14 +4,16 @@ import * as NodeCrypto from "node:crypto"; import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import { assert, it } from "@effect/vitest"; import { + type ManagedPolicyDocument, ScheduledTaskUpsertInput, ScheduledTaskWebhookDeliveryId, 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"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; @@ -22,6 +24,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; @@ -172,6 +175,7 @@ const withService = ( readonly relayHookBaseUrl?: string; readonly sql?: SqlClient.SqlClient; readonly workers?: Map>; + readonly policy?: ManagedPolicyDocument; } = {}, ) => Effect.gen(function* () { @@ -181,9 +185,9 @@ const withService = ( 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) => @@ -248,6 +252,7 @@ const withService = ( ScheduledTaskService.ScheduledTaskWebhookOrigin, Effect.succeed({ relayHookBaseUrl: options.relayHookBaseUrl ?? null }), ), + ManagedPolicy.layerTest(options.policy), ); return yield* Effect.gen(function* () { const service = yield* ScheduledTaskService.ScheduledTaskService; @@ -426,6 +431,62 @@ it.effect("checks the configured signature and keeps the secret write-only", () ), ); +it.effect("a policy without webhook tasks keeps existing ones but refuses saves and requests", () => + withService(({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + const timer = yield* webhookTaskInput({ + id: "scheduled-task:timer", + schedule: { type: "interval", everyMs: 3_600_000 }, + }); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const blocked = "Example Corp's policy does not allow webhook tasks."; + const listed = (yield* service.list()).tasks.find(({ id }) => id === task.id); + assert.equal(listed?.blockedByPolicy, blocked); + assert.isTrue(listed?.enabled); + + const refused = yield* service.upsert(yield* webhookTaskInput()).pipe(Effect.flip); + assert.equal(refused.message, blocked); + const created = yield* service + .upsert(yield* webhookTaskInput({ id: "scheduled-task:new-hook" })) + .pipe(Effect.flip); + assert.equal(created.message, blocked); + + // Turned off, it can still be edited, but not turned back on. + yield* service.setEnabled({ id: task.id, enabled: false }); + yield* service.upsert(yield* webhookTaskInput({ enabled: false })); + const reenabled = yield* service + .setEnabled({ id: task.id, enabled: true }) + .pipe(Effect.flip); + assert.equal(reenabled.message, blocked); + + const result = yield* service.triggerWebhook(requestFor(task)); + assert.equal(result._tag, "blocked_by_policy"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_deliveries`).length, 0); + + // Timer tasks are unaffected, including a webhook task switched to a timer. + assert.isUndefined((yield* service.upsert(timer)).task.blockedByPolicy); + const switched = yield* service.upsert( + yield* webhookTaskInput({ schedule: { type: "interval", everyMs: 3_600_000 } }), + ); + assert.isUndefined(switched.task.blockedByPolicy); + }), + { + sql, + policy: { + schemaVersion: 1, + organization: "Example Corp", + limits: { webhookTasks: false }, + }, + }, + ); + }), + ), +); + it.effect("logs but does not run deliveries to a disabled task, and refuses run now", () => withService(({ service, launches }) => Effect.gen(function* () { @@ -970,6 +1031,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( @@ -1005,6 +1166,57 @@ it.effect("recovers a persisted coalescing window after restarting the service", ); }); +it.effect("a pending dispatch interrupted at its permit does not block later polls", () => { + const workers = new Map>(); + // One shared dispatcher lets the test run the retry's forked worker to completion. + const mixed = new EffectScheduler.MixedScheduler("sync"); + const dispatcher = mixed.makeDispatcher(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.content-type}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* TestClock.adjust("10 seconds"); + const poll = workers.get("webhook-coalescing")!; + + const entered = yield* Deferred.make>(); + const interruptAtPermit = Tracer.make({ + span: (options) => { + if (options.name === "ScheduledTaskService.webhookPermit") { + const fiber = Fiber.getCurrent()!; + Deferred.doneUnsafe(entered, Effect.succeed(fiber)); + fiber.interruptUnsafe(); + } + return new Tracer.NativeSpan(options); + }, + }); + const firstPoll = yield* poll.pipe(Effect.withTracer(interruptAtPermit), Effect.forkChild); + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(yield* Deferred.await(entered)))); + yield* Fiber.await(firstPoll); + + yield* poll; + dispatcher.flush(); + + assert.equal(yield* Queue.size(launches), 1); + assert.lengthOf(yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`, 0); + }), + { workers }, + ).pipe( + Effect.provideService(EffectScheduler.Scheduler, { + executionMode: mixed.executionMode, + makeDispatcher: () => dispatcher, + shouldYield: (fiber) => mixed.shouldYield(fiber), + }), + ); +}); + it.effect("rolls back replay and pending state when recording a delivery fails", () => { const workers = new Map>(); return withService( @@ -1199,6 +1411,32 @@ it.effect("start failures release both replay keys and relay claims for redelive ), ); +it.effect("a start failure releases the relay claim when the task row is replaced mid-run", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + const request = requestFor(task, { relayDeliveryId: "replaced-mid-run" }); + const first = yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ).pipe(Effect.forkChild); + yield* Queue.take(launches); + // A same-id recreate while the launch is out: the stored row is no longer this run's. + yield* sql`UPDATE scheduled_tasks SET last_run_status = 'never', last_run_at = NULL`; + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(first); + const retry = yield* service.triggerWebhook(request); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + }), + { gate, failLaunches: { remaining: 1 } }, + ); + }), +); + it.effect("main retention keeps fifty runs and preserves filtered and dedupe buckets", () => withService(({ service, sql, launches }) => Effect.gen(function* () { 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/webhookFilter.test.ts b/apps/server/src/scheduledTasks/webhookFilter.test.ts index 1194a1aed5177..2c7f2cc4062e8 100644 --- a/apps/server/src/scheduledTasks/webhookFilter.test.ts +++ b/apps/server/src/scheduledTasks/webhookFilter.test.ts @@ -16,6 +16,28 @@ const issueComment: WebhookRequest = { }; describe("findFailedWebhookFilter", () => { + it("matches exists on wildcard object values", () => { + assert.isNull( + findFailedWebhookFilter([{ path: "body.issue.labels[]", op: "exists" }], issueComment), + ); + assert.isNull( + findFailedWebhookFilter([{ path: "body.issues[].labels", op: "exists" }], { + ...issueComment, + bodyText: JSON.stringify({ issues: [{ labels: [] }] }), + }), + ); + }); + + it("rejects exists when wildcard values are all null or missing", () => { + assert.deepEqual( + findFailedWebhookFilter([{ path: "body.issues[].labels", op: "exists" }], { + ...issueComment, + bodyText: JSON.stringify({ issues: [{ labels: null }, {}] }), + }), + { index: 0, path: "body.issues[].labels" }, + ); + }); + it("matches a wildcard path when any element matches", () => { assert.isNull( findFailedWebhookFilter( diff --git a/apps/server/src/scheduledTasks/webhookFilter.ts b/apps/server/src/scheduledTasks/webhookFilter.ts index 882f300364288..2ad0e0237e079 100644 --- a/apps/server/src/scheduledTasks/webhookFilter.ts +++ b/apps/server/src/scheduledTasks/webhookFilter.ts @@ -36,6 +36,8 @@ function matchesScalar(filter: ScheduledTaskWebhookFilter, text: string): boolea */ function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean { if (Array.isArray(value) && /\[\]/.test(filter.path)) { + if (filter.op === "exists") + return value.some((element) => element !== null && element !== undefined); const texts = value.map(scalarText).filter((text): text is string => text !== undefined); if (filter.op === "notEquals") return texts.every((text) => text !== filter.value); return texts.some((text) => matchesScalar(filter, text)); diff --git a/apps/server/src/scheduledTasks/webhookRoute.test.ts b/apps/server/src/scheduledTasks/webhookRoute.test.ts index 95ff46c2a75ff..d920840c70015 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.test.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.test.ts @@ -197,6 +197,7 @@ describe("webhook route", () => { [{ _tag: "not_found" }, 404, "not_found"], [{ _tag: "rejected_signature" }, 401, "rejected_signature"], [{ _tag: "disabled" }, 409, "disabled"], + [{ _tag: "blocked_by_policy" }, 403, "blocked_by_policy"], [{ _tag: "rate_limited", outcome: "rate_limited" }, 429, "rate_limited"], [{ _tag: "rate_limited", outcome: "queue_full" }, 429, "queue_full"], [{ _tag: "expired" }, 410, "expired"], diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index dc6ce74bfa3f8..a7e0c512cbe41 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -131,6 +131,8 @@ export const layer = HttpApiBuilder.group( return json(401, { error: "invalid_signature" }, "rejected_signature"); case "disabled": return json(409, { error: "hook_disabled" }, "disabled"); + case "blocked_by_policy": + return json(403, { error: "blocked_by_policy" }, "blocked_by_policy"); case "rate_limited": return json(429, { error: result.outcome }, result.outcome); case "expired": diff --git a/apps/server/src/secrets/settingsSecretValue.ts b/apps/server/src/secrets/settingsSecretValue.ts index b17458e2d8f13..83a0adc5f11dd 100644 --- a/apps/server/src/secrets/settingsSecretValue.ts +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -1,10 +1,10 @@ -import { secretReferencesInSettings } from "@t3tools/contracts"; +import { secretReferencesInSettings, withSettingsSecrets } from "@t3tools/contracts"; import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; /** - * Replaces each reference in the fields `schema` declares as `SettingsSecretValue` with the secret + * Replaces each reference in the slots `schema` declares as `SettingsSecretValue` with the secret * it names, read in one batch. An unreadable secret comes back as `""`, so it behaves as unset. */ export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(function* < @@ -13,8 +13,8 @@ export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(funct const pending = secretReferencesInSettings(schema, settings); if (pending.length === 0) return settings; const values = yield* resolver.read(pending.map(({ reference }) => reference)); - const resolved = Object.fromEntries( - pending.map(({ field }, index) => [field, values[index] ?? ""] as const), + return withSettingsSecrets( + settings, + pending.map((slot, index) => ({ ...slot, value: values[index] ?? "" })), ); - return { ...settings, ...resolved }; }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 452addfb4a667..a4433b59e8b77 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -11,6 +11,7 @@ import * as DelegatedTaskQuestionReactor from "./orchestration-v2/DelegatedTaskQ import * as NodeHttp from "node:http"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentHttpApi, type RepositoryIdentity } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -47,7 +48,7 @@ import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewG import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as DelegatedTaskAnalytics from "./telemetry/DelegatedTaskAnalytics.ts"; -import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts"; +import * as ProviderUsageAnalytics from "./telemetry/ProviderUsageAnalytics.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts"; @@ -59,6 +60,7 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; import * as DocumentText from "./provider/DocumentText.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as NativeChildWorkQuery from "./orchestration-v2/NativeChildWorkQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts"; @@ -94,6 +96,8 @@ import * as ServerOwnership from "./serverOwnership.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as ManagedLimits from "./policy/managedLimits.ts"; +import * as ManagedPolicy from "./policy/ManagedPolicy.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts"; @@ -149,6 +153,7 @@ import { RELAY_URL_SECRET, } from "./cloud/config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as EnvironmentAuthPolicy from "./auth/EnvironmentAuthPolicy.ts"; import * as CloudHttp from "./cloud/http.ts"; import * as GuestHttp from "./guest/http.ts"; import * as CloudLink from "./cloud/CloudLink.ts"; @@ -202,7 +207,12 @@ import * as OrchestrationHttp from "./orchestration-v2/http.ts"; import * as ProjectHttp from "./project/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; +import { + disableTailscaleServe, + ensureTailscaleServe, + readTailscaleServeRoutesTo, + readTailscaleStatus, +} from "@t3tools/tailscale"; import * as ServerActivation from "./serverActivation.ts"; // Escaped MCP handoff provenance can exceed find-my-way's 100-character segment default. @@ -244,6 +254,7 @@ const layerPtyAdapter = NodePtyAdapter.layer; const layerServerSettings = ServerSettings.layer.pipe( Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(ManagedPolicy.layer), Layer.provideMerge(SqlitePersistence.layerConfig), ); @@ -298,13 +309,42 @@ const layerResourceDiagnostics = Layer.mergeAll( const layerRelayClient = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - return RelayClient.layerCloudflared({ baseDir: config.baseDir }); + const policy = yield* ManagedPolicy.ManagedPolicy; + const layerCloudflared = RelayClient.layerCloudflared({ baseDir: config.baseDir }); + if (ManagedLimits.allowsConnect(policy)) return layerCloudflared; + // The connector only carries T3 Connect, so a policy without Connect never installs one. + const refused = Effect.fn("RelayClient.installRefusedByPolicy")(function* () { + return yield* new RelayClient.RelayClientInstallError({ + reason: "managed_by_policy", + message: ManagedLimits.refuseConnect(policy).message, + }); + }); + return Layer.effect( + RelayClient.RelayClient, + Effect.gen(function* () { + const relayClient = yield* RelayClient.RelayClient; + return RelayClient.RelayClient.of({ + ...relayClient, + install: refused(), + installWithProgress: refused, + }); + }), + ).pipe(Layer.provide(layerCloudflared)); }), -); +).pipe(Layer.provide(ManagedPolicy.layer)); const layerHttpServer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + // Before binding: a host the policy does not allow must never listen, not even briefly. + const policy = yield* ManagedPolicy.ManagedPolicy; + const bindHost = yield* ManagedLimits.checkServerReachability(policy, { + authPolicy: EnvironmentAuthPolicy.resolveServerAuthPolicy(config), + host: config.host, + tailscaleServeEnabled: config.tailscaleServeEnabled, + privateNetworkOnly: config.privateNetworkOnly, + }); + yield* ManagedLimits.closeTailscaleServeTo(policy, { host: bindHost, port: config.port }); return NodeHttpServer.layer( () => { const server = rejectRequestsUntilServing( @@ -320,7 +360,7 @@ const layerHttpServer = Layer.unwrap( return server; }, { - host: config.host ?? "127.0.0.1", + host: bindHost, port: config.port, gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, // Context takeover stays enabled (ws default) so the compression @@ -331,9 +371,11 @@ const layerHttpServer = Layer.unwrap( }, ); }), -); +).pipe(Layer.provide(ManagedPolicy.layer)); -const layerPlatformServices = DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)); +const layerPlatformServices = DocumentText.layer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.layerConfig)); @@ -520,7 +562,7 @@ const layerScheduledTaskWebhookOrigin = Layer.effect( const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( Layer.provide(layerScheduledTaskWebhookOrigin), - Layer.provide(ProviderEventIngestor.layerAnalytics), + Layer.provide(ProviderUsageAnalytics.layer), Layer.provide(layerCheckpointStore), Layer.provide(layerGitWorkflow), Layer.provide( @@ -532,10 +574,10 @@ const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( ), ); -const layerOrchestrationApplication = CheckpointDiffQuery.layer.pipe( - Layer.provideMerge(layerCheckpointStore), - Layer.provideMerge(layerOrchestrationV2Runtime), -); +const layerOrchestrationApplication = Layer.merge( + CheckpointDiffQuery.layer, + NativeChildWorkQuery.layer, +).pipe(Layer.provideMerge(layerCheckpointStore), Layer.provideMerge(layerOrchestrationV2Runtime)); const layerThreadSettlementWorker = Layer.effectDiscard( ThreadSettlementService.make.pipe(Effect.flatMap((service) => service.start())), @@ -701,7 +743,8 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provideMerge(layerResourceDiagnostics), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(TriageService.layer.pipe(Layer.provide(GitVcsDriver.layer))), - Layer.provideMerge(AnalyticsService.layer), + // Layers memoize by reference, so this is the same policy the settings layer reads. + Layer.provideMerge(AnalyticsService.layer.pipe(Layer.provide(ManagedPolicy.layer))), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), Layer.provideMerge(DirectEndpoints.layer), @@ -816,6 +859,7 @@ const layerMakeServer = Layer.unwrap( config, port: address.port, serviceManaged: launcher.managed, + managedPolicy: yield* ManagedPolicy.loadedPolicy(yield* ManagedPolicy.ManagedPolicy), }); yield* ownership .publish(state) @@ -839,12 +883,17 @@ const layerMakeServer = Layer.unwrap( } const localPort = address.port; + const localHost = loopbackUrlHostForAddress(address); return yield* ensureTailscaleServe({ localPort, servePort: config.tailscaleServePort, - localHost: loopbackUrlHostForAddress(address), + localHost, }).pipe( - Effect.as({ localPort, servePort: config.tailscaleServePort }), + Effect.as({ + localPort, + localHost: localHost.replace(/^\[|\]$/gu, ""), + servePort: config.tailscaleServePort, + }), Effect.tap(() => Effect.logInfo("Tailscale Serve configured", { localPort, @@ -862,11 +911,44 @@ const layerMakeServer = Layer.unwrap( }), (configured) => configured - ? disableTailscaleServe({ servePort: configured.servePort }).pipe( - Effect.tap(() => - Effect.logInfo("Tailscale Serve disabled", { - servePort: configured.servePort, + ? Effect.all( + [ + readTailscaleServeRoutesTo({ + host: configured.localHost, + port: configured.localPort, }), + readTailscaleStatus, + ], + { concurrency: 2 }, + ).pipe( + // Another service may hold the root route by now, or it may sit under a + // former name, where `off` would act on the current one; only ours goes. + Effect.map(([routes, status]) => + routes.some( + (route) => + route.servePort === configured.servePort && + route.mount === "/" && + route.owner === undefined && + route.ambiguous === undefined && + route.host?.toLowerCase() === status.magicDnsName?.toLowerCase(), + ), + ), + Effect.orElseSucceed(() => false), + Effect.flatMap((ours) => + ours + ? disableTailscaleServe({ servePort: configured.servePort }).pipe( + Effect.tap(() => + Effect.logInfo("Tailscale Serve disabled", { + servePort: configured.servePort, + }), + ), + ) + : Effect.logInfo( + "Tailscale Serve left on; its root route is not known to be ours", + { + servePort: configured.servePort, + }, + ), ), Effect.catch((cause) => Effect.logWarning("Failed to disable Tailscale Serve", { @@ -882,6 +964,11 @@ const layerMakeServer = Layer.unwrap( const layerCloudDesiredLinkReconcile = Layer.effectDiscard( Effect.gen(function* () { const cloudLink = yield* CloudLink.CloudLink; + // CloudLink unlinks on startup when the policy turns Connect off; nothing to restore. + if (!ManagedLimits.allowsConnect(yield* ManagedPolicy.ManagedPolicy)) { + yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); + return; + } const releaseManagedTunnel = cloudLink.releaseManagedTunnelOnShutdown().pipe( Effect.timeout("10 seconds"), Effect.tap((released) => @@ -1099,7 +1186,9 @@ const layerMakeServer = Layer.unwrap( ), ), Layer.provide(layerActivation), - Layer.provideMerge(RelayTracing.layerServerRelayBroker), + Layer.provideMerge( + RelayTracing.layerServerRelayBroker.pipe(Layer.provide(ManagedPolicy.layer)), + ), Layer.provideMerge(layerHttpServer), Layer.provide(layerApplicationObservability), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 6d89f45cb792e..565f8111849e0 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -5,7 +5,7 @@ import * as NodeSqlite from "node:sqlite"; import { SERVER_EXIT_CODE_STATE_DIR_OWNED } 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 Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -184,9 +184,9 @@ export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(functi statePath: string, ) { const stateDir = NodePath.dirname(statePath); - const crypto = yield* Crypto.Crypto; - const ownerId = yield* crypto - .randomUUIDv4() + const random = yield* SecureRandom.SecureRandom; + const ownerId = yield* random + .uuidV4() .pipe(Effect.mapError((cause) => new ServerOwnershipError({ stateDir, cause }))); const lockPath = NodePath.join(stateDir, "server-owner.sqlite"); const lock = yield* Effect.acquireRelease( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 54fef56e311d8..e37f3078548ce 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -14,7 +14,7 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; 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 Effect from "effect/Effect"; @@ -261,8 +261,8 @@ const resolveWelcomeBase = Effect.gen(function* () { }); const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const randomUUID = crypto.randomUUIDv4(); + const random = yield* SecureRandom.SecureRandom; + const randomUUID = random.uuidV4(); const serverConfig = yield* ServerConfig.ServerConfig; const projects = yield* ProjectService.ProjectService; const threads = yield* ThreadManagement.ThreadManagementService; @@ -451,7 +451,7 @@ const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: Startup const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; const commandGate = yield* makeCommandGate; @@ -557,7 +557,9 @@ const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: Startup autoBootstrap: (serverConfig.autoBootstrapProjectFromCwd ? runStartupPhase( "welcome.autobootstrap", - resolveAutoBootstrapWelcomeTargets.pipe(Effect.provideService(Crypto.Crypto, crypto)), + resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(SecureRandom.SecureRandom, random), + ), ) : Effect.succeed({}) ).pipe(Effect.map((targets): AutoBootstrapWelcomeTargets => targets)), diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index b75ab72594add..5a14cf139577e 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -3,6 +3,7 @@ import * as NodeFS from "node:fs"; import * as NodeSqlite from "node:sqlite"; import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; @@ -145,7 +146,7 @@ describe("server ownership", () => { }), ), Layer.succeed(HostProcess.Environment, {}), - ).pipe(Layer.provideMerge(NodeServices.layer)); + ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); const makeState = ServerRuntimeState.makePersistedServerRuntimeState({ config: { host: undefined, devUrl: undefined }, port: 3773, @@ -373,7 +374,7 @@ describe("server ownership", () => { assert.include(result.failure.message, "t3 service restart"); assert.include(result.failure.message, "No server was stopped"); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -392,7 +393,7 @@ describe("server ownership", () => { if (result._tag !== "Failure") return; assert.equal(result.failure._tag, "OlderBootServiceError"); assert.equal(Runtime.getErrorExitCode(result.failure), 78); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("refuses while the loaded service has not restarted into its lock-aware update", () => @@ -406,7 +407,7 @@ describe("server ownership", () => { }, }); assert.equal(result._tag === "Failure" && result.failure._tag, "OlderBootServiceError"); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("allows lock-aware stable services that are loaded or stopped and enabled", () => @@ -429,7 +430,7 @@ describe("server ownership", () => { const { result } = yield* startBesideBootService({ ...fixture, activeVersion: "0.1.0" }); assert.equal(result._tag, "Success"); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("allows services that cannot compete for this home", () => @@ -466,7 +467,7 @@ describe("server ownership", () => { const { result } = yield* startBesideBootService(fixture); assert.equal(result._tag, "Success"); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("allows startup when the service configuration or manager is unavailable", () => @@ -484,7 +485,7 @@ describe("server ownership", () => { const { result } = yield* startBesideBootService({ platform: "darwin", ...fixture }); assert.equal(result._tag, "Success"); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); }); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index aca62ebb01ecf..49d9a48f991bc 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -22,6 +22,16 @@ export const PersistedServerRuntimeState = Schema.Struct({ startedAt: Schema.String, serviceManaged: Schema.optional(Schema.Boolean), serverVersion: Schema.optional(Schema.String), + // The managed policy this server loaded at startup, null for none; absent from older servers. + managedPolicy: Schema.optional( + Schema.NullOr( + Schema.Struct({ + source: Schema.String, + organization: Schema.String, + fingerprint: Schema.String, + }), + ), + ), }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; @@ -58,6 +68,7 @@ export const makePersistedServerRuntimeState = (input: { >; readonly port: number; readonly serviceManaged?: boolean; + readonly managedPolicy?: PersistedServerRuntimeState["managedPolicy"]; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ version: 1, @@ -70,6 +81,7 @@ export const makePersistedServerRuntimeState = (input: { startedAt: DateTime.formatIso(now), ...(input.serviceManaged ? { serviceManaged: true } : {}), serverVersion: packageJson.version, + ...(input.managedPolicy === undefined ? {} : { managedPolicy: input.managedPolicy }), })); export const persistServerRuntimeState = (input: { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index ef8b4d6afae79..8d50f0b597b57 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,6 +1,9 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, + globalIdleCompaction, + type ManagedPolicyDocument, ModelSelection, ProjectId, ProjectScript, @@ -31,15 +34,17 @@ import * as ServerConfig from "./config.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerSettingsModule from "./serverSettings.ts"; +import * as ManagedPolicy from "./policy/ManagedPolicy.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); const decodeServerSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); -const layerServerSettings = () => +const layerServerSettings = (policy?: ManagedPolicyDocument) => ServerSettingsModule.layer.pipe( Layer.provide(ServerSecretStore.layer), + Layer.provide(ManagedPolicy.layerTest(policy)), Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), Layer.provideMerge( Layer.fresh( @@ -53,6 +58,7 @@ const layerServerSettings = () => const layerServerSettingsWithSecrets = () => ServerSettingsModule.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), + Layer.provide(ManagedPolicy.layerTest()), Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), Layer.provideMerge( Layer.fresh( @@ -96,7 +102,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; @@ -1084,6 +1090,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Layer.provide(ServerSecretStore.layer)); const layerSettings = ServerSettingsModule.layer.pipe( Layer.provide(layerSecret), + Layer.provide(ManagedPolicy.layerTest()), Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), Layer.provideMerge( Layer.fresh( @@ -1130,6 +1137,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { })), ).pipe(Layer.provide(ServerSecretStore.layer)); const layerSettings = ServerSettingsModule.layer.pipe( + Layer.provide(ManagedPolicy.layerTest()), Layer.provide(layerSecret), Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), Layer.provideMerge( @@ -1232,7 +1240,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { return yield* fresh.getSettings; }).pipe( Effect.provide( - Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)), + Layer.fresh(ServerSettingsModule.layer).pipe( + Layer.provide(Layer.merge(ServerSecretStore.layer, ManagedPolicy.layerTest())), + ), ), ); assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected); @@ -1538,20 +1548,34 @@ it.layer(NodeServices.layer)("server settings", (it) => { provider: SecretProviderId.make("personal"), reference: "op://Private/Bitbucket/token", }; + const gitHubReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/GitHub/token", + }; yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: "bb-access" } }, + sourceControlHosts: { + bitbucket: { accessToken: "bb-access" }, + github: { tokens: { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } }, + }, }); assert.isTrue(Option.isSome(yield* stored("bitbucket", "accessToken", null))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "github.com"))); const referenced = yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: reference } }, + sourceControlHosts: { + bitbucket: { accessToken: reference }, + github: { tokens: { "github.com": gitHubReference, "ghe.acme.test": "ghp_ghe" } }, + }, }); assert.deepEqual(referenced.sourceControlHosts.bitbucket?.accessToken, reference); assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); - assert.deepEqual( - ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts - .bitbucket?.accessToken, - reference, - ); + assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); + const redacted = + ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts; + assert.deepEqual(redacted.bitbucket?.accessToken, reference); + const tokens = redacted.github?.tokens as Record; + assert.deepEqual(tokens["github.com"], gitHubReference); + assert.notEqual(tokens["ghe.acme.test"], "ghp_ghe"); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); @@ -1567,6 +1591,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }), }); const layerSettings = ServerSettingsModule.layer.pipe( + Layer.provide(ManagedPolicy.layerTest()), Layer.provide(layerFailingSecretStore(cause)), Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), Layer.provideMerge( @@ -1823,6 +1848,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }), ); const layerSettings = ServerSettingsModule.layer.pipe( + Layer.provide(ManagedPolicy.layerTest()), + Layer.provide(ManagedPolicy.layerTest()), Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), Layer.provide(layerSecretStore), Layer.provideMerge( @@ -1984,3 +2011,680 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); }); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( + "server settings under a managed policy", + (it) => { + const policy: ManagedPolicyDocument = { + schemaVersion: 1, + organization: "Example Corp", + settings: { + defaultRuntimeMode: { value: "approval-required", locked: false }, + enableAgentDeviceAccess: { value: false, locked: true }, + }, + }; + + it.effect("uses policy defaults until the user picks something else", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + + assert.equal((yield* service.getSettings).defaultRuntimeMode, "approval-required"); + + // The built-in default differs from the policy's, so choosing it must persist. + yield* service.updateSettings({ defaultRuntimeMode: "full-access" }); + assert.equal((yield* service.getSettings).defaultRuntimeMode, "full-access"); + assert.include(yield* fs.readFileString(config.settingsPath), '"full-access"'); + + // Choosing the policy's value leaves nothing in the file to outlive the policy. + yield* service.updateSettings({ defaultRuntimeMode: "approval-required" }); + assert.notInclude(yield* fs.readFileString(config.settingsPath), "defaultRuntimeMode"); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); + + it.effect("keeps a stored value that equals a policy default through unrelated writes", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ defaultRuntimeMode: "approval-required" }), + ); + + yield* service.updateSettings({ defaultAutoPull: true }); + assert.include(yield* fs.readFileString(config.settingsPath), '"approval-required"'); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); + + it.effect("keeps stored values under whole-object policy defaults, and only the user's", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const selection = { + ...DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, + model: "managed", + }; + const chosenModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-mini" }; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + storageCleanup: { logsAfterDays: 30, browserArtifactsAfterDays: 7 }, + textGenerationModelSelection: selection, + }), + ); + + // Picking another model on the policy's instance differs from the policy in one field only. + yield* service.updateSettings({ + defaultAutoPull: true, + defaultModelSelection: chosenModel, + }); + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(stored.storageCleanup, { + logsAfterDays: 30, + browserArtifactsAfterDays: 7, + }); + // Model selections are saved whole, never as the fields that differ from the policy's. + assert.deepEqual(stored.textGenerationModelSelection, selection); + assert.deepEqual(stored.defaultModelSelection, chosenModel); + // The switch the server derives from the policy's idle compaction stays out of the file. + assert.notProperty(stored, "compactIdleThreads"); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + settings: { + storageCleanup: { + value: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, logsAfterDays: 30 }, + locked: false, + }, + "textGenerationModelSelection.model": { value: "managed", locked: false }, + defaultModelSelection: { + value: { instanceId: "codex", model: "gpt-managed" }, + locked: false, + }, + idleCompaction: { value: "beforeCacheExpires", locked: false }, + }, + }), + ), + ), + ); + + it.effect( + "keeps an idle compaction choice saved before idleCompaction over a policy default", + () => + // The file holds only the legacy switch: off differs from the policy's default, on matches it. + Effect.forEach([false, true], (compactIdleThreads) => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const chosen = compactIdleThreads ? "beforeCacheExpires" : "onReturn"; + yield* fs.writeFileString(config.settingsPath, JSON.stringify({ compactIdleThreads })); + + assert.equal((yield* service.getSettings).idleCompaction, chosen); + yield* service.updateSettings({ defaultAutoPull: true }); + // What applies once the policy is gone is still the user's choice. + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.equal(globalIdleCompaction(stored), chosen); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + settings: { idleCompaction: { value: "beforeCacheExpires", locked: false } }, + }), + ), + ), + ), + ); + + it.effect("lays locks over the user's own and per-project values without persisting them", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const projectId = ProjectId.make("project-locked"); + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + enableAgentDeviceAccess: true, + projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } }, + }), + ); + + const settings = yield* service.getSettings; + assert.isFalse(settings.enableAgentDeviceAccess); + assert.isFalse(settings.projectSettingsOverrides[projectId]?.enableAgentDeviceAccess); + assert.deepEqual(service.managedPolicy, { + organization: "Example Corp", + lockedSettings: ["enableAgentDeviceAccess"], + }); + + const rejected = yield* service + .updateSettings({ enableAgentDeviceAccess: true, defaultAutoPull: true }) + .pipe(Effect.flip); + assert.equal(rejected.operation, "write-managed-setting"); + assert.equal(rejected.managedSetting, "enableAgentDeviceAccess"); + assert.equal( + rejected.message, + "enableAgentDeviceAccess is managed by Example Corp and cannot be changed.", + ); + assert.isFalse((yield* service.getSettings).defaultAutoPull); + + const projectRejected = yield* service + .updateSettings({ + projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } }, + }) + .pipe(Effect.flip); + assert.equal(projectRejected.managedSetting, "enableAgentDeviceAccess"); + + // Unrelated writes, and writes that keep the locked value, go through. + yield* service.updateSettings({ enableAgentDeviceAccess: false, defaultAutoPull: true }); + assert.isTrue((yield* service.getSettings).defaultAutoPull); + + // The user's own value stays on disk, ready for when the policy is removed. + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.isTrue(stored.enableAgentDeviceAccess); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); + it.effect("accepts a project override that brings the locked value inside a new setting", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const projectId = ProjectId.make("project-text-generation"); + const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-locked" }; + + yield* service.updateSettings({ + projectSettingsOverrides: { [projectId]: { textGenerationModelSelection: selection } }, + }); + + const project = (yield* service.getSettings).projectSettingsOverrides[projectId]; + assert.equal(project?.textGenerationModelSelection?.model, "gpt-locked"); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + settings: { + "textGenerationModelSelection.model": { value: "gpt-locked", locked: true }, + }, + }), + ), + ), + ); + + it.effect( + "compares a project's partial override with a locked entry by the keys it holds", + () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const codexId = ProviderInstanceId.make("codex"); + const projectWith = (enabled: boolean) => ({ + projectSettingsOverrides: { + [ProjectId.make("project-codex")]: { providerInstances: { [codexId]: { enabled } } }, + }, + }); + + yield* service.updateSettings(projectWith(true)); + const refused = yield* service.updateSettings(projectWith(false)).pipe(Effect.flip); + assert.equal(refused.managedSetting, "providerInstances.codex"); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + settings: { + "providerInstances.codex": { + value: { driver: "codex", enabled: true }, + locked: true, + }, + }, + }), + ), + ), + ); + + it.effect("lowers default runtime modes above the ceiling without rewriting them", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const raised = ProjectId.make("project-raised"); + const supervised = ProjectId.make("project-supervised"); + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + defaultRuntimeMode: "auto", + projectSettingsOverrides: { + [raised]: { defaultRuntimeMode: "full-access" }, + [supervised]: { defaultRuntimeMode: "approval-required" }, + }, + }), + ); + + const settings = yield* service.getSettings; + assert.equal(settings.defaultRuntimeMode, "auto-accept-edits"); + assert.equal( + settings.projectSettingsOverrides[raised]?.defaultRuntimeMode, + "auto-accept-edits", + ); + assert.equal( + settings.projectSettingsOverrides[supervised]?.defaultRuntimeMode, + "approval-required", + ); + + yield* service.updateSettings({ defaultAutoPull: true }); + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.equal(stored.defaultRuntimeMode, "auto"); + assert.equal(stored.projectSettingsOverrides[raised].defaultRuntimeMode, "full-access"); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + limits: { maxRuntimeMode: "auto-accept-edits" }, + }), + ), + ), + ); + }, +); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( + "server settings under a provider policy", + (it) => { + const claude = ProviderDriverKind.make("claudeAgent"); + const codex = ProviderDriverKind.make("codex"); + const cursor = ProviderDriverKind.make("cursor"); + const bedrock = { name: "CLAUDE_CODE_USE_BEDROCK", value: "1", sensitive: false }; + const policy: ManagedPolicyDocument = { + schemaVersion: 1, + organization: "Example Corp", + limits: { + providers: { + allowedDrivers: [claude, codex], + instances: { + [claude]: { + locked: true, + environment: [{ name: bedrock.name, value: bedrock.value }], + binaryPath: "/opt/claude/bin/claude", + }, + [codex]: { locked: false, launchArgs: "-c model_provider=gateway" }, + }, + }, + }, + }; + + it.effect("refuses enabling a driver the policy leaves out, and keeps existing ones", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const cursorId = ProviderInstanceId.make("cursor_work"); + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + providerInstances: { + [cursorId]: { driver: cursor, enabled: true }, + // A default instance id is no proof of its driver. + codex: { driver: cursor, enabled: true }, + }, + textGenerationModelSelection: { instanceId: cursorId, model: "auto" }, + }), + ); + + const settings = yield* service.getSettings; + // Never deleted: the instance stays for its threads, and the registry shows it unavailable. + assert.equal(settings.providerInstances[cursorId]?.driver, cursor); + // Built-in text generation does not fall back onto a disallowed driver either. + assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); + + const rejected = yield* service + .updateProviderInstance({ + operation: "create", + instanceId: ProviderInstanceId.make("cursor_new"), + instance: { driver: cursor, enabled: true }, + }) + .pipe(Effect.flip); + assert.equal(rejected.operation, "use-disallowed-provider"); + assert.equal(rejected.message, "Example Corp's policy does not allow the Cursor provider."); + // Switching it on for one project is enabling it too. + const perProject = yield* service + .updateSettings({ + projectSettingsOverrides: { + [ProjectId.make("project-a")]: { + providerInstances: { [cursorId]: { enabled: true } }, + }, + }, + }) + .pipe(Effect.flip); + assert.equal(perProject.operation, "use-disallowed-provider"); + + // The client echoes a default instance that was running before it was configured. + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId: ProviderInstanceId.make("cursor"), + instance: { driver: cursor, enabled: true }, + }); + + // Turning it off, or removing it, is still the user's call. + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId: cursorId, + instance: { driver: cursor, enabled: false }, + }); + yield* service.updateProviderInstance({ operation: "remove", instanceId: cursorId }); + assert.isUndefined((yield* service.getSettings).providerInstances[cursorId]); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); + + it.effect("keeps a locked Codex pin from being bypassed by managed setup", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const codexId = ProviderInstanceId.make("codex_chatgpt"); + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + providerInstances: { [codexId]: { driver: codex, config: { setupMode: "managed" } } }, + }), + ); + + const effective = (yield* service.getSettings).providerInstances[codexId]; + assert.deepEqual(effective?.config, { binaryPath: "/opt/gateway/codex" }); + // A stored value stays for when the policy is gone; a new one is refused. + const refused = yield* service + .updateProviderInstance({ + operation: "upsert", + instanceId: ProviderInstanceId.make("codex_new"), + instance: { driver: codex, config: { setupMode: "managed" } }, + }) + .pipe(Effect.flip); + assert.equal(refused.managedSetting, "providerInstances.codex_new.config.setupMode"); + // Adding a Codex instance sends the default setup, which leaves the pin in charge. + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId: ProviderInstanceId.make("codex_cli"), + instance: { driver: codex, config: { setupMode: "existing" } }, + }); + }).pipe( + Effect.provide( + layerServerSettings({ + schemaVersion: 1, + organization: "Example Corp", + limits: { + providers: { + instances: { [codex]: { locked: true, binaryPath: "/opt/gateway/codex" } }, + }, + }, + }), + ), + ), + ); + + it.effect("pins instance fields at read time and refuses edits to locked ones", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const claudeId = ProviderInstanceId.make("claudeAgent"); + const codexId = ProviderInstanceId.make("codex"); + const userVariable = { name: "MY_FLAG", value: "on", sensitive: false }; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + providerInstances: { + [claudeId]: { + driver: claude, + environment: [{ ...bedrock, value: "0" }, userVariable, { ...bedrock, value: "2" }], + config: { binaryPath: "/usr/local/bin/claude" }, + }, + }, + }), + ); + + const effective = yield* service.getSettings; + const claudeInstance = effective.providerInstances[claudeId]; + assert.deepEqual(claudeInstance?.environment, [bedrock, userVariable]); + assert.deepEqual(claudeInstance?.config, { binaryPath: "/opt/claude/bin/claude" }); + // The implicit default Codex instance gets the unlocked default too. + assert.deepEqual(effective.providerInstances[codexId]?.config, { + launchArgs: "-c model_provider=gateway", + }); + + const lockedBinary = yield* service + .updateProviderInstance({ + operation: "upsert", + instanceId: claudeId, + instance: { ...claudeInstance!, config: { binaryPath: "/tmp/claude" } }, + }) + .pipe(Effect.flip); + assert.equal(lockedBinary.operation, "write-managed-setting"); + assert.equal( + lockedBinary.managedSetting, + "providerInstances.claudeAgent.config.binaryPath", + ); + const lockedVariable = yield* service + .updateProviderInstance({ + operation: "upsert", + instanceId: claudeId, + instance: { ...claudeInstance!, environment: [userVariable] }, + }) + .pipe(Effect.flip); + assert.equal( + lockedVariable.managedSetting, + "providerInstances.claudeAgent.environment.CLAUDE_CODE_USE_BEDROCK", + ); + + // Saving what the client was shown, plus an unrelated edit, goes through without baking + // the policy into the file: the user's own values come back once the policy is removed. + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId: claudeId, + instance: { ...claudeInstance!, displayName: "Claude (work)" }, + }); + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId: codexId, + instance: effective.providerInstances[codexId]!, + }); + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(stored.providerInstances[claudeId], { + driver: claude, + displayName: "Claude (work)", + environment: [{ ...bedrock, value: "0" }, { ...bedrock, value: "2" }, userVariable], + config: { binaryPath: "/usr/local/bin/claude" }, + }); + assert.isUndefined(stored.providerInstances[codexId]); + + // Unlocked fields are the user's to override. + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId: codexId, + instance: { driver: codex, config: { launchArgs: "--mine" } }, + }); + assert.deepEqual((yield* service.getSettings).providerInstances[codexId]?.config, { + launchArgs: "--mine", + }); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); + }, +); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( + "server settings under a runtime mode ceiling", + (it) => { + const claude = ProviderDriverKind.make("claudeAgent"); + const acpRegistry = ProviderDriverKind.make("acpRegistry"); + const policy: ManagedPolicyDocument = { + schemaVersion: 1, + organization: "Example Corp", + limits: { + maxRuntimeMode: "auto-accept-edits", + providers: { + instances: { + [claude]: { + locked: false, + environment: [{ name: "CLAUDE_CODE_USE_BEDROCK", value: "1" }], + binaryPath: "/opt/claude/bin/claude", + }, + }, + }, + }, + }; + + it.effect("keeps the secret of a variable it hides when the instance is saved", () => { + const shared = ServerSecretStore.layer.pipe( + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-hidden-secret-" })), + ), + ); + const withPolicy = ( + document: ManagedPolicyDocument | undefined, + use: ( + service: ServerSettingsModule.ServerSettingsService["Service"], + ) => Effect.Effect, + ) => + ServerSettingsModule.ServerSettingsService.pipe( + Effect.flatMap(use), + Effect.provide( + ServerSettingsModule.layer.pipe(Layer.provide(ManagedPolicy.layerTest(document))), + ), + ); + const claudeId = ProviderInstanceId.make("claudeAgent"); + const token = { name: "ANTHROPIC_API_KEY", value: "sk-kept", sensitive: true }; + return Effect.gen(function* () { + yield* withPolicy(undefined, (service) => + service.updateProviderInstance({ + operation: "upsert", + instanceId: claudeId, + instance: { driver: claude, environment: [token] }, + }), + ); + // The ceiling shows only pinned variables, so the client saves without the token. + yield* withPolicy(policy, (service) => + service.getSettings.pipe( + Effect.flatMap((settings) => + service.updateProviderInstance({ + operation: "upsert", + instanceId: claudeId, + instance: { + ...settings.providerInstances[claudeId]!, + displayName: "Claude (work)", + }, + }), + ), + ), + ); + const restored = yield* withPolicy(undefined, (service) => service.getSettings); + assert.equal(restored.providerInstances[claudeId]?.environment?.[0]?.value, token.value); + }).pipe(Effect.provide(shared)); + }); + + it.effect("ignores stored launch configuration and refuses new values", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const claudeId = ProviderInstanceId.make("claudeAgent"); + const registryId = ProviderInstanceId.make("acp_devin"); + const userVariable = { + name: "CLAUDE_CONFIG_DIR", + value: "/tmp/allow-all", + sensitive: false, + }; + const storedClaude = { + driver: claude, + environment: [userVariable, { ...userVariable, value: "/tmp/later-wins" }], + config: { + binaryPath: "/tmp/wrapper", + launchArgs: "--dangerously-skip-permissions", + homePath: "/tmp/allow-all", + autoCompactWindow: "300000", + }, + }; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + providerInstances: { + [claudeId]: storedClaude, + [registryId]: { + driver: acpRegistry, + config: { agentId: "devin", commandPath: "/tmp/agent", commandArgs: ["--yolo"] }, + }, + }, + }), + ); + + // Even an unlocked pin wins, and the user's launch values and variables are not used. + const effective = yield* service.getSettings; + const claudeInstance = effective.providerInstances[claudeId]!; + assert.deepEqual(claudeInstance.config, { + binaryPath: "/opt/claude/bin/claude", + autoCompactWindow: "300000", + }); + assert.deepEqual(claudeInstance.environment, [ + { name: "CLAUDE_CODE_USE_BEDROCK", value: "1", sensitive: false }, + ]); + assert.deepEqual(effective.providerInstances[registryId]?.config, { agentId: "devin" }); + + const refused = (instance: typeof claudeInstance) => + service + .updateProviderInstance({ operation: "upsert", instanceId: claudeId, instance }) + .pipe(Effect.flip); + const launchArgs = yield* refused({ + ...claudeInstance, + config: { ...claudeInstance.config!, launchArgs: "--permission-mode bypassPermissions" }, + }); + assert.equal(launchArgs.operation, "write-managed-setting"); + assert.equal(launchArgs.managedSetting, "providerInstances.claudeAgent.config.launchArgs"); + const binary = yield* refused({ + ...claudeInstance, + config: { ...claudeInstance.config!, binaryPath: "/tmp/other-wrapper" }, + }); + assert.equal(binary.managedSetting, "providerInstances.claudeAgent.config.binaryPath"); + const environment = yield* refused({ + ...claudeInstance, + environment: [{ name: "NODE_OPTIONS", value: "--require /tmp/x.js", sensitive: false }], + }); + assert.equal(environment.managedSetting, "providerInstances.claudeAgent.environment"); + const commandArgs = yield* service + .updateProviderInstance({ + operation: "upsert", + instanceId: registryId, + instance: { + driver: acpRegistry, + config: { agentId: "devin", commandArgs: ["--dangerously-skip-permissions"] }, + }, + }) + .pipe(Effect.flip); + assert.equal(commandArgs.managedSetting, "providerInstances.acp_devin.config.commandArgs"); + + // Unrelated writes carry the stored instance through unchanged, duplicates included. + yield* service.updateSettings({ defaultAutoPull: true }); + + // What the client was shown saves, with user-owned edits, and the stored values survive. + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId: claudeId, + instance: { + ...claudeInstance, + displayName: "Claude (work)", + config: { ...claudeInstance.config!, autoCompactWindow: "400000" }, + }, + }); + const stored = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(stored.providerInstances[claudeId], { + ...storedClaude, + displayName: "Claude (work)", + config: { ...storedClaude.config, autoCompactWindow: "400000" }, + }); + }).pipe(Effect.provide(layerServerSettings(policy))), + ); + }, +); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 67a1c3c3414f3..1ead9e27bfcf5 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -16,6 +16,7 @@ import { ServerSettings, ServerSettingsError, type ServerSettingsPatch, + type ManagedPolicySummary, globalIdleCompaction, withIdleCompaction, } from "@t3tools/contracts"; @@ -52,6 +53,7 @@ import { isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import * as ManagedPolicy from "./policy/ManagedPolicy.ts"; import { secretProviderDefinitions } from "./secrets/secretProviderDefinitions.ts"; import { readSecretFieldValues, @@ -225,6 +227,9 @@ export class ServerSettingsService extends Context.Service< readonly ready: Effect.Effect; + /** The administrator's policy as clients see it; absent when unmanaged. */ + readonly managedPolicy?: ManagedPolicySummary | undefined; + readonly getSettings: Effect.Effect; readonly updateSettings: ( @@ -246,11 +251,15 @@ export class ServerSettingsService extends Context.Service< readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } >()("t3/serverSettings/ServerSettingsService") { - static readonly layerTest = (overrides: DeepPartial = {}) => layerTest(overrides); + static readonly layerTest = ( + overrides: DeepPartial = {}, + managedPolicy?: ManagedPolicySummary, + ) => layerTest(overrides, managedPolicy); } const makeTest = Effect.fn("serverSettings.makeTest")(function* ( overrides: DeepPartial = {}, + managedPolicy?: ManagedPolicySummary, ) { const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } = overrides; @@ -281,6 +290,7 @@ const makeTest = Effect.fn("serverSettings.makeTest")(function* ( ); return { + managedPolicy, start: Effect.void, ready: Effect.void, getSettings, @@ -303,8 +313,11 @@ const makeTest = Effect.fn("serverSettings.makeTest")(function* ( } satisfies ServerSettingsService["Service"]; }); -export const layerTest = (overrides: DeepPartial = {}) => - Layer.effect(ServerSettingsService, makeTest(overrides)); +/** Test settings; `managedPolicy` only tells consumers a policy applies, it does not lock values. */ +export const layerTest = ( + overrides: DeepPartial = {}, + managedPolicy?: ManagedPolicySummary, +) => Layer.effect(ServerSettingsService, makeTest(overrides, managedPolicy)); // Accept retired token delivery only on load so one old value cannot reset all settings. const PersistedResponseStreamingMode = Schema.Union([ @@ -319,24 +332,24 @@ const PersistedResponseStreamingMode = Schema.Union([ }), ), ); -const ServerSettingsJson = fromLenientJson( - Schema.Struct({ - ...ServerSettings.fields, - responseStreamingMode: PersistedResponseStreamingMode.pipe( - Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), +const PersistedServerSettings = Schema.Struct({ + ...ServerSettings.fields, + responseStreamingMode: PersistedResponseStreamingMode.pipe( + Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), + ), + projectSettingsOverrides: Schema.Record( + ProjectId, + keepUnknownOverrideKeys( + Schema.Struct({ + ...ProjectSettingsOverrides.fields, + responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), + }), ), - projectSettingsOverrides: Schema.Record( - ProjectId, - keepUnknownOverrideKeys( - Schema.Struct({ - ...ProjectSettingsOverrides.fields, - responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), - }), - ), - ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), - }), -); -const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), +}); +// Parse first and decode second, so a managed policy can fill its defaults in between. +const decodeSettingsFileJsonExit = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown)); +const decodePersistedServerSettingsExit = Schema.decodeUnknownExit(PersistedServerSettings); const LegacyProviderSettingsJson = fromLenientJson( Schema.Struct({ providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), @@ -461,17 +474,33 @@ function selectionSupportsTextGeneration( return settings.providerInstances[selection.instanceId]?.driver !== ACP_REGISTRY_DRIVER; } -function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { - return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) && - selectionSupportsTextGeneration(settings, settings.textGenerationModelSelection) +type DriverAllowed = (driver: ProviderDriverKind) => boolean; +const anyDriver: DriverAllowed = () => true; + +function resolveTextGenerationProvider( + settings: ServerSettings, + isDriverAllowed: DriverAllowed = anyDriver, +): ServerSettings { + const selection = settings.textGenerationModelSelection; + return isModelSelectionProviderEnabled(settings, selection) && + selectionSupportsTextGeneration(settings, selection) && + isDriverAllowed( + settings.providerInstances[selection.instanceId]?.driver ?? + ProviderDriverKind.make(selection.instanceId), + ) ? settings - : fallbackTextGenerationProvider(settings); + : fallbackTextGenerationProvider(settings, isDriverAllowed); } -function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { +function fallbackTextGenerationProvider( + settings: ServerSettings, + isDriverAllowed: DriverAllowed, +): ServerSettings { const fallback = fallbackTextGenerationModelSelection( settings.providerInstances, (instanceId, driver) => + // A default instance id can be configured with another driver. + isDriverAllowed(settings.providerInstances[instanceId]?.driver ?? driver) && resolveProviderInstanceEnabled( settings.providerInstances[instanceId] ?? { driver, config: {} }, ), @@ -489,6 +518,10 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "textGenerationModelSelection", "fallbackModelSelection", "pullRequestMergeMethod", + // Empty by default, these hold one whole value, which a managed policy can default. + ...Object.entries(DEFAULT_SERVER_SETTINGS).flatMap(([key, value]) => + value === null ? [key] : [], + ), ]); function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { @@ -610,6 +643,17 @@ const make = Effect.gen(function* () { const pathService = yield* Path.Path; const secretStore = yield* ServerSecretStore.ServerSecretStore; const sql = yield* SqlClient.SqlClient; + const { + settings: managed, + providers: managedProviders, + summary: managedPolicy, + } = yield* ManagedPolicy.ManagedPolicy; + // Locks are laid over every settings value that leaves this service, never persisted. + const toEffective = (settings: ServerSettings) => + resolveTextGenerationProvider( + managedProviders.applyLimits(managed.applyLocks(settings)), + managedProviders.isDriverAllowed, + ); const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; const changesPubSub = yield* PubSub.unbounded(); @@ -643,10 +687,22 @@ const make = Effect.gen(function* () { ), ); + // The file before this write, parsed; only read when policy defaults need it. + const readStoredSettings = Effect.gen(function* () { + if (!(yield* readConfigExists)) return undefined; + const parsed = decodeSettingsFileJsonExit(yield* readRawConfig); + return Exit.isSuccess(parsed) ? parsed.value : undefined; + }); + const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { + const stripped = stripDefaultServerSettings(settings, managed.defaults) ?? {}; const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, + managed.keepStored === undefined + ? stripped + : managed.keepStored(stripped, settings, yield* readStoredSettings, (key) => + ATOMIC_SETTINGS_KEYS.has(key), + ), ); return yield* writeFileStringAtomically({ @@ -698,14 +754,17 @@ const make = Effect.gen(function* () { ); const loadSettingsFromDisk = Effect.gen(function* () { - let settings = DEFAULT_SERVER_SETTINGS; + let settings = managed.defaults; let legacyProviders: Readonly> | undefined; let legacySourceControl: Parameters[1] = {}; let parseFailure = Option.none(); if (yield* readConfigExists) { const raw = yield* readRawConfig; - const decoded = decodeServerSettingsJsonExit(raw); + const parsed = decodeSettingsFileJsonExit(raw); + const decoded = Exit.isFailure(parsed) + ? parsed + : decodePersistedServerSettingsExit(managed.withDefaults(parsed.value)); const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); if (legacySettings._tag === "Success") { legacyProviders = legacySettings.value.providers; @@ -717,7 +776,7 @@ const make = Effect.gen(function* () { if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : legacySettings; // The file may hold an opt-out we can no longer read; never resume threads on a guess. - settings = { ...DEFAULT_SERVER_SETTINGS, continueThreadsAfterServerUpdate: false }; + settings = { ...managed.defaults, continueThreadsAfterServerUpdate: false }; if (failure._tag === "Failure") { parseFailure = Option.some(Cause.squash(failure.cause)); yield* Effect.logWarning("failed to parse settings.json, using defaults", { @@ -900,7 +959,7 @@ const make = Effect.gen(function* () { if (materialized?.source === source) return Effect.succeed(materialized.value); const generation = secretWriteGeneration; return materializeSettingsSecrets(source).pipe( - Effect.map(resolveTextGenerationProvider), + Effect.map(toEffective), Effect.tap((value) => Effect.sync(() => { if (generation === secretWriteGeneration) materialized = { source, value }; @@ -924,7 +983,7 @@ const make = Effect.gen(function* () { ), ), ), - Stream.map(resolveTextGenerationProvider), + Stream.map(toEffective), ); type SecretChange = { @@ -1070,6 +1129,7 @@ const make = Effect.gen(function* () { }; const updateAndPersistSettings = ( + patch: ServerSettingsPatch, update: (current: ServerSettings) => Effect.Effect, ): Effect.Effect => writeSemaphore.withPermits(1)( @@ -1083,7 +1143,35 @@ const make = Effect.gen(function* () { }); } const updated = yield* update(current); - const persisted = yield* persistSettingsSecrets(current, updated); + // Vet before secrets: a variable the policy hides from clients is not one they removed. + const vetted = managed.vetWrite(current, yield* normalizeServerSettings(updated), patch); + if (vetted._tag === "Locked") { + return yield* new ServerSettingsError({ + settingsPath, + operation: "write-managed-setting", + managedSetting: vetted.path, + managedBy: managedPolicy?.organization, + }); + } + const vettedProviders = managedProviders.vetWrite(current, vetted.settings); + if (vettedProviders._tag === "Locked") { + return yield* new ServerSettingsError({ + settingsPath, + operation: "write-managed-setting", + managedSetting: vettedProviders.path, + managedBy: managedPolicy?.organization, + }); + } + if (vettedProviders._tag === "Disallowed") { + return yield* new ServerSettingsError({ + settingsPath, + operation: "use-disallowed-provider", + providerInstanceId: vettedProviders.instanceId, + providerLabel: vettedProviders.label, + managedBy: managedPolicy?.organization, + }); + } + const persisted = yield* persistSettingsSecrets(current, vettedProviders.settings); const next = yield* normalizeServerSettings(persisted.settings); const materializedNext = yield* Effect.uninterruptibleMask(() => Effect.gen(function* () { @@ -1103,7 +1191,7 @@ const make = Effect.gen(function* () { }).pipe(Effect.ensuring(invalidateMaterialized)), ); yield* Cache.set(settingsCache, cacheKey, { settings: next, parseFailure: Option.none() }); - const resolved = resolveTextGenerationProvider(materializedNext); + const resolved = toEffective(materializedNext); materialized = { source: next, value: resolved }; yield* emitChange(next); return resolved; @@ -1214,17 +1302,18 @@ const make = Effect.gen(function* () { }); return { + managedPolicy, start, ready: Deferred.await(startedDeferred), getSettings: getMaterializedSettings, updateSettings: (patch) => - updateAndPersistSettings((current) => + updateAndPersistSettings(patch, (current) => ensureSecretProviderWritesAllowed(patch, settingsPath).pipe( Effect.as(applyServerSettingsPatch(current, patch)), ), ), updateProviderInstance: (mutation, patch = {}) => - updateAndPersistSettings((current) => + updateAndPersistSettings(patch, (current) => Effect.gen(function* () { yield* ensureSecretProviderWritesAllowed(patch, settingsPath); yield* ensureProviderInstanceMutationAllowed(current, mutation, settingsPath); diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 3d4daa659e58a..1bebce51b3876 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -12,6 +12,7 @@ import { import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { grokClient } from "@t3tools/provider-grok/client"; +import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -28,7 +29,15 @@ const providerSecretFields = new Map([ ["codex", secretSettingsFields(CodexSettings)], ["claudeAgent", secretSettingsFields(ClaudeSettings)], ["antigravity", secretSettingsFields(AntigravitySettings)], - ...[cursorClient, grokClient, museClient, openCodeClient, piClient, acpRegistryClient].map( + ...[ + cursorClient, + grokClient, + kiroClient, + museClient, + openCodeClient, + piClient, + acpRegistryClient, + ].map( (definition) => [definition.driverKind, secretSettingsFields(definition.settingsSchema)] as const, ), diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 744c992e80149..1ea93f5fdb9d3 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,5 +1,6 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; 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 { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -116,7 +117,7 @@ function layer(input: { Layer.provide(Layer.succeed(FileSystem.FileSystem, input.fileSystem)), Layer.provideMerge(NodePath.layer), ) - : layerService.pipe(Layer.provideMerge(NodeServices.layer)); + : layerService.pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); } const lookupFailingWith = (cause: unknown, detail: string) => @@ -209,7 +210,7 @@ it.effect("clones a looked-up repository after creating missing parent directori }), ), ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each(["--bare", "source.git"])( @@ -243,7 +244,7 @@ it.effect.each(["--bare", "source.git"])( prefix: "t3-clone-options-config-", }), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ), @@ -307,7 +308,7 @@ it.effect("reports clone progress from git's stderr and keeps its error text on assert.strictEqual(error.provider, "github"); assert.include(error.detail, "could not reach GitHub"); assert.notInclude(error.message, "s3c@ret"); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); function rejectingSsh( @@ -356,7 +357,7 @@ const cloneInTemporaryDirectory = ( destinationPath: path.join(parent, "t3code"), }); }).pipe(Effect.provide(layer({ git }).pipe(Layer.orDie))); - }).pipe(Effect.provide(NodeServices.layer)); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); it.effect("retries a clone over HTTPS when the SSH login is refused", () => Effect.gen(function* () { @@ -385,6 +386,25 @@ it.effect("reports both failures when the HTTPS retry fails too", () => }), ); +it.effect.each([ + "git@ssh.dev.azure.com:v3/Fabrikam/Project1/fab_repo", + "ssh://user:pa@rt@ssh.dev.azure.com/v3/Fabrikam/Project1/fab_repo", + "ssh://us%40er:pa%40ss@ssh.dev.azure.com/v3/Fabrikam/Project1/fab_repo", +])("retries Azure SSH URL %s using its HTTPS host and repository path", (remoteUrl) => + Effect.gen(function* () { + const urls: Array = []; + const result = yield* cloneInTemporaryDirectory(rejectingSsh(urls, false), { + remoteUrl, + }); + + assert.deepStrictEqual(urls, [ + remoteUrl, + "https://dev.azure.com/Fabrikam/Project1/_git/fab_repo", + ]); + assert.strictEqual(result.remoteUrl, "https://dev.azure.com/Fabrikam/Project1/_git/fab_repo"); + }), +); + it.effect.each([ ["the caller asked for SSH", "ssh", "authentication_failed"], ["git failed for another reason", undefined, "remote_unreachable"], @@ -487,7 +507,7 @@ it.effect("strips embedded credentials from the remote URL it reports", () => ); assert.equal(result.remoteUrl, "https://github.com/octocat/t3code.git"); assert.equal(result.cloneUrl, "https://user:s3cret@github.com/octocat/t3code.git"); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("discards only a directory git wrote to", () => @@ -518,7 +538,7 @@ it.effect("discards only a directory git wrote to", () => assert.deepStrictEqual(yield* fs.readDirectory(partial), []); assert.deepStrictEqual(yield* fs.readDirectory(foreign), ["notes.txt"]); assert.strictEqual(yield* fs.readFileString(replaced), "not a directory"); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("redacts query tokens and userinfo containing '@' from reported URLs", () => @@ -539,7 +559,7 @@ it.effect("redacts query tokens and userinfo containing '@' from reported URLs", }); assert.equal(nested.remoteUrl, "https://github.com/octocat/t3code.git"); }).pipe(Effect.provide(layer({}))); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("preserves destination probe failures instead of treating them as missing paths", () => { @@ -711,7 +731,7 @@ it.effect("clones without an interactive credential prompt it has nowhere to dra GIT_ASKPASS: "", GIT_PROGRESS_DELAY: "0", GIT_TERMINAL_PROMPT: "0", - LC_ALL: "C", + LANGUAGE: "C", SSH_ASKPASS: "", SSH_ASKPASS_REQUIRE: "never", }, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 9e20b2b2e69f9..93758e59b0971 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -82,7 +82,8 @@ const CLONE_ENV = { // credential-manager prompts that would hang without a tty. ...NON_INTERACTIVE_GIT_ENV, GIT_PROGRESS_DELAY: "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) { diff --git a/apps/server/src/sourceControl/SourceControlSignIn.ts b/apps/server/src/sourceControl/SourceControlSignIn.ts index 3dd9ca03e2062..31f7837322fec 100644 --- a/apps/server/src/sourceControl/SourceControlSignIn.ts +++ b/apps/server/src/sourceControl/SourceControlSignIn.ts @@ -28,6 +28,7 @@ export class SourceControlSignIn extends Context.Service< ) => Effect.Effect; readonly access: (input: SourceControlAppAccessInput) => Effect.Effect; readonly starred: (input: SourceControlStarredInput) => Effect.Effect; + readonly star: (input: SourceControlStarredInput) => Effect.Effect; } >()("t3/sourceControl/SourceControlSignIn") {} @@ -70,6 +71,11 @@ const make = Effect.gen(function* () { .isRepositoryStarred; return isStarred === undefined ? false : yield* isStarred(input.host, input.repository); }), + star: Effect.fn("SourceControlSignIn.star")(function* (input) { + const star = drivers.find(({ driver }) => driver.kind === input.provider)?.instance + .starRepository; + return star === undefined ? false : yield* star(input.host, input.repository); + }), }); }); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index bfbe4a1c8ac94..be1da6f3d610c 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -14,7 +14,14 @@ const source = (reference: string) => ({ reference, }); -const secretsByReference = new Map([["bitbucket#token", "bb_from_provider"]]); +const secretsByReference = new Map([ + ["bitbucket#token", "bb_from_provider"], + ["github#token", "ghp_from_provider"], +]); + +const perHostHosts = { + github: { tokens: { "github.com": source("github#token"), "ghe.acme.test": "ghp_literal" } }, +}; const hosts = { bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, @@ -69,11 +76,25 @@ it.effect("leaves the resolver unused for a kind that holds no reference", () => }), ); +it.effect("replaces a per-host token reference and keeps the literal beside it", () => + Effect.gen(function* () { + const { reads, resolver } = countingResolver(); + const resolved = yield* resolveSourceControlHostSecrets(resolver, perHostHosts, "github"); + assert.deepEqual(resolved, { + github: { tokens: { "github.com": "ghp_from_provider", "ghe.acme.test": "ghp_literal" } }, + }); + assert.deepEqual(reads, ["github#token"]); + }), +); + it("collects the references held in credential fields and skips literal values", () => { assert.deepEqual(collectSourceControlHostSecretReferences(hosts), [ source("missing#token"), source("bitbucket#token"), ]); + assert.deepEqual(collectSourceControlHostSecretReferences(perHostHosts), [ + source("github#token"), + ]); assert.deepEqual( collectSourceControlHostSecretReferences({ bitbucket: { accessToken: "literal" }, diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index cf86ade6b0eee..82de25be4de69 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -53,7 +53,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), + ]); } type SourceControlHostsSettings = Readonly>>>; diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 9604e67cfcf60..937af138d9db1 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"; @@ -580,7 +581,7 @@ const pausedMeasurementFixture = (storageCleanup?: StorageCleanupSettings) => }); const cleanupTestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const runCleanupTest = ( effect: Effect.Effect< @@ -2101,6 +2102,36 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live.each([ + ["unchanged", "removed", "No commits beyond the project branch"], + ["unmerged", "kept", "Has commits beyond the project branch"], + ["detached", "kept", "Project branch is unavailable"], + ["merged-only", "kept", "No remote is configured for merged-worktree cleanup"], + ] as const)("cleans archived local-only worktrees: %s", ([state, outcome, reason]) => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command, setThreads } = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeUnchanged: state !== "merged-only", + worktreeOnMerge: true, + }); + yield* command(repo, ["branch", "-m", "main", "trunk"]); + setThreads([shell({ branch: "feature", worktreePath: worktree, archivedAt: at(-DAY_MS) })]); + if (state === "unmerged") { + yield* fs.writeFileString(`${worktree}/tracked.txt`, "unmerged work\n"); + yield* command(worktree, ["commit", "-am", "unmerged work"]); + } + if (state === "detached") yield* command(repo, ["checkout", "--detach"]); + + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ outcome, reason }); + expect(yield* fs.exists(worktree)).toBe(outcome === "kept"); + expect( + (yield* command(repo, ["show-ref", "--verify", "refs/heads/feature"])).stdout, + ).not.toBe(""); + }), + ), + ); it.live( "discovers trunk without a remote HEAD or tracking ref and removes an integrated worktree", () => diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 13ad286861b9a..cf3759519c679 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -892,42 +892,66 @@ const make = Effect.gen(function* () { : `settled for ${settledDays} days`; if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { const repositoryCwd = path.resolve(project.workspaceRoot); - const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); - const defaultBranchKey = `${repositoryCwd}\0${remote}`; - let branch = defaultBranches.get(defaultBranchKey); - if (branch === undefined) { - branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); - // Without a local HEAD ref, only the remote can identify its default; - // a conventional name may be a feature branch. - if (branch === null) { - branch = yield* git - .execute({ - operation: "StorageCleanup.resolveDefaultBranch", - cwd: repositoryCwd, - args: ["ls-remote", "--symref", "--", remote, "HEAD"], - timeoutMs: 20_000, - }) - .pipe( - Effect.map( - (result) => - /^ref: refs\/heads\/(.+)\tHEAD\r?$/m.exec(result.stdout)?.[1] ?? null, - ), - Effect.orElseSucceed(() => null), - ); - } - defaultBranches.set(defaultBranchKey, branch); - } - if (branch === null) return keep("default branch is unavailable"); - const defaultRef = `refs/remotes/${remote}/${branch}`; - const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set(); - if (!refreshed.has(defaultRef)) { - yield* git.fetchRemoteTrackingBranch({ + const remotes = yield* git.execute({ + operation: "StorageCleanup.listRemotes", + cwd: repositoryCwd, + args: ["remote"], + }); + const remote = + remotes.stdout.trim() === "" + ? null + : yield* git.resolvePrimaryRemoteName(repositoryCwd); + let branch: string | null | undefined; + let defaultRef: string; + if (remote === null) { + if (!settings.worktreeUnchanged) + return keep("no remote is configured for merged-worktree cleanup"); + const projectHead = yield* git.execute({ + operation: "StorageCleanup.resolveProjectBranch", cwd: repositoryCwd, - remoteName: remote, - remoteBranch: branch, + args: ["symbolic-ref", "--quiet", "HEAD"], + allowNonZeroExit: true, }); - refreshed.add(defaultRef); - refreshedDefaultRefs.set(repositoryCwd, refreshed); + if (projectHead.exitCode !== 0) return keep("project branch is unavailable"); + defaultRef = projectHead.stdout.trim(); + branch = defaultRef.slice("refs/heads/".length); + } else { + const defaultBranchKey = `${repositoryCwd}\0${remote}`; + branch = defaultBranches.get(defaultBranchKey); + if (branch === undefined) { + branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); + // Without a local HEAD ref, only the remote can identify its default; + // a conventional name may be a feature branch. + if (branch === null) { + branch = yield* git + .execute({ + operation: "StorageCleanup.resolveDefaultBranch", + cwd: repositoryCwd, + args: ["ls-remote", "--symref", "--", remote, "HEAD"], + timeoutMs: 20_000, + }) + .pipe( + Effect.map( + (result) => + /^ref: refs\/heads\/(.+)\tHEAD\r?$/m.exec(result.stdout)?.[1] ?? null, + ), + Effect.orElseSucceed(() => null), + ); + } + defaultBranches.set(defaultBranchKey, branch); + } + if (branch === null) return keep("default branch is unavailable"); + defaultRef = `refs/remotes/${remote}/${branch}`; + const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set(); + if (!refreshed.has(defaultRef)) { + yield* git.fetchRemoteTrackingBranch({ + cwd: repositoryCwd, + remoteName: remote, + remoteBranch: branch, + }); + refreshed.add(defaultRef); + refreshedDefaultRefs.set(repositoryCwd, refreshed); + } } const base = yield* git.resolveCommit({ cwd: worktreePath, @@ -941,8 +965,12 @@ const make = Effect.gen(function* () { }); const integrated = ancestor.exitCode === 0; eligible = integrated && settings.worktreeUnchanged; - if (eligible) removalReason = "no commits beyond the default branch"; - if (!eligible && settings.worktreeOnMerge && thread.branch !== null) { + if (eligible) + removalReason = + remote === null + ? "no commits beyond the project branch" + : "no commits beyond the default branch"; + if (!eligible && remote !== null && settings.worktreeOnMerge && thread.branch !== null) { const pullRequest = yield* gitManager.branchPullRequest( { cwd: worktreePath, branch: thread.branch }, { refresh: true }, @@ -955,6 +983,7 @@ const make = Effect.gen(function* () { }); if (eligible) removalReason = "pull request was merged"; } + if (!eligible && remote === null) return keep("has commits beyond the project branch"); } if (!eligible) return keep( diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 417841cb53e39..98e3d640c604c 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -1,4 +1,5 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Clock from "effect/Clock"; @@ -16,7 +17,10 @@ import * as HttpServerRequest from "effect/http/HttpServerRequest"; import * as HttpServerResponse from "effect/http/HttpServerResponse"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { ManagedPolicyDocument } from "@t3tools/contracts"; + import * as ServerConfig from "../config.ts"; +import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import { getTelemetryIdentity } from "./Identify.ts"; import * as AnalyticsService from "./AnalyticsService.ts"; @@ -119,6 +123,7 @@ const sendFor = ( never, AnalyticsService.AnalyticsService | Effect.Services > = recordTwentyEvents, + policy?: ManagedPolicyDocument, ) => Effect.gen(function* () { const batches: Array> = []; @@ -151,6 +156,7 @@ const sendFor = ( Layer.succeed(HostProcess.Platform, "win32"), Layer.succeed(HostProcess.Architecture, "x64"), Layer.succeed(HttpClient.HttpClient, client), + ManagedPolicy.layerTest(policy), ), ), ); @@ -164,7 +170,33 @@ const sendFor = ( return batches; }); -it.layer(NodeServices.layer)("AnalyticsService test", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService test", (it) => { + it.effect("waits for desktop shell consent and honors a late telemetry opt-out", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1" }; + const batches = yield* sendFor( + 0, + respondWithStatus(204), + Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + yield* analytics.record("test.before-discovery"); + yield* analytics.flush; + env.T3CODE_TELEMETRY_ENABLED = "false"; + delete env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING; + yield* analytics.record("test.opted-out"); + yield* analytics.flush; + env.T3CODE_TELEMETRY_ENABLED = "true"; + yield* analytics.record("test.allowed"); + yield* analytics.flush; + }), + ).pipe(Effect.provideService(HostProcess.Environment, env)); + assert.deepEqual( + batches.flat().map((event) => event.event), + ["test.allowed"], + ); + }), + ); + it.effect("client owner reports cannot use up the server's owner cap", () => Effect.gen(function* () { const batches = yield* sendFor( @@ -214,6 +246,24 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { }), ); + it.effect( + "sends nothing when a managed policy turns telemetry off, despite the environment", + () => + Effect.gen(function* () { + const policy = { schemaVersion: 1, organization: "Example Corp" } as const; + const allowed = yield* sendFor(1, respondWithStatus(204), recordTwentyEvents, { + ...policy, + limits: { telemetry: true }, + }); + assert.equal(allowed.length, 1); + const denied = yield* sendFor(1, respondWithStatus(204), recordTwentyEvents, { + ...policy, + limits: { telemetry: false }, + }); + assert.deepEqual(denied, []); + }), + ); + it.effect("drops a batch the endpoint rejects without sending it again", () => Effect.gen(function* () { const batches = yield* sendFor(10, respondWithStatus(400)); @@ -260,6 +310,7 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { Layer.mergeAll( Layer.succeed(HostProcess.Platform, "linux"), Layer.succeed(HostProcess.Architecture, "arm64"), + ManagedPolicy.layerTest(), ), ), Layer.provideMerge(NodeHttpServer.layerTest), @@ -350,6 +401,7 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { Layer.mergeAll( Layer.succeed(HostProcess.Platform, "linux"), Layer.succeed(HostProcess.Architecture, "arm64"), + ManagedPolicy.layerTest(), ), ), Layer.provideMerge(NodeHttpServer.layerTest), diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 4edc6621b0c3e..91b00eeb12335 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -3,8 +3,9 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import type { ClientOs } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; 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 Exit from "effect/Exit"; @@ -20,6 +21,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; +import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import { getTelemetryIdentity, type TelemetryOwner } from "./Identify.ts"; interface BufferedAnalyticsEvent { @@ -116,10 +118,33 @@ function serverOsFromNodePlatform(platform: string): ClientOs { /** @public */ export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; + const hostEnvironment = yield* HostProcess.Environment; + const awaitingShellConsent = hostEnvironment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING === "1"; + // A managed policy can switch analytics off over the environment, never on. + const policyAllows = + ManagedPolicy.deniedBy(yield* ManagedPolicy.ManagedPolicy, "telemetry") === undefined; + // A cached PATH does not establish consent from the current login shell. + const telemetryEnabled = Effect.suspend(() => { + if (!telemetryConfig.enabled || !policyAllows) return Effect.succeed(false); + if (!awaitingShellConsent) return Effect.succeed(true); + if (hostEnvironment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING === "1") + return Effect.succeed(false); + const preference = hostEnvironment.T3CODE_TELEMETRY_ENABLED; + return Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe( + Config.withDefault(true), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ + env: preference === undefined ? {} : { T3CODE_TELEMETRY_ENABLED: preference }, + }), + ), + Effect.orElseSucceed(() => false), + ); + }); const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig.ServerConfig; const { distinctId, installId } = yield* getTelemetryIdentity; - const crypto = yield* Crypto.Crypto; + const crypto = yield* SecureRandom.SecureRandom; const bufferRef = yield* Ref.make>([]); const deliveryRef = yield* Ref.make({ failedBatch: [], @@ -172,7 +197,7 @@ export const make = Effect.gen(function* () { const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* ( events: ReadonlyArray, ) { - if (!telemetryConfig.enabled || !distinctId) return; + if (!(yield* telemetryEnabled) || !distinctId) return; const payload = { api_key: telemetryConfig.posthogKey, @@ -284,10 +309,10 @@ export const make = Effect.gen(function* () { const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( function* (event, properties) { - if (!telemetryConfig.enabled || !distinctId) return; + if (!(yield* telemetryEnabled) || !distinctId) return; // Catch Node UUID defects too; best-effort telemetry drops events without UUIDs. - const uuid = yield* Effect.exit(crypto.randomUUIDv7()); + const uuid = yield* Effect.exit(crypto.uuidV7()); if (Exit.isFailure(uuid)) return; const enqueueResult = yield* enqueueBufferedEvent(uuid.value, event, properties); diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts index 6fa6c8500817e..6a0951875106f 100644 --- a/apps/server/src/telemetry/Identify.test.ts +++ b/apps/server/src/telemetry/Identify.test.ts @@ -1,6 +1,7 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -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"; @@ -21,9 +22,9 @@ interface CapturedLog { } const sha256 = Effect.fn("test.sha256")(function* (value: string) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; return Hex.encode( - yield* crypto.digest("SHA-256", new TextEncoder().encode(value)).pipe(Effect.orDie), + yield* digest.digest(Digest.sha256, new TextEncoder().encode(value)).pipe(Effect.orDie), ); }); @@ -69,7 +70,7 @@ const writeHomeFile = Effect.fn("test.writeHomeFile")(function* ( return filePath; }); -it.layer(NodeServices.layer)("telemetry identity", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("telemetry identity", (it) => { it.effect("keeps the old distinct_id rule and a fixed install id as logins change", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 0baebc1e1803d..7ea7e9d296771 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,4 +1,4 @@ -import * as Crypto from "effect/Crypto"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as FileSystem from "effect/FileSystem"; @@ -225,8 +225,8 @@ const upsertAnonymousId = Effect.gen(function* () { return existing.value; } - const anonymousId = yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), + const anonymousId = yield* SecureRandom.SecureRandom.pipe( + Effect.flatMap((crypto) => crypto.uuidV4()), Effect.mapError( (cause) => new TelemetryAnonymousIdGenerationError({ diff --git a/apps/server/src/telemetry/ProviderDimensions.ts b/apps/server/src/telemetry/ProviderDimensions.ts index 4e0447a1e2c44..f81a22e844aef 100644 --- a/apps/server/src/telemetry/ProviderDimensions.ts +++ b/apps/server/src/telemetry/ProviderDimensions.ts @@ -4,6 +4,8 @@ import type { ServerProvider, } from "@t3tools/contracts"; +import { BUNDLED_MODEL_MANIFEST } from "../provider/ModelManifest.ts"; + /** * Drivers whose model catalogs are published by the vendor. Other drivers * (OpenCode, Pi, ACP agents) list models from the user's own configuration, @@ -39,6 +41,44 @@ export function providerDimensions( : { provider: provider.driver }; } +const DATED_SNAPSHOT = /-\d{8}$/u; + +// The bundled manifest is reviewed in this repo. Codex lists whatever its configured +// model provider serves, so only the manifest vouches for Codex, Claude, and Antigravity. +const MANIFEST_LABELS: ReadonlyMap = new Map( + Object.values(BUNDLED_MODEL_MANIFEST.providers ?? {}).flatMap((catalog) => + catalog.models.flatMap((model) => + [model.slug, ...(model.aliases ?? [])].map((name) => [name, model.slug] as const), + ), + ), +); +const REGISTRY_LABEL_DRIVERS: ReadonlySet = new Set(["cursor", "grok"]); + +/** + * The vendor catalog slug that a native or routed model id names, such as + * `anthropic/claude-opus-5-5` or a dated snapshot. Models outside the bundled + * manifest and the Cursor and Grok catalogs, including user-added ones, return undefined. + */ +export function catalogModelLabel( + providers: ReadonlyArray, + model: string, +): string | undefined { + const leaf = model.slice(model.lastIndexOf("/") + 1); + const candidates = [model, leaf, leaf.replace(DATED_SNAPSHOT, "")]; + for (const candidate of candidates) { + const label = MANIFEST_LABELS.get(candidate); + if (label !== undefined) return label; + } + for (const provider of providers) { + if (!REGISTRY_LABEL_DRIVERS.has(provider.driver)) continue; + const match = provider.models.find( + (entry) => !entry.isCustom && candidates.includes(entry.slug), + ); + if (match !== undefined) return match.slug; + } + return undefined; +} + /** The thread fields agent analytics reads. Ids are used for lookups, never reported. */ export type AgentThread = Pick< OrchestrationV2ThreadShell, diff --git a/apps/server/src/telemetry/ProviderUsageAnalytics.test.ts b/apps/server/src/telemetry/ProviderUsageAnalytics.test.ts new file mode 100644 index 0000000000000..bd0a7c7da64b9 --- /dev/null +++ b/apps/server/src/telemetry/ProviderUsageAnalytics.test.ts @@ -0,0 +1,350 @@ +import { + NodeId, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + RunId, + type OrchestrationV2ProviderTurn, + type ServerProvider, + type TurnTokenUsage, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as AnalyticsService from "./AnalyticsService.ts"; +import { catalogModelLabel } from "./ProviderDimensions.ts"; +import { + ProviderTurnAnalytics, + RequestUsageLedger, + layer, + providerTurnProperties, + type ProviderTurnAnalyticsContext, + type ProviderTurnSegment, +} from "./ProviderUsageAnalytics.ts"; + +const providers = [ + // Codex lists whatever its configured model provider serves, including private deployments. + { driver: "codex", models: [{ slug: "gpt-6-sol" }, { slug: "acme-azure-deployment" }] }, + { driver: "claudeAgent", models: [{ slug: "claude-opus-5-5" }, { slug: "claude-haiku-4-5" }] }, + { driver: "opencode", models: [{ slug: "ollama/acme-internal" }] }, +].map( + ({ driver, models }) => + ({ + instanceId: ProviderInstanceId.make(driver), + driver, + models: models.map((model) => ({ ...model, isCustom: false })), + }) as unknown as ServerProvider, +); +const label = (model: string) => catalogModelLabel(providers, model); + +const at = (ms: number) => DateTime.makeUnsafe(ms); +const complete = (input: number, output: number, extra: Partial = {}) => + ({ + usageStatus: "complete", + usageScope: "main_agent", + hasSubagents: false, + inputTokens: input, + outputTokens: output, + ...extra, + }) as TurnTokenUsage; + +const turn = ( + id: string, + usage: TurnTokenUsage | undefined, + status: OrchestrationV2ProviderTurn["status"] = "completed", +): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(id), + providerThreadId: ProviderThreadId.make("provider-thread"), + nodeId: NodeId.make(`node:${id}`), + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status, + startedAt: at(1_000), + completedAt: at(4_000), + ...(usage === undefined ? {} : { turnTokenUsage: usage }), +}); + +const context = ( + runId: string, + segment: ProviderTurnSegment, + model = "gpt-6-sol", +): ProviderTurnAnalyticsContext => ({ + runId: RunId.make(runId), + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model, + options: [ + { id: "reasoningEffort", value: "xhigh" }, + { id: "serviceTier", value: "priority" }, + ], + }, + runtimeMode: "full-access", + interactionMode: "default", + origin: { createdBy: "user", creationSource: "web", scheduled: false, subagent: false }, + segment, +}); + +const settled = (runId: string, childrenRunning = false) => ({ + runId: RunId.make(runId), + status: "completed" as const, + completedAt: at(9_000), + childrenRunning, +}); + +describe("provider usage reporting", () => { + it.effect( + "records one request across a continuation, a child, a replay, and a late child", + () => { + const recorded: Array = []; + const layerTest = layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(AnalyticsService.AnalyticsService)({ + record: (event, properties) => + Effect.sync(() => void recorded.push([event, properties])), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(providers), + }), + ), + ), + ); + return Effect.gen(function* () { + const analytics = yield* ProviderTurnAnalytics; + const root = context("run-a", "initial"); + yield* analytics.runStarted({ runId: RunId.make("run-a"), driver: "codex", context: root }); + const first = { driver: "codex", context: root }; + const firstTurn = turn("root", complete(100, 10, { models: ["gpt-6-sol"] })); + yield* analytics.turnCompleted({ ...first, providerTurn: firstTurn }); + yield* analytics.turnCompleted({ ...first, providerTurn: firstTurn }); + yield* analytics.turnCompleted({ + driver: "codex", + context: context("run-a", "continuation"), + providerTurn: turn("goal", complete(50, 5, { models: ["gpt-6-sol"] })), + }); + yield* analytics.turnCompleted({ + driver: "codex", + context: context("run-a", "native_child"), + providerTurn: turn("child", complete(20, 2)), + }); + yield* analytics.runSettled(settled("run-a")); + yield* analytics.turnCompleted({ + driver: "codex", + context: context("run-a", "native_child"), + providerTurn: turn("late-child", complete(7, 1)), + }); + yield* analytics.runSettled(settled("run-a")); + yield* analytics.runStarted({ + runId: RunId.make("run-b"), + driver: "codex", + context: context("run-b", "initial"), + }); + yield* analytics.runSettled(settled("run-b")); + + const requests = recorded.filter(([event]) => event === "provider.request.completed"); + expect(recorded.filter(([event]) => event === "provider.turn.completed")).toHaveLength(5); + expect(requests).toHaveLength(2); + // The whole payload: no run, turn, or thread ids, prompts, or raw model names. + expect(requests[0]?.[1]).toEqual({ + reportVersion: 2, + provider: "codex", + requestKind: "user", + terminalStatus: "completed", + lifecycleObserved: true, + model: "gpt-6-sol", + effort: "xhigh", + serviceTier: "fast", + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "user", + creationSource: "web", + scheduled: false, + subagent: false, + providerTurns: 2, + continuations: 1, + steeringRestarts: 0, + usageStatus: "complete", + inputTokens: 150, + outputTokens: 15, + nativeChildTurns: 1, + childUsageStatus: "complete", + childInputTokens: 20, + childOutputTokens: 2, + durationMs: 8_000, + }); + expect(requests[1]?.[1]).toMatchObject({ providerTurns: 0, usageStatus: "unavailable" }); + }).pipe(Effect.provide(layerTest)); + }, + ); + + it("keeps a request partial when its start, a steered segment, or a counter is missing", () => { + const ledger = new RequestUsageLedger(); + ledger.start(RunId.make("restarted"), "codex", { + ...context("restarted", "initial"), + restartContinuation: true, + }); + ledger.add({ + driver: "codex", + context: context("restarted", "initial"), + providerTurn: turn("after-restart", complete(10, 1)), + }); + expect(ledger.settle(settled("restarted"), label)).toMatchObject({ + restartContinuation: true, + lifecycleObserved: false, + usageStatus: "partial", + inputTokens: 10, + }); + + ledger.start(RunId.make("steered"), "claudeAgent", context("steered", "initial")); + ledger.add({ + driver: "claudeAgent", + context: context("steered", "initial"), + providerTurn: turn( + "steered-away", + { usageStatus: "partial", usageScope: "main_agent", hasSubagents: false, outputTokens: 4 }, + "interrupted", + ), + }); + ledger.add({ + driver: "claudeAgent", + context: context("steered", "steering_restart"), + providerTurn: turn("steered-to", complete(30, 6)), + }); + expect(ledger.settle(settled("steered"), label)).toMatchObject({ + usageStatus: "partial", + steeringRestarts: 1, + inputTokens: 30, + outputTokens: 10, + }); + + ledger.start(RunId.make("silent"), "grok", context("silent", "initial")); + ledger.add({ + driver: "grok", + context: context("silent", "initial"), + providerTurn: turn("silent", undefined), + }); + const silent = ledger.settle(settled("silent", true), label); + expect(silent).toMatchObject({ + usageStatus: "unavailable", + childrenRunning: true, + childUsageStatus: "unavailable", + }); + expect(silent).not.toHaveProperty("outputTokens"); + }); + + it("confirms only the models a provider reported", () => { + const opus = context("models", "initial", "claude-opus-5-5"); + const properties = (usage: TurnTokenUsage | undefined) => + providerTurnProperties( + { driver: "claudeAgent", context: opus, providerTurn: turn("t", usage) }, + label, + ); + expect(properties(complete(1, 1, { models: ["claude-haiku-4-5-20251001"] }))).toMatchObject({ + model: "claude-opus-5-5", + observedModel: "claude-haiku-4-5", + mixedModels: false, + }); + const unconfirmed = properties(complete(1, 1)); + expect(unconfirmed).toMatchObject({ model: "claude-opus-5-5" }); + expect(unconfirmed).not.toHaveProperty("mixedModels"); + expect(properties(complete(1, 1, { models: ["a", "b"] }))).toMatchObject({ mixedModels: true }); + + const ledger = new RequestUsageLedger(); + ledger.start(RunId.make("mixed"), "codex", context("mixed", "initial")); + ledger.add({ + driver: "codex", + context: context("mixed", "initial"), + providerTurn: turn("root", complete(1, 1, { models: ["gpt-6-sol"] })), + }); + ledger.add({ + driver: "codex", + context: context("mixed", "native_child"), + providerTurn: turn("child", complete(1, 1, { models: ["gpt-6-luna"] })), + }); + expect(ledger.settle(settled("mixed"), label)).toMatchObject({ mixedModels: true }); + }); + + it("keeps zero, absent, local-command, and ledger usage distinct", () => { + const properties = (usage: TurnTokenUsage | undefined) => + providerTurnProperties( + { driver: "claudeAgent", context: context("z", "initial"), providerTurn: turn("t", usage) }, + label, + ); + expect(properties(complete(0, 0))).toMatchObject({ + usageStatus: "complete", + usageReported: true, + inputTokens: 0, + outputTokens: 0, + }); + const absent = properties(undefined); + expect(absent).toMatchObject({ usageStatus: "unavailable", usageReported: false }); + expect(absent).not.toHaveProperty("outputTokens"); + expect( + properties( + complete(0, 0, { localCommand: true, otherUsage: { inputTokens: 900, outputTokens: 80 } }), + ), + ).toMatchObject({ localCommand: true, otherInputTokens: 900, otherOutputTokens: 80 }); + expect(properties(complete(74_167, 236, { reasoningTokens: 30 }))).toMatchObject({ + outputTokens: 236, + reasoningTokens: 30, + }); + }); + + it("reports routed catalog models and closed efforts only", () => { + const routed = (model: string, variant: string) => + providerTurnProperties( + { + driver: "opencode", + context: { + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model, + options: [{ id: "variant", value: variant }], + }, + }, + providerTurn: turn("t", complete(1, 1, { models: [model] })), + }, + label, + ); + expect(routed("my-proxy/claude-opus-5-5", "high")).toMatchObject({ + model: "claude-opus-5-5", + observedModel: "claude-opus-5-5", + effort: "high", + }); + for (const local of [ + routed("ollama/acme-internal", "secret-variant"), + routed("acme-azure-deployment", "high"), + ]) { + expect(local).not.toHaveProperty("model"); + expect(local).not.toHaveProperty("observedModel"); + } + expect(routed("ollama/acme-internal", "secret-variant")).not.toHaveProperty("effort"); + }); + + it("reports closed service tiers from a tier option or a fast mode toggle", () => { + const tier = (option: { id: string; value: string | boolean }) => + providerTurnProperties( + { + driver: "codex", + context: { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + options: [option], + }, + }, + providerTurn: turn("t", complete(1, 1)), + }, + label, + ).serviceTier; + expect(tier({ id: "serviceTier", value: "ultrafast" })).toBe("ultrafast"); + expect(tier({ id: "serviceTier", value: "default" })).toBe("standard"); + expect(tier({ id: "serviceTier", value: "/home/me/secret" })).toBeUndefined(); + expect(tier({ id: "fastMode", value: true })).toBe("fast"); + expect(tier({ id: "fastMode", value: false })).toBe("standard"); + }); +}); diff --git a/apps/server/src/telemetry/ProviderUsageAnalytics.ts b/apps/server/src/telemetry/ProviderUsageAnalytics.ts new file mode 100644 index 0000000000000..ce7f29ba1f8d5 --- /dev/null +++ b/apps/server/src/telemetry/ProviderUsageAnalytics.ts @@ -0,0 +1,427 @@ +/** + * Provider token reporting: one `provider.turn.completed` per provider turn and one + * `provider.request.completed` per run, built from the same turns so each counts once. + * + * @module ProviderUsageAnalytics + */ +import type { + ModelSelection, + OrchestrationV2Actor, + OrchestrationV2CreationSource, + OrchestrationV2ProviderTurn, + OrchestrationV2RunAttempt, + ProviderInteractionMode, + RunId, + RuntimeMode, + TurnTokenUsage, +} 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 { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as AnalyticsService from "./AnalyticsService.ts"; +import { catalogModelLabel } from "./ProviderDimensions.ts"; + +// Bump when a field changes meaning, so reports can split old and new rows. +const REPORT_VERSION = 2; + +/** + * Where the run's starting message came from. Most turns start without a + * person (provider continuations, agent sends, delegated subagents), so + * reports need this to tell user turns apart. + */ +export interface ProviderTurnOrigin { + readonly createdBy: OrchestrationV2Actor; + readonly creationSource: OrchestrationV2CreationSource; + readonly scheduled: boolean; + readonly subagent: boolean; +} + +/** What started this provider turn inside its run. */ +export type ProviderTurnSegment = + | OrchestrationV2RunAttempt["reason"] + | "continuation" + | "native_child"; + +export interface ProviderTurnAnalyticsContext { + readonly runId?: RunId; + readonly modelSelection: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly origin?: ProviderTurnOrigin; + readonly segment?: ProviderTurnSegment; + readonly restartContinuation?: boolean; +} + +export interface ProviderTurnReport { + readonly driver: string; + readonly providerTurn: OrchestrationV2ProviderTurn; + readonly context?: ProviderTurnAnalyticsContext; +} + +export interface RunSettledReport { + readonly runId: RunId; + readonly status: OrchestrationV2ProviderTurn["status"]; + readonly completedAt: DateTime.Utc; + // Run-owned children were still open when the run settled; their later turns are not added. + readonly childrenRunning: boolean; +} + +type Properties = Readonly>; +/** Maps a native model id to a vendor catalog label, or undefined to keep it out. */ +export type ModelLabeler = (model: string) => string | undefined; + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "interrupted", + "cancelled", +]); + +const REPORTED_EFFORTS: ReadonlySet = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultrathink", + "ultracode", + "brutal", +]); + +const defined = (entries: Properties): Properties => + Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined)); + +// OpenCode names its effort option `variant`, and variants can be user-defined. +function selectedEffort(selection: ModelSelection): string | undefined { + const effort = + getModelSelectionStringOptionValue(selection, "reasoningEffort") ?? + getModelSelectionStringOptionValue(selection, "effort") ?? + getModelSelectionStringOptionValue(selection, "variant"); + return effort !== undefined && REPORTED_EFFORTS.has(effort) ? effort : undefined; +} + +// Codex sends a tier id (its "fast" config shows up as "priority"); Claude has a fastMode toggle. +const REPORTED_SERVICE_TIERS: ReadonlyMap = new Map([ + ["default", "standard"], + ["flex", "flex"], + ["fast", "fast"], + ["priority", "fast"], + ["ultrafast", "ultrafast"], +]); + +function selectedServiceTier(selection: ModelSelection): string | undefined { + const tier = getModelSelectionStringOptionValue(selection, "serviceTier"); + if (tier !== undefined) return REPORTED_SERVICE_TIERS.get(tier); + const fastMode = getModelSelectionBooleanOptionValue(selection, "fastMode"); + return fastMode === undefined ? undefined : fastMode ? "fast" : "standard"; +} + +/** + * Confirmed models across counted work. Unknown when any piece of work has no + * reported model, unless two distinct models were already seen. + */ +function modelObservation( + models: ReadonlyArray | undefined>, + label: ModelLabeler, +): Properties { + const distinct = new Set(models.flatMap((entry) => entry ?? [])); + if (distinct.size > 1) return { mixedModels: true }; + if (distinct.size === 0 || models.some((entry) => entry === undefined || entry.length === 0)) { + return {}; + } + return defined({ mixedModels: false, observedModel: label([...distinct][0]!) }); +} + +function selectionProperties( + context: ProviderTurnAnalyticsContext | undefined, + label: ModelLabeler, +) { + const selection = context?.modelSelection; + return defined({ + model: selection === undefined ? undefined : label(selection.model), + effort: selection === undefined ? undefined : selectedEffort(selection), + serviceTier: selection === undefined ? undefined : selectedServiceTier(selection), + runtimeMode: context?.runtimeMode, + interactionMode: context?.interactionMode, + ...context?.origin, + }); +} + +function childKind(context: ProviderTurnAnalyticsContext | undefined) { + if (context?.segment === "native_child") return "native"; + return context?.origin?.subagent === true ? "t3" : undefined; +} + +function counters(usage: TurnTokenUsage | undefined): Properties { + return defined({ + inputTokens: usage?.inputTokens, + cachedInputTokens: usage?.cachedInputTokens, + cacheCreationTokens: usage?.cacheCreationTokens, + outputTokens: usage?.outputTokens, + reasoningTokens: usage?.reasoningTokens, + }); +} + +export function providerTurnProperties( + report: ProviderTurnReport, + label: ModelLabeler, +): Properties { + const { providerTurn, context } = report; + const usage = providerTurn.turnTokenUsage; + return defined({ + reportVersion: REPORT_VERSION, + provider: report.driver, + terminalStatus: providerTurn.status, + usageStatus: usage?.usageStatus ?? "unavailable", + usageScope: usage?.usageScope ?? "main_agent", + usageReported: usage !== undefined, + hasSubagents: usage?.hasSubagents, + ...counters(usage), + localCommand: usage?.localCommand === true ? true : undefined, + otherInputTokens: usage?.otherUsage?.inputTokens, + otherOutputTokens: usage?.otherUsage?.outputTokens, + ...selectionProperties(context, label), + ...modelObservation([usage?.models], label), + segment: context?.segment, + childKind: childKind(context), + durationMs: + providerTurn.startedAt && providerTurn.completedAt + ? Math.max( + 0, + DateTime.toEpochMillis(providerTurn.completedAt) - + DateTime.toEpochMillis(providerTurn.startedAt), + ) + : undefined, + }); +} + +/** How the request reached the provider; only `user` counts as a person asking. */ +function requestKind(origin: ProviderTurnOrigin | undefined) { + if (origin === undefined) return "unknown"; + if (origin.scheduled) return "scheduled"; + if (origin.subagent) return "t3_child"; + if (origin.createdBy === "user") return "user"; + if (origin.creationSource === "provider") return "provider_continuation"; + return origin.createdBy; +} + +function sumOf( + usages: ReadonlyArray, + field: + | "inputTokens" + | "cachedInputTokens" + | "cacheCreationTokens" + | "outputTokens" + | "reasoningTokens", + requireAll: boolean, +): number | undefined { + const values = usages.map((usage) => usage[field]); + const known = values.filter((value) => value !== undefined); + if (known.length === 0 || (requireAll && known.length < values.length)) return undefined; + return known.reduce((sum, value) => sum + value, 0); +} + +/** complete only when every turn is complete; partial when any turn has counts. */ +function combinedStatus(usages: ReadonlyArray) { + if (usages.length > 0 && usages.every((usage) => usage?.usageStatus === "complete")) { + return "complete"; + } + return usages.some( + (usage) => usage?.inputTokens !== undefined || usage?.outputTokens !== undefined, + ) + ? "partial" + : "unavailable"; +} + +interface RequestTurn { + readonly child: boolean; + readonly segment: ProviderTurnSegment | undefined; + readonly providerTurn: OrchestrationV2ProviderTurn; +} + +interface RequestEntry { + driver: string | undefined; + context: ProviderTurnAnalyticsContext | undefined; + readonly lifecycleObserved: boolean; + readonly turns: Map; +} + +const MAX_TRACKED_RUNS = 2048; + +/** + * Collects terminal provider turns per run in this process. A replayed turn + * replaces its earlier copy, a settled run ignores late turns, and a run whose + * initial attempt started before this process reports `lifecycleObserved: false`. + */ +export class RequestUsageLedger { + private readonly open = new Map(); + private readonly settled = new Set(); + + start(runId: RunId, driver: string, context: ProviderTurnAnalyticsContext): void { + if (this.settled.has(runId) || this.open.has(runId)) return; + // A restart continuation carries on a run whose earlier turns this process never saw. + const lifecycleObserved = context.restartContinuation !== true; + this.open.set(runId, { driver, context, lifecycleObserved, turns: new Map() }); + evictOldest(this.open); + } + + add(report: ProviderTurnReport): void { + const runId = report.context?.runId; + if (runId === undefined || this.settled.has(runId)) return; + if (!TERMINAL_STATUSES.has(report.providerTurn.status)) return; + let entry = this.open.get(runId); + if (entry === undefined) { + entry = { driver: undefined, context: undefined, lifecycleObserved: false, turns: new Map() }; + this.open.set(runId, entry); + evictOldest(this.open); + } + const child = report.context?.segment === "native_child"; + if (!child) { + entry.driver ??= report.driver; + entry.context ??= report.context; + } + entry.turns.set(String(report.providerTurn.id), { + child, + segment: report.context?.segment, + providerTurn: report.providerTurn, + }); + } + + settle(report: RunSettledReport, label: ModelLabeler): Properties | undefined { + if (this.settled.has(report.runId)) return undefined; + this.settled.add(report.runId); + evictOldest(this.settled); + const entry = this.open.get(report.runId); + this.open.delete(report.runId); + if (entry === undefined) return undefined; + + const turns = [...entry.turns.values()]; + const root = turns.filter((turn) => !turn.child); + const children = turns.filter((turn) => turn.child); + const rootUsage = root.map((turn) => turn.providerTurn.turnTokenUsage); + const childUsage = children.map((turn) => turn.providerTurn.turnTokenUsage); + const { lifecycleObserved } = entry; + const measuredRoot = rootUsage.filter((usage) => usage !== undefined); + const status = combinedStatus(rootUsage); + const rootComplete = status === "complete"; + const otherUsage = measuredRoot.map((usage) => usage.otherUsage); + const allOtherKnown = otherUsage.length > 0 && otherUsage.every((usage) => usage !== undefined); + const anyRootSubagents = measuredRoot.some((usage) => usage.hasSubagents); + const measuredChildren = childUsage.filter((usage) => usage !== undefined); + const childStatus = + children.length === 0 && !anyRootSubagents && !report.childrenRunning + ? "none" + : children.length === 0 + ? "unavailable" + : report.childrenRunning && combinedStatus(childUsage) === "complete" + ? "partial" + : combinedStatus(childUsage); + const startedAt = turns + .map((turn) => turn.providerTurn.startedAt) + .filter((value) => value !== null) + .map(DateTime.toEpochMillis); + + return defined({ + reportVersion: REPORT_VERSION, + provider: entry.driver ?? "unknown", + requestKind: requestKind(entry.context?.origin), + restartContinuation: entry.context?.restartContinuation === true ? true : undefined, + terminalStatus: report.status, + lifecycleObserved, + ...selectionProperties(entry.context, label), + providerTurns: root.length, + continuations: root.filter((turn) => turn.segment === "continuation").length, + steeringRestarts: root.filter((turn) => turn.segment === "steering_restart").length, + usageStatus: lifecycleObserved || status === "unavailable" ? status : "partial", + inputTokens: sumOf(measuredRoot, "inputTokens", rootComplete), + cachedInputTokens: sumOf(measuredRoot, "cachedInputTokens", true), + cacheCreationTokens: sumOf(measuredRoot, "cacheCreationTokens", true), + outputTokens: sumOf(measuredRoot, "outputTokens", rootComplete), + reasoningTokens: sumOf(measuredRoot, "reasoningTokens", true), + localCommand: measuredRoot.some((usage) => usage.localCommand === true) ? true : undefined, + otherInputTokens: allOtherKnown + ? otherUsage.reduce((sum, usage) => sum + (usage?.inputTokens ?? 0), 0) + : undefined, + otherOutputTokens: allOtherKnown + ? otherUsage.reduce((sum, usage) => sum + (usage?.outputTokens ?? 0), 0) + : undefined, + nativeChildTurns: children.length, + childUsageStatus: childStatus, + childInputTokens: sumOf(measuredChildren, "inputTokens", false), + childOutputTokens: sumOf(measuredChildren, "outputTokens", false), + childrenRunning: report.childrenRunning ? true : undefined, + ...modelObservation( + [...rootUsage, ...childUsage].map((usage) => usage?.models), + label, + ), + durationMs: + startedAt.length === 0 + ? undefined + : Math.max(0, DateTime.toEpochMillis(report.completedAt) - Math.min(...startedAt)), + }); + } +} + +function evictOldest(collection: Map | Set) { + if (collection.size <= MAX_TRACKED_RUNS) return; + const oldest = collection.keys().next().value; + if (oldest !== undefined) collection.delete(oldest); +} + +export class ProviderTurnAnalytics extends Context.Reference<{ + readonly runStarted: (input: { + readonly runId: RunId; + readonly driver: string; + readonly context: ProviderTurnAnalyticsContext; + }) => Effect.Effect; + readonly turnCompleted: (report: ProviderTurnReport) => Effect.Effect; + readonly runSettled: (report: RunSettledReport) => Effect.Effect; +}>("t3/telemetry/ProviderTurnAnalytics", { + defaultValue: () => ({ + runStarted: () => Effect.void, + turnCompleted: () => Effect.void, + runSettled: () => Effect.void, + }), +}) {} + +export const layer = Layer.effect( + ProviderTurnAnalytics, + Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + const registry = yield* ProviderRegistry.ProviderRegistry; + const ledger = new RequestUsageLedger(); + const labeler = Effect.map( + registry.getProviders, + (providers): ModelLabeler => + (model) => + catalogModelLabel(providers, model), + ); + return { + runStarted: Effect.fn("ProviderTurnAnalytics.runStarted")((input) => + Effect.sync(() => ledger.start(input.runId, input.driver, input.context)), + ), + turnCompleted: Effect.fn("ProviderTurnAnalytics.turnCompleted")(function* (report) { + ledger.add(report); + yield* analytics.record( + "provider.turn.completed", + providerTurnProperties(report, yield* labeler), + ); + }), + runSettled: Effect.fn("ProviderTurnAnalytics.runSettled")(function* (report) { + const properties = ledger.settle(report, yield* labeler); + if (properties !== undefined) { + yield* analytics.record("provider.request.completed", properties); + } + }), + }; + }), +); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 8e1a2d2f7ec3e..05b8ea8c46387 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 { @@ -406,7 +407,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", () => @@ -765,6 +770,30 @@ it.layer( }), ); + it.effect("labels a running command by its whole process name, spaces included", () => + Effect.gen(function* () { + const { manager } = yield* createManager(5, { + subprocessPollIntervalMs: 1_000, + processKillGraceMs: 0, + processTable: Effect.succeed([ + { pid: 9000, ppid: 1, name: "zsh" }, + { pid: 200, ppid: 9000, name: "Google Chrome" }, + ]), + }).pipe(Effect.provide(layerWithHostPlatform("darwin"))); + const label = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "activity" + ? Deferred.succeed(label, event.label).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + yield* manager.open(openInput()); + yield* TestClock.adjust("1 second"); + + expect(yield* Deferred.await(label)).toBe("Google Chrome"); + }).pipe(Effect.provide(TestClock.layer())), + ); + // Answers process-table queries after `queryDuration`, honoring the caller's // timeout the way ProcessRunner's `timedOutResult` mode does. const slowProcessTable = ( diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9d3942c6cb62b..c881dbd7b19a8 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -365,10 +365,9 @@ function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): stri ) { trimmed = trimmed.slice(1, -1).trim(); } - const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); - if (firstToken.length === 0) return null; + // Process names and macOS `comm` paths can contain spaces ("Google Chrome"). const separators = platform === "win32" ? /[\\/]/ : /\//; - const base = firstToken.split(separators).at(-1) ?? firstToken; + const base = (trimmed.split(separators).at(-1) ?? trimmed).trim(); const withoutExe = platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; return withoutExe.length > 0 ? withoutExe : null; diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 3fcafcc151789..7e1f1446d1a55 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId, ProviderSetupError, } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -60,6 +61,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* readonly startError?: AcpError; readonly rejectAdmission?: boolean; readonly sessionId?: string; + readonly startupSkills?: boolean; } = {}, ) { const fs = yield* FileSystem.FileSystem; @@ -135,6 +137,13 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* start: () => Effect.gen(function* () { if (options.startError) return yield* options.startError; + if (options.startupSkills) { + for (const directory of [".agents", ".gemini"]) { + yield* fs + .makeDirectory(path.join(cwd, directory, "skills"), { recursive: true }) + .pipe(Effect.orDie); + } + } yield* fs.makeDirectory(brainDirectory, { recursive: true }).pipe(Effect.orDie); yield* fs.writeFileString(`${sessionBase}.db`, "helper session").pipe(Effect.orDie); yield* fs.writeFileString(`${sessionBase}.db-wal`, "helper journal").pipe(Effect.orDie); @@ -279,11 +288,12 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* }); it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { - it.effect( - "generates all helper types in empty workspaces and removes only owned session files", - () => + it.effect.each([false, true])( + "generates all helper types and cleans up with startupSkills=%s", + (startupSkills) => Effect.gen(function* () { const fixture = yield* makeFixture({ + startupSkills, outputs: [ '{"subject":" Repair Google login.\\nExtra line","body":" Keep the remote callback. ","branch":"Repair Login"}', '```json\n{"title":" Repair Google login\\nExtra line","body":" ## Summary\\nSupport remote callbacks. "}\n```', @@ -442,29 +452,51 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("rejects a helper that writes files in its temporary workspace", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const fixture = yield* makeFixture({ - prompt: ({ cwd, emit }) => - Effect.gen(function* () { - yield* fs - .writeFileString(path.join(cwd, "unexpected.txt"), "unexpected") - .pipe(Effect.orDie); - yield* emit({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: '{"title":"Ignored output"}' }, - }); - return { stopReason: "end_turn" }; - }), - }); - const error = yield* fixture.textGeneration - .generateThreadTitle(fixture.titleInput) - .pipe(Effect.flip); - expect(error.detail).toContain("wrote files"); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), + it.effect.each([ + { relativePath: "unexpected.txt", kind: "file" }, + { relativePath: ".agents/skills/unexpected.txt", kind: "file" }, + { relativePath: ".gemini/unexpected", kind: "directory" }, + { relativePath: ".agents/skills", kind: "file" }, + ...(symlinksSupported ? [{ relativePath: ".gemini/skills", kind: "symlink" }] : []), + ])( + "rejects an unexpected $kind at $relativePath in its temporary workspace", + ({ relativePath, kind }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = + kind === "symlink" + ? yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-link-test-" }) + : undefined; + const fixture = yield* makeFixture({ + startupSkills: true, + prompt: ({ cwd, emit }) => + Effect.gen(function* () { + const entry = path.join(cwd, relativePath); + if (yield* fs.exists(entry).pipe(Effect.orDie)) { + yield* fs.remove(entry, { recursive: true }).pipe(Effect.orDie); + } + yield* ( + target !== undefined + ? fs.symlink(target, entry) + : kind === "directory" + ? fs.makeDirectory(entry) + : fs.writeFileString(entry, "unexpected") + ).pipe(Effect.orDie); + yield* emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: '{"title":"Ignored output"}' }, + }); + return { stopReason: "end_turn" }; + }), + }); + const error = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.flip); + expect(error.detail).toContain("wrote files"); + if (target !== undefined) expect(yield* fs.exists(target)).toBe(true); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), ); it.effect("uses the native default without sending T3's default selection as a model ID", () => @@ -576,3 +608,15 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); }); + +it.effect("retains filesystem services supplied only during construction", () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ startupSkills: true }).pipe( + Effect.provide(NodeServices.layer), + ); + expect(yield* fixture.textGeneration.generateThreadTitle(fixture.titleInput)).toEqual({ + title: "Repair login", + }); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 921a2f118e34a..22fce172b1836 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -94,6 +94,34 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const hasOnlyRuntimeDirectories = Effect.fn( + "AntigravityTextGeneration.hasOnlyRuntimeDirectories", + )(function* (cwd: string) { + const root = yield* fs.realPath(cwd); + // ACP creates these empty skill directories before the first prompt. + const allowed = new Set([ + path.join(root, ".agents"), + path.join(root, ".agents", "skills"), + path.join(root, ".gemini"), + path.join(root, ".gemini", "skills"), + ]); + const directories = [root]; + for (const directory of directories) { + for (const name of yield* fs.readDirectory(directory)) { + const entry = path.join(directory, name); + if ( + !allowed.has(entry) || + (yield* fs.realPath(entry)) !== entry || + (yield* fs.stat(entry)).type !== "Directory" + ) { + return false; + } + directories.push(entry); + } + } + return true; + }); + const available = isAntigravityTextGenerationAvailable(options.profileDirectory).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -253,7 +281,7 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener ); }).pipe(Effect.scoped); - if ((yield* fs.readDirectory(cwd)).length > 0) { + if (!(yield* hasOnlyRuntimeDirectories(cwd))) { return yield* new TextGenerationError({ operation, detail: "Antigravity wrote files during text generation.", diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 3ea0502f83b33..197d412d93405 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -57,6 +57,7 @@ function makeFakeClaudeBinary(dir: string, lifecyclePort?: number) { function withFakeClaudeEnv( input: { output: string; + rejectsNoSessionPersistence?: boolean; lifecyclePort?: number; exitCode?: number; stderr?: string; @@ -75,6 +76,8 @@ function withFakeClaudeEnv( 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; @@ -89,6 +92,12 @@ function withFakeClaudeEnv( 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 { @@ -135,6 +144,13 @@ function withFakeClaudeEnv( 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 { @@ -320,6 +336,28 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { }), ); + it.effect("retries without --no-session-persistence when the Claude CLI rejects it", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ structured_output: { title: "Reconnect failures" } }), + rejectsNoSessionPersistence: true, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Investigate reconnect failures", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }); + + expect(generated.title).toBe("Reconnect failures"); + }), + ), + ); + it.effect.each([ { verbose: false, label: "normal" }, { verbose: true, label: "verbose" }, @@ -430,6 +468,14 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { apiError, ], ["stderr when stdout has no error", "not json", wrapperNotice], + [ + "a stdout error naming --no-session-persistence without retrying", + JSON.stringify({ + is_error: true, + result: "error: unknown option '--no-session-persistence'", + }), + "error: unknown option '--no-session-persistence'", + ], ] as const)("reports %s when the Claude CLI fails", ([, output, expected]) => withFakeClaudeEnv({ output, stderr: wrapperNotice, exitCode: 1 }, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 8fa9ccb1c8094..51acced2745e5 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -49,6 +49,10 @@ const CLAUDE_INVESTIGATION_ALLOWED = [ // Denies override allows; block git --output/-o because allowed trailing args can otherwise write anywhere. const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(","); +const NO_SESSION_PERSISTENCE = "--no-session-persistence"; +// Claude Code before 2.0.63 exits with this on stderr; those runs retry without the flag. +const NO_SESSION_PERSISTENCE_REJECTED = `error: unknown option '${NO_SESSION_PERSISTENCE}'`; + const ClaudeOutputEnvelope = Schema.Struct({ structured_output: Schema.Unknown, }); @@ -181,7 +185,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "Failed to encode Claude CLI settings.", ); - const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { + const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* ( + noSessionPersistence: boolean, + ) { const workingDirectory = operation === "generateThreadTitle" ? yield* fileSystem @@ -217,6 +223,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ] : ["--tools", ""]), "--disable-slash-commands", + ...(noSessionPersistence ? [NO_SESSION_PERSISTENCE] : []), // managed-mcp.json already isolates servers and makes Claude reject this flag. ...(managedMcpConfig ? [] : ["--strict-mcp-config"]), "--permission-mode", @@ -255,25 +262,32 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu { concurrency: "unbounded" }, ); - if (exitCode !== 0) { + return { stdout, stderr, exitCode }; + }); + + const rawStdout = yield* runClaudeCommand(true).pipe( + Effect.filterOrElse( + (result) => + result.exitCode === 0 || !result.stderr.includes(NO_SESSION_PERSISTENCE_REJECTED), + () => runClaudeCommand(false), + ), + Effect.flatMap(({ stdout, stderr, exitCode }) => { + if (exitCode === 0) return Effect.succeed(stdout); const stderrDetail = stderr.trim(); const stdoutDetail = stdout.trim(); const detail = claudeErrorResult(stdoutDetail) ?? (stderrDetail.length > 0 ? stderrDetail : stdoutDetail); - return yield* new TextGenerationError({ - operation, - detail: - detail.length > 0 - ? `Claude CLI command failed: ${detail}` - : `Claude CLI command failed with code ${exitCode}.`, - }); - } - - return stdout; - }); - - const rawStdout = yield* runClaudeCommand().pipe( + return Effect.fail( + new TextGenerationError({ + operation, + detail: + detail.length > 0 + ? `Claude CLI command failed: ${detail}` + : `Claude CLI command failed with code ${exitCode}.`, + }), + ); + }), Effect.scoped, Effect.timeoutOption(investigate?.timeoutMs ?? CLAUDE_TIMEOUT_MS), Effect.flatMap( diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index c74f77db4d061..26ab9e79c7103 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -4,6 +4,7 @@ import * as NodeOS from "node:os"; import * as NodeURL from "node:url"; import * as NodeFS from "node:fs"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -25,7 +26,7 @@ const mockAgentPath = NodePath.join(__dirname, "../provider/testFixtures/grok-te const layerGrokTextGenerationTest = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-grok-text-generation-test-", -}).pipe(Layer.provideMerge(NodeServices.layer)); +}).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); function makeAcpGrokWrapper(dir: string, env: Record): string { return writeFakeCli({ diff --git a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs index d5ca2f185eb8d..22d406a4dcba0 100644 --- a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs +++ b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs @@ -25,6 +25,13 @@ if (!argv.includes("--disable-slash-commands")) { if (!argv.includes("--strict-mcp-config")) { fail("text generation must not load configured MCP servers", 9); } +if (process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE === "1") { + if (argv.includes("--no-session-persistence")) { + fail("error: unknown option '--no-session-persistence'", 1); + } +} else if (!argv.includes("--no-session-persistence")) { + fail("text generation must not save a resumable session", 16); +} const settingsIndex = argv.indexOf("--settings"); if (settingsIndex === -1 || JSON.parse(argv[settingsIndex + 1]).disableAllHooks !== true) { fail("text generation must disable hooks", 10); diff --git a/apps/server/src/triage/TriageService.test.ts b/apps/server/src/triage/TriageService.test.ts index d3ffc8c91d03e..db0bda6a4bd53 100644 --- a/apps/server/src/triage/TriageService.test.ts +++ b/apps/server/src/triage/TriageService.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { GitCommandError } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; @@ -39,7 +40,7 @@ it("falls back to main when the server's version has no tag", () => { const TestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-triage-service-" })), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const RELEASE_COMMIT = "7027b1f"; diff --git a/apps/server/src/triage/TriageService.ts b/apps/server/src/triage/TriageService.ts index f7a93f24a6641..14d12c0af9e15 100644 --- a/apps/server/src/triage/TriageService.ts +++ b/apps/server/src/triage/TriageService.ts @@ -23,7 +23,8 @@ const T3CODE_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; const GIT_ENV = { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", - LC_ALL: "C", + // English messages without overriding LC_CTYPE, which a global post-checkout hook inherits. + LANGUAGE: "C", } satisfies NodeJS.ProcessEnv; /** Starts in-app triage sessions: the same files `t3 triage` writes, plus a source clone. */ diff --git a/apps/server/src/triage/triagePrompt.test.ts b/apps/server/src/triage/triagePrompt.test.ts index e034ad107fecd..faa35bb4fd2f9 100644 --- a/apps/server/src/triage/triagePrompt.test.ts +++ b/apps/server/src/triage/triagePrompt.test.ts @@ -72,6 +72,7 @@ const contextInput = { logsDir: "/home/u/.t3/userdata/logs", serviceLogPath: "/home/u/.t3/userdata/logs/boot-service.log", desktopBackendLogGlob: "/home/u/.t3/userdata/logs/server-child*.log*", + relayClientLogPath: "/home/u/.t3/userdata/logs/relay-client.ndjson", serverTracePath: "/home/u/.t3/userdata/logs/server.trace.ndjson", providerEventLogPath: "/home/u/.t3/userdata/logs/provider/events.log", terminalLogsDir: "/home/u/.t3/userdata/logs/terminals", diff --git a/apps/server/src/triage/triagePrompt.ts b/apps/server/src/triage/triagePrompt.ts index b845cb4743d57..7a7625dcbe15c 100644 --- a/apps/server/src/triage/triagePrompt.ts +++ b/apps/server/src/triage/triagePrompt.ts @@ -217,6 +217,7 @@ export interface TriageContextInput { readonly logsDir: string; readonly serviceLogPath: string; readonly desktopBackendLogGlob: string; + readonly relayClientLogPath: string; readonly serverTracePath: string; readonly providerEventLogPath: string; readonly terminalLogsDir: string; @@ -302,6 +303,7 @@ ${input.entry.kind === "app" ? IN_APP_SECTION : ""}${input.sourceClone === undef - Logs dir: ${input.paths.logsDir} - Service log (systemd/launchd service installs only): ${input.paths.serviceLogPath} - Desktop backend logs (glob; one file per backend plus rotated copies; written only when a backend crashes or fails to start): ${input.paths.desktopBackendLogGlob} +- T3 Connect tunnel log (ndjson; what the server logged about its cloudflared connector, with rotated copies \`.1\` and \`.2\`): ${input.paths.relayClientLogPath} - Server trace (ndjson): ${input.paths.serverTracePath} - Provider event log: ${input.paths.providerEventLogPath} - Terminal logs: ${input.paths.terminalLogsDir} diff --git a/apps/server/src/triage/triageSession.ts b/apps/server/src/triage/triageSession.ts index 14654cf7d28cc..e71dd935ef853 100644 --- a/apps/server/src/triage/triageSession.ts +++ b/apps/server/src/triage/triageSession.ts @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as BootService from "../cloud/bootService.ts"; +import * as ManagedEndpointRuntime from "../cloud/ManagedEndpointRuntime.ts"; import type * as ServerConfig from "../config.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { @@ -80,6 +81,7 @@ export const writeTriageSession = Effect.fn("triage.writeTriageSession")(functio logsDir: paths.logsDir, serviceLogPath: path.join(paths.logsDir, BootService.BOOT_SERVICE_LOG_FILE), desktopBackendLogGlob: path.join(paths.logsDir, "server-child*.log*"), + relayClientLogPath: path.join(paths.logsDir, ManagedEndpointRuntime.RELAY_CLIENT_LOG_FILE), serverTracePath: paths.serverTracePath, providerEventLogPath: paths.providerEventLogPath, terminalLogsDir: paths.terminalLogsDir, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9600f0a45adfb..9cf97cd4c7c4d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -9,6 +9,7 @@ import * as NodeZlib from "node:zlib"; import { assert, describe, 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 * as HostProcess from "@t3tools/shared/HostProcess"; import { mergeUsage } from "@t3tools/shared/usageMerge"; @@ -30,6 +31,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Context from "effect/Context"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -67,12 +69,17 @@ const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unkno const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeUnknownJsonString = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); -function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { +function claudeLine( + id: number, + outputTokens: number, + model = "claude-fable-5", + sessionId = "session-1", +): string { return `${JSON.stringify({ type: "assistant", timestamp: "2026-08-01T10:00:00Z", requestId: `req_${id}`, - sessionId: "session-1", + sessionId, message: { id: `msg_${id}`, model, @@ -144,6 +151,7 @@ const layerService = (input: { readonly environment?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; readonly hostname?: string; + readonly secretStore?: Partial; }) => layerCursorUsageAccounts( CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)), @@ -153,11 +161,16 @@ const layerService = (input: { Layer.provideMerge(ProviderHostLive.layer), Layer.provideMerge(CodexUsageHistory.layer.pipe(Layer.provideMerge(Sqlite.layerMemory))), Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), - Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), + Layer.provideMerge( + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: () => Effect.succeedNone, + ...input.secretStore, + }), + ), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), NodePath.join(input.home, input.prefix)), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), Layer.provideMerge(Layer.succeed(HostProcess.Hostname, input.hostname ?? "usage-test-host")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), @@ -234,10 +247,12 @@ function makeFakeCursor() { error: null as string | null, gate: undefined as Deferred.Deferred | undefined, calls: [] as { readonly sinceMs: number; readonly untilMs: number }[], + credentials: [] as unknown[], }; - const read = (_credential: unknown, sinceMs: number, untilMs: number) => + const read = (credential: unknown, sinceMs: number, untilMs: number) => Effect.gen(function* () { state.calls.push({ sinceMs, untilMs }); + state.credentials.push(credential); if (state.gate !== undefined) yield* Deferred.await(state.gate); const { accountKey, error } = state; if (error !== null) return { accountKey, records: [], missing: false, error }; @@ -281,6 +296,10 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { return summary.sources.find((source) => source.fingerprint.provider === "cursor"); } +function cursorSources(summary: { readonly sources: readonly UsageSource[] }) { + return summary.sources.filter((source) => source.fingerprint.provider === "cursor"); +} + const seedCodexHistory = (input: { id: string; nativeTurnId?: string; @@ -1462,6 +1481,37 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.effect("ignores a stale macOS Keychain login when Cursor is not installed", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const cursor = makeFakeCursor(); + cursor.state.error = "Sign in to Cursor again to read account usage."; + const readCursorSource = Effect.gen(function* () { + const service = yield* makeWithCursor(cursor.read); + const summary = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + return cursorSource(summary); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cursor-keychain-uninstalled", + home, + settings: { ...settings, cursorKeychainUsageEnabled: true }, + platform: "darwin", + environment: {}, + }), + ), + ); + + assert.isUndefined(yield* readCursorSource); + assert.lengthOf(cursor.state.calls, 0); + + yield* Effect.promise(() => NodeFSP.mkdir(NodePath.join(home, ".cursor"))); + const installed = yield* readCursorSource; + assert.strictEqual(installed?.message, "Sign in to Cursor again to read account usage."); + assert.lengthOf(cursor.state.calls, 1); + }).pipe(Effect.scoped), + ); + it.live("ignores stale Cursor file logins when the active credential store differs", () => Effect.gen(function* () { const { settings, home } = yield* setup; @@ -1476,11 +1526,6 @@ describe("UsageService", () => { environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, authPath: ["config", "cursor", "auth.json"], }, - { - platform: "linux" as const, - environment: { CURSOR_API_KEY: "different-account" }, - authPath: ["config", "cursor", "auth.json"], - }, ].entries()) { const authPath = NodePath.join(home, ...testCase.authPath); yield* Effect.promise(async () => { @@ -1510,6 +1555,212 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("reads the Cursor account of an API key or T3 sign-in instead of a CLI login", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const signIn = new TextEncoder().encode( + encodeUnknownJsonString({ + version: 1, + backendUrl: "https://api2.cursor.sh", + apiKey: "signed-in-key", + createdAtMs: 0, + }), + ); + for (const [index, testCase] of [ + { environment: { CURSOR_API_KEY: "environment-key" }, stored: Option.none() }, + { environment: {}, stored: Option.some(signIn) }, + ].entries()) { + const cursor = makeFakeCursor(); + const summary = yield* Effect.gen(function* () { + const service = yield* makeWithCursor(cursor.read); + return yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + }).pipe( + Effect.provide( + layerService({ + prefix: `usage-service-cursor-api-key-${index}`, + home, + settings, + platform: "darwin", + environment: testCase.environment, + secretStore: { get: () => Effect.succeed(testCase.stored) }, + }), + ), + ); + assert.deepStrictEqual(cursor.state.credentials, [ + { kind: "apiKey", apiKey: index === 0 ? "environment-key" : "signed-in-key" }, + ]); + assert.strictEqual(cursorSource(summary)?.status, "ok"); + } + }).pipe(Effect.scoped), + ); + + it.live("does not show a previous Cursor account's history after the API key changes", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* TestClock.setTime(CURSOR_NOW); + let apiKey = "first-account-key"; + const signIn = () => + Option.some( + new TextEncoder().encode( + encodeUnknownJsonString({ + version: 1, + backendUrl: "https://api2.cursor.sh", + apiKey, + createdAtMs: 0, + }), + ), + ); + const cursor = makeFakeCursor(); + cursor.state.events = [{ timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }]; + yield* Effect.gen(function* () { + const service = yield* makeWithCursor(cursor.read); + const first = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.isTrue(first.buckets.some((bucket) => bucket.provider === "cursor")); + + apiKey = "second-account-key"; + cursor.state.error = "Cursor account usage could not be read."; + const second = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.isFalse(second.buckets.some((bucket) => bucket.provider === "cursor")); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cursor-key-change", + home, + settings, + platform: "darwin", + environment: {}, + secretStore: { get: () => Effect.sync(signIn) }, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.live("keeps a new Cursor key's refresh visible beside a failing key", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const cursor = makeFakeCursor(); + const started = yield* Deferred.make(); + const gate = yield* Deferred.make(); + yield* Effect.gen(function* () { + const context = yield* Layer.build( + layerCursorUsageAccounts( + Layer.succeed(CursorAccountReader.CursorAccountReader, { + read: (credential, sinceMs, untilMs) => + (credential as { apiKey?: string }).apiKey === "failing-key" + ? Effect.succeed({ + accountKey: null, + records: [], + missing: false, + error: "Cursor account usage could not be read.", + }) + : Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(gate)), + Effect.andThen(cursor.read(credential, sinceMs, untilMs)), + ), + }), + ), + ); + const accounts = Context.get(context, CursorUsageAccounts.CursorUsageAccounts); + const scan = (awaitRefresh: boolean) => + accounts.scan({ + instances: [ + { + instanceId: ProviderInstanceId.make("cursor"), + environment: { CURSOR_API_KEY: "failing-key" }, + }, + { + instanceId: ProviderInstanceId.make("cursor-work"), + environment: { CURSOR_API_KEY: "new-key" }, + }, + ], + keychainUsageEnabled: false, + windowStartMs: 0, + retentionCutoffMs: 0, + awaitRefresh, + }); + const first = yield* Effect.forkChild(scan(true)); + yield* Deferred.await(started); + const sources = yield* scan(false); + assert.isTrue(sources.some((source) => source.refreshing === true)); + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(first); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cursor-new-key", + home, + settings, + platform: "linux", + environment: {}, + }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live("reports one Cursor source when two API keys belong to the same account", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* TestClock.setTime(CURSOR_NOW); + const cursor = makeFakeCursor(); + cursor.state.events = [{ timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }]; + let revoked = false; + yield* Effect.gen(function* () { + const service = yield* makeWithCursor((credential, sinceMs, untilMs) => + revoked && (credential as { apiKey?: string }).apiKey === "second-key" + ? Effect.succeed({ + accountKey: null, + records: [], + missing: false, + error: "Cursor account usage could not be read.", + }) + : cursor.read(credential, sinceMs, untilMs), + ); + const first = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.strictEqual(cursor.state.credentials.length, 2); + assert.deepStrictEqual( + cursorSources(first).map((source) => source.distinctSessions), + [1], + ); + + // The revoked key keeps its stale cache, which must not replace the working key's history. + revoked = true; + cursor.state.events.push({ timestampMs: CURSOR_NOW + 60 * 1000, outputTokens: 5 }); + yield* TestClock.adjust(Duration.minutes(2)); + const second = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.deepStrictEqual( + cursorSources(second).map((source) => source.status), + ["ok"], + ); + assert.strictEqual(totalOutputTokens(second), 12); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cursor-shared-account", + home, + settings: { + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("cursor")]: { + driver: ProviderDriverKind.make("cursor"), + environment: [{ name: "CURSOR_API_KEY", value: "first-key", sensitive: true }], + }, + [ProviderInstanceId.make("cursor-work")]: { + driver: ProviderDriverKind.make("cursor"), + environment: [{ name: "CURSOR_API_KEY", value: "second-key", sensitive: true }], + }, + }, + }, + platform: "darwin", + environment: {}, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.live( "includes OpenCode history but does not substitute desktop usage for an unavailable Cursor account", () => @@ -2046,6 +2297,111 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("filters original model IDs before aliases, pricing, buckets and session counts", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile( + transcript, + claudeLine(1, 3, "orbit-1", "mixed") + + claudeLine(2, 5, "orbit-2", "mixed") + + claudeLine(3, 7, "drift-9", "mixed") + + claudeLine(4, 11, "drift-9", "excluded-only"), + ), + ); + const rates = { + "orbit-1": { input_cost_per_token: 0.000001, output_cost_per_token: 0.00001 }, + "orbit-2": { input_cost_per_token: 0.000002, output_cost_per_token: 0.00002 }, + }; + const include = { + // The codex selector must not match the Claude drift-9 records. + include: [ + { provider: "claude", model: "orbit-1" }, + { model: "orbit-2" }, + { provider: "codex", model: "drift-9" }, + ], + }; + const exclude = { exclude: [{ model: "drift-9" }] }; + const HOURLY: UsageSummaryInput = { + ...WINDOW, + resolution: "hour", + sinceTime: "2026-08-01T09:00:00Z", + untilTime: "2026-08-01T11:00:00Z", + }; + const cells = (summary: UsageSummary) => + summary.buckets.map(({ model, totals, costUsd, records, sessions }) => ({ + model, + outputTokens: totals.outputTokens, + costUsd: Math.round(costUsd * 1e6), + records, + sessions, + })); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + // Same service, concurrent reads: the in-flight key must keep these scans apart. + const [unfiltered, noOp, keepNothing, included, reordered, excluded, hourly] = + yield* Effect.all( + [ + service.readSummary(WINDOW), + service.readSummary({ ...WINDOW, modelFilter: { exclude: [] } }), + service.readSummary({ ...WINDOW, modelFilter: { include: [] } }), + service.readSummary({ ...WINDOW, modelFilter: include }), + service.readSummary({ + ...WINDOW, + modelFilter: { include: [...include.include].reverse() }, + }), + service.readSummary({ ...WINDOW, modelFilter: exclude }), + service.readSummary({ ...HOURLY, modelFilter: include }), + ], + { concurrency: "unbounded" }, + ); + + // Unfiltered keeps today's behavior: the alias folds drift-9 into orbit-1. + assert.deepStrictEqual(cells(unfiltered), [ + { model: "orbit-1", outputTokens: 21, costUsd: 240, records: 3, sessions: 2 }, + { model: "orbit-2", outputTokens: 5, costUsd: 120, records: 1, sessions: 1 }, + ]); + assert.strictEqual(unfiltered.sources[0]?.distinctSessions, 2); + assert.strictEqual(unfiltered.modelFilter, undefined); + assert.strictEqual(noOp, unfiltered); + assert.deepStrictEqual(keepNothing.buckets, []); + assert.strictEqual(keepNothing.sources[0]?.distinctSessions, 0); + + const kept = [ + { model: "orbit-1", outputTokens: 3, costUsd: 40, records: 1, sessions: 1 }, + { model: "orbit-2", outputTokens: 5, costUsd: 120, records: 1, sessions: 1 }, + ]; + assert.deepStrictEqual(cells(included), kept); + assert.deepStrictEqual(cells(excluded), kept); + assert.deepStrictEqual(cells(hourly), kept); + assert.strictEqual(hourly.buckets[0]?.hourStart, "2026-08-01T10:00:00.000Z"); + for (const summary of [included, excluded, hourly]) { + assert.strictEqual(summary.sources[0]?.distinctSessions, 1); + } + // Equal filters share one scan and echo one canonical form. + assert.strictEqual(reordered, included); + assert.deepStrictEqual(included.modelFilter, { + include: [ + { provider: "claude", model: "orbit-1" }, + { provider: "codex", model: "drift-9" }, + { model: "orbit-2" }, + ], + }); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-service-model-filter-test", + home, + settings: { ...settings, usageModelAliases: { "drift-9": "orbit-1" } }, + ratesDocument: rates, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -2268,7 +2624,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live.each([4, 5, 6, 7, 8])( + it.live.each([4, 5, 6, 7, 8, 10])( "upgrades a v%s cache: reparses live Codex usage, keeps deleted rollouts and the old file", (version) => Effect.gen(function* () { @@ -2335,7 +2691,7 @@ describe("UsageService", () => { delete file.cs.lastCumulativeUsage; delete file.cs.malformedRecords; } - if (version === 8 && path === livePath) { + if (version >= 8 && path === livePath) { file.r = []; file.mr = 1; file.cs.malformedRecords = 1; @@ -2367,7 +2723,7 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v10.json"); + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v11.json"); assert.strictEqual( yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, @@ -2394,6 +2750,106 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("re-parses a clean v10 Codex rollout that grew after an interrupted request", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const sessions = NodePath.join(home, "codex", "sessions"); + const live = NodePath.join(sessions, "live.jsonl"); + const usage = (input: number, output: number) => ({ + input_tokens: input, + output_tokens: output, + total_tokens: input + output, + }); + const lines = (values: readonly unknown[]) => + values.map((value) => `${encodeUnknownJsonString(value)}\n`).join(""); + const request = (second: number, last: unknown) => ({ + type: "token_usage_record", + timestamp: `2026-08-01T10:00:0${second}Z`, + payload: { usage: last }, + }); + const tokenCount = (second: number, last: unknown, total: unknown) => ({ + type: "event_msg", + timestamp: `2026-08-01T10:00:0${second}Z`, + payload: { + type: "token_count", + info: { last_token_usage: last, total_token_usage: total }, + }, + }); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + live, + lines([ + { type: "session_meta", payload: { id: "live" } }, + { type: "turn_context", payload: { turn_id: "turn-1", model: "gpt-6-astra" } }, + request(1, usage(100, 20)), + tokenCount(1, usage(100, 20), usage(100, 20)), + // Interrupted after this request finished: no token_count reports it. + request(2, usage(150, 10)), + ]), + ); + }); + + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const tokens = (summary: UsageSummary) => + summary.buckets.reduce((sum, bucket) => sum + totalTokens(bucket.totals), 0); + const first = yield* UsageService.make; + assert.strictEqual(tokens(yield* first.readSummary(WINDOW)), 120); + yield* first.awaitPersisted; + + yield* Effect.promise(async () => { + const cachePath = NodePath.join(stateDir, SCAN_CACHE_FILE_NAME); + const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record }>; + }; + // The v10 parser ignored `token_usage_record`, so its clean state held nothing pending. + for (const file of Object.values(document.files)) { + file.cs = Object.fromEntries( + Object.entries(file.cs).filter(([key]) => !key.startsWith("unreported")), + ); + } + await NodeFSP.rm(cachePath); + await NodeFSP.writeFile( + NodePath.join(stateDir, "usage-scan-cache-v10.json"), + encodeUnknownJsonString({ ...document, version: 10 }), + ); + await NodeFSP.appendFile( + live, + lines([ + { type: "turn_context", payload: { turn_id: "turn-2", model: "gpt-6-astra" } }, + request(5, usage(200, 30)), + tokenCount(5, usage(200, 30), usage(450, 60)), + ]), + ); + }); + + const upgraded = yield* UsageService.make; + const summary = yield* upgraded.readSummary(WINDOW); + assert.deepStrictEqual( + { + tokens: tokens(summary), + malformedRecords: summary.sources.find( + (source) => source.fingerprint.provider === "codex", + )?.malformedRecords, + }, + { tokens: 510, malformedRecords: 0 }, + ); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cache-v10-interrupted-test", + home, + settings, + ratesDocument: { + "gpt-6-astra": { input_cost_per_token: 0, output_cost_per_token: 1 }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -2608,7 +3064,10 @@ describe("UsageService", () => { it.live("reports saved usage of a removed directory only for windows it reaches", () => Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-08-12T00:00:00Z")); const { transcript, settings, home } = yield* setup; + // Saved usage is pruned after the retention window, so pin "now" near the write. + yield* TestClock.setTime(Date.parse("2026-08-15T00:00:00Z")); yield* Effect.promise(async () => { await NodeFSP.writeFile(transcript, claudeLine(1, 5)); const lastWrite = Date.parse("2026-08-01T10:00:00Z") / 1000; @@ -2634,7 +3093,7 @@ describe("UsageService", () => { assert.deepStrictEqual(later.buckets, []); assert.strictEqual(later.sources[0]?.status, "missing"); assert.strictEqual(later.sources[0]?.scannedFiles, 0); - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); it.live("does not share an in-flight scan after custom prices change", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index f5f10fb156f3f..7e2e8586ed95f 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -39,7 +39,7 @@ import type { TranscriptUsageFormat, UsageRecord, } from "@t3tools/provider-core/server/usage"; -import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; +import { canonicalModelFilter, resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -724,6 +724,7 @@ export const make = Effect.gen(function* () { rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), modelAliases: resolveModelAliases(settings.usageModelAliases), + ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), }); const sources: UsageSource[] = []; @@ -882,6 +883,7 @@ export const make = Effect.gen(function* () { sources, pricing: pricing(), scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), } satisfies UsageSummary; }); @@ -897,9 +899,15 @@ export const make = Effect.gen(function* () { input.untilTime ?? null, settings, input.awaitRefresh === true, + input.modelFilter ?? null, ]); - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const readSummary = Effect.fn("UsageService.readSummary")(function* ( + requested: UsageSummaryInput, + ) { + const { modelFilter: requestedFilter, ...unfiltered } = requested; + const modelFilter = canonicalModelFilter(requestedFilter); + const input = modelFilter === undefined ? unfiltered : { ...unfiltered, modelFilter }; const settings = yield* readSettings; const key = scanKey(input, settings); const deferred = yield* Effect.uninterruptible( diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 0119adfd940f3..4a76d32c15dea 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -345,6 +345,70 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect.each([ + { + name: "spend budget", + budget: { + spend: { + enabled: true, + used: { amount_minor: 4_631, currency: "USD", exponent: 2 }, + limit: { amount_minor: 50_000, currency: "USD", exponent: 2 }, + }, + }, + }, + { + name: "extra usage", + budget: { + extra_usage: { + is_enabled: true, + used_credits: 4_631, + monthly_limit: 50_000, + utilization: 9, + }, + }, + }, + ])("keeps Claude monthly spend from $name beside rolling windows", ({ budget }) => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: () => ({ + status: 200, + body: { five_hour: { utilization: 10, resets_at: null }, ...budget }, + }), + }); + const result = yield* (yield* test.api).readAccounts(config); + expect( + result[0]?.usageLimits.windows.map((window) => [window.id, window.usedPercent]), + ).toEqual([ + ["five_hour", 10], + ["monthly_spend", expect.closeTo(9.262, 10)], + ]); + }), + ); + + it.effect("omits disabled Claude budgets without losing rolling windows", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: () => ({ + status: 200, + body: { + five_hour: { utilization: 10, resets_at: null }, + spend: { enabled: false, used: null, limit: null }, + extra_usage: { + is_enabled: false, + used_credits: null, + monthly_limit: null, + utilization: null, + }, + }, + }), + }); + const result = yield* (yield* test.api).readAccounts(config); + expect(result[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["five_hour"]); + }), + ); + it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () => Effect.gen(function* () { const test = fixture(); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 91504d8002295..daa1ea909f034 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -6,7 +6,7 @@ import { type UsageLimitSourceConfig, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -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"; @@ -55,6 +55,17 @@ const ClaudeWindow = Schema.Struct({ const ClaudeUsage = Schema.Struct({ five_hour: Schema.optional(Schema.NullOr(ClaudeWindow)), seven_day: Schema.optional(Schema.NullOr(ClaudeWindow)), + spend: Schema.optional(Schema.Unknown), + extra_usage: Schema.optional( + Schema.NullOr( + Schema.Struct({ + is_enabled: Schema.Boolean, + monthly_limit: Schema.NullOr(Schema.Number), + used_credits: Schema.NullOr(Schema.Number), + utilization: Schema.NullOr(Schema.Number), + }), + ), + ), limits: Schema.optional( Schema.Array( Schema.Struct({ @@ -145,12 +156,12 @@ const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(fun accountId: string, creditId: string, ) { - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const name = new TextEncoder().encode(`${accountId}:${creditId}`); const input = new Uint8Array(CREDIT_REDEEM_NAMESPACE.length + name.length); input.set(CREDIT_REDEEM_NAMESPACE); input.set(name, CREDIT_REDEEM_NAMESPACE.length); - const bytes = (yield* crypto.digest("SHA-1", input).pipe(Effect.orDie)).slice(0, 16); + const bytes = (yield* digest.digest(Digest.sha1, input).pipe(Effect.orDie)).slice(0, 16); bytes[6] = (bytes[6]! & 0x0f) | 0x50; bytes[8] = (bytes[8]! & 0x3f) | 0x80; const hex = Hex.encode(bytes); @@ -159,7 +170,7 @@ const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(fun export const makeCliproxyApi = Effect.gen(function* () { const client = yield* HttpClient.HttpClient; - const crypto = yield* Crypto.Crypto; + const digest = yield* Digest.Digest; const management = Effect.fn("CliproxyApi.management")(function* ( config: UsageLimitSourceConfig, @@ -279,6 +290,13 @@ export const makeCliproxyApi = Effect.gen(function* () { ] : [], ); + const rateLimits = { + five_hour: usage.five_hour ?? null, + seven_day: usage.seven_day ?? null, + model_scoped, + spend: usage.spend, + extra_usage: usage.extra_usage ?? null, + }; return { ...base, plan: "Claude Subscription", @@ -286,11 +304,7 @@ export const makeCliproxyApi = Effect.gen(function* () { checkedAt, response: { rate_limits_available: true, - rate_limits: { - five_hour: usage.five_hour ?? null, - seven_day: usage.seven_day ?? null, - model_scoped, - }, + rate_limits: rateLimits, }, }).limits, }; @@ -387,7 +401,7 @@ export const makeCliproxyApi = Effect.gen(function* () { redeem_request_id: yield* creditRedeemRequestId( account.id_token?.chatgpt_account_id ?? account.id, creditId, - ).pipe(Effect.provideService(Crypto.Crypto, crypto)), + ).pipe(Effect.provideService(Digest.Digest, digest)), credit_id: creditId, }); const response = yield* decodeConsumeResponse(body); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 17903cd062eae..8f94c84a3c470 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -3,6 +3,8 @@ import type { UsageBucket, UsageCategoryCost, UsageDay, + UsageModelFilter, + UsageModelSelector, UsageResolution, UsageTokenTotals, } from "@t3tools/contracts"; @@ -67,6 +69,7 @@ export interface AggregateOptions { readonly rates: RateTable; readonly priceOverrides?: RateTable; readonly modelAliases?: ReadonlyMap; + readonly modelFilter?: UsageModelFilter; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; @@ -88,6 +91,38 @@ export function resolveModelAliases( return resolved; } +/** Matches original IDs, so an alias cannot pull excluded usage into a kept model. */ +function keepsUsageRecord(filter: UsageModelFilter | undefined, record: UsageRecord): boolean { + if (filter === undefined) return true; + const model = record.model.trim(); + const matches = (selector: UsageModelSelector) => + selector.model === model && + (selector.provider === undefined || selector.provider === record.provider); + return (filter.include?.some(matches) ?? true) && !(filter.exclude?.some(matches) ?? false); +} + +const selectorKey = (selector: UsageModelSelector) => + JSON.stringify([selector.provider ?? null, selector.model]); + +const canonicalSelectors = (selectors: readonly UsageModelSelector[]) => + [...new Map(selectors.map((selector) => [selectorKey(selector), selector]))] + // Code-unit order, so servers in different locales echo the same form. + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([, selector]) => selector); + +/** Equal filters get one form, so they share scans and clients can compare echoed filters. */ +export function canonicalModelFilter( + filter: UsageModelFilter | undefined, +): UsageModelFilter | undefined { + if (filter === undefined) return undefined; + const exclude = filter.exclude?.length ? canonicalSelectors(filter.exclude) : undefined; + if (filter.include === undefined && exclude === undefined) return undefined; + return { + ...(filter.include === undefined ? {} : { include: canonicalSelectors(filter.include) }), + ...(exclude === undefined ? {} : { exclude }), + }; +} + export interface AggregateResult { readonly buckets: readonly UsageBucket[]; readonly duplicatesDropped: number; @@ -129,6 +164,7 @@ export class UsageAggregator { } add(input: UsageRecord, sourcePath?: string): boolean { + if (!keepsUsageRecord(this.#options.modelFilter, input)) return false; const record = this.#mapModel(input); if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 2ce6d6f74eff7..b22625b7e490c 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -258,4 +258,32 @@ describe("usage pricing", () => { expect(lookupRate(table, "provider-b/example-model")?.inputCostPerToken).toBe(3); expect(lookupRate(table, "example-model")).toBeNull(); }); + + it("prices a bare name at the model maker's rate when a reseller marks it up", () => { + const reseller = { "kyma/gemini-4-argon": rate(5.4e-6) }; + const makers = { + "gemini/gemini-4-argon": rate(2e-6, 1e-7), + "vertex_ai/gemini-4-argon": rate(2e-6, 1e-7), + }; + + for (const document of [ + { ...reseller, ...makers }, + { ...makers, ...reseller }, + ]) { + const table = parseRateTable(document); + expect(lookupRate(table, "gemini-4-argon")).toEqual( + lookupRate(table, "gemini/gemini-4-argon"), + ); + } + }); + + it("leaves a bare name unpriced when model makers' own entries disagree", () => { + const table = parseRateTable({ + "openai/example-model": rate(1), + "xai/example-model": rate(3), + "kyma/example-model": rate(1), + }); + + expect(lookupRate(table, "example-model")).toBeNull(); + }); }); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index fa512218d7c0c..1f515abb736c8 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -89,6 +89,17 @@ function fastMultiplier(entry: LiteLlmEntry): number | null { return fast !== null && fast > 0 ? fast : null; } +// LiteLLM prefixes for the model makers' own APIs; their price beats a cloud's or reseller's for a bare name. +const FIRST_PARTY_PROVIDERS = new Set([ + "anthropic", + "deepseek", + "gemini", + "meta", + "mistral", + "openai", + "xai", +]); + /** Drops models missing input or output rates to avoid understating cost. */ export function parseRateTable(document: unknown): RateTable { const table = new Map(); @@ -113,18 +124,19 @@ export function parseRateTable(document: unknown): RateTable { }); } - const aliasCandidates = new Map(); + const aliasCandidates = new Map(); for (const [key, rate] of table) { const alias = bareModelName(key); if (alias.length === 0 || alias === key || table.has(alias)) continue; + const firstParty = FIRST_PARTY_PROVIDERS.has(key.slice(0, key.indexOf("/"))); const held = aliasCandidates.get(alias); - if (held === undefined) { - aliasCandidates.set(alias, rate); - } else if (held !== null) { - aliasCandidates.set(alias, mergeAliasRate(held, rate)); + if (held === undefined || (firstParty && !held.firstParty)) { + aliasCandidates.set(alias, { rate, firstParty }); + } else if (held.rate !== null && firstParty === held.firstParty) { + aliasCandidates.set(alias, { rate: mergeAliasRate(held.rate, rate), firstParty }); } } - for (const [alias, rate] of aliasCandidates) { + for (const [alias, { rate }] of aliasCandidates) { if (rate !== null) table.set(alias, rate); } diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 477b8bb6fc4f6..fa659c4a443b3 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -161,7 +161,7 @@ describe("scan cache round trip", () => { expect(restored).toEqual(original); }); - it.each([5, 6, 7, 8])( + it.each([5, 6, 7, 8, 10])( "retains v%s history and compatible positions but invalidates obsolete state", (version) => { const original = cacheWith([["/gone.jsonl", 100, [record({ speed: "fast" })]]]); @@ -218,25 +218,25 @@ describe("scan cache round trip", () => { }; const restored = decodeScanCache(JSON.parse(JSON.stringify(legacy)), TEST_FORMAT_MAP); - expect(restored.get("/gone.jsonl")).toEqual({ - ...original.get("/gone.jsonl"), - size: -1, - position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, - }); - expect(restored.get("/gone-codex.jsonl")).toEqual({ - ...original.get("/gone-codex.jsonl"), - size: -1, - position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, - }); - expect(restored.get("/clean-codex.jsonl")).toEqual( - version >= 8 - ? cleanCodex + expect(restored.get("/gone.jsonl")).toEqual( + version >= 10 + ? original.get("/gone.jsonl") : { - ...cleanCodex, + ...original.get("/gone.jsonl"), size: -1, position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, }, ); + expect(restored.get("/gone-codex.jsonl")).toEqual({ + ...original.get("/gone-codex.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); + expect(restored.get("/clean-codex.jsonl")).toEqual({ + ...cleanCodex, + size: version >= 8 ? cleanCodex.size : -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); expect(restored.get("/gone-pi.jsonl")).toEqual( version >= 6 ? original.get("/gone-pi.jsonl") diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 70c0c3df17e3e..c9032d64c05bd 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -9,7 +9,7 @@ import * as Schema from "effect/Schema"; import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 10 as const; +const USAGE_SCAN_CACHE_VERSION = 11 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -17,8 +17,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * replace it, dropping saved usage for deleted transcripts. Separate files keep * both. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v10.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v11.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v10.json", "usage-scan-cache-v9.json", "usage-scan-cache-v8.json", "usage-scan-cache-v7.json", @@ -291,16 +292,19 @@ export function decodeScanCache( continue; } // Keep history for deleted rollouts, but re-parse incompatible reducer state. - // v9 repairs skipped initial Codex requests; clean v8 entries can still resume. + // v9 and v11 repair skipped Codex requests; clean v8+ entries stay served while unchanged. // v10 adds Claude advisor calls, which no older Claude entry holds. const legacy = (entry.p === "claude" && version < 10) || (format.state !== undefined && (version < 6 || - (entry.p === "codex" && (version < 8 || (version < 9 && malformedRecords > 0))))); + (entry.p === "codex" && (version < 8 || (version < 11 && malformedRecords > 0))))); // A corrupt state disqualifies the entry: resuming with it would attach // appended usage to the wrong model or replay fork-copied history. if (!legacy && !isValidState.get(entry.p)?.(entry.cs)) continue; + // Before v11 a clean scan could end past an interrupted request whose usage its state dropped, + // so a grown file re-parses whole instead of resuming. + const resumable = !legacy && !(entry.p === "codex" && version < 11); const provider = entry.p; const records = decodeRecords(entry.r, provider); @@ -314,14 +318,14 @@ export function decodeScanCache( records, tailRecords, malformedRecords, - position: legacy - ? { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null } - : { + position: resumable + ? { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, state: entry.cs, - }, + } + : { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, }); } diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index f2b4fb43b48b4..105884d81ccfb 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -7,7 +7,7 @@ import * as NodePath from "node:path"; import * as NodeZlib from "node:zlib"; import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; -import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; +import { totalTokens, type TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; import * as Schema from "effect/Schema"; import { TEST_FORMATS } from "./usageTestFormats.ts"; @@ -381,6 +381,82 @@ describe("readTranscriptRecords resume", () => { }, ); + it.each([ + { mode: "buffered", streamingThresholdBytes: Infinity }, + { mode: "streaming", streamingThresholdBytes: 0 }, + ])( + "bills a Codex request whose turn was interrupted before its token_count ($mode)", + async (options) => { + const path = NodePath.join(dir, "rollout.jsonl"); + const usage = (input: number, output: number) => ({ + input_tokens: input, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: output, + reasoning_output_tokens: 0, + total_tokens: input + output, + }); + const context = (turn: number) => ({ + type: "turn_context", + timestamp: `2026-11-02T10:00:0${turn * 4 - 4}.000Z`, + payload: { turn_id: `turn-${turn}`, model: "gpt-6-sol" }, + }); + const request = (turn: number, id: number, timestamp: string, last: unknown) => ({ + type: "token_usage_record", + timestamp, + payload: { turn_id: `turn-${turn}`, response_id: `resp_${id}`, usage: last }, + }); + const tokenCount = (timestamp: string, last: unknown, total: unknown) => ({ + type: "event_msg", + timestamp, + payload: { + type: "token_count", + info: { last_token_usage: last, total_token_usage: total }, + }, + }); + const lines = [ + { + type: "session_meta", + timestamp: "2026-11-02T10:00:00.000Z", + payload: { id: "aborted-turn-repro", model_provider: "openai" }, + }, + context(1), + request(1, 1, "2026-11-02T10:00:01.000Z", usage(100, 20)), + tokenCount("2026-11-02T10:00:01.001Z", usage(100, 20), usage(100, 20)), + // Interrupted after this request finished: no token_count reports it. + request(1, 2, "2026-11-02T10:00:02.000Z", usage(150, 10)), + { + type: "event_msg", + timestamp: "2026-11-02T10:00:03.000Z", + payload: { type: "turn_aborted", turn_id: "turn-1", reason: "interrupted" }, + }, + context(2), + request(2, 3, "2026-11-02T10:00:05.000Z", usage(200, 30)), + tokenCount("2026-11-02T10:00:05.001Z", usage(200, 30), usage(450, 60)), + ]; + // Lines longer than a read chunk take the projected path when streaming. + const padding = "x".repeat(256 * 1024); + await NodeFSP.writeFile( + path, + lines.map((line) => `${JSON.stringify({ padding, ...line })}\n`).join(""), + ); + + const result = await readTranscriptRecords(path, TEST_FORMATS.codex, undefined, options); + assert.isNotNull(result); + const requests = [...result.records, ...result.tailRecords] + .map((record) => totalTokens(record.totals)) + .toSorted((a, b) => a - b); + assert.deepStrictEqual( + { + billedTokens: requests.reduce((sum, tokens) => sum + tokens, 0), + requests, + malformedRecords: result.malformedRecords, + }, + { billedTokens: 510, requests: [120, 160, 230], malformedRecords: 0 }, + ); + }, + ); + it("re-parses from the start when the guard bytes no longer match", async () => { const path = NodePath.join(dir, "claude.jsonl"); await NodeFSP.writeFile(path, claudeLine(1, 5)); diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index ba2b4abe50af7..9262688fd10b5 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"; @@ -35,11 +36,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) => @@ -596,7 +597,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) { @@ -679,11 +690,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 })), ), )( @@ -699,6 +711,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( @@ -706,19 +719,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, @@ -729,13 +754,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)), ); @@ -1110,7 +1141,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* () { @@ -1123,7 +1157,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")( @@ -1159,7 +1193,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))), ); const gitCommandCount = (operation: string, outcome: string) => @@ -1212,7 +1246,7 @@ it.effect("GitVcsDriver meters each git command once under its operation", () => assert.strictEqual(timing.count, 2); assert.strictEqual(timing.sum, 500); }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.provideService(Metric.MetricRegistry, new Map()), ), ); @@ -1236,7 +1270,7 @@ it.effect("GitVcsDriver counts an allowed non-zero exit as a success", () => assert.strictEqual(timing.count, 1); assert.strictEqual(timing.sum, 250); }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.provideService(Metric.MetricRegistry, new Map()), ), ); @@ -1257,7 +1291,7 @@ it.effect("GitVcsDriver counts a failed git command as a failure", () => assert.strictEqual(timing.count, 1); assert.strictEqual(timing.sum, 250); }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.provideService(Metric.MetricRegistry, new Map()), ), ); @@ -1322,7 +1356,7 @@ it.effect("git metrics finalize cancellation without exposing arbitrary operatio assert.strictEqual(timing.count, 1); assert.strictEqual(timing.sum, 250); }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.provideService(Metric.MetricRegistry, new Map()), ), ); @@ -1378,7 +1412,7 @@ it.effect("git metrics count fsync fallback and transient retry subprocesses sep assert.strictEqual(timing.count, 3); assert.strictEqual(timing.sum, 750); }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.provideService(Metric.MetricRegistry, new Map()), ), ); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index fe49a6b1391af..15392cb96ce5e 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"; @@ -285,6 +285,11 @@ export interface GitWriteConfigValueInput { readonly value: string; } +export interface GitWorktreeBaseRemote { + remoteName: string; + branchName: string; +} + export interface GitSetBranchUpstreamInput { cwd: string; branch: string; @@ -392,10 +397,13 @@ export class GitVcsDriver extends Context.Service< ) => Effect.Effect; readonly resolveWorktreeBaseRemote: ( input: GitFetchWorktreeBaseCommitInput, - ) => Effect.Effect<{ remoteName: string; branchName: string } | null, GitCommandError>; - /** Fetches the configured upstream, explicit remote ref, or origin; null uses the local base. */ + ) => Effect.Effect; + /** + * Fetches the configured upstream, explicit remote ref, or origin; null uses the local base. + * A caller that already ran `resolveWorktreeBaseRemote` passes its result as `remote`. + */ readonly fetchWorktreeBaseCommit: ( - input: GitFetchWorktreeBaseCommitInput, + input: GitFetchWorktreeBaseCommitInput & { readonly remote?: GitWorktreeBaseRemote }, ) => Effect.Effect; readonly fetchRemoteBranch: ( input: GitFetchRemoteBranchInput, @@ -436,6 +444,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; @@ -558,7 +567,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( @@ -902,7 +911,7 @@ 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 commitEnv: NodeJS.ProcessEnv = { ...process.env, @@ -959,43 +968,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; @@ -1004,20 +1030,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) { @@ -1366,6 +1418,11 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( operation, cwd: input.cwd, args: [ + // 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", @@ -1422,7 +1479,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( execute({ operation, cwd: input.cwd, - args: [...args, "--name-only", "-z", "--no-renames", "--no-ext-diff"], + args: [ + "-c", + "diff.relative=false", + // log.showSignature prints verification text into the path list. + "-c", + "log.showSignature=false", + ...args, + "--name-only", + "-z", + "--no-renames", + "--no-ext-diff", + ], maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, outputMode: "error", }).pipe(Effect.map((result) => result.stdout.split("\0"))); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8363e16c32476..1c3e075d165c8 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( @@ -176,7 +178,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", () => @@ -196,7 +204,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)), + ), + ), + ), ); }); @@ -314,22 +328,24 @@ it.effect.each(["root", "nested", "worktree"] as const)( }).pipe(Effect.provide(layerTest)), ); -it.effect("invalidates origin remote cache when a driver mutation adds origin", () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); +it.effect.each(["origin", "github"] as const)( + "recognizes %s as the primary remote after a driver mutation", + (remoteName) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); - const before = yield* driver.statusDetailsLocal(cwd); - assert.equal(before.hasOriginRemote, false); + assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, false); + assert.equal((yield* driver.listRefs({ cwd })).hasPrimaryRemote, false); - yield* driver.ensureRemote({ cwd, preferredName: "origin", url: remote }); + yield* driver.ensureRemote({ cwd, preferredName: remoteName, url: remote }); - const after = yield* driver.statusDetailsLocal(cwd); - assert.equal(after.hasOriginRemote, true); - }).pipe(Effect.provide(layerTest)), + assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, true); + assert.equal((yield* driver.listRefs({ cwd })).hasPrimaryRemote, true); + }).pipe(Effect.provide(layerTest)), ); it.effect.each([ @@ -352,7 +368,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({ @@ -445,7 +461,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( @@ -512,7 +528,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, ); @@ -550,6 +566,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", () => @@ -758,7 +887,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, @@ -919,7 +1048,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", () => @@ -941,7 +1076,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)), + ), + ), + ), ); }); @@ -1001,7 +1142,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", () => @@ -1033,7 +1180,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", () => @@ -1068,7 +1221,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", () => @@ -1104,7 +1263,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", () => @@ -1155,7 +1320,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", () => @@ -1268,7 +1439,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({ @@ -1300,7 +1471,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", () => @@ -1330,7 +1507,13 @@ 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("lets a worktree checkout that is still working run past five minutes", () => @@ -1378,7 +1561,74 @@ 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", () => + Effect.gen(function* () { + const updateStarted = yield* Deferred.make(); + const updateStopped = yield* Deferred.make(); + const finished = yield* Ref.make>([]); + const claimed = yield* Ref.make(null); + const spawner = ChildProcessSpawner.make((command) => { + if ( + ChildProcess.isStandardCommand(command) && + command.args[0] === "submodule" && + command.args[1] === "update" + ) { + return Effect.acquireRelease( + Effect.succeed( + ChildProcessSpawner.makeHandle({ + ...makeSuccessfulHandle(""), + exitCode: Deferred.succeed(updateStarted, undefined).pipe( + Effect.andThen(Effect.never), + ), + }), + ), + () => Deferred.succeed(updateStopped, undefined), + ); + } + return Effect.succeed(makeSuccessfulHandle("")); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const path = yield* Path.Path; + const worktreePath = path.join(cwd, "worktree"); + yield* writeTextFile(worktreePath, ".gitmodules", ""); + const creation = yield* driver + .createWorktree( + { cwd, refName: "main", newRefName: "bugfix/cancel-submodules", path: worktreePath }, + { + progress: { + onWorktreeClaimed: (value) => Ref.set(claimed, value), + onSubmodulesFinished: (result) => Ref.update(finished, (items) => [...items, result]), + }, + }, + ) + .pipe(Effect.forkChild); + yield* Deferred.await(updateStarted); + yield* TestClock.adjust("6 minutes"); + yield* Fiber.interrupt(creation); + const exit = yield* Fiber.await(creation); + assert.isTrue(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)); + 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(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); describe("rejected git refs", () => { @@ -1398,7 +1648,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), ), ), @@ -1604,7 +1854,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 ( @@ -1613,7 +1863,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); }); @@ -2013,9 +2263,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"); @@ -2023,27 +2278,34 @@ 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 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, + 2, + ); + 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}`); + } } } }), @@ -2135,18 +2397,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({ @@ -2158,6 +2421,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"); }), @@ -2280,23 +2544,31 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - 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])( @@ -2577,6 +2849,47 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reads range patches from source without textconv, color, or diff.relative", () => + 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, ["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 driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const projectCwd = `${cwd}/app`; + yield* fileSystem.makeDirectory(projectCwd); + + const context = yield* driver.readRangeContext(projectCwd, initialBranch); + + 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(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`)); + }), + ); + it.effect("loads new and deleted files without reading their missing side", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2789,6 +3102,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(); @@ -2935,16 +3312,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"); @@ -3087,13 +3472,14 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, "README.md", "# test\ncommitted\n"); yield* git(cwd, ["commit", "-am", "commit edit"]); yield* writeTextFile(cwd, "nested/untracked.txt", "one\ntwo\n"); + yield* writeTextFile(cwd, "new [1].txt", "literal\n"); const status = yield* driver.statusDetailsLocal(pathService.join(cwd, "nested"), { includeBranchChanges: true, }); assert.deepStrictEqual(status.branchChanges, { baseRef: "main", - insertions: 3, + insertions: 4, deletions: 0, }); assert.isUndefined((yield* driver.statusDetailsLocal(cwd)).branchChanges); @@ -3150,25 +3536,60 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const untrackedScanRoots: string[][] = []; + const spawner = ChildProcessSpawner.make((command) => { + if ( + ChildProcess.isStandardCommand(command) && + command.args.includes("ls-files") && + command.args.includes("--others") + ) { + untrackedScanRoots.push( + command.args.slice(command.args.indexOf("--") + 1).map(String), + ); + } + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); yield* git(cwd, ["branch", "-M", "main"]); yield* git(cwd, ["checkout", "-b", "feature/many-untracked"]); + yield* writeTextFile(cwd, ".git/info/exclude", "ignored.txt\n"); + yield* writeTextFile(cwd, "bulk[1]/ignored.txt", "ignored\n"); yield* Effect.forEach( - Array.from({ length: 1_001 }, (_, index) => `bulk/${index}.txt`), + Array.from({ length: 1_001 }, (_, index) => `bulk[1]/${index}.txt`), (file) => writeTextFile(cwd, file, "one\ntwo\n"), { concurrency: 32, discard: true }, ); - const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); - assert.deepStrictEqual(status.branchChanges, { - baseRef: "main", - insertions: 2_002, - deletions: 0, - }); - assert.deepStrictEqual( - status.workingTree.files.map((file) => file.path), - ["bulk/"], - ); + for (const scenario of ["normal", "all", "no", "long names"]) { + if (scenario === "long names") { + yield* Effect.forEach( + Array.from({ length: 50 }, (_, index) => `${"a".repeat(180)}-${index}.txt`), + (file) => writeTextFile(cwd, file, "long name\n"), + { concurrency: 32, discard: true }, + ); + } + const mode = scenario === "long names" ? "normal" : scenario; + yield* git(cwd, ["config", "status.showUntrackedFiles", mode]); + untrackedScanRoots.length = 0; + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: "main", + insertions: scenario === "long names" ? 2_052 : 2_002, + deletions: 0, + }); + assert.deepStrictEqual(untrackedScanRoots, [ + scenario === "normal" ? [":(top,literal)bulk[1]/"] : [], + ]); + if (scenario === "normal") { + assert.deepStrictEqual( + status.workingTree.files.map((file) => file.path), + ["bulk[1]/"], + ); + } + } }), ); @@ -3662,6 +4083,47 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("flattens a generated name when a branch at its parent path blocks it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + // A local `t3` branch is why launches start on the flat `t3-` temporary branch. + yield* git(cwd, ["branch", "t3"]); + yield* git(cwd, ["checkout", "-b", "t3-abcd1234"]); + yield* git(cwd, ["branch", "t3-fix-login"]); + + const renamed = yield* driver.renameBranch({ + cwd, + oldBranch: "t3-abcd1234", + newBranch: "t3/fix-login", + }); + + assert.equal(renamed.branch, "t3-fix-login-1"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "t3-fix-login-1"); + }), + ); + + it.effect("skips a flattened name that a nested branch blocks", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "t3"]); + yield* git(cwd, ["checkout", "-b", "t3-abcd1234"]); + yield* git(cwd, ["branch", "t3-fix-login/child"]); + + const renamed = yield* driver.renameBranch({ + cwd, + oldBranch: "t3-abcd1234", + newBranch: "t3/fix-login", + }); + + assert.equal(renamed.branch, "t3-fix-login-1"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "t3-fix-login-1"); + }), + ); + it.effect("rejects a missing branch without restoring a matching dirty file", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3823,6 +4285,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 = process.env.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) => @@ -3858,6 +4352,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(); @@ -3876,7 +4402,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" && @@ -4037,7 +4563,12 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("checks out submodules in a new worktree", () => + it.effect.each([ + ["recursive", false], + ["recursive", true], + ["top-level", false], + ["top-level", true], + ] as const)("finishes a slow submodule checkout (%s, progress: %s)", ([submodules, progress]) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; @@ -4057,6 +4588,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const submoduleRepo = yield* makeTmpDir("git-submodule-"); yield* initRepoWithCommit(submoduleRepo); + yield* writeTextFile(submoduleRepo, ".gitattributes", "* text eol=lf\n"); yield* writeTextFile(submoduleRepo, "SHARED.md", "# shared\n"); yield* git(submoduleRepo, ["add", "."]); yield* git(submoduleRepo, ["commit", "-m", "shared"]); @@ -4065,23 +4597,80 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const { initialBranch } = yield* initRepoWithCommit(cwd); yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]); yield* git(cwd, ["commit", "-m", "add submodule"]); + const recordedHead = yield* git(submoduleRepo, ["rev-parse", "HEAD"]); + yield* writeTextFile(submoduleRepo, "SHARED.md", "# newer remote default\n"); + yield* git(submoduleRepo, ["commit", "-am", "advance remote default"]); + + const updateStarted = yield* Deferred.make(); + const releaseUpdate = yield* Deferred.make(); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if ( + ChildProcess.isStandardCommand(command) && + command.args[0] === "submodule" && + command.args[1] === "update" + ) { + yield* Deferred.succeed(updateStarted, undefined); + yield* Deferred.await(releaseUpdate); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(layerServerConfig), + ); + const finished = yield* Ref.make>([]); + const lines = yield* Ref.make>([]); const worktreePath = pathService.join( yield* makeTmpDir("git-worktrees-"), "submodule-worktree", ); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.createWorktree({ - cwd, - path: worktreePath, - refName: initialBranch, - newRefName: "feature/submodules", - }); + const creation = yield* driver + .createWorktree( + { + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/submodules", + }, + { + submodules, + progress: { + onSubmodulesFinished: (result) => + Ref.update(finished, (items) => [...items, result]), + ...(progress + ? { + onSubmoduleLine: (line: string) => + Ref.update(lines, (items) => [...items, line]), + } + : {}), + }, + }, + ) + .pipe(Effect.forkChild); + yield* Deferred.await(updateStarted); + yield* TestClock.adjust("6 minutes"); + yield* Deferred.succeed(releaseUpdate, undefined); + yield* Fiber.join(creation); + assert.deepEqual(yield* Ref.get(finished), [{ ok: true, detail: null }]); assert.equal( yield* fileSystem.exists(pathService.join(worktreePath, "shared", "SHARED.md")), true, ); + assert.equal( + yield* git(pathService.join(worktreePath, "shared"), ["rev-parse", "HEAD"]), + recordedHead, + ); + assert.equal(yield* git(worktreePath, ["status", "--porcelain"]), ""); + assert.equal( + yield* git(pathService.join(worktreePath, "shared"), ["status", "--porcelain"]), + "", + ); + if (progress) assert.isNotEmpty(yield* Ref.get(lines)); }), ); @@ -4098,6 +4687,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { '[submodule "missing"]\n\tpath = missing\n\turl = /nonexistent/repo.git\n', ); yield* git(cwd, ["add", "."]); + const gitlink = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["update-index", "--add", "--cacheinfo", `160000,${gitlink},missing`]); yield* git(cwd, ["commit", "-m", "add unreachable submodule"]); const worktreePath = pathService.join( @@ -4105,15 +4696,27 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "broken-submodule-worktree", ); const driver = yield* GitVcsDriver.GitVcsDriver; - const created = yield* driver.createWorktree({ - cwd, - path: worktreePath, - refName: initialBranch, - newRefName: "feature/broken-submodules", - }); + const finished = yield* Ref.make>([]); + const created = yield* driver.createWorktree( + { + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/broken-submodules", + }, + { + progress: { + onSubmodulesFinished: (result) => Ref.update(finished, (items) => [...items, result]), + }, + }, + ); assert.equal(created.worktree.path, worktreePath); assert.equal(yield* fileSystem.exists(worktreePath), true); + const results = yield* Ref.get(finished); + assert.lengthOf(results, 1); + assert.isFalse(results[0]!.ok); + assert.include(results[0]!.detail, "GitVcsDriver.createWorktree.updateSubmodules"); }), ); @@ -4560,7 +5163,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); @@ -4777,6 +5380,29 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("commit context", () => { + it.effect("prepares source patches without running textconv", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["config", "diff.probe.textconv", "touch textconv-ran; echo converted"]); + yield* writeTextFile(cwd, ".gitattributes", "*.doc diff=probe\n"); + yield* writeTextFile(cwd, "notes.doc", "original source\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "source document"]); + yield* writeTextFile(cwd, "notes.doc", "updated source\n"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + const context = yield* driver.prepareCommitContext(cwd, ["notes.doc"]); + + assert.include(context?.stagedPatch ?? "", "-original source"); + assert.include(context?.stagedPatch ?? "", "+updated source"); + assert.notInclude(context?.stagedPatch ?? "", "converted"); + assert.include(context?.stagedSummary ?? "", "notes.doc"); + assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); + }), + ); + it.effect("can omit the patch while preserving change detection and the real index", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4824,6 +5450,31 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("stops a selected-file commit when another process holds the index lock", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const head = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* writeTextFile(cwd, "a.txt", "a\n"); + yield* writeTextFile(cwd, "staged.txt", "staged\n"); + yield* git(cwd, ["add", "staged.txt"]); + const lockPath = (yield* Path.Path).join(cwd, ".git", "index.lock"); + yield* (yield* FileSystem.FileSystem).writeFileString(lockPath, ""); + const { driver } = yield* makeDriverReleasingLock( + (args) => args.includes("reset"), + lockPath, + ); + + const error = yield* driver + .commit(cwd, "Add a", "", { stage: { filePaths: ["a.txt"] } }) + .pipe(Effect.flip); + + assert.equal(error.operation, "GitVcsDriver.commit.reset"); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), head); + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "staged.txt"); + }), + ); + it.effect.each([ { location: "root", ref: "refs/heads/MERGE_HEAD" }, { location: "nested", ref: "refs/heads/MERGE_HEAD" }, @@ -5316,6 +5967,55 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("fetches a worktree base without downloading tags", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + const localHead = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["commit", "--allow-empty", "-m", "tagged release"]); + const remoteHead = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["tag", "v1"]); + yield* git(cwd, ["push", "origin", initialBranch, "v1"]); + yield* git(cwd, ["tag", "-d", "v1"]); + yield* git(cwd, ["reset", "--hard", localHead]); + yield* git(cwd, ["update-ref", "-d", `refs/remotes/origin/${initialBranch}`]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + assert.deepEqual(yield* driver.fetchWorktreeBaseCommit({ cwd, refName: initialBranch }), { + commitSha: remoteHead, + remoteRefName: `origin/${initialBranch}`, + }); + assert.equal(yield* git(cwd, ["tag", "--list", "v1"]), ""); + }), + ); + + it.effect("fetches the remote a caller already resolved for the worktree base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", initialBranch]); + const head = yield* git(cwd, ["rev-parse", "HEAD"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + assert.isNull(yield* driver.fetchWorktreeBaseCommit({ cwd, refName: "unpublished" })); + assert.deepEqual( + yield* driver.fetchWorktreeBaseCommit({ + cwd, + refName: "unpublished", + remote: { remoteName: "origin", branchName: initialBranch }, + }), + { commitSha: head, remoteRefName: `origin/${initialBranch}` }, + ); + }), + ); + it.effect("fails instead of falling back when the worktree base remote is unreachable", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -5678,7 +6378,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 71945b0b6f71c..2736cbc6d4fcb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2,7 +2,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"; @@ -81,7 +81,10 @@ 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 that diff.suppressBlankEmpty prints without a space. const REVIEW_DIFF_ARGS = [ + "-c", + "diff.suppressBlankEmpty=false", "diff", "--find-renames", "--no-color", @@ -943,7 +946,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>(); @@ -1183,7 +1186,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", }, }); @@ -1247,16 +1251,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( cwd: string, - desiredBranch: string, + requestedBranch: string, ) { - const isDesiredTaken = yield* branchExists(cwd, desiredBranch); + // A branch at a parent path (such as `t3`) blocks every ref nested under it, so no suffix + // would help; flatten to a sibling the way temporary worktree branches already do. + const segments = requestedBranch.split("/"); + let blockedByParent = false; + for (let index = 1; index < segments.length && !blockedByParent; index += 1) { + blockedByParent = yield* branchExists(cwd, segments.slice(0, index).join("/")); + } + const desiredBranch = blockedByParent ? segments.join("-") : requestedBranch; + // A branch nested under the name (`t3-fix/child`) blocks it as surely as one with the name. + const isBranchNameTaken = (name: string) => + Effect.zipWith( + branchExists(cwd, name), + runGitStdout( + "GitVcsDriver.branchExists", + cwd, + ["for-each-ref", "--count=1", "--format=%(refname)", `refs/heads/${name}/`], + true, + ), + (exists, nested) => exists || nested.trim().length > 0, + ); + + const isDesiredTaken = yield* isBranchNameTaken(desiredBranch); if (!isDesiredTaken) { return desiredBranch; } for (let suffix = 1; suffix <= 100; suffix += 1) { const candidate = `${desiredBranch}-${suffix}`; - const isCandidateTaken = yield* branchExists(cwd, candidate); + const isCandidateTaken = yield* isBranchNameTaken(candidate); if (!isCandidateTaken) { return candidate; } @@ -1272,13 +1297,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; @@ -1410,22 +1436,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, @@ -1438,20 +1475,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 @@ -1471,6 +1494,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), { @@ -1530,18 +1560,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), }, ); - const originExistsCache = yield* Cache.makeWith( + const primaryRemoteExistsCache = yield* Cache.makeWith( (gitCommonDir: string) => Effect.gen(function* () { const path = yield* Path.Path; const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; return yield* executeGit( - "GitVcsDriver.statusDetails.originExists", + "GitVcsDriver.statusDetails.primaryRemoteExists", fetchCwd, - ["--git-dir", gitCommonDir, "remote", "get-url", "origin"], + ["--git-dir", gitCommonDir, "remote"], { allowNonZeroExit: true }, - ).pipe(Effect.map((result) => result.exitCode === 0)); + ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim().length > 0)); }), { capacity: 2_048, @@ -1561,7 +1591,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : gitCommonDir; const cacheKey = commonDir ?? normalizeRepositoryPathsCacheKey(cwd); yield* Cache.invalidate(defaultBranchCache, cacheKey); - yield* Cache.invalidate(originExistsCache, cacheKey); + yield* Cache.invalidate(primaryRemoteExistsCache, cacheKey); }, ); @@ -2011,7 +2041,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) { @@ -2175,8 +2205,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) : resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)), statusCacheKey - ? Cache.get(originExistsCache, statusCacheKey).pipe(Effect.orElseSucceed(() => false)) - : originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), + ? Cache.get(primaryRemoteExistsCache, statusCacheKey).pipe( + Effect.orElseSucceed(() => false), + ) + : listRemoteNames(cwd).pipe( + Effect.map((remoteNames) => remoteNames.length > 0), + Effect.orElseSucceed(() => false), + ), ], { concurrency: "unbounded" }, ); @@ -2211,6 +2246,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let aheadOfDefaultCount = 0; let hasWorkingTreeChanges = false; const changedFilesWithoutNumstat = new Set(); + const untrackedRoots: string[] = []; const statusRecords = statusStdout.split("\0"); for (let index = 0; index < statusRecords.length; index++) { @@ -2235,7 +2271,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (line.length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; const pathValue = parsePorcelainPath(line); - if (pathValue) changedFilesWithoutNumstat.add(pathValue); + if (pathValue) { + changedFilesWithoutNumstat.add(pathValue); + if (line.startsWith("? ")) untrackedRoots.push(pathValue); + } // A rename record is followed by its original path as a separate record. Listing it // keeps the deletion selectable when committing only some files. if (line.startsWith("2 ")) { @@ -2261,6 +2300,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* options?.includeBranchChanges ? readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName, { knownOriginDefaultBranch: defaultBranch, + untrackedRoots, }).pipe(Effect.orElseSucceed(() => undefined)) : Effect.undefined, ], @@ -2285,13 +2325,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let insertions = 0; let deletions = 0; - const files = Array.from(fileStatMap.entries()) - .map(([filePath, stat]) => { - insertions += stat.insertions; - deletions += stat.deletions; - return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; - }) - .toSorted((a, b) => a.path.localeCompare(b.path)); + const files = Array.from(fileStatMap.entries()).map(([filePath, stat]) => { + insertions += stat.insertions; + deletions += stat.deletions; + return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + }); for (const filePath of changedFilesWithoutNumstat) { if (fileStatMap.has(filePath)) continue; @@ -2421,9 +2459,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "Cannot commit selected files during a merge. Commit all files to finish the merge.", }); } - yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset"]).pipe( - Effect.catchTags({ GitCommandError: () => Effect.void }), - ); + yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]); } yield* runGit( "GitVcsDriver.commit.addSelected", @@ -2489,7 +2525,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) { @@ -2502,7 +2539,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const stagedPatch = yield* runGitStdoutWithOptions( "GitVcsDriver.prepareCommitContext.stagedPatch", cwd, - ["diff", "--no-ext-diff", "--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, @@ -2895,7 +2942,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", "--end-of-options", commitRange], + ["log", "--oneline", "--no-color", "--end-of-options", commitRange], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2904,7 +2951,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, @@ -2913,7 +2968,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--no-ext-diff", "--patch", "--minimal", "--end-of-options", diffRange], + [ + "-c", + "diff.relative=false", + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--patch", + "--minimal", + "--end-of-options", + diffRange, + ], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2939,7 +3005,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, [ @@ -3104,15 +3179,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readBranchChangeTotals = Effect.fn("readBranchChangeTotals")(function* ( cwd: string, branch: string | null, - options?: { readonly knownOriginDefaultBranch: string | null }, + options?: { + readonly knownOriginDefaultBranch: string | null; + readonly untrackedRoots: ReadonlyArray; + }, ) { - const { baseRef, mergeBase } = yield* resolveReviewMergeBase( + const resolved = yield* resolveReviewMergeBase( cwd, branch, undefined, options?.knownOriginDefaultBranch ?? null, ); - const untracked = yield* prepareUntrackedReviewIndex(cwd, []); + // 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 = + roots.length <= 128 && roots.reduce((length, root) => length + root.length + 16, 0) < 8_000 + ? roots.map((root) => `:(top,literal)${root}`) + : []; + // Empty status can mean untracked files are hidden; retain the full scan. + const untracked = yield* prepareUntrackedReviewIndex(cwd, pathspecs); if (untracked === null) { return yield* new GitCommandError({ operation: "GitVcsDriver.readBranchChangeTotals", @@ -3300,7 +3390,15 @@ 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 needsCurrentBranch = + input.commitSha === undefined && + input.file?.sourceKind !== "commit" && + input.file?.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), @@ -3316,7 +3414,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) => @@ -3895,7 +3993,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), - hasPrimaryRemote: remoteNames.includes("origin"), + hasPrimaryRemote: remoteNames.length > 0, } satisfies GitRefsSnapshot; }); @@ -4097,28 +4195,37 @@ 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; - const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; + // Both reads leave the repository alone, so they share one round trip before the checkout. + const [configuredCheckoutWorkers, base] = yield* Effect.all( + [ + readConfigValue(input.cwd, "checkout.workers"), + input.newRefName && input.baseRefName + ? resolveBranchBase(input.cwd, input.baseRefName) + : Effect.succeed(null), + ], + { concurrency: "unbounded" }, + ); + const checkoutWorkers = configuredCheckoutWorkers ?? "0"; yield* executeGitWithStableDiagnostics( "GitVcsDriver.createWorktree", input.cwd, @@ -4142,6 +4249,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) { @@ -4195,17 +4312,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* submoduleMode.value === "recursive" ? ["submodule", "update", "--init", "--recursive"] : ["submodule", "update", "--init"], - onSubmoduleLine - ? { - env: { LC_ALL: "C" }, - progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, - } - : {}, + { + // Cloning scales with remote size; let it finish or be cancelled, like worktree add. + timeoutMs: null, + ...(onSubmoduleLine + ? { + env: { LANGUAGE: "C" }, + progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, + } + : {}), + }, ), ), Effect.matchEffect({ onFailure: (cause) => - Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { + Effect.logWarning("worktree submodule checkout failed; submodules may be incomplete", { worktreePath, cause, }).pipe( @@ -4223,33 +4344,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - const newRefName = input.newRefName; - if (newRefName && input.baseRefName) { - const base = yield* resolveBranchBase(input.cwd, input.baseRefName); - if (base) { - yield* Effect.gen(function* () { + if (newRefName && base) { + yield* Effect.gen(function* () { + yield* writeConfigValue({ + cwd: input.cwd, + key: branchBaseConfigKey(newRefName), + value: base.branchName, + }); + if (base.baseRef.startsWith("refs/remotes/")) { + // The base key names a branch on the host; this one keeps the remote it came from. yield* writeConfigValue({ cwd: input.cwd, - key: branchBaseConfigKey(newRefName), - value: base.branchName, + key: `branch.${newRefName}.t3-merge-base-ref`, + value: base.baseRef, }); - if (base.baseRef.startsWith("refs/remotes/")) { - // The base key names a branch on the host; this one keeps the remote it came from. - yield* writeConfigValue({ - cwd: input.cwd, - key: `branch.${input.newRefName}.t3-merge-base-ref`, - value: base.baseRef, - }); - } - }).pipe( - Effect.catchTags({ - GitCommandError: () => - Effect.logWarning( - "worktree base-ref configuration failed; using base-branch fallback", - ), - }), - ); - } + } + }).pipe( + Effect.catchTags({ + GitCommandError: () => + Effect.logWarning("worktree base-ref configuration failed; using base-branch fallback"), + }), + ); } return { @@ -4415,7 +4530,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branch = parseRemoteRefWithRemoteNames(input.refName, [input.remoteName])?.branchName ?? input.refName; const scopedArgs = [ - ...args, + "fetch", + "--quiet", + "--no-tags", + "--", + input.remoteName, `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`, ]; const result = yield* runFetch(scopedArgs); @@ -4495,7 +4614,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchWorktreeBaseCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchWorktreeBaseCommit"] = Effect.fn("fetchWorktreeBaseCommit")(function* (input) { - const target = yield* resolveWorktreeBaseRemote(input); + const target = input.remote ?? (yield* resolveWorktreeBaseRemote(input)); if (target === null) return null; const { remoteName, branchName } = target; const remoteRefName = `${remoteName}/${branchName}`; 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 00218397481ff..c668a35e99272 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -65,6 +65,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 = @@ -183,6 +185,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); @@ -365,9 +368,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 042f8274543e5..be3a286ffc32b 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -656,9 +656,12 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect("an explicit refresh reads local totals after the fetch", () => { + it.effect("a remote poll refreshes Changes totals when divergence counts stay the same", () => { const state: Parameters[0] = { - currentLocalStatus: baseLocalStatus, + currentLocalStatus: { + ...baseLocalStatus, + branchChanges: { baseRef: "origin/main", insertions: 745, deletions: 24 }, + }, currentRemoteStatus: baseRemoteStatus, localStatusCalls: 0, remoteStatusCalls: 0, @@ -668,24 +671,79 @@ describe("VcsStatusBroadcaster", () => { return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const fetchStarted = yield* Deferred.make(); - const finishFetch = yield* Deferred.make(); - state.beforeRemoteStatus = Deferred.succeed(fetchStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishFetch)), + yield* broadcaster.getStatus({ cwd: "/repo" }); + const subscribed = yield* Deferred.make(); + const polled = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: "/repo" }).pipe( + Stream.runForEach((event) => { + if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined); + if (event._tag === "remoteUpdated") return Deferred.succeed(polled, undefined); + return Effect.void; + }), + Effect.forkScoped, ); - const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); - yield* Deferred.await(fetchStarted); - - const fetchedLocal: VcsStatusLocalResult = { + yield* Deferred.await(subscribed); + state.currentLocalStatus = { ...baseLocalStatus, - branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 }, + branchChanges: { baseRef: "origin/main", insertions: 107, deletions: 4 }, }; - state.currentLocalStatus = fetchedLocal; - yield* Deferred.succeed(finishFetch, undefined); + state.currentRemoteStatus = remoteStatusWithPr; + yield* TestClock.adjust("30 seconds"); + yield* Deferred.await(polled); + const status = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.deepStrictEqual(status.branchChanges, state.currentLocalStatus.branchChanges); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); - const status = yield* Fiber.join(refresh); - assert.deepStrictEqual(status.branchChanges, fetchedLocal.branchChanges); - }).pipe(Effect.provide(layerTestFor(state))); + it.effect("an explicit refresh reads local totals after the fetch", () => { + return Effect.gen(function* () { + const fetchStarted = yield* Deferred.make(); + const finishFetch = yield* Deferred.make(); + let local: VcsStatusLocalResult = { + ...baseLocalStatus, + branchChanges: { baseRef: "origin/main", insertions: 745, deletions: 24 }, + }; + let cachedLocal: VcsStatusLocalResult | undefined; + const readLocalStatus = () => Effect.sync(() => (cachedLocal ??= local)); + const layerTest = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(layerBackgroundPolicy(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: readLocalStatus, + invalidateStatus: () => + Effect.sync(() => { + cachedLocal = undefined; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + cachedLocal = undefined; + }), + currentBranch: () => Effect.succeed(baseLocalStatus.refName), + invalidateRemoteStatus: () => Effect.void, + remoteStatus: () => + Deferred.succeed(fetchStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishFetch)), + Effect.as(baseRemoteStatus), + ), + }), + ), + ); + yield* Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(fetchStarted); + // Another reader can refill the local cache while fetch is still running. + yield* readLocalStatus(); + local = { + ...baseLocalStatus, + branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 }, + }; + yield* Deferred.succeed(finishFetch, undefined); + const status = yield* Fiber.join(refresh); + assert.deepStrictEqual(status.branchChanges, local.branchChanges); + }).pipe(Effect.provide(layerTest)); + }); }); it.effect("publishes local status while an earlier lookup holds the remote lock", () => { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index a8839d0c1cd0c..327e0bcb1bb41 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -477,23 +477,14 @@ export const make = Effect.gen(function* () { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { - const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value; const remoteRead = yield* readRemoteStatus(cwd, options); - const remote = remoteRead?.value; const pulled = options?.refreshUpstream === false ? null : yield* maybeAutoPull(cwd, remoteRead, options?.policyCwds ?? [cwd]); if (pulled !== null) return pulled.remote; - if ( - remote && - (!previousRemote || - previousRemote.aheadCount !== remote.aheadCount || - previousRemote.behindCount !== remote.behindCount || - previousRemote.aheadOfDefaultCount !== remote.aheadOfDefaultCount) - ) { - yield* refreshLocalStatusCore(cwd); - } + // Base refs and merge bases can move without changing divergence counts. + yield* refreshLocalStatusCore(cwd); return yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); }), ); @@ -530,8 +521,7 @@ export const make = Effect.gen(function* () { yield* workflow.invalidateStatus(cwd); const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream }); // Fetch can move the base used for local Changes totals. - const local = yield* workflow.localStatus({ cwd }); - yield* updateCachedLocalStatus(cwd, local, { publish: true }); + yield* refreshLocalStatusCore(cwd); // Without a fetch, the cached behind count cannot justify an automatic pull. const pulled = refreshUpstream ? yield* maybeAutoPull(cwd, remoteRead, [rawCwd]) : null; if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 6bef5f04263c6..154a260eea0f4 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { FileFinder } from "@ff-labs/fff-node"; import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -673,6 +674,76 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(match.lineContent.slice(range.start, range.end)).toBe("wörld"); }), ); + + 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-" }); + yield* writeTextFile(cwd, "src/notes.ts", "const needle = 1;\n"); + let scansOutlastWait = false; + let finders = 0; + let rescans = 0; + const createFinder = FileFinder.create.bind(FileFinder); + vi.spyOn(FileFinder, "create").mockImplementation((options) => { + const created = createFinder(options); + if (created.ok) { + const finder = created.value; + finders += 1; + const scanFiles = finder.scanFiles.bind(finder); + finder.scanFiles = () => { + rescans += 1; + return scanFiles(); + }; + const waitForIndexReady = finder.waitForIndexReady.bind(finder); + finder.waitForIndexReady = (timeoutMs) => + scansOutlastWait + ? Promise.resolve({ ok: true, value: false }) + : waitForIndexReady(timeoutMs); + } + return created; + }); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searchNeedle = workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + yield* searchNeedle; + + scansOutlastWait = true; + yield* workspaceEntries.refresh(cwd); + const result = yield* searchNeedle; + + expect(result.matches.map((match) => match.path)).toEqual(["src/notes.ts"]); + // The second search ran on the rescanned live finder, not a rebuild. + expect({ finders, rescans }).toEqual({ finders: 1, rescans: 1 }); + }), + ); }); describe("browse", () => { diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 0f252d600a5ba..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"; @@ -23,7 +24,23 @@ import { vi } from "vite-plus/test"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, realpath: vi.fn(actual.realpath) }; + return { ...actual, realpath: vi.fn(actual.realpath), open: vi.fn(actual.open) }; +}); + +const injectShortReads = Effect.fn("injectShortReads")(function* (target: string) { + const native = yield* Effect.promise(() => vi.importActual("node:fs/promises")); + const open = vi.mocked(NodeFSP.open).mockImplementation(async (path, flags, mode) => { + const handle = await native.open(path, flags, mode); + if (path === target) { + const read = handle.read.bind(handle); + Object.defineProperty(handle, "read", { + value: (buffer: Uint8Array, offset: number, length: number, position: number) => + read(buffer, offset, Math.min(length, 2), position), + }); + } + return handle; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => open.mockImplementation(native.open))); }); const UNKNOWN_WORKSPACE_PREFIX = "t3code-unknown-workspace-"; @@ -50,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* () { @@ -162,6 +179,57 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }); describe("readFile", () => { + it.effect("reads UTF-8 files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: "src/index.ts", + }); + + expect(result).toEqual({ + relativePath: "src/index.ts", + contents: "export const answer = 42;\n", + byteLength: 26, + truncated: false, + }); + }), + ); + + it.effect("finishes short reads before decoding UTF-8 text", () => + Effect.gen(function* () { + const files = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const cwd = yield* makeTempDir; + const contents = "A café 🐈\n"; + yield* writeTextFile(cwd, "short.txt", contents); + const target = yield* fs.realPath(`${cwd}/short.txt`); + yield* injectShortReads(target); + expect(yield* files.readFile({ cwd, relativePath: "short.txt" })).toEqual({ + relativePath: "short.txt", + contents, + byteLength: Buffer.byteLength(contents), + truncated: false, + }); + }), + ); + + it.effect("rejects binary bytes delivered after an initial short read", () => + Effect.gen(function* () { + const files = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "short.txt", "ab\0cd"); + const target = yield* fs.realPath(`${cwd}/short.txt`); + yield* injectShortReads(target); + const error = yield* files.readFile({ cwd, relativePath: "short.txt" }).pipe(Effect.flip); + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError); + }), + ); + it.effect("reads host files outside the workspace root by absolute path", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e611d2bd71a3d..489fedc2dce09 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -405,19 +405,24 @@ export const make = Effect.gen(function* () { const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); const buffer = Buffer.alloc(bytesToRead); - const { bytesRead } = yield* Effect.tryPromise({ - try: () => handle.read(buffer, 0, bytesToRead, 0), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - operationPath: realTargetPath, - operation: "read", - cause, - }), - }); - const fileBytes = buffer.subarray(0, bytesRead); + let offset = 0; + while (offset < bytesToRead) { + const { bytesRead } = yield* Effect.tryPromise({ + try: () => handle.read(buffer, offset, bytesToRead - offset, offset), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "read", + cause, + }), + }); + if (bytesRead <= 0) break; + offset += bytesRead; + } + const fileBytes = buffer.subarray(0, offset); if (fileBytes.includes(0)) { return yield* new WorkspaceBinaryFileError({ workspaceRoot: input.cwd, diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8f2b64cf26a34..8b8782b200504 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -117,10 +117,7 @@ export class WorkspaceSearchIndex extends Context.Service< readonly searchContents: ( input: Omit, ) => Effect.Effect; - readonly refresh: () => Effect.Effect< - void, - WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut - >; + readonly refresh: () => Effect.Effect; } >()("t3/workspace/WorkspaceSearchIndex") {} @@ -419,6 +416,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason: result.error, }); } + // A rescan that outlasts the wait keeps running, and the finder answers + // from its previous index until it finishes, so the index stays usable. yield* waitForIndexReady( cwd, finder, @@ -428,7 +427,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason, cause, }), - ); + ).pipe(Effect.catchTags({ WorkspaceSearchIndexScanTimedOut: () => Effect.void })); }); const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( @@ -506,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.test.ts b/apps/server/src/ws.test.ts index 641845f322ea6..36d58fea2a328 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,4 +1,5 @@ import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -21,6 +22,7 @@ import { import * as Deferred from "effect/Deferred"; 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 PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; @@ -40,6 +42,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as NativeChildWorkQuery from "./orchestration-v2/NativeChildWorkQuery.ts"; import * as CommandOutputHub from "./orchestration-v2/CommandOutputHub.ts"; import * as CodexInstallation from "./provider/CodexInstallation.ts"; import * as DeviceService from "./device/DeviceService.ts"; @@ -110,6 +113,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfigModule from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as ManagedPolicy from "./policy/ManagedPolicy.ts"; import * as EventStore from "./orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; @@ -123,10 +127,11 @@ import * as ProjectEnrichment from "./project/ProjectEnrichmentService.ts"; import * as ProjectService from "./project/ProjectService.ts"; import { layerWsRpc, + resolveOpenDiscoveryForConfig, subscribeOrchestrationV2Shell, subscribeOrchestrationV2Thread, WS_RPC_SERVER_OPTIONS, - withLateEditorConfig, + withLateConfigDiscovery, } from "./ws.ts"; const snapshotConfig = (fields: Partial) => @@ -141,7 +146,7 @@ const settingsUpdated = (settings: object): ServerConfigStreamEvent => ({ it.effect("resends late editors without rolling back updates already sent", () => Effect.gen(function* () { const settingsSent = yield* Deferred.make(); - const events = yield* withLateEditorConfig( + const events = yield* withLateConfigDiscovery( snapshotConfig({ settings: { enableProviderUpdateChecks: true } as never }), Stream.make(settingsUpdated({ enableProviderUpdateChecks: false })), { @@ -149,6 +154,8 @@ it.effect("resends late editors without rolling back updates already sent", () = resolveFileManagerRevealKind: () => Deferred.await(settingsSent).pipe(Effect.as("file-explorer" as const)), }, + { resolveTargets: () => Effect.succeed([]) }, + { resolve: () => Effect.succeed([]) }, ).pipe( Stream.tap((event) => event.type === "settingsUpdated" ? Deferred.succeed(settingsSent, undefined) : Effect.void, @@ -168,23 +175,67 @@ it.effect("resends late editors without rolling back updates already sent", () = }), ); -it.effect("resends a file manager reveal kind that missed the snapshot", () => +it.effect("resends a reveal kind and remote targets that outlast the snapshot timeout", () => Effect.gen(function* () { - const events = yield* withLateEditorConfig( + const targets = [{ kind: "mdns" as const, host: "test.local" }]; + const finishDiscovery = yield* Deferred.make(); + const collecting = yield* withLateConfigDiscovery( snapshotConfig({ availableEditors: ["file-manager"] }), Stream.empty, { resolveAvailableEditors: () => Effect.succeed(["file-manager"]), resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), }, - ).pipe(Stream.runCollect); + { resolveTargets: () => Deferred.await(finishDiscovery) }, + { resolve: () => Effect.succeed([]) }, + ).pipe(Stream.runCollect, Effect.forkScoped({ startImmediately: true })); + yield* TestClock.adjust("6 seconds"); + yield* Deferred.succeed(finishDiscovery, targets); + const events = yield* Fiber.join(collecting); - const [late] = Array.from(events); - assert.equal(events.length, 1); + const [late, remote] = Array.from(events); + assert.equal(events.length, 2); assert.equal(late?.type, "snapshot"); if (late?.type === "snapshot") { assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer"); } + assert.equal(remote?.type, "snapshot"); + if (remote?.type === "snapshot") { + assert.deepEqual(remote.config.remoteOpenTargets, targets); + assert.equal(remote.config.shellRevealInFileManagerKind, "file-explorer"); + } + }).pipe(Effect.scoped), +); + +it.effect("runs editor, remote open target and direct endpoint discovery side by side", () => + Effect.gen(function* () { + // Each discovery finishes only once the other two have started, so a + // config that ran them one after another would never resolve. + const editorsStarted = yield* Deferred.make(); + const targetsStarted = yield* Deferred.make(); + const endpointsStarted = yield* Deferred.make(); + const startThenAwait = (started: Deferred.Deferred, value: A) => + Deferred.succeed(started, undefined).pipe( + Effect.andThen( + Effect.forEach([editorsStarted, targetsStarted, endpointsStarted], Deferred.await), + ), + Effect.as(value), + ); + + const discovery = yield* resolveOpenDiscoveryForConfig({ + editors: startThenAwait(editorsStarted, ["file-manager" as const]), + fileManagerRevealKind: Effect.succeed("finder" as const), + remoteOpenTargets: startThenAwait(targetsStarted, []), + directEndpoints: startThenAwait(endpointsStarted, []), + }); + + assert.deepEqual(discovery, { + availableEditors: ["file-manager"], + shellRevealInFileManager: true, + shellRevealInFileManagerKind: "finder", + remoteOpenTargets: [], + directEndpoints: [], + }); }), ); @@ -374,6 +425,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 = ( @@ -494,6 +670,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(AntigravityInstallation.AntigravityInstallation)({ managedDirectory: "/unused" }), Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({}), + Layer.mock(NativeChildWorkQuery.NativeChildWorkQuery)({}), Layer.mock(CommandOutputHub.CommandOutputHub)({}), Layer.mock(CodexInstallation.CodexInstallation)({ managedDirectory: "/unused" }), Layer.mock(DeviceService.DeviceService)({}), @@ -556,7 +733,9 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( const layerConfigSources = Layer.mergeAll( Keybindings.layer, - ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)), + ServerSettings.layer.pipe( + Layer.provide(Layer.merge(ServerSecretStore.layer, ManagedPolicy.layerTest())), + ), ).pipe( Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), Layer.provideMerge( @@ -564,12 +743,19 @@ const layerConfigSources = Layer.mergeAll( ServerConfigModule.layerTest(process.cwd(), { prefix: "t3-config-subscription-test-" }), ), ), - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); -it.effect( - "preserves settings during discovery and keybindings during snapshot ACK at the config RPC boundary", - () => +it.effect.each([ + "pending", + "cached", + "direct-pending", + "direct-cached", + "direct-failed", + "direct-interrupted", +] as const)( + "%s endpoint discovery preserves initial config and updates through discovery and ACK", + (discovery) => Effect.gen(function* () { const keybindings = yield* Keybindings.Keybindings; const settings = yield* ServerSettings.ServerSettingsService; @@ -584,6 +770,18 @@ it.effect( const initialKeybindings = (yield* keybindings.getSnapshot).keybindings; const discoveryStarted = yield* Deferred.make(); const finishDiscovery = yield* Deferred.make(); + const remoteDiscoveryStarted = yield* Deferred.make(); + const finishRemoteDiscovery = yield* Deferred.make(); + const targets = [{ kind: "mdns" as const, host: "test.local" }]; + const direct = discovery.startsWith("direct-"); + const bound = [{ kind: "lan" as const, httpBaseUrl: "http://192.168.1.10:3773/" }]; + const endpoints = [ + ...bound, + { kind: "tailnet" as const, httpBaseUrl: "https://test.ts.net/" }, + ]; + const directStarted = yield* Deferred.make(); + const finishDirect = yield* Deferred.make(); + const directStopped = yield* Deferred.make(); const responses = yield* Queue.unbounded(); const configRpcServices = Layer.mergeAll( layerUnusedConfigRpcServices, @@ -602,9 +800,33 @@ it.effect( }), Layer.mock(UsageLimitSources.UsageLimitSources)({ streamChanges: Stream.make([]) }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ - resolveTargets: () => Effect.succeed([]), + resolveTargets: () => + Deferred.succeed(remoteDiscoveryStarted, undefined).pipe( + Effect.andThen( + discovery === "cached" || direct + ? Effect.void + : Deferred.await(finishRemoteDiscovery), + ), + Effect.as(targets), + ), + }), + Layer.mock(DirectEndpoints.DirectEndpoints)({ + resolveAvailable: () => Effect.succeed(discovery === "direct-cached" ? endpoints : bound), + resolve: () => + direct + ? Deferred.succeed(directStarted, undefined).pipe( + Effect.andThen( + discovery === "direct-cached" ? Effect.void : Deferred.await(finishDirect), + ), + Effect.andThen( + discovery === "direct-failed" + ? Effect.die("probe failed") + : Effect.succeed(endpoints), + ), + Effect.onExit(() => Deferred.succeed(directStopped, undefined)), + ) + : Effect.succeed(bound), }), - Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), @@ -677,12 +899,21 @@ it.effect( headers: Headers.empty, }); yield* Deferred.await(discoveryStarted); + assert.isTrue(yield* Deferred.isDone(remoteDiscoveryStarted)); const duringDiscovery = yield* settings.updateSettings({ defaultAutoPull: true }); yield* Deferred.succeed(finishDiscovery, undefined); const snapshot = yield* Queue.take(responses); assert.equal(snapshot.type, "snapshot"); if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot"); + assert.deepEqual( + snapshot.config.remoteOpenTargets, + discovery === "cached" || direct ? targets : [], + ); + assert.deepEqual( + snapshot.config.directEndpoints, + discovery === "direct-cached" ? endpoints : bound, + ); assert.isFalse(snapshot.config.settings.defaultAutoPull); assert.deepEqual(snapshot.config.keybindings, initialKeybindings); const duringAck = yield* keybindings.upsertKeybindingRule({ @@ -702,7 +933,7 @@ it.effect( const expectedSettings = [duringDiscovery, latestSettings].map( ServerSettings.redactServerSettingsForClient, ); - const expectedKeybindings = [duringAck, latestKeybindings]; + const expectedKeybindings = [duringAck.keybindings, latestKeybindings.keybindings]; // The two sources can interleave, but each must retain its own publication order. for (let i = 0; i < 4; i++) { const event = yield* Queue.take(responses); @@ -719,7 +950,45 @@ it.effect( } assert.isEmpty(expectedSettings); assert.isEmpty(expectedKeybindings); + if (discovery === "pending") { + yield* Deferred.await(remoteDiscoveryStarted); + yield* Deferred.succeed(finishRemoteDiscovery, undefined); + const resolved = yield* Queue.take(responses); + assert.equal(resolved.type, "snapshot"); + if (resolved.type !== "snapshot") return yield* Effect.die("Missing remote targets"); + assert.deepEqual(resolved.config.remoteOpenTargets, targets); + assert.deepEqual( + resolved.config.settings, + ServerSettings.redactServerSettingsForClient(latestSettings), + ); + assert.deepEqual(resolved.config.keybindings, latestKeybindings.keybindings); + yield* acknowledge; + } + if (direct) { + yield* Deferred.await(directStarted); + if (discovery === "direct-pending") { + yield* Deferred.succeed(finishDirect, undefined); + const resolved = yield* Queue.take(responses); + assert.equal(resolved.type, "snapshot"); + if (resolved.type !== "snapshot") return yield* Effect.die("Missing direct endpoints"); + assert.deepEqual(resolved.config.directEndpoints, endpoints); + assert.deepEqual( + resolved.config.settings, + ServerSettings.redactServerSettingsForClient(latestSettings), + ); + assert.deepEqual(resolved.config.keybindings, latestKeybindings.keybindings); + yield* acknowledge; + } else if (discovery === "direct-failed") { + yield* Deferred.succeed(finishDirect, undefined); + yield* Deferred.await(directStopped); + yield* settings.updateSettings({ defaultAutoPull: false }); + const event = yield* Queue.take(responses); + assert.equal(event.type, "settingsUpdated"); + yield* acknowledge; + } + } yield* server.write(0, { _tag: "Interrupt", requestId, interruptors: [] }); + if (direct) yield* Deferred.await(directStopped); }).pipe( Effect.provide( Layer.mergeAll( @@ -782,7 +1051,10 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ resolveTargets: () => Effect.succeed([]), }), - Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), + Layer.mock(DirectEndpoints.DirectEndpoints)({ + resolve: () => Effect.succeed([]), + resolveAvailable: () => Effect.succeed([]), + }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4781fcf78dde6..492764c6ae5c8 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 PeerLinkRequests from "./peer/PeerLinkRequests.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; @@ -9,6 +9,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Base64 from "effect/encoding/Base64"; 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"; @@ -108,6 +109,7 @@ import { import { RpcGroup, RpcSerialization, RpcServer } from "effect/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as NativeChildWorkQuery from "./orchestration-v2/NativeChildWorkQuery.ts"; import * as ServerConfig from "./config.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; @@ -171,9 +173,6 @@ import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; -import * as ModelManifest from "./provider/ModelManifest.ts"; -import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; -import { latestProviderVersionCacheKeys } from "@t3tools/provider-core/server/maintenanceResolver"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; @@ -269,6 +268,7 @@ import { import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; import * as AgentSessionImporter from "./project/AgentSessionImporter.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; +import { requestUrl } from "./requestUrl.ts"; const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); const isProviderUploadFeedbackError = Schema.is(ProviderUploadFeedbackError); @@ -313,10 +313,12 @@ const resolveEditorConfig = Effect.fn("ws.resolveEditorConfig")(function* }; }); -export const withLateEditorConfig = ( +export const withLateConfigDiscovery = ( config: ClientServerConfig, liveUpdates: Stream.Stream, launcher: EditorDiscovery, + remoteOpenTargets: RemoteOpenTargets.RemoteOpenTargets["Service"], + directEndpoints: Pick, ) => { const lateEditorConfig = Stream.fromEffect(launcher.resolveAvailableEditors()).pipe( Stream.filter( @@ -324,7 +326,8 @@ export const withLateEditorConfig = ( editors.join() !== config.availableEditors.join() || (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined), ), - // Leave the unshared reveal probe unbounded so timeouts cannot cancel every attempt on slow hosts. + // Unbounded, unlike the snapshot: this exists to deliver a probe that + // outlasts the snapshot's timeout. Stream.mapEffect((editors) => resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()), ), @@ -336,7 +339,22 @@ export const withLateEditorConfig = ( Stream.map((editorConfig) => ({ type: "editorsResolved" as const, editorConfig })), ); - return Stream.merge(liveUpdates, lateEditorConfig).pipe( + const lateRemoteOpenTargets = Stream.fromEffect(remoteOpenTargets.resolveTargets()).pipe( + Stream.filter( + (targets) => JSON.stringify(targets) !== JSON.stringify(config.remoteOpenTargets ?? []), + ), + Stream.map((targets) => ({ type: "remoteOpenTargetsResolved" as const, targets })), + ); + + const lateDirectEndpoints = Stream.fromEffect(directEndpoints.resolve()).pipe( + Stream.catchCause(() => Stream.empty), + Stream.map((endpoints) => ({ type: "directEndpointsResolved" as const, endpoints })), + ); + + return Stream.merge( + liveUpdates, + Stream.merge(lateEditorConfig, Stream.merge(lateRemoteOpenTargets, lateDirectEndpoints)), + ).pipe( Stream.mapAccum( (): ClientServerConfig => config, (current, event): readonly [ClientServerConfig, ReadonlyArray] => { @@ -351,6 +369,17 @@ export const withLateEditorConfig = ( const next = { ...rest, ...event.editorConfig }; return [next, [{ version: 1, type: "snapshot", config: next }]]; } + case "remoteOpenTargetsResolved": { + const next = { ...current, remoteOpenTargets: event.targets }; + return [next, [{ version: 1, type: "snapshot", config: next }]]; + } + case "directEndpointsResolved": { + if (JSON.stringify(current.directEndpoints ?? []) === JSON.stringify(event.endpoints)) { + return [current, []]; + } + const next = { ...current, directEndpoints: event.endpoints }; + return [next, [{ version: 1, type: "snapshot", config: next }]]; + } case "keybindingsUpdated": return [{ ...current, ...event.payload }, [event]]; case "providerStatuses": @@ -366,6 +395,31 @@ export const withLateEditorConfig = ( ); }; +// Discoveries run side by side so the slowest one, not their sum, bounds the +// server config snapshot. Each degrades on timeout rather than failing it. +export const resolveOpenDiscoveryForConfig = (discovery: { + readonly editors: Effect.Effect, E1, R1>; + readonly fileManagerRevealKind: Effect.Effect; + readonly remoteOpenTargets: Effect.Effect, E3, R3>; + readonly directEndpoints: Effect.Effect, E4, R4>; +}) => + Effect.all( + { + editorConfig: resolveAvailableEditorsForConfig(discovery.editors).pipe( + Effect.flatMap((availableEditors) => + resolveEditorConfig( + availableEditors, + resolveFileManagerRevealKindForConfig(discovery.fileManagerRevealKind), + ), + ), + ), + // Same discovery-with-timeout treatment as editors: a slow probe must + // not stall server.getConfig, so it degrades to an empty list. + remoteOpenTargets: resolveAvailableEditorsForConfig(discovery.remoteOpenTargets), + directEndpoints: resolveAvailableEditorsForConfig(discovery.directEndpoints), + }, + { concurrency: "unbounded" }, + ).pipe(Effect.map(({ editorConfig, ...endpoints }) => ({ ...editorConfig, ...endpoints }))); function withoutWorkspaceSnapshots(providers: ReadonlyArray): ServerProvider[] { return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => provider); } @@ -650,7 +704,7 @@ function shouldUseBoundedThreadSnapshot(input: { // Keep telemetry per socket; several client types can connect simultaneously. function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { - const url = HttpServerRequest.toURL(request); + const url = requestUrl(request); if (Option.isNone(url)) { return {}; } @@ -700,7 +754,7 @@ function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) function readClientOwners( request: HttpServerRequest.HttpServerRequest, ): ReadonlyArray { - const url = HttpServerRequest.toURL(request); + const url = requestUrl(request); if (Option.isNone(url)) { return []; } @@ -813,7 +867,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs Stream.map((stored) => ({ kind: "event" as const, sequence: stored.sequence, - event: projectDomainEventForWire(stored.event), + event: stored.event, })), coalesceThreadLiveStream, Stream.mapError( @@ -1065,14 +1119,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 }, @@ -1282,13 +1359,13 @@ export const layerWsRpc = ( const deviceHostContext = yield* Effect.context>>(); const orchestrationEngine = yield* Orchestrator.OrchestratorV2; - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; // 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}`)), ); @@ -1311,6 +1388,7 @@ export const layerWsRpc = ( const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const agentSessionImporter = yield* AgentSessionImporter.AgentSessionImporter; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const nativeChildWork = yield* NativeChildWorkQuery.NativeChildWorkQuery; const keybindings = yield* Keybindings.Keybindings; const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; @@ -1324,8 +1402,6 @@ export const layerWsRpc = ( const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; - const modelManifest = yield* ModelManifest.ModelManifest; - const providerLatestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const acpRegistryCatalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const acpRegistryRuntimeCoordinator = @@ -1524,7 +1600,7 @@ export 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()); const launched = yield* Effect.result( startup.enqueueCommand( threadLaunch.launch({ @@ -1728,6 +1804,7 @@ export const layerWsRpc = ( const loadServerConfig = Effect.fn("ws.loadServerConfig")(function* (options: { readonly usageLimitsCommand: boolean; readonly workspaceSnapshotsOnDemand: boolean; + readonly deferNetworkDiscovery: boolean; }) { const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getProviders; @@ -1744,10 +1821,16 @@ export const layerWsRpc = ( const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; const lastEventLoopStall = yield* eventLoopMonitor.latestNoticeableStall; - const editorConfig = yield* resolveEditorConfig( - yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), - resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), - ); + const discovery = yield* resolveOpenDiscoveryForConfig({ + editors: externalLauncher.resolveAvailableEditors(), + fileManagerRevealKind: externalLauncher.resolveFileManagerRevealKind(), + remoteOpenTargets: options.deferNetworkDiscovery + ? Effect.succeed([]) + : remoteOpenTargets.resolveTargets(), + directEndpoints: options.deferNetworkDiscovery + ? directEndpoints.resolveAvailable() + : directEndpoints.resolve(), + }); return { environment, @@ -1757,11 +1840,7 @@ export const layerWsRpc = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - ...editorConfig, - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), + ...discovery, observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, @@ -1791,6 +1870,9 @@ export const layerWsRpc = ( otlpLogsEnabled: config.otlpLogsUrl !== undefined, }, settings, + ...(serverSettings.managedPolicy === undefined + ? {} + : { managedPolicy: serverSettings.managedPolicy }), shellResumeCompletionMarker: true, shellThreadFamilies: true, shellOlderSettled: true, @@ -1922,6 +2004,8 @@ export const layerWsRpc = ( threadManagement.forceStopThread(input), [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => readWorkflowScript({ scriptPath: input.scriptPath }), + [ORCHESTRATION_V2_WS_METHODS.getNativeChildWork]: (input) => + nativeChildWork.getNativeChildWork(input), [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => threadManagement.getTurnItem(input).pipe( Effect.mapError( @@ -2179,7 +2263,11 @@ export const layerWsRpc = ( ), [WS_METHODS.serverProbe]: (_input) => Effect.succeed({}), [WS_METHODS.serverGetConfig]: (_input) => - loadServerConfig({ usageLimitsCommand: false, workspaceSnapshotsOnDemand: false }), + loadServerConfig({ + usageLimitsCommand: false, + workspaceSnapshotsOnDemand: false, + deferNetworkDiscovery: false, + }), [WS_METHODS.serverSearchAcpRegistry]: (input) => acpRegistryCatalog .search(input) @@ -2283,30 +2371,11 @@ export const layerWsRpc = ( /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (input) { if (input.refreshModels) { - yield* modelManifest.forceRefresh; - 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, + }); } // Await refresh; RPC scope closure would interrupt a fork before the hub answered. if (input.instanceId === undefined) { @@ -2491,14 +2560,12 @@ export const layerWsRpc = ( [WS_METHODS.serverUpsertKeybinding]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (rule) { - const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; + return yield* keybindings.upsertKeybindingRule(rule); }), [WS_METHODS.serverRemoveKeybinding]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (rule) { - const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; + return yield* keybindings.removeKeybindingRule(rule); }), [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow, [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, @@ -2682,6 +2749,7 @@ export const layerWsRpc = ( [WS_METHODS.sourceControlSignInCancel]: (input) => sourceControlSignIn.cancel(input), [WS_METHODS.sourceControlAppAccess]: (input) => sourceControlSignIn.access(input), [WS_METHODS.sourceControlStarred]: (input) => sourceControlSignIn.starred(input), + [WS_METHODS.sourceControlStar]: (input) => sourceControlSignIn.star(input), [WS_METHODS.projectCloneStart]: (input) => projectCloneTracker.start(input, { createProject: (project) => @@ -3102,9 +3170,19 @@ export const layerWsRpc = ( // Buffer changes through discovery and the initial snapshot's RPC ACK wait. const keybindingsChanges = yield* keybindings.subscribeChanges; const settingsChanges = yield* serverSettings.subscribeChanges; + const remoteTargets = yield* Effect.forkScoped(remoteOpenTargets.resolveTargets(), { + startImmediately: true, + }); + const directResult = yield* Effect.forkScoped( + directEndpoints + .resolve() + .pipe(Effect.catchCause(() => directEndpoints.resolveAvailable())), + { startImmediately: true }, + ); const config = yield* loadServerConfig({ usageLimitsCommand, workspaceSnapshotsOnDemand, + deferNetworkDiscovery: true, }); const keybindingsUpdates = keybindingsChanges.pipe( Stream.map((event) => ({ @@ -3191,9 +3269,24 @@ export const layerWsRpc = ( ), ); + const remoteResult = yield* Effect.sync(() => remoteTargets.pollUnsafe()); + const initialConfig = + remoteResult?._tag === "Success" + ? { ...config, remoteOpenTargets: remoteResult.value } + : config; return Stream.concat( - rpcInitialItems([{ version: 1 as const, type: "snapshot" as const, config }]), - withLateEditorConfig(config, liveUpdates, externalLauncher), + rpcInitialItems([ + { version: 1 as const, type: "snapshot" as const, config: initialConfig }, + ]), + withLateConfigDiscovery( + initialConfig, + liveUpdates, + externalLauncher, + { + resolveTargets: () => Fiber.join(remoteTargets), + }, + { resolve: () => Fiber.join(directResult) }, + ), ); }), ), @@ -3287,8 +3380,8 @@ export const layer = Layer.unwrap( "/ws", Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const requestUrl = HttpServerRequest.toURL(request); - if (Option.isNone(requestUrl) || !hasCompatibleOrchestrationProtocol(requestUrl.value)) { + const url = requestUrl(request); + if (Option.isNone(url) || !hasCompatibleOrchestrationProtocol(url.value)) { return HttpServerResponse.jsonUnsafe( { code: "orchestration_protocol_incompatible", diff --git a/apps/web/package.json b/apps/web/package.json index 4407645ea143f..1d79dc07cd2ed 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,17 +41,20 @@ "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", "@tiptap/extension-blockquote": "^3.31.3", + "@tiptap/extension-bold": "^3.31.3", "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-code-block": "^3.31.3", "@tiptap/extension-heading": "^3.31.3", "@tiptap/extension-horizontal-rule": "^3.31.3", "@tiptap/extension-italic": "^3.31.3", "@tiptap/extension-list": "^3.31.3", + "@tiptap/extension-strike": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", + "canvas-confetti": "1.9.4", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", "dompurify": "^3.4.16", @@ -95,6 +98,7 @@ "@effect/vitest": "catalog:", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", + "@types/canvas-confetti": "1.9.0", "@types/compression": "^1.8.1", "@types/culori": "^4.0.1", "@types/hast": "^3.0.4", diff --git a/apps/web/scripts/warm-dep-cache.ts b/apps/web/scripts/warm-dep-cache.ts index 1b3bbee034642..8607371535e58 100644 --- a/apps/web/scripts/warm-dep-cache.ts +++ b/apps/web/scripts/warm-dep-cache.ts @@ -10,6 +10,14 @@ import { optimizeDeps, resolveConfig } from "vite"; const webRoot = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); +const configStart = performance.now(); const config = await resolveConfig({ root: webRoot, logLevel: "error" }, "serve"); +if (process.env.T3CODE_STARTUP_PROFILE_DIR) { + performance.measure("web.warmDepCache.resolveConfig", { start: configStart }); +} +const optimizeStart = performance.now(); await optimizeDeps(config); +if (process.env.T3CODE_STARTUP_PROFILE_DIR) { + performance.measure("web.warmDepCache.optimizeDeps", { start: optimizeStart }); +} console.log("[warm-dep-cache] web dependency cache is warm"); 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/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index 97da6d4ca8a5c..758d14b1aba6a 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -3,7 +3,8 @@ import { computeDpopJwkThumbprint, DpopPublicJwk, } from "@t3tools/shared/dpop"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -27,18 +28,7 @@ const DPOP_KEY_STORE_NAME = "keys"; const DPOP_KEY_ID = "relay-dpop-proof-key"; const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk); -export const layer = 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)); - }), - }), -); +export const layer = Layer.mergeAll(SecureRandom.layerWebCrypto, Digest.layerWebCrypto); function dpopError(message: string, cause?: unknown) { return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) }); @@ -150,7 +140,7 @@ export const createBrowserDpopProof = Effect.fn("dpop.createBrowserDpopProof")(f }): Effect.fn.Return< { readonly proof: string; readonly thumbprint: string }, BrowserDpopError, - Crypto.Crypto + SecureRandom.SecureRandom > { const normalizedUrl = yield* Effect.try({ try: () => new URL(input.url), @@ -158,10 +148,7 @@ export const createBrowserDpopProof = Effect.fn("dpop.createBrowserDpopProof")(f }); normalizedUrl.search = ""; normalizedUrl.hash = ""; - const jti = yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), - Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), - ); + const jti = yield* SecureRandom.SecureRandom.pipe(Effect.flatMap((random) => random.uuidV4())); const proof = yield* Effect.tryPromise({ try: () => new SignJWT({ diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 68b2c92c05c1a..698712914fde1 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -82,7 +82,7 @@ function layerRegistry(options?: { const session: RpcSession = { client, initialConfig: Effect.never, - subscribeServerConfig: (input) => client.subscribeServerConfig(input), + subscribeServerConfig: (input = {}) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 745adf17f171f..0d293d03a424e 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -1,6 +1,6 @@ import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { RelayWebClientId } from "@t3tools/contracts/relay"; -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 Semaphore from "effect/Semaphore"; @@ -16,7 +16,7 @@ import { const layerRelayDpopSigner = Layer.effect( ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; + const random = yield* SecureRandom.SecureRandom; const keyLoadSemaphore = yield* Semaphore.make(1); let loadedKey: BrowserDpopKey | null = null; const loadOrCreateBrowserDpopKey = keyLoadSemaphore.withPermit( @@ -60,7 +60,7 @@ const layerRelayDpopSigner = Layer.effect( ), ); return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), Effect.map((proof) => proof.proof), Effect.mapError( (error) => diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 2ce491703d194..a1f2c544262aa 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -5,6 +5,7 @@ import { getCloneDestinationPath, getCloneDirectoryName, } from "@t3tools/client-runtime/operations/projects"; +import { resolvePastedPath } from "@t3tools/shared/path"; import { type EnvironmentId, type ProjectIgnoredFile, @@ -316,7 +317,9 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe setPathOverride(event.target.value)} + onChange={(event) => + setPathOverride(resolvePastedPath(destinationPath, event.target.value)) + } spellCheck={false} autoComplete="off" /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 95848c209d8c5..fab250f6adaf8 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -18,7 +18,6 @@ import { } from "@t3tools/contracts"; import { GitBranchIcon } from "lucide-react"; import { - useDeferredValue, useEffect, useImperativeHandle, useOptimistic, @@ -216,7 +215,6 @@ export function BranchToolbarBranchSelector({ const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); - const deferredBranchQuery = useDeferredValue(branchQuery); const branchStatusQuery = useEnvironmentQuery( branchCwd === null @@ -226,18 +224,17 @@ export function BranchToolbarBranchSelector({ input: { cwd: branchCwd }, }), ); + const trimmedBranchQuery = branchQuery.trim(); const branchRefState = usePaginatedBranches({ environmentId, cwd: branchCwd, // Substring search needs sanitized names to detect new-branch collisions; refs cannot contain ASCII // spaces. - query: sanitizeNewRefName(deferredBranchQuery.trim()), + query: sanitizeNewRefName(trimmedBranchQuery), }); - const trimmedBranchQuery = branchQuery.trim(); - const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); const refs = branchRefState.refs; const branchByName = new Map(refs.map((refName) => [refName.name, refName] as const)); - const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); + const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); const isSelectingWorktreeBase = effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; @@ -260,12 +257,12 @@ export function BranchToolbarBranchSelector({ ...(pickerCreateBranchItemValue ? [pickerCreateBranchItemValue] : []), ]; const filteredBranchPickerItems = - normalizedDeferredBranchQuery.length === 0 + normalizedBranchQuery.length === 0 ? branchPickerItems : branchPickerItems.filter((itemValue) => shouldIncludeBranchPickerItem({ itemValue, - normalizedQuery: normalizedDeferredBranchQuery, + normalizedQuery: normalizedBranchQuery, createBranchItemValue: pickerCreateBranchItemValue, checkoutPullRequestItemValue, }), @@ -726,11 +723,7 @@ export function BranchToolbarBranchSelector({ )} /> - + diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 41bd2b008be89..ca511de43642c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -73,7 +73,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked || forceNewWorktree) { const lockedRow = ( {activeWorktreePath ? ( diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 4dfe1ae50f024..66232885fb280 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -68,7 +68,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (envLocked || (onEnvironmentChange === undefined && onAddToMachine === undefined)) { const lockedRow = ( { }; }); +const appearance = vi.hoisted(() => ({ + value: "light" as "light" | "dark", + listeners: new Set<() => void>(), +})); +vi.mock(import("../hooks/useTheme"), async (original) => { + const { useSyncExternalStore } = await import("react"); + const subscribe = (listener: () => void) => { + appearance.listeners.add(listener); + return () => appearance.listeners.delete(listener); + }; + return { + ...(await original()), + useRenderedAppearance: () => useSyncExternalStore(subscribe, () => appearance.value), + }; +}); + const settingsOverrides = vi.hoisted(() => ({ mathRenderingEnabled: false })); vi.mock(import("../hooks/useSettings"), async (original) => { const actual = await original(); @@ -111,6 +127,7 @@ it("keeps compact audio playback loaded across signed URL renewals", async () => }); function setup() { + appearance.value = "light"; parsedSources.length = 0; settingsOverrides.mathRenderingEnabled = false; renderDiagram.mockReset(); @@ -152,6 +169,34 @@ it("loads a host image through the page's own route when there is no environment ); }); +it("loads the source for the current theme through the image route", async () => { + setup(); + const pixel = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + const hostImageUrl = (path: string) => `${pixel}#${path}`; + await using screen = await render( + + Shot' + } + /> + , + ); + await expect + .element(screen.getByAltText("Shot")) + .toHaveAttribute("src", `${pixel}#/work/light.png`); + + await act(() => { + appearance.value = "dark"; + for (const listener of appearance.listeners) listener(); + }); + await expect + .element(screen.getByAltText("Shot")) + .toHaveAttribute("src", `${pixel}#/work/dark.png`); +}); + it("reuses the parse of a streaming reply's finished blocks when it remounts", async () => { setup(); const message = ( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9413b13a088cf..dd37be3b4b6a8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1380,6 +1380,7 @@ const CHAT_MARKDOWN_COMPONENTS = { workspaceMutationId, renderContextReference, fileLinkChip, + resolvedTheme, } = use(ChatMarkdownRendererContext); const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const contextReference = typeof src === "string" ? parseComposerContextHref(src) : null; @@ -1391,10 +1392,13 @@ const CHAT_MARKDOWN_COMPONENTS = { {label} ); } - const localSrc = node?.properties?.dataLocalSrc; + // A ``'s source for this theme, resolved like any other image. + const themeSrc = node?.properties?.[resolvedTheme === "dark" ? "dataDarkSrc" : "dataLightSrc"]; + const localSrc = typeof themeSrc === "string" ? undefined : node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; const standalone = node?.properties?.dataStandalone === true; - const authoredSrc = typeof localSrc === "string" ? localSrc : src; + const authoredSrc = + typeof themeSrc === "string" ? themeSrc : typeof localSrc === "string" ? localSrc : src; const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; const srcString = typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : ""; @@ -1614,7 +1618,12 @@ const CHAT_MARKDOWN_COMPONENTS = { fenceTitle={fenceTitle} theme={resolvedTheme} isStreaming={isStreaming && !isClosedCodeFence(node, text, codeBlock.code)} - onExpand={(src) => expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })} + onExpand={(src) => + expandMedia({ + images: [{ src, name: "Mermaid diagram", mimeType: "image/svg+xml" }], + index: 0, + }) + } > {highlightedCode} diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 8fe98b78da113..96eb837db4d69 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -7,6 +7,9 @@ import { expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; import { AppRoot } from "~/AppRoot"; +// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines) +// takes 6-11s; importing it here keeps that out of the test's timeout. +import "~/components/ChatView"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { getRouter } from "~/router"; 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 14125c684045d..81b6992a59d3b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,10 +3,10 @@ import { buildExpiredTerminalContextToastCopy, mergeDirectPreviewAnnotation, planComposerTurn, + restorePlanFollowUpComposer, snapshotComposerSend, withMessageContext, deriveAvailableRevertTurnCounts, - deriveWorkspaceMutationId, } from "./ChatView.logic"; import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; @@ -193,6 +193,40 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(false); }); + it.each([ + ["queued", "message-queued", true], + ["queued", "message-before-send", false], + ["preparing", "message-queued", false], + ["starting", "message-queued", false], + ] as const)("acknowledges %s with latest message %s: %s", (status, messageId, acknowledged) => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestRun: completedTurn, runtime: readySession }), + { latestUserMessageId: MessageId.make("message-before-send") }, + ); + // The shell hides the silent compaction run, so its runtime reports the queued message's run. + const queuedRun = { + ...completedTurn, + runId: RunId.make("turn-queued"), + status: "queued" as const, + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: null, + completedAt: null, + }; + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestRun: queuedRun, + latestUserMessageId: MessageId.make(messageId), + runtime: { ...readySession, status }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(acknowledged); + }); + it("waits for the matching running turn before acknowledging", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestRun: completedTurn, runtime: readySession }), @@ -1095,6 +1129,61 @@ describe("planComposerTurn", () => { }); }); +describe("restorePlanFollowUpComposer", () => { + // The draft is the failed follow-up's thread; the refs and cursor are whatever thread is on + // screen. + it.each([ + { + name: "puts the follow-up back while its thread is open", + before: { draft: "", refs: "", cursor: null }, + routeIsCurrent: true, + after: { draft: "Tighten step 2", refs: "Tighten step 2", cursor: "Tighten step 2" }, + }, + { + name: "keeps a draft typed while the follow-up was in flight", + before: { draft: "Next idea", refs: "Next idea", cursor: null }, + routeIsCurrent: true, + after: { draft: "Next idea", refs: "Next idea", cursor: null }, + }, + { + name: "leaves another thread's composer alone after navigating away", + before: { draft: "", refs: "Other thread", cursor: null }, + routeIsCurrent: false, + after: { draft: "Tighten step 2", refs: "Other thread", cursor: null }, + }, + ])("$name", ({ before, routeIsCurrent, after }) => { + const composer: { draft: string; refs: string; cursor: string | null } = { ...before }; + restorePlanFollowUpComposer({ + snapshot: { + prompt: "Tighten step 2", + environmentContexts: [], + terminalContexts: [], + reviewComments: [], + previewAnnotations: [], + threadContexts: [], + }, + draftHasUserContent: composer.draft.trim().length > 0, + routeIsCurrent, + writeComposerRefs: (snapshot) => { + composer.refs = snapshot.prompt; + }, + writePrompt: (prompt) => { + composer.draft = prompt; + }, + writeTerminalContexts: () => undefined, + writeReviewComments: () => undefined, + writePreviewAnnotations: () => undefined, + writeThreadContexts: () => undefined, + writeEnvironmentContexts: () => undefined, + resetCursor: ({ prompt }) => { + composer.cursor = prompt; + }, + }); + + expect(composer).toEqual(after); + }); +}); + describe("withMessageContext", () => { const context = { records: [] } as unknown as NonNullable< Parameters[0]["context"] @@ -1169,29 +1258,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 49fe3a441fbb8..4e17fdebf7b52 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1101,14 +1101,15 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } + const latestUserMessageChanged = + input.localDispatch.latestUserMessageId !== (input.latestUserMessageId ?? null); if (input.phase === "connecting" || derivePhase(input.runtime ?? null) === "connecting") { - return false; + // A message queued behind other work, such as an idle compaction the shell hides, has landed. + return input.runtime?.status === "queued" && latestUserMessageChanged; } const latestRun = input.latestRun ?? null; const runtime = input.runtime ?? null; - const latestUserMessageChanged = - input.localDispatch.latestUserMessageId !== (input.latestUserMessageId ?? null); const latestRunChanged = input.localDispatch.latestRunId !== (latestRun?.runId ?? null) || input.localDispatch.latestRunRequestedAt !== (latestRun?.requestedAt ?? null) || @@ -1165,7 +1166,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 ); } @@ -1179,8 +1180,13 @@ export interface PlanFollowUpComposerSnapshot { readonly environmentContexts: ReadonlyArray; } +// A draft typed while the send was in flight is newer and stays. Composer refs and the cursor +// belong to the thread on screen, so they follow only while the follow-up's route is current. export function restorePlanFollowUpComposer(input: { readonly snapshot: PlanFollowUpComposerSnapshot; + readonly draftHasUserContent: boolean; + readonly routeIsCurrent: boolean; + readonly writeComposerRefs: (snapshot: PlanFollowUpComposerSnapshot) => void; readonly writePrompt: (prompt: string) => void; readonly writeTerminalContexts: (contexts: ReadonlyArray) => void; readonly writeReviewComments: (comments: ReadonlyArray) => void; @@ -1193,12 +1199,15 @@ export function restorePlanFollowUpComposer(input: { detectTrigger: boolean; }) => void; }): void { + if (input.draftHasUserContent) return; + if (input.routeIsCurrent) input.writeComposerRefs(input.snapshot); input.writePrompt(input.snapshot.prompt); input.writeTerminalContexts(input.snapshot.terminalContexts); input.writeReviewComments(input.snapshot.reviewComments); input.writePreviewAnnotations(input.snapshot.previewAnnotations); input.writeThreadContexts(input.snapshot.threadContexts); input.writeEnvironmentContexts(input.snapshot.environmentContexts); + if (!input.routeIsCurrent) return; input.resetCursor({ cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length), prompt: input.snapshot.prompt, @@ -1609,28 +1618,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 d2a176a444369..497ccfc877771 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { ChildThreadApprovals } from "./chat/ChildThreadApprovals"; import { workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; @@ -8,7 +9,6 @@ import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnounce import { deriveAvailableRevertTurnCounts, deriveThreadPendingBackgroundTasks, - deriveWorkspaceMutationId, presentThreadShellFromProjection, } from "./ChatView.logic"; import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; @@ -42,10 +42,6 @@ import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent"; import { usePendingThreadRequests } from "./chat/usePendingThreadRequests"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; -import { - latestExecutedRun, - latestRootProviderFailure, -} from "@t3tools/shared/orchestrationV2ThreadError"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel"; @@ -77,6 +73,7 @@ import { import { deriveProviderSubagentStatus, deriveReportedModelSelection, + deriveResumableRunId, formatModelSelectionEffort, deriveRunlessWorkStartedAt, deriveThreadActivityRun, @@ -95,6 +92,7 @@ import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { codexFeedbackMessage, + deriveWorkspaceMutationId, isCompleteTurnItemRow, shouldShowLoadEarlierControl, type CodexFeedbackSubmission, @@ -113,6 +111,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"; @@ -272,6 +271,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { useCitedHistory } from "./chat/citationProcedure"; import type { MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { ThreadTimeline } from "./chat/ThreadTimeline"; +import { NativeChildWorkDialog } from "./chat/NativeChildWorkDialog"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { resolveScrollToEndClearance } from "./composerFooterLayout"; @@ -384,6 +384,7 @@ const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_TURN_ITEMS: ThreadStructure["turnItems"] = []; const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = []; +const EMPTY_COMPOSER_BANNER_ITEMS: ReadonlyArray = []; const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); @@ -815,16 +816,10 @@ export default function ChatView(props: ChatViewProps) { isServerThread && serverProjection?.runs.some((run) => run.status === "queued" && run.queueHeld === true) === true; - const resumableRunId = (() => { - if (!isServerThread || serverProjection === null) return null; - const run = latestExecutedRun(serverProjection.runs); - if (run?.status === "interrupted") return run.id; - return run?.status === "failed" && - serverRuntime?.lastErrorClass === "usage_limit" && - latestRootProviderFailure(run, serverProjection.turnItems)?.class === "usage_limit" - ? run.id - : null; - })(); + const resumableRunId = + !isServerThread || serverProjection === null + ? null + : deriveResumableRunId(serverProjection, serverRuntime); const parentSubagentThreadId = activeThread?.lineage.relationshipToParent === "subagent" ? activeThread.lineage.parentThreadId @@ -874,20 +869,18 @@ export default function ChatView(props: ChatViewProps) { const sideChatParent = useQuietThreadShell(sideChatParentRef); const parentThreadLink = (() => { if (sideChatParentRef !== null) { + const title = sideChatParent?.title ?? "Parent thread"; + // Embedded, the parent is the view around it. + if (embedded) return { title, sideChat: true }; return { - title: sideChatParent?.title ?? "Parent thread", + title, sideChat: true, - // Embedded, the parent is the view around it. - ...(embedded - ? {} - : { - open: (): void => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(sideChatParentRef), - }); - }, - }), + open: (): void => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(sideChatParentRef), + }); + }, }; } if (parentSubagentThreadRef !== null) { @@ -3186,6 +3179,11 @@ export default function ChatView(props: ChatViewProps) { (folders: Parameters[0]) => composerRef.current?.addDroppedFolders(folders), ); + // Above the early return, where React Compiler can cache it for the side chat pane. + const sideChatParentPanel = { + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }; if (!activeThread) { return embedded ? null : ; @@ -3205,10 +3203,7 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext={getShortcutContextStable} onSendAnnotation={sendPreviewAnnotationStable} onCloseSurface={closeRightPanelSurface} - sideChatParentPanel={{ - openToolDiff: onOpenToolDiffStable, - runShellCommand: runSideChatShellCommandStable, - }} + sideChatParentPanel={sideChatParentPanel} terminal={{ launchContext: activeTerminalLaunchContext ?? null, keybindings, @@ -3372,6 +3367,7 @@ export default function ChatView(props: ChatViewProps) { {isWorkspaceFileDragActive ? : null} - + + + {showScrollToBottom && !isThreadFindActive && ( + + ) : null + } /> ) : null} {!composerMounted ? null : ( @@ -3591,7 +3598,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId={activeThreadId} activeThread={activeThread} activeThreadShell={activeThreadShell} - promptHistoryMessages={timelineMessages} + promptHistoryEntries={timelineEntries} forceExpandedOnMobile={ forceExpandedMobileComposer && isDraftHeroState } @@ -3626,7 +3633,11 @@ export default function ChatView(props: ChatViewProps) { : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} - bannerItems={isDraftHeroState ? [] : composerBannerItems} + bannerItems={ + isDraftHeroState || composerBannerItems.length === 0 + ? EMPTY_COMPOSER_BANNER_ITEMS + : composerBannerItems + } resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} @@ -3666,7 +3677,9 @@ export default function ChatView(props: ChatViewProps) { } activeThreadModelSelection={activeThread?.modelSelection} activeProjectId={activeProject?.id ?? null} - activeContextWindow={activeContextWindow} + contextWindowEntries={serverVisibleTurnItems} + contextWindowLiveUsage={activeThreadLiveTokenUsage} + contextWindowProviderThread={activeProviderThread} compactThreadUnavailable={compactThreadUnavailable} compactDisabled={compactDisabled} compactDisabledReason={compactDisabledReason} diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 3883a3e2258e9..9faeab83c6ffc 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -1,13 +1,28 @@ -import { describe, expect, it } from "vite-plus/test"; +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"; -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", () => { @@ -30,4 +45,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.composition.browser.test.tsx b/apps/web/src/components/CommandPalette.composition.browser.test.tsx index 4787885905985..2eb5841180825 100644 --- a/apps/web/src/components/CommandPalette.composition.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.composition.browser.test.tsx @@ -21,6 +21,7 @@ vi.mock("../hooks/useEnvironmentTheme", () => ({ useEnvironmentThemeDefinitions: vi.mock("../hooks/useSettings", async (original) => ({ ...(await original()), useClientSettings: () => DEFAULT_CLIENT_SETTINGS, + useUpdateEnvironmentSettings: () => () => undefined, })); vi.mock("../hooks/useScratchProject", () => ({ useScratchProject: () => ({ scratchEnvironmentId: () => null }), @@ -66,6 +67,10 @@ vi.mock("../state/entities", async (original) => ({ ...(await original()), useProjects: () => [], useThreadShells: () => [], + useVisibleThreadShells: () => [], + useHiddenThreadKeys: () => new Set(), + useAnyEnvironmentHiding: () => false, + useEnvironmentThreadRefs: () => [], useServerConfigs: () => new Map(), waitForProject: async () => undefined, })); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 486d80a988666..ddac4fc4d7c17 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -16,6 +16,7 @@ import { getNewProjectPublishTarget, getNewProjectPublishTargets, getNewProjectRepository, + githubIncompleteOwnerRepositoryMessage, normalizePastedCloneUrl, addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, @@ -32,6 +33,7 @@ import { createBrowseNavigationCoordinator, filterFilesystemBrowseEntries, getFilesystemBrowsePath, + getFilesystemBrowseTabCompletion, } from "@t3tools/client-runtime/state/filesystem"; import { isAtomCommandInterrupted, @@ -126,7 +128,16 @@ import { useEnvironments, usePrimaryEnvironmentId, } from "../state/environments"; -import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; +import { + useAnyEnvironmentHiding, + useHiddenThreadKeys, + useEnvironmentThreadRefs, + useProjects, + useServerConfigs, + useVisibleThreadShells, + waitForProject, +} from "../state/entities"; +import { visibleLinkedThreads } from "@t3tools/client-runtime/state/threads"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -140,6 +151,7 @@ import { isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "@t3tools/client-runtime/state/projects"; +import { resolvePastedPath } from "@t3tools/shared/path"; import { dispatchCommandPalette, getCommandPaletteReturnFocus, @@ -200,6 +212,7 @@ import { type SearchOverlayMode, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; +import { isSidebarHiddenThread } from "./Sidebar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { selectCommandPaletteClientSettings, @@ -395,6 +408,11 @@ function OpenCommandPaletteDialog(props: { }, ); const linkedThreadSearch = initialSearch.linkedThreadSearch; + // Rows were captured when the palette opened; drop any hidden since then. + const hiddenKeys = useHiddenThreadKeys(); + const linkedEnvironmentId = linkedThreadSearch?.linkedThreads?.environmentId ?? null; + const hidingAnywhere = useAnyEnvironmentHiding(); + const linkedThreadRefs = useEnvironmentThreadRefs(linkedEnvironmentId); const [searchState, dispatchSearch] = useReducer(reduceCommandPaletteSearchState, null, () => { const view = props.initialContext ? null : openIntentView(props.openIntent); return view @@ -476,7 +494,8 @@ function OpenCommandPaletteDialog(props: { if (activeThreadReferenceCopyTarget === null) return; await copyThreadReference(activeThreadReferenceCopyTarget); }; - const threads = useThreadShells(); + // 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 providersByEnvironment = useEnvironmentProviders(); const { changeThemeItem, changeAppearanceItem, themeEditorItem } = useCommandPaletteThemeItems(); @@ -621,11 +640,9 @@ function OpenCommandPaletteDialog(props: { addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.remoteUrl, ) : ""; - const browsePath = getFilesystemBrowsePath( - query, - browseEnvironmentPlatform, - browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null, - ); + const canBrowsePaths = + browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null; + const browsePath = getFilesystemBrowsePath(query, browseEnvironmentPlatform, canBrowsePaths); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); @@ -902,7 +919,7 @@ function OpenCommandPaletteDialog(props: { function handleQueryChange(nextQuery: string): void { browseNavigation.invalidate(); clearTypedHighlight(); - setQuery(nextQuery); + setQuery(canBrowsePaths ? resolvePastedPath(query, nextQuery) : nextQuery); if (nextQuery === "" && currentView?.initialQuery) { popView(); } @@ -1716,6 +1733,13 @@ function OpenCommandPaletteDialog(props: { isLinkedThreadSearch && linkedThreadSearch?.linkedThreads ? buildLinkedThreadActionItems({ ...linkedThreadSearch.linkedThreads, + threads: visibleLinkedThreads( + linkedThreadSearch.linkedThreads.threads, + linkedThreadSearch.linkedThreads.environmentId, + linkedThreadRefs, + hiddenKeys, + hidingAnywhere, + ), query: linkedThreadSearch.query, icon: , runThread: async (thread) => { @@ -1939,6 +1963,21 @@ function OpenCommandPaletteDialog(props: { return; } + const incompleteOwnerMessage = githubIncompleteOwnerRepositoryMessage( + addProjectCloneFlow.source, + rawRepository, + ); + if (incompleteOwnerMessage) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Repository name incomplete", + description: incompleteOwnerMessage, + }), + ); + return; + } + const provider = addProjectRemoteSourceProvider(addProjectCloneFlow.source); if (!provider) { const destinationPath = getCloneDestinationPath( @@ -2492,6 +2531,30 @@ function OpenCommandPaletteDialog(props: { } if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return; + + if (event.key === "Tab" && isBrowsing && !relativePathNeedsActiveProject) { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + if (highlightedItemValue === "browse:up") { + void browseUp(); + return; + } + const highlightedEntry = visibleBrowseEntries.find( + (entry) => `browse:${entry.fullPath}` === highlightedItemValue, + ); + const completion = highlightedEntry + ? { name: highlightedEntry.name, isComplete: true } + : getFilesystemBrowseTabCompletion(visibleBrowseEntries, browsePath.filterQuery); + if (completion?.isComplete) { + void browseTo(completion.name); + } else if (completion && !pinnedCloneDirectoryName) { + setHighlightedItemValue(null); + setQuery(`${getBrowseDirectoryPath(query)}${completion.name}`); + } + return; + } + const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined; if ( event.key === "ArrowDown" && diff --git a/apps/web/src/components/CommandPaletteLookups.ts b/apps/web/src/components/CommandPaletteLookups.ts index 6c9fcc629f732..3eb0ff69bf1c7 100644 --- a/apps/web/src/components/CommandPaletteLookups.ts +++ b/apps/web/src/components/CommandPaletteLookups.ts @@ -7,7 +7,7 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useClientSettings } from "../hooks/useSettings"; import { getProjectOrderKey } from "@t3tools/client-runtime/state/project-grouping"; -import { useProjects, useThreadShells } from "../state/entities"; +import { useProjects, useVisibleThreadShells } from "../state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -55,7 +55,7 @@ export const selectCommandPaletteClientSettings = (settings: ClientSettings) => export function useSortedProjectGroups(environmentLabelById: ReadonlyMap) { const projects = useProjects(); - const threads = useThreadShells(); + const threads = useVisibleThreadShells(); const projectOrder = useUiStateStore((store) => store.projectOrder); const clientSettings = useClientSettings(selectCommandPaletteClientSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); diff --git a/apps/web/src/components/CommandPaletteThreadItems.tsx b/apps/web/src/components/CommandPaletteThreadItems.tsx index d106b4d1069da..ddccc1c3a54e1 100644 --- a/apps/web/src/components/CommandPaletteThreadItems.tsx +++ b/apps/web/src/components/CommandPaletteThreadItems.tsx @@ -23,7 +23,7 @@ import { deriveProviderInstanceEntries, type ProviderInstanceEntry, } from "../providerInstances"; -import { useThreadShells } from "../state/entities"; +import { useVisibleThreadShells } from "../state/entities"; import { useEnvironmentProviders, useEnvironments, @@ -99,7 +99,7 @@ export function useCommandPaletteThreadItems(input: { }) { const { activeThread, threadSearchQuery } = input; const navigate = useNavigate(); - const threads = useThreadShells(); + const threads = useVisibleThreadShells(); const { projectEnvironmentLocationById } = useEnvironmentLookups(); const providerEntryByEnvironmentAndInstanceId = useProviderEntryByEnvironmentAndInstanceId(); const messageSearch = useConnectedThreadSearch(threadSearchQuery); diff --git a/apps/web/src/components/ComposerContextLabel.tsx b/apps/web/src/components/ComposerContextLabel.tsx index 30567ac355f8a..89b54d5419821 100644 --- a/apps/web/src/components/ComposerContextLabel.tsx +++ b/apps/web/src/components/ComposerContextLabel.tsx @@ -24,7 +24,7 @@ export function ComposerContextLabel({ data-composer-label-motion className={cn( "block w-full min-w-0 truncate", - displayMode === "panel" && THREAD_DETAILS_PANEL_LABEL_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, displayMode === "toolbar" && "max-w-[240px] transition-opacity duration-180 ease-drawer group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none", )} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index abc9549ee3d00..3df1e47613370 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -73,7 +73,9 @@ import { ComposerBoldSelectionOnlyExtension, convertBulletItemToTask, ComposerCodeExtension, + ComposerBoldExtension, ComposerItalicExtension, + ComposerStrikeExtension, ComposerTaskItemExtension, ComposerTaskListExtension, flatToMarkdown, @@ -831,7 +833,8 @@ function createComposerExtensions(richText: boolean) { listKeymap: false, undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY }, italic: false, - ...(richText ? {} : { bold: false, strike: false }), + bold: false, + strike: false, }), ComposerUndoGroupingExtension, ComposerTextDirectionExtension, @@ -844,6 +847,8 @@ function createComposerExtensions(richText: boolean) { ? [ ComposerBoldSelectionOnlyExtension, ComposerItalicExtension, + ComposerBoldExtension, + ComposerStrikeExtension, ComposerCodeExtension, ComposerCodeBlockExtension.extend({ addNodeView() { @@ -1013,7 +1018,7 @@ function ComposerPromptEditorTiptapInner( const handleEditorChange = (updated: TiptapEditor) => { const map = serializeEditorDoc(updated.state.doc); - const contextIds = literalText ? [] : map.contextIds; + const contextIds = literalText ? [] : [...map.contextIds]; const { from, to } = updated.state.selection; const fromFlat = pmToFlat(map, from); const toFlat = pmToFlat(map, to); @@ -1468,7 +1473,7 @@ function ComposerPromptEditorTiptapInner( value: map.value, cursor: collapseExpandedComposerCursor(map.value, expandedCursor, literalText), expandedCursor, - contextIds: literalText ? [] : map.contextIds, + contextIds: literalText ? [] : [...map.contextIds], }; const toFlat = pmToFlat(map, to); selectionRangeRef.current = { diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 43c466184bf2c..91d4a54fd9e64 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -124,7 +124,7 @@ function CustomSnoozeDialog() {
{mode === "date" ? (
-
+
= Atom.Atom>; + +const state = vi.hoisted(() => ({ + thread: null as ReturnType | null, + threadRef: null as ScopedThreadRef | null, + previews: new Map>(), + config: null as unknown as Atom.Atom, + status: null as unknown as ResultAtom, + localRefs: null as unknown as ResultAtom, + remoteRefs: null as unknown as ResultAtom, + commits: null as unknown as ResultAtom, +})); + +vi.mock("~/state/server", () => ({ + serverEnvironment: { configValueAtom: () => state.config }, +})); +vi.mock("~/state/review", () => ({ + reviewEnvironment: { + diffPreview: ({ input }: { input: { baseRef?: string } }) => + state.previews.get(input.baseRef ?? null)!, + branchCommits: () => state.commits, + diffFileContents: {}, + }, +})); +vi.mock("~/state/vcs", () => ({ + vcsEnvironment: { + status: () => state.status, + listRefs: ({ input }: { input: { refKind: "local" | "remote" } }) => + input.refKind === "local" ? state.localRefs : state.remoteRefs, + }, +})); +vi.mock("~/state/entities", () => ({ + useQuietThreadShell: () => state.thread, + useProject: () => ({ workspaceRoot: "/project", repositoryIdentity: null }), + useThreadCheckpointSummaries: () => [], + useQuietThreadVisibleTurnItems: () => [], +})); +vi.mock("~/state/filesystem", () => ({ + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), +})); +vi.mock("~/hooks/useThreadRouteTarget", () => ({ useThreadRouteRef: () => state.threadRef })); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: ClientSettings) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + updateClientSettings: async () => {}, +})); +vi.mock("~/hooks/useTheme", () => ({ useRenderedAppearance: () => "dark" })); +vi.mock("~/state/queries", () => ({ + useCheckpointDiff: () => ({ data: null, error: null, isPending: false }), + useTurnItemDetail: () => ({ data: null, error: null, isPending: false }), +})); +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 })); + +import DiffPanel from "./DiffPanel"; +import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore"; + +const cwd = "/project-worktree"; +const errorMessage = "Could not find a common commit between 'origin/orphan' and HEAD."; + +function preview(): ReviewDiffPreviewResult { + return { + cwd, + generatedAt: DateTime.makeUnsafe("2026-10-30T00:00:00Z"), + sources: [ + { + id: "branch", + kind: "branch-range", + title: "Changes", + baseRef: "main", + headRef: "dev", + diff: "", + diffHash: "empty", + truncated: false, + }, + ], + }; +} + +function setup(failedBaseRef: string | null) { + const reset = () => { + useDiffPanelStore.setState({ + byThreadKey: {}, + branchBaseRefByThreadKey: {}, + keepSelectionOnOpenThreadKey: null, + }); + useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); + }; + reset(); + state.threadRef = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + }; + state.thread = makeThreadFixture({ + ...state.threadRef, + id: state.threadRef.threadId, + projectId: ProjectId.make("project"), + branch: "dev", + worktreePath: cwd, + }); + state.previews.clear(); + state.previews.set(null, Atom.make(AsyncResult.success(preview()))); + state.previews.set("main", Atom.make(AsyncResult.success(preview()))); + state.previews.set(failedBaseRef, Atom.make(AsyncResult.fail(new Error(errorMessage)))); + state.config = Atom.make(null); + state.status = Atom.make( + AsyncResult.success({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "dev", + hasWorkingTreeChanges: false, + workingTree: { rootPath: cwd, files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); + const refs = (names: string[], isRemote: boolean): VcsListRefsResult => ({ + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: names.length, + refs: names.map((name) => ({ + name, + isRemote, + ...(isRemote ? { remoteName: "origin" } : {}), + current: name === "dev", + isDefault: name === "main", + worktreePath: null, + })), + }); + state.localRefs = Atom.make(AsyncResult.success(refs(["dev", "main"], false))); + state.remoteRefs = Atom.make(AsyncResult.success(refs(["origin/orphan"], true))); + state.commits = Atom.make(AsyncResult.success({ commits: [], truncated: false })); + useDiffPanelStore.getState().selectBranchBaseRef(state.threadRef, failedBaseRef); + const registry = AtomRegistry.make(); + return { + registry, + threadRef: state.threadRef, + [Symbol.dispose]() { + registry.dispose(); + reset(); + }, + }; +} + +it.each([ + { + name: "a saved unrelated branch", + failedBaseRef: "origin/orphan", + recovery: "Automatic", + baseRef: null, + }, + { + name: "an automatic base with unrelated history", + failedBaseRef: null, + recovery: "main", + baseRef: "main", + }, +])( + "recovers from $name without leaving the diff panel", + async ({ failedBaseRef, recovery, baseRef }) => { + using context = setup(failedBaseRef); + await using screen = await render( + + + , + ); + await expect.element(screen.getByText(errorMessage, { exact: true })).toBeVisible(); + const selector = screen.getByRole("combobox", { name: /Change comparison target/ }); + await vi.waitFor(() => expect(selector.query()).not.toBeNull()); + await userEvent.click(selector); + await expect.element(page.getByRole("option", { name: "main", exact: true })).toBeVisible(); + await userEvent.click(page.getByRole("option", { name: recovery, exact: true })); + await expect.element(screen.getByText("No net changes in this selection.")).toBeVisible(); + await expect.element(screen.getByText(errorMessage, { exact: true })).not.toBeInTheDocument(); + const key = scopedThreadKey(context.threadRef); + await vi.waitFor(() => { + expect(useDiffPanelStore.getState().byThreadKey[key]).toEqual({ kind: "branch", baseRef }); + expect(useDiffPanelStore.getState().branchBaseRefByThreadKey[key]).toBe(baseRef); + }); + }, +); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ef95c24d578a2..0f1d1e9e28379 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -67,6 +67,7 @@ import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./Dif import { DiffStatLabel } from "./chat/DiffStatLabel"; import { AnnotatableCodeView } from "./diffs/AnnotatableCodeView"; import { DiffFileTree } from "./diffs/DiffFileTree"; +import { FileExplorerPane } from "./files/FileExplorerPane"; import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; import { Button } from "./ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -403,6 +404,8 @@ export default function DiffPanel({ ? "commit" : "branch-range"), ); + const comparisonHeadRef = selectedGitSource?.headRef ?? gitStatusQuery.data?.refName ?? "HEAD"; + const comparisonBaseRef = selectedGitSource?.baseRef ?? selectedBaseRef ?? "Automatic"; const selectedCommitSubject = selectedCommitSha !== null && selectedGitSource?.headRef === selectedCommitSha ? selectedGitSource.commitSubject @@ -518,12 +521,14 @@ export default function DiffPanel({ } return currentLoadDiffFiles(fileDiff); }); + const canSelectBranchBase = canReadFiles && canRefreshGitDiff && selectedGitScope === "branch"; + const branchRefsCwd = branchDiffPreview.data?.cwd ?? activeCwd; const localBranchRefs = useEnvironmentQuery( - isGitSelection && selectedGitScope === "branch" && activeThread && branchDiffPreview.data?.cwd + canSelectBranchBase && activeThread && branchRefsCwd ? vcsEnvironment.listRefs({ environmentId: activeThread.environmentId, input: { - cwd: branchDiffPreview.data.cwd, + cwd: branchRefsCwd, includeMatchingRemoteRefs: true, refKind: "local", ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}), @@ -533,11 +538,11 @@ export default function DiffPanel({ : null, ); const remoteBranchRefs = useEnvironmentQuery( - isGitSelection && selectedGitScope === "branch" && activeThread && branchDiffPreview.data?.cwd + canSelectBranchBase && activeThread && branchRefsCwd ? vcsEnvironment.listRefs({ environmentId: activeThread.environmentId, input: { - cwd: branchDiffPreview.data.cwd, + cwd: branchRefsCwd, includeMatchingRemoteRefs: true, refKind: "remote", ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}), @@ -547,7 +552,7 @@ export default function DiffPanel({ : null, ); const baseRefChoices = buildBaseRefChoices( - localBranchRefs.data?.refs.filter((ref) => ref.name !== selectedGitSource?.headRef) ?? [], + localBranchRefs.data?.refs.filter((ref) => ref.name !== comparisonHeadRef) ?? [], remoteBranchRefs.data?.refs ?? [], ); const matchingBaseRefChoices = filterBaseRefChoices(baseRefChoices, baseRefQuery); @@ -949,10 +954,10 @@ export default function DiffPanel({ - {isGitSelection && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( + {canSelectBranchBase && (
} > - - {selectedGitSource.headRef ?? "HEAD"} - + {comparisonHeadRef} - {`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`} + {`${comparisonHeadRef} → ${comparisonBaseRef}`} @@ -978,7 +981,7 @@ export default function DiffPanel({ vs - {`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`} + {`${comparisonHeadRef} → ${comparisonBaseRef}`} } className="min-w-9 max-w-48 shrink-0" - aria-label={`Change comparison target. Currently ${selectedGitSource.baseRef}`} + aria-label={`Change comparison target. Currently ${comparisonBaseRef}`} > - {selectedGitSource.baseRef} + {comparisonBaseRef}
{fileTreeOpen ? ( - + ) : null}
) : ( diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 5c202fccf5abc..3c58675d6f6d9 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -5,6 +5,7 @@ import { assert, describe, it } from "vite-plus/test"; import { buildMenuItems, requiresDefaultBranchConfirmation, + resolveChangesTotals, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveQuickAction, @@ -247,3 +248,53 @@ describe("resolveAutoFeatureBranchName", () => { assert.equal(ref, "feature/ticket-1-2"); }); }); + +describe("resolveChangesTotals", () => { + it("does not let an empty branchChanges hide a dirty working tree", () => { + assert.deepEqual( + resolveChangesTotals( + status({ + hasWorkingTreeChanges: true, + branchChanges: { baseRef: "main", insertions: 0, deletions: 0 }, + workingTree: { + files: [{ path: "a.ts", insertions: 5, deletions: 1 }], + insertions: 5, + deletions: 1, + }, + }), + ), + { insertions: 5, deletions: 1 }, + ); + }); + + it("keeps non-zero branchChanges over the working tree", () => { + assert.deepEqual( + resolveChangesTotals( + status({ + branchChanges: { baseRef: "main", insertions: 20, deletions: 3 }, + workingTree: { + files: [{ path: "a.ts", insertions: 1, deletions: 0 }], + insertions: 1, + deletions: 0, + }, + }), + ), + { insertions: 20, deletions: 3 }, + ); + }); + + it("falls back to the working tree when branchChanges is omitted", () => { + assert.deepEqual( + resolveChangesTotals( + status({ + workingTree: { + files: [{ path: "a.ts", insertions: 2, deletions: 0 }], + insertions: 2, + deletions: 0, + }, + }), + ), + { insertions: 2, deletions: 0 }, + ); + }); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 98a55c3577e01..ca396893accda 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -425,3 +425,28 @@ export function resolveLiveThreadBranchUpdate(input: { branch: input.gitStatus.refName, }; } + +export type ChangesLineTotals = { + readonly insertions: number; + readonly deletions: number; +}; + +// `branchChanges ?? workingTree` treats `{0,0}` as present, so a zero branch total hid a dirty +// working tree and the Changes row read +0 while Diff still showed the work. +export function resolveChangesTotals( + status: VcsStatusResult | null | undefined, +): 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 }; +} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index a125ceacaed24..6afb76c3760e9 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -52,6 +52,7 @@ import { type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, + resolveChangesTotals, resolveDefaultBranchActionDialogCopy, resolveFeatureBranchUnavailableReason, resolveGitActionProgressPresentation, @@ -1048,7 +1049,10 @@ export default function GitActionsControl({ const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; - const changesTotals = gitStatusForActions?.branchChanges ?? gitStatusForActions?.workingTree; + const changesTotals = resolveChangesTotals(gitStatusForActions) ?? { + insertions: 0, + deletions: 0, + }; const allFiles = gitStatusForActions?.workingTree.files ?? []; // Changed-file paths are repository-relative, so they must resolve against @@ -1118,8 +1122,8 @@ export default function GitActionsControl({ gitActionProgress === null && !gitStatusError && quickAction.disabled && - !changesTotals?.insertions && - !changesTotals?.deletions; + !changesTotals.insertions && + !changesTotals.deletions; const pendingDefaultBranchActionCopy = pendingDefaultBranchAction ? resolveDefaultBranchActionDialogCopy({ action: pendingDefaultBranchAction.action, @@ -1636,19 +1640,17 @@ export default function GitActionsControl({ - +{formatDiffCount(changesTotals?.insertions ?? 0)} + +{formatDiffCount(changesTotals.insertions)} - -{formatDiffCount(changesTotals?.deletions ?? 0)} + -{formatDiffCount(changesTotals.deletions)} diff --git a/apps/web/src/components/GitHubStarPrompt.test.ts b/apps/web/src/components/GitHubStarPrompt.test.ts index 34f763646d6dd..f748ec8b82209 100644 --- a/apps/web/src/components/GitHubStarPrompt.test.ts +++ b/apps/web/src/components/GitHubStarPrompt.test.ts @@ -3,28 +3,54 @@ import { describe, expect, it, vi } from "vite-plus/test"; import type { toastManager } from "./ui/toast"; -const { add, starred } = vi.hoisted(() => ({ +const { add, update, openExternal, commands, starred, page } = vi.hoisted(() => ({ add: vi.fn(), - starred: { value: false, duringCheck: () => {} }, + update: vi.fn(), + openExternal: vi.fn(async (_url: string) => {}), + commands: { checkStarred: {}, star: {} }, + starred: { value: false, failed: false, starWorks: true, duringCheck: () => {} }, + page: { + focused: true, + activeElement: null as { matches: (selector: string) => boolean } | null, + events: new EventTarget(), + }, })); -vi.mock("./ui/toast", () => ({ toastManager: { add, close: vi.fn() } })); +vi.mock("./ui/toast", () => ({ toastManager: { add, update, close: vi.fn() } })); +vi.mock("../localApi", () => ({ readLocalApi: () => ({ shell: { openExternal } }) })); vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); -vi.mock("../state/sourceControl", () => ({ sourceControlEnvironment: { checkStarred: {} } })); +vi.mock("../state/sourceControl", () => ({ sourceControlEnvironment: commands })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ - runAtomCommand: async () => { + runAtomCommand: async (_registry: unknown, command: unknown) => { + if (command === commands.star) return { _tag: "Success", value: starred.starWorks }; starred.duringCheck(); - return { _tag: "Success", value: starred.value }; + return starred.failed ? { _tag: "Failure" } : { _tag: "Success", value: starred.value }; }, })); const environmentId = EnvironmentId.make("environment-1"); +const IDLE_MS = 30_000; function setup({ sent = 0, writable = true, alreadyStarred = false } = {}) { add.mockReset(); + update.mockReset(); + openExternal.mockClear(); starred.value = alreadyStarred; + starred.failed = false; + starred.starWorks = true; starred.duringCheck = () => {}; + page.focused = true; + page.activeElement = null; + page.events = new EventTarget(); const store = new Map([["t3code:github-star-prompt:sent", String(sent)]]); + vi.stubGlobal("document", { + hasFocus: () => page.focused, + get activeElement() { + return page.activeElement; + }, + }); vi.stubGlobal("window", { + addEventListener: page.events.addEventListener.bind(page.events), + removeEventListener: page.events.removeEventListener.bind(page.events), localStorage: { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => { @@ -40,10 +66,48 @@ function setup({ sent = 0, writable = true, alreadyStarred = false } = {}) { async function startSession() { vi.resetModules(); const session = await import("./GitHubStarPrompt"); - return { send: () => session.recordPromptSent(environmentId) }; + return { + // Sends a prompt, then leaves the page idle long enough to ask. + send: async () => { + const sent = session.recordPromptSent(environmentId); + await vi.advanceTimersByTimeAsync(IDLE_MS); + await sent; + }, + sendWithoutWaiting: () => void session.recordPromptSent(environmentId), + }; } +// The toast's handlers ignore their click event. +const click = (props: unknown) => (props as { onClick: () => unknown }).onClick(); + describe("recordPromptSent", () => { + vi.useFakeTimers(); + + it("asks only after the focused page is idle", async () => { + setup({ sent: 5 }); + (await startSession()).sendWithoutWaiting(); + await vi.advanceTimersByTimeAsync(IDLE_MS - 1); + page.events.dispatchEvent(new Event("keydown")); + await vi.advanceTimersByTimeAsync(IDLE_MS - 1); + expect(add).not.toHaveBeenCalled(); + + page.focused = false; + await vi.advanceTimersByTimeAsync(IDLE_MS); + expect(add).not.toHaveBeenCalled(); + + page.focused = true; + page.activeElement = { matches: (selector) => selector.includes("iframe") }; + page.events.dispatchEvent(new Event("focus")); + await vi.advanceTimersByTimeAsync(IDLE_MS); + expect(add).not.toHaveBeenCalled(); + + page.activeElement = null; + page.events.dispatchEvent(new Event("pointerdown")); + await vi.advanceTimersByTimeAsync(IDLE_MS); + expect(add).toHaveBeenCalledOnce(); + vi.unstubAllGlobals(); + }); + it("asks after five prompts, once per session, and never again once closed", async () => { setup(); let session = await startSession(); @@ -81,6 +145,18 @@ describe("recordPromptSent", () => { vi.unstubAllGlobals(); }); + it("skips an unavailable server check without permanently dismissing", async () => { + const store = setup({ sent: 5 }); + starred.failed = true; + await (await startSession()).send(); + expect(add).not.toHaveBeenCalled(); + expect(store.has("t3code:github-star-prompt:dismissed")).toBe(false); + starred.failed = false; + await (await startSession()).send(); + expect(add).toHaveBeenCalledOnce(); + vi.unstubAllGlobals(); + }); + it("never asks when another tab dismisses it during the star check", async () => { const store = setup({ sent: 5 }); starred.duringCheck = () => store.set("t3code:github-star-prompt:dismissed", "true"); @@ -88,4 +164,36 @@ describe("recordPromptSent", () => { expect(add).not.toHaveBeenCalled(); vi.unstubAllGlobals(); }); + it("stars on click, opens GitHub when starring fails, and asks for a click once the click expired", async () => { + const store = setup({ sent: 5 }); + await (await startSession()).send(); + click(add.mock.lastCall?.[0].actionProps); + await vi.advanceTimersByTimeAsync(0); + expect(update.mock.lastCall?.[1]).toMatchObject({ title: "Thanks for the star!" }); + expect(store.get("t3code:github-star-prompt:dismissed")).toBe("true"); + expect(openExternal).not.toHaveBeenCalled(); + + setup({ sent: 5 }); + starred.starWorks = false; + await (await startSession()).send(); + click(add.mock.lastCall?.[0].actionProps); + await vi.advanceTimersByTimeAsync(0); + expect(openExternal).toHaveBeenCalledWith("https://github.com/pingdotgg/t3code"); + expect(update.mock.lastCall?.[1]).not.toMatchObject({ + actionProps: { children: "Open GitHub" }, + }); + + setup({ sent: 5 }); + starred.starWorks = false; + vi.stubGlobal("navigator", { userActivation: { isActive: false } }); + await (await startSession()).send(); + click(add.mock.lastCall?.[0].actionProps); + await vi.advanceTimersByTimeAsync(0); + const fallback = update.mock.lastCall?.[1]; + expect(fallback).toMatchObject({ actionProps: { children: "Open GitHub" } }); + expect(openExternal).not.toHaveBeenCalled(); + click(typeof fallback === "object" ? fallback.actionProps : undefined); + expect(openExternal).toHaveBeenCalledOnce(); + vi.unstubAllGlobals(); + }); }); diff --git a/apps/web/src/components/GitHubStarPrompt.tsx b/apps/web/src/components/GitHubStarPrompt.tsx index d157d72960d93..679d61b1f3502 100644 --- a/apps/web/src/components/GitHubStarPrompt.tsx +++ b/apps/web/src/components/GitHubStarPrompt.tsx @@ -14,6 +14,9 @@ const SENT_KEY = "t3code:github-star-prompt:sent"; const DISMISSED_KEY = "t3code:github-star-prompt:dismissed"; const REPOSITORY = "pingdotgg/t3code"; const PROMPTS_BEFORE_ASKING = 5; +const IDLE_MS = 30_000; +// User-driven actions (send, navigate, type, scroll) start with one of these; touch fires pointerdown. +const ACTIVITY_EVENTS = ["pointerdown", "keydown", "wheel", "focus"] as const; let shownThisSession = false; @@ -24,22 +27,41 @@ function dismiss() { } // Every close path, including both buttons, runs `onClose`, which saves the dismissal. -function showStarPrompt() { +function showStarPrompt(environmentId: EnvironmentId) { const close = () => toastManager.close(id); + const openInBrowser = () => { + close(); + void readLocalApi() + ?.shell.openExternal(`https://github.com/${REPOSITORY}`) + .catch(() => undefined); + }; + const starInPlace = async () => { + toastManager.update(id, { actionProps: { children: "Starring…", disabled: true } }); + if (await starRepository(environmentId)) { + // Thank them in place: a closing and opening toast would slide under the confetti. + dismiss(); + toastManager.update(id, { + type: "success", + title: "Thanks for the star!", + description: "It really helps T3 Code grow.", + timeout: 5_000, + actionProps: undefined, + data: {}, + }); + celebrate(); + return; + } + // Browsers block new tabs once the click's activation expires; desktop opens through its bridge. + if (window.desktopBridge || navigator.userActivation?.isActive !== false) + return openInBrowser(); + toastManager.update(id, { actionProps: { children: "Open GitHub", onClick: openInBrowser } }); + }; const id = toastManager.add({ title: "Enjoying T3 Code?", description: "Give us a star on GitHub to let us know!", timeout: 0, onClose: dismiss, - actionProps: { - children: "Star on GitHub", - onClick: () => { - close(); - void readLocalApi() - ?.shell.openExternal(`https://github.com/${REPOSITORY}`) - .catch(() => undefined); - }, - }, + actionProps: { children: "Star on GitHub", onClick: () => void starInPlace() }, data: { actionLayout: "stacked-end", leadingIcon: , @@ -49,27 +71,76 @@ function showStarPrompt() { }); } +// One burst out of the toast. The library loads only here, and its canvas stops when done. +function celebrate() { + void import("canvas-confetti") + .then(({ default: confetti }) => + confetti({ + particleCount: 160, + spread: 360, + startVelocity: 38, + gravity: 1.6, + ticks: 140, + origin: { x: 0.83, y: 0.15 }, + disableForReducedMotion: true, + }), + ) + .catch(() => undefined); +} + +// Resolves after IDLE_MS without input while this window has focus. +function waitForIdle() { + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const restart = () => { + clearTimeout(timer); + timer = setTimeout(() => { + // Input inside another app or an embedded frame never reaches us; the next focus restarts. + if (!document.hasFocus() || document.activeElement?.matches("iframe, webview")) return; + for (const type of ACTIVITY_EVENTS) window.removeEventListener(type, restart, true); + resolve(); + }, IDLE_MS); + }; + for (const type of ACTIVITY_EVENTS) { + window.addEventListener(type, restart, { capture: true, passive: true }); + } + restart(); + }); +} + // The server's GitHub account stands in for the user's; it may not be theirs on a shared server. +const starInput = (environmentId: EnvironmentId) => ({ + environmentId, + input: { + provider: SourceControlProviderKind.make("github"), + host: "github.com", + repository: REPOSITORY, + }, +}); + async function alreadyStarred(environmentId: EnvironmentId) { const result = await runAtomCommand( appAtomRegistry, sourceControlEnvironment.checkStarred, - { - environmentId, - input: { - provider: SourceControlProviderKind.make("github"), - host: "github.com", - repository: REPOSITORY, - }, - }, + starInput(environmentId), { label: "check GitHub star", reportFailure: false, reportDefect: false }, ); + return result._tag === "Success" ? result.value : undefined; +} + +async function starRepository(environmentId: EnvironmentId) { + const result = await runAtomCommand( + appAtomRegistry, + sourceControlEnvironment.star, + starInput(environmentId), + { label: "star on GitHub", reportFailure: false, reportDefect: false }, + ); return result._tag === "Success" && result.value; } /** * Counts a sent prompt. After more than five, asks once per session for a star until dismissed, - * unless the environment's GitHub account already starred the repo. + * unless the environment's GitHub account already starred the repo. Waits until the user is idle. */ export async function recordPromptSent(environmentId: EnvironmentId): Promise { if (shownThisSession) return; @@ -83,12 +154,15 @@ export async function recordPromptSent(environmentId: EnvironmentId): Promise vi.fn()); +vi.mock("../navigationHistory", () => ({ canNavigateHistory: () => true, navigateHistory })); + +function swipe(target: Element, deltaX: number) { + for (const delta of [deltaX, 0]) { + target.dispatchEvent(new WheelEvent("wheel", { deltaX: delta, bubbles: true, composed: true })); + } +} + +describe("HistorySwipeNavigation", () => { + it.each([ + ["scrolling left inside a shadow-root scroller keeps the thread", "shadow", 100, -130, false], + ["scrolling right inside a shadow-root scroller keeps the thread", "shadow", 100, 130, false], + [ + "scrolling after a shadow scroller remount keeps the thread", + "shadow-remount", + 100, + 130, + false, + ], + ["scrolling left inside a scroller keeps the thread", "light", 100, -130, false], + ["swiping back at a shadow-root scroller's left edge navigates", "shadow", 0, -130, true], + ["swiping back over a panel without overflow navigates", "none", 0, -130, true], + ["a swipe that started in a closed dialog keeps the thread", "closed-dialog", 0, -130, false], + ] as const)("%s", async (_name, scroller, scrollLeft, deltaX, navigates) => { + navigateHistory.mockReset(); + await using screen = await render(); + const panel = screen.container.appendChild(document.createElement("div")); + const root = scroller.startsWith("shadow") ? panel.attachShadow({ mode: "open" }) : panel; + const viewport = root.appendChild(document.createElement("div")); + const noOverflow = scroller === "none" || scroller === "closed-dialog"; + viewport.style.cssText = `width: 200px; overflow-x: ${noOverflow ? "visible" : "auto"}`; + const content = viewport.appendChild(document.createElement("div")); + content.style.cssText = `height: 20px; width: ${noOverflow ? 100 : 1000}px`; + viewport.scrollLeft = scrollLeft; + + if (scroller === "closed-dialog") { + panel.setAttribute("role", "dialog"); + content.dispatchEvent(new WheelEvent("wheel", { deltaX: -1, bubbles: true, composed: true })); + panel.remove(); + screen.container.appendChild(viewport); + } + + if (scroller === "shadow-remount") { + content.dispatchEvent(new WheelEvent("wheel", { deltaX: 1, bubbles: true, composed: true })); + const replacement = viewport.cloneNode(true) as HTMLDivElement; + viewport.replaceWith(replacement); + replacement.scrollLeft = scrollLeft; + swipe(replacement, deltaX); + } else { + swipe(content, deltaX); + } + + expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0); + }); +}); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index c020878b25363..767b4285c696f 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -11,7 +11,7 @@ const MOMENTUM_SAMPLE_SIZE = 5; type SwipeBounds = { left: number; top: number; width: number; height: number }; type SwipeGesture = - | { kind: "pending"; target: EventTarget | null; y: number; sumX: number; sumY: number } + | { kind: "pending"; path: EventTarget[]; y: number; sumX: number; sumY: number } | { kind: "tracking"; direction: HistoryDirection; @@ -37,13 +37,10 @@ function readMainContentBounds(): SwipeBounds { return { left, top, width, height }; } -function canScrollHorizontally(target: EventTarget | null, deltaX: number): boolean { - for ( - let element = target instanceof Element ? target : null; - element; - element = element.parentElement - ) { - if (element.scrollWidth <= element.clientWidth) continue; +// `event.target` stops at a shadow host; the composed path also reaches scrollers inside it. +function canScrollHorizontally(path: readonly EventTarget[], deltaX: number): boolean { + for (const element of path) { + if (!(element instanceof Element) || element.scrollWidth <= element.clientWidth) continue; const { overflowX } = getComputedStyle(element); if (overflowX !== "auto" && overflowX !== "scroll") continue; const canScroll = @@ -58,10 +55,12 @@ function canScrollHorizontally(target: EventTarget | null, deltaX: number): bool function lockSwipeAxis( pending: Extract, ): Exclude { - const { target, sumX, sumY } = pending; + const { path, sumX, sumY } = pending; if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" }; - if (target instanceof Element && target.closest('[role="dialog"]')) return { kind: "ignored" }; - if (canScrollHorizontally(target, sumX)) return { kind: "ignored" }; + if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) { + return { kind: "ignored" }; + } + if (canScrollHorizontally(path, sumX)) return { kind: "ignored" }; const direction = sumX < 0 ? "back" : "forward"; if (!canNavigateHistory(direction)) return { kind: "ignored" }; return { @@ -119,14 +118,14 @@ export function HistorySwipeNavigation() { gesture = event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL ? { kind: "ignored" } - : { kind: "pending", target: event.target, y: event.clientY, sumX: 0, sumY: 0 }; + : { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 }; } if (gesture.kind === "ignored") return; if (gesture.kind === "pending") { gesture.sumX += event.deltaX; gesture.sumY += event.deltaY; if (Math.abs(gesture.sumX) + Math.abs(gesture.sumY) < SWIPE_AXIS_LOCK_DISTANCE) return; - gesture = lockSwipeAxis(gesture); + gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...event.composedPath()] }); if (gesture.kind === "ignored") return; } else { const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts new file mode 100644 index 0000000000000..05602fda4c4c5 --- /dev/null +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ServerConfigStreamEvent } from "@t3tools/contracts"; + +import { createKeybindingsUpdateToastController } from "./KeybindingsUpdateToast.logic"; + +function keybindingsUpdated(issueMessages: ReadonlyArray): ServerConfigStreamEvent { + return { + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [], + issues: issueMessages.map((message, index) => ({ + kind: "keybindings.invalid-entry" as const, + message, + index, + })), + }, + }; +} + +describe("keybindings update toast", () => { + it("shows one warning when a save and the file watcher report the same issue", () => { + let currentTime = 0; + const controller = createKeybindingsUpdateToastController({ now: () => currentTime }); + const invalid = { _tag: "InvalidConfiguration", message: "unknown command" }; + + expect(controller.handle(keybindingsUpdated(["unknown command"]))).toEqual(invalid); + currentTime = 100; + expect(controller.handle(keybindingsUpdated(["unknown command"]))).toBeNull(); + + currentTime = 200; + expect(controller.handle(keybindingsUpdated(["bad shortcut"]))).toEqual({ + _tag: "InvalidConfiguration", + message: "bad shortcut", + }); + + currentTime = 5_000; + expect(controller.handle(keybindingsUpdated(["bad shortcut"]))).toEqual({ + _tag: "InvalidConfiguration", + message: "bad shortcut", + }); + currentTime = 5_100; + expect(controller.handle(keybindingsUpdated([]))).toEqual({ _tag: "Success" }); + currentTime = 5_200; + expect(controller.handle(keybindingsUpdated([]))).toBeNull(); + }); +}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.ts index ed13e6a1c800b..f7724f38d13b5 100644 --- a/apps/web/src/components/KeybindingsUpdateToast.logic.ts +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.ts @@ -1,6 +1,6 @@ import type { ServerConfigStreamEvent } from "@t3tools/contracts"; -const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000; +const KEYBINDINGS_TOAST_COOLDOWN_MS = 2_000; export type KeybindingsUpdateToastDecision = | { readonly _tag: "Success" } @@ -14,7 +14,7 @@ export function createKeybindingsUpdateToastController(input: { readonly now?: () => number; }): KeybindingsUpdateToastController { const now = input.now ?? Date.now; - let lastSuccessToastAt: number | null = null; + let lastToast: { readonly key: string; readonly at: number } | null = null; return { handle: (event) => { @@ -23,23 +23,24 @@ export function createKeybindingsUpdateToastController(input: { } const issue = event.payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); - if (issue) { - return { - _tag: "InvalidConfiguration", - message: issue.message, - }; - } + const decision: KeybindingsUpdateToastDecision = issue + ? { _tag: "InvalidConfiguration", message: issue.message } + : { _tag: "Success" }; + // A save and the server's file watcher each publish the same snapshot, + // so an identical toast inside the cooldown is the same change twice. + const key = issue ? `invalid:${issue.message}` : "success"; const currentTime = now(); if ( - lastSuccessToastAt !== null && - currentTime - lastSuccessToastAt < KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS + lastToast !== null && + lastToast.key === key && + currentTime - lastToast.at < KEYBINDINGS_TOAST_COOLDOWN_MS ) { return null; } - lastSuccessToastAt = currentTime; - return { _tag: "Success" }; + lastToast = { key, at: currentTime }; + return decision; }, }; } diff --git a/apps/web/src/components/ManagedPolicy.tsx b/apps/web/src/components/ManagedPolicy.tsx new file mode 100644 index 0000000000000..cce0689563e85 --- /dev/null +++ b/apps/web/src/components/ManagedPolicy.tsx @@ -0,0 +1,71 @@ +import type { ManagedPolicySummary } from "@t3tools/contracts"; +import { BuildingIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** + * A control an administrator's policy turns off: shown inert, with the policy's reason on hover + * and focus. Without a reason it renders the control untouched. + */ +export function ManagedControl({ + reason, + children, +}: { + reason: string | undefined; + children: ReactNode; +}) { + if (reason === undefined) return children; + return ( + + + } + > + + {children} + + + {reason} + + ); +} + +/** One line of policy text with the building mark, for places a whole feature is managed. */ +export function ManagedNotice({ children }: { children: ReactNode }) { + return ( +

+

+ ); +} + +/** "This environment is managed by ", linking the support page when there is one. */ +export function ManagedByOrganization({ policy }: { policy: ManagedPolicySummary }) { + return ( + + This environment is managed by {policy.organization}. + {policy.supportUrl ? ( + <> + {" "} +
+ Get help + + + ) : null} + + ); +} diff --git a/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx new file mode 100644 index 0000000000000..d6ca540686f0e --- /dev/null +++ b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx @@ -0,0 +1,153 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + AuthProvidersManageScope, + type AuthSessionState, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useEffect } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; + +const state = vi.hoisted(() => ({ + environments: [] as { + environmentId: EnvironmentId; + label: string; + connection: { phase: "connected" | "disconnected" }; + }[], + providers: new Map>(), + requests: [] as EnvironmentId[], + sessions: new Map>>(), +})); + +vi.mock(import("~/state/environments"), () => ({ + useEnvironments: (() => ({ environments: state.environments })) as never, + useEnvironmentProviders: () => state.providers, +})); +vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => { + const actual = await original(); + return { + ...actual, + createEnvironmentSessionAtoms: (( + ...args: Parameters + ) => ({ + ...actual.createEnvironmentSessionAtoms(...args), + sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, + })) as typeof actual.createEnvironmentSessionAtoms, + }; +}); +vi.mock(import("~/state/server"), () => ({ + serverEnvironment: { updateProvider: {} } as never, +})); +vi.mock(import("~/state/use-atom-command"), () => ({ + useAtomCommand: (() => + async ({ environmentId }: { environmentId: EnvironmentId }) => { + state.requests.push(environmentId); + return AsyncResult.success(undefined); + }) as never, +})); + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { useProviderUpdates } from "./ProviderUpdatesAction"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-21T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "npm install -g @openai/codex", + canUpdate: true, + checkedAt: "2026-10-21T00:00:00.000Z", + message: "Update available.", + }, +}; + +function session( + scopes: NonNullable, +): AsyncResult.AsyncResult { + return AsyncResult.success({ + authenticated: true, + scopes, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", + }, + }); +} + +function setup() { + state.environments = [ + { environmentId: EnvironmentId.make("read-only"), label: "Read only" }, + { environmentId: EnvironmentId.make("writable"), label: "Writable" }, + { environmentId: EnvironmentId.make("unresolved"), label: "Unresolved" }, + ].map((environment) => ({ ...environment, connection: { phase: "connected" } })); + state.providers = new Map( + state.environments.map(({ environmentId }) => [environmentId, [provider]]), + ); + state.requests = []; + const sessions = new Map>>( + [ + [state.environments[0]!.environmentId, Atom.make(session(["orchestration:read"]))], + [state.environments[1]!.environmentId, Atom.make(session([AuthProvidersManageScope]))], + [ + state.environments[2]!.environmentId, + Atom.make>( + AsyncResult.initial(), + ), + ], + ], + ); + state.sessions = sessions; + return { sessions }; +} + +it("bulk provider updates exclude connections without a confirmed management grant", async () => { + const fixture = setup(); + let updates!: ReturnType; + function Probe() { + const nextUpdates = useProviderUpdates(); + useEffect(() => { + updates = nextUpdates; + }, [nextUpdates]); + return null; + } + await using _screen = await render( + + + , + ); + + const writableId = state.environments[1]!.environmentId; + expect(updates.machines.map(({ environmentId }) => environmentId)).toEqual([writableId]); + await act(async () => { + await updates.updateAll({ quiet: true }); + }); + expect(state.requests).toEqual([writableId]); + + await act(() => { + appAtomRegistry.set(fixture.sessions.get(writableId)!, session(["orchestration:read"])); + }); + expect(updates.machines).toEqual([]); + state.requests = []; + await act(async () => { + await updates.updateAll({ quiet: true }); + }); + expect(state.requests).toEqual([]); +}); diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index 8d45d66b71213..57573806e45db 100644 --- a/apps/web/src/components/ProviderUpdatesAction.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -1,9 +1,10 @@ -import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; +import { AuthProvidersManageScope, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { useRef, useState } from "react"; import { useEnvironmentProviders, useEnvironments } from "~/state/environments"; import { serverEnvironment } from "~/state/server"; +import { useEnvironmentsWithScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { canOneClickUpdateProviderCandidate, @@ -17,13 +18,17 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export function useProviderUpdates() { const { environments } = useEnvironments(); + const manageableIds = useEnvironmentsWithScope(environments, AuthProvidersManageScope); const providersByEnvironment = useEnvironmentProviders(); const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); const machines = environments.flatMap((environment) => { const providers = providersByEnvironment.get(environment.environmentId) ?? []; - if (environment.connection.phase !== "connected") { + if ( + environment.connection.phase !== "connected" || + !manageableIds.has(environment.environmentId) + ) { return []; } const candidates = collectProviderUpdateCandidates(providers).filter((candidate) => diff --git a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx index c388ec33af0b5..5dc65e57f1ca3 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx @@ -265,4 +265,11 @@ describe("root reopen shortcut", () => { expect(useClosedViewStore.getState().entries).toEqual([]); }, ); + it("forwards address-bar focus from a focused desktop page", async () => { + setup(); + await using _shortcut = await renderShortcut(); + expect(state.setShortcuts).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ command: "preview.focusUrl" })]), + ); + }); }); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index ce187f5db5e96..77cc7b3d3068d 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -43,12 +43,13 @@ import { } from "../threadRoutes"; import { toastManager } from "./ui/toast"; -const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [ +const PREVIEW_FORWARDED_COMMANDS = [ "workspace.focusNext", "workspace.focusPrevious", "sidebar.toggle", "rightPanel.toggle", "rightPanel.toggleMaximized", + "preview.focusUrl", ] as const; const isGlobalPullRequests = (ref: ScopedThreadRef) => @@ -205,7 +206,7 @@ export function ReopenClosedViewShortcut() { // Guest browser processes miss app keydown listeners; desktop forwards the chords as menu actions. const commands = [ ...(hasHistory ? (["view.reopenClosed"] as const) : []), - ...PREVIEW_FORWARDED_LAYOUT_COMMANDS, + ...PREVIEW_FORWARDED_COMMANDS, ]; void preview .setForwardedShortcuts( diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 141f139aefd7b..32a2811aca189 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AuthEnvironmentMaintainScope, type AuthSessionState, + managedUpdatesDenial, sessionGrantsScope, } from "@t3tools/contracts"; import type { AsyncResult } from "effect/reactivity"; @@ -26,8 +27,11 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { serverEnvironment, updateOutdatedServer } from "~/state/server"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useAtomCommand } from "~/state/use-atom-command"; -import { manualServerUpdateCommand } from "~/versionSkew"; import { useServerConfigs } from "~/state/entities"; +import { useManagedPolicy } from "~/state/managedPolicy"; +import { environmentPresentations } from "~/state/presentation"; +import { ManagedControl } from "./ManagedPolicy"; +import { manualServerUpdateCommand } from "~/versionSkew"; import { crossesReleaseTrack } from "@t3tools/shared/cliRelease"; import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; @@ -44,6 +48,14 @@ export function serverUpdateStageLabel(stage: ServerUpdateStage): string { return UPDATE_STAGE_LABELS[stage]; } +/** Why the environment's policy refuses server updates, read outside render. */ +function readUpdatesDenial(environmentId: EnvironmentId): string | undefined { + return managedUpdatesDenial( + appAtomRegistry.get(environmentPresentations.serverConfigsAtom).get(environmentId) + ?.managedPolicy, + ); +} + const CROSS_TRACK_NOTE = "Switch tracks in About → Update track"; /** Hosts refuse a Stable/Nightly crossing outside a track switch, which checks the database first. */ @@ -103,6 +115,17 @@ export function useServerUpdate() { { quiet = false }: { readonly quiet?: boolean } = {}, ): Promise => { const { environmentId, serverLabel, selfUpdate, targetVersion } = target; + const denial = readUpdatesDenial(environmentId); + if (denial !== undefined) { + if (!quiet) { + toastManager.add({ + type: "error", + title: `${serverLabel} not updated`, + description: denial, + }); + } + return { status: "failed", message: denial }; + } if ( !canUpdateServer(appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId))) || pendingUpdateEnvironmentIds.has(environmentId) @@ -163,6 +186,7 @@ export function ServerUpdatesAction({ (target) => target.selfUpdate !== null && (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate) && + managedUpdatesDenial(serverConfigs.get(target.environmentId)?.managedPolicy) === undefined && !refusedCrossing(target, serverConfigs.get(target.environmentId)?.environment.serverVersion), ); const handleUpdate = async () => { @@ -248,7 +272,8 @@ export function ServerUpdateAction({ const isDesktopAppUpdate = selfUpdate === "desktop-managed"; const serverVersion = useServerConfigs().get(environmentId)?.environment.serverVersion; const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); - const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); + const canUpdate = useAtomValue(sessionStateAtom, canUpdateServer); + const updatesDenial = managedUpdatesDenial(useManagedPolicy(environmentId)); const update = useServerUpdate(); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: installation?.kind === "npm-global" ? "update command" : "relaunch command", @@ -298,6 +323,18 @@ export function ServerUpdateAction({ }); }; + if (updatesDenial !== undefined) { + return appearance === "icon" ? ( + + + + ) : ( + {updatesDenial} + ); + } + if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { return ( 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 f6e66a1ac6506..669c805492bcc 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,19 +2,19 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models"; import { isThreadWorking as isSidebarThreadWorking } from "@t3tools/client-runtime/state/thread-inbox"; import * as DateTime from "effect/DateTime"; import { deriveActiveWorkStartedAt } from "../session-logic.ts"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { shouldCreateNewThreadInCurrentProject, - archiveSelectedThreadEntries, deleteSelectedThreadEntries, getFallbackThreadIdAfterDelete, hasUnseenCompletion, resolveSidebarRowThreadId, + belongsInSidebarWorkingShelf, + isSidebarEnvironmentDisconnected, orderItemsByPreferredIds, pinOrderKeyBetween, - resolveProjectStatusIndicator, resolveSidebarSweepKeys, resolveSidebarThreadStatus, resolveThreadStatusPill, @@ -26,7 +26,6 @@ import { planSidebarThreadDrop, resolveSidebarReorderableThreadKeys, sortPinnedThreadsForSidebar, - sortProjectsForSidebar, sortScopedProjectsForSidebar, sortSidebarV2ProjectGroups, shouldNavigateAfterThreadPark, @@ -138,43 +137,6 @@ describe("deleteSelectedThreadEntries", () => { }); }); -describe("archiveSelectedThreadEntries", () => { - const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; - const success = { _tag: "Success" } as const; - const failure = { _tag: "Failure" } as const; - - it("stops at a mutation failure and retains prior successes", async () => { - const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { - if (entry.threadKey === "two") return failure; - onArchived(); - return success; - }); - const outcome = await archiveSelectedThreadEntries({ entries, archive }); - - expect(archive).toHaveBeenCalledTimes(2); - expect(outcome).toEqual({ - archivedThreadKeys: ["one"], - mutationFailure: failure, - followupFailures: [], - }); - }); - - it("continues after a post-archive failure", async () => { - const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { - onArchived(); - return entry.threadKey === "two" ? failure : success; - }); - const outcome = await archiveSelectedThreadEntries({ entries, archive }); - - expect(archive).toHaveBeenCalledTimes(3); - expect(outcome).toEqual({ - archivedThreadKeys: ["one", "two", "three"], - mutationFailure: null, - followupFailures: [failure], - }); - }); -}); - describe("sidebar thread lineage helpers", () => { it("maps an open subagent to its nearest visible ancestor's sidebar row", () => { const environmentId = EnvironmentId.make("environment-row"); @@ -587,40 +549,6 @@ describe("resolveThreadStatusPill", () => { }); }); -describe("resolveProjectStatusIndicator", () => { - it("ranks waiting below active work and above plan-ready", () => { - const waiting = { - label: "Waiting" as const, - colorClass: "text-sidebar-muted-foreground", - dotClass: "bg-sidebar-muted-foreground", - pulse: false, - }; - - expect( - resolveProjectStatusIndicator([ - waiting, - { - label: "Working", - colorClass: "text-sky-600", - dotClass: "bg-sky-500", - pulse: true, - }, - ]), - ).toMatchObject({ label: "Working" }); - expect( - resolveProjectStatusIndicator([ - { - label: "Plan Ready", - colorClass: "text-violet-600", - dotClass: "bg-violet-500", - pulse: false, - }, - waiting, - ]), - ).toMatchObject({ label: "Waiting" }); - }); -}); - function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { @@ -701,54 +629,6 @@ describe("getFallbackThreadIdAfterDelete", () => { expect(fallbackThreadId).toBe(ThreadId.make("thread-next")); }); }); -describe("sortProjectsForSidebar", () => { - it("sorts projects by the most recent user message across their threads", () => { - const projects = [ - makeProject({ id: ProjectId.make("project-1"), title: "Older project" }), - makeProject({ id: ProjectId.make("project-2"), title: "Newer project" }), - ]; - const threads = [ - makeThread({ - projectId: ProjectId.make("project-1"), - updatedAt: "2026-03-09T10:20:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "user", - text: "older project user message", - runId: null, - createdAt: "2026-03-09T10:01:00.000Z", - updatedAt: "2026-03-09T10:01:00.000Z", - streaming: false, - }, - ], - }), - makeThread({ - id: ThreadId.make("thread-2"), - projectId: ProjectId.make("project-2"), - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [ - { - id: "message-2" as never, - role: "user", - text: "newer project user message", - runId: null, - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - streaming: false, - }, - ], - }), - ]; - - const sorted = sortProjectsForSidebar(projects, threads, "updated_at"); - - expect(sorted.map((project) => project.id)).toEqual([ - ProjectId.make("project-2"), - ProjectId.make("project-1"), - ]); - }); -}); describe("sortScopedProjectsForSidebar", () => { it("keeps identical project ids in different environments separate", () => { @@ -1089,6 +969,38 @@ 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 730a3508bb8cc..55cf694c2b79e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,6 +16,7 @@ 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 ContextMenuItem, @@ -184,6 +185,29 @@ 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 = @@ -563,38 +587,6 @@ export async function deleteSelectedThreadEntries< return { deletedThreadKeys, firstFailure }; } -export async function archiveSelectedThreadEntries< - TEntry extends { readonly threadKey: string }, - TResult extends { readonly _tag: "Success" | "Failure" }, ->(input: { - entries: readonly TEntry[]; - archive: (entry: TEntry, onArchived: () => void) => Promise; -}): Promise<{ - archivedThreadKeys: readonly string[]; - mutationFailure: Extract | null; - followupFailures: readonly Extract[]; -}> { - const archivedThreadKeys: string[] = []; - const followupFailures: Extract[] = []; - - for (const entry of input.entries) { - let didArchive = false; - const result = await input.archive(entry, () => { - didArchive = true; - }); - if (didArchive || result._tag === "Success") archivedThreadKeys.push(entry.threadKey); - if (result._tag === "Success") continue; - const failure = result as Extract; - if (didArchive) { - followupFailures.push(failure); - continue; - } - return { archivedThreadKeys, mutationFailure: failure, followupFailures }; - } - - return { archivedThreadKeys, mutationFailure: null, followupFailures }; -} - export function isSidebarHiddenThread(thread: Pick): boolean { return !isListedThreadLineage(thread.lineage); } @@ -686,18 +678,6 @@ export interface ThreadStatusPill { pulse: boolean; } -const THREAD_STATUS_PRIORITY: Record = { - "Pending Approval": 5, - "Awaiting Input": 4, - "Guest Request": 3.75, - Failed: 3.5, - Working: 3, - Connecting: 3, - Waiting: 2.5, - "Plan Ready": 2, - Completed: 1, -}; - type ThreadStatusInput = Pick< SidebarThreadSummary, | "hasActionableProposedPlan" @@ -1230,24 +1210,6 @@ export function resolveThreadStatusPill(input: { return null; } -export function resolveProjectStatusIndicator( - statuses: ReadonlyArray, -): ThreadStatusPill | null { - let highestPriorityStatus: ThreadStatusPill | null = null; - - for (const status of statuses) { - if (status === null) continue; - if ( - highestPriorityStatus === null || - THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label] - ) { - highestPriorityStatus = status; - } - } - - return highestPriorityStatus; -} - export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { @@ -1316,29 +1278,6 @@ function sortProjectsByActivity( .map(({ project }) => project); } -export function sortProjectsForSidebar< - TProject extends SidebarProject, - TThread extends Pick & ThreadSortInput, ->( - projects: readonly TProject[], - threads: readonly TThread[], - sortOrder: SidebarProjectSortOrder, -): TProject[] { - const threadsByProjectId = new Map(); - for (const thread of threads) { - const existing = threadsByProjectId.get(thread.projectId) ?? []; - existing.push(thread); - threadsByProjectId.set(thread.projectId, existing); - } - - return sortProjectsByActivity( - projects, - sortOrder, - (project) => threadsByProjectId.get(project.id) ?? [], - (left, right) => left.title.localeCompare(right.title) || left.id.localeCompare(right.id), - ); -} - export function sortLogicalProjectsForSidebar< TProject extends LogicalSidebarProject, TThread extends ScopedSidebarThread, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e6f23149dfedb..c21191d88f5b2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ -import { attachSidebarTree, SIDEBAR_TREE_PROPS } from "../lib/sidebarTree"; +import { liveShellEnvironmentIdsAtom } from "../state/shell"; +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"; @@ -138,6 +139,7 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, + useEnvironmentConnectionSummaries, useEnvironmentMachines, usePrimaryEnvironmentId, } from "../state/environments"; @@ -184,6 +186,8 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, + belongsInSidebarWorkingShelf, + isSidebarEnvironmentDisconnected, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -351,12 +355,17 @@ 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. readonly clockKey: string; } +function scrollSidebarToTop(content: HTMLElement | null) { + content?.closest("[data-slot=scroll-area-viewport]")?.scrollTo({ top: 0 }); +} + function threadShellKey(thread: Pick): string { return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); } @@ -432,6 +441,18 @@ 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, @@ -476,6 +497,7 @@ function useSidebarThreads(input: { optimisticDrop, workingShelfEnabled, sortActiveBySend: folderGroups !== null, + disconnectedEnvironmentIds, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -611,6 +633,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { optimisticDrop, workingShelfEnabled, sortActiveBySend, + disconnectedEnvironmentIds, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -619,8 +642,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => - workingShelfEnabled && - (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) + belongsInSidebarWorkingShelf(thread, { + enabled: workingShelfEnabled, + disconnectedEnvironmentIds, + isWorking: manuallyMoved ? isThreadWorking : inboxObservation.isWorking, + }) ? working : active; const snoozed: EnvironmentThreadShell[] = []; @@ -980,6 +1006,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. @@ -1032,6 +1059,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const [renamingTitle, setRenamingTitle] = useState(""); const settlingThreadKeysRef = useRef(new Set()); const threadListRef = useRef(null); + const sidebarContentRef = useRef(null); const dragLabelOffsetRef = useRef(0); const listMotionRef = useRef | null>(null); const [dragState, setDragState] = useState<{ @@ -1057,9 +1085,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; // A selection made under one scope can hold threads the next scope hides. + // The viewport outlives the scope and does not anchor, so a new list starts at the top. const setProjectScopeKeys = (keys: readonly string[]) => { clearSelection(); setPersistedProjectScopeKeys(keys); + scrollSidebarToTop(sidebarContentRef.current); }; // A scope whose project is gone reads as all projects once every snapshot has // loaded. The stored keys are left alone, so the scope returns with its project. @@ -1968,10 +1998,17 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve dragState === null && sidebarListItems.length + visibleDraftSessionCount > 0; // Cache on both fields so layout-key changes trigger the effect even when animate is unchanged. const listMotionPass = { layoutKey: listMotionLayoutKey, animate: listMotionAnimate }; + const shelfExpansionKey = [workingShelfExpanded, snoozedShelfExpanded, settledShelfExpanded].join( + ":", + ); + const previousShelfExpansionKeyRef = useRef(shelfExpansionKey); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells the list motion controller to animate or not after each layout-affecting commit useLayoutEffect(() => { - listMotionRef.current?.update(listMotionPass.animate); - }, [listMotionPass]); + // Shelf rows would travel over stationary headers below the bottom-anchored shelf. + const shelvesUnchanged = previousShelfExpansionKeyRef.current === shelfExpansionKey; + previousShelfExpansionKeyRef.current = shelfExpansionKey; + listMotionRef.current?.update(listMotionPass.animate && shelvesUnchanged); + }, [listMotionPass, shelfExpansionKey]); const handleThreadDragOver = (event: DragOverEvent) => { const target = event.over ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) @@ -2914,7 +2951,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const handleNewThreadClick = (event?: ReactMouseEvent) => { if ( - boringSidebarEnabled || + legacySidebarEnabled || shouldCreateNewThreadInCurrentProject( event?.shiftKey ?? false, scopeGroups.length, @@ -2948,6 +2985,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve @@ -3020,10 +3058,12 @@ 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} - {project && showProjectEnvironments ? ( - - ) : null} {project && canPinProject(project) ? ( toggleProjectGroupPin(project)} /> ) : null} + {project && showProjectEnvironments ? ( + + ) : null} {project ? ( + ), + }), + }, + ]; const shelfItems = hasBlockingComposerTopDrawer - ? props.bannerItems.filter( + ? bannerItems.filter( (item) => item.group === "environment" && (item.variant === "error" || item.variant === "warning"), ) - : props.bannerItems; + : bannerItems; const insertComposerText = ( text: string, @@ -2475,11 +2522,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) localEnvironmentDisabled: isLocalEnvironmentDisabled(), environmentId, primaryEnvironmentId, + connection: readPreparedConnection(environmentId), + isDesktopRenderer: window.desktopBridge !== undefined, }); if (target === "remote") { toastManager.add({ type: "error", - title: "Folders can only be referenced in the local desktop environment", + title: "Folders can only be referenced on a server running on this computer", }); return; } @@ -3289,7 +3338,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? isComposerMenuLoading ? composerTriggerKind === "pull-request" ? "Finding pull request..." - : "Searching workspace files..." + : composerTriggerKind === "skill" || composerTriggerKind === "slash-command" + ? "Searching workspace skills..." + : "Searching workspace files..." : composerMenuEmptyState : ""}
diff --git a/apps/web/src/components/chat/ChatHeader.browser.test.tsx b/apps/web/src/components/chat/ChatHeader.browser.test.tsx new file mode 100644 index 0000000000000..115de39efe25d --- /dev/null +++ b/apps/web/src/components/chat/ChatHeader.browser.test.tsx @@ -0,0 +1,73 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +const persisted = vi.hoisted(() => ({ title: "" })); +vi.mock(import("../../state/session"), async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) => ({ + ...(await importOriginal()), + useOrchestrationCommand: (() => + async ({ input }: { input: { title: string } }) => { + persisted.title = input.title; + return { _tag: "Success" }; + }) as never, +})); +vi.mock(import("~/hooks/useThreadActionMenu"), () => ({ + useThreadActionMenu: () => ({ openMenu: () => {}, closeMenu: () => {} }), +})); + +import { render } from "../../../test/render"; +import { ChatHeader } from "./ChatHeader"; + +function header(title: string) { + return ( + <> + {}} + rightPanelOpen={false} + onNewThreadInProject={() => {}} + /> +
Outside rename
+ + ); +} + +it.each(["blur", "Enter"] as const)( + "preserves a concurrent title update when an untouched rename ends with %s", + async (finish) => { + persisted.title = "New thread"; + await using screen = await render(header(persisted.title)); + await screen.getByRole("button", { name: "Thread actions for New thread" }).dblClick(); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await expect.element(input).toHaveValue("New thread"); + + persisted.title = "Fix login redirect"; + await screen.rerender(header(persisted.title)); + if (finish === "Enter") await userEvent.keyboard("{Enter}"); + else await screen.getByText("Outside rename").click(); + + await expect.element(input).not.toBeInTheDocument(); + expect(persisted.title).toBe("Fix login redirect"); + + await screen.rerender(header(persisted.title)); + await screen.getByRole("button", { name: "Thread actions for Fix login redirect" }).dblClick(); + await screen.getByRole("textbox", { name: "Thread title" }).fill("My edited title"); + persisted.title = "Another concurrent title"; + await screen.rerender(header(persisted.title)); + await userEvent.keyboard("{Enter}"); + await expect + .element(screen.getByRole("textbox", { name: "Thread title" })) + .not.toBeInTheDocument(); + expect(persisted.title).toBe("My edited title"); + }, +); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 144402d1dfe9f..49f21a1ddc829 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -175,7 +175,10 @@ export const ChatHeader = memo(function ChatHeader({ const commitRename = (title: string) => { setRenaming(null); if (!readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)) return; - const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); + const resolution = resolveRenameCommit({ + title, + originalTitle: renamingTitle ?? activeThreadTitle, + }); if (resolution.action === "reject-empty") { toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); return; diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx index f2740de785b29..c810890ebf96b 100644 --- a/apps/web/src/components/chat/ChatMarkdownMedia.tsx +++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx @@ -5,6 +5,7 @@ import { TriangleAlertIcon } from "lucide-react"; import { createContext, memo, + use, useState, type ComponentProps, type CSSProperties, @@ -71,6 +72,8 @@ const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, ); export const MarkdownLinkContext = createContext(false); +/** The thread's workspace mutation id, so a host image the agent rewrites gets a fresh URL. */ +export const WorkspaceMutationIdContext = createContext(undefined); function expandableMarkdownImageProps( onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, @@ -339,13 +342,15 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { const resource = props.resource; + const threadWorkspaceMutationId = use(WorkspaceMutationIdContext); + const workspaceMutationId = props.workspaceMutationId ?? threadWorkspaceMutationId; const assetUrl = useAssetUrlState( props.environmentId, resource, - props.workspaceMutationId !== undefined && + workspaceMutationId !== undefined && props.kind !== "video" && (resource._tag === "media-file" || resource._tag === "workspace-file") - ? props.workspaceMutationId + ? workspaceMutationId : undefined, ); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); diff --git a/apps/web/src/components/chat/ChildThreadApprovals.browser.test.tsx b/apps/web/src/components/chat/ChildThreadApprovals.browser.test.tsx new file mode 100644 index 0000000000000..ef136a1760137 --- /dev/null +++ b/apps/web/src/components/chat/ChildThreadApprovals.browser.test.tsx @@ -0,0 +1,52 @@ +import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests"; +import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../test/render"; + +const state = vi.hoisted(() => ({ respond: vi.fn(), approvalsAtom: null as unknown })); + +vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.respond })); +vi.mock("../../state/threads", () => ({ + childThreadApprovalsAtom: () => state.approvalsAtom, + threadEnvironment: { respondToApproval: {} }, +})); + +import { ChildThreadApprovals } from "./ChildThreadApprovals"; + +function setup() { + state.respond.mockReset(); + state.respond + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Child environment offline")))) + .mockResolvedValueOnce(AsyncResult.success(undefined)); + const thread = { + environmentId: EnvironmentId.make("child-env"), + threadId: ThreadId.make("child"), + }; + state.approvalsAtom = Atom.make>([ + { + thread, + title: "Compile task", + approval: { + requestId: RuntimeRequestId.make("approval"), + requestKind: "command", + detail: "Run compiler", + createdAt: "2026-11-01T00:00:00.000Z", + responseCapability: "live", + }, + }, + ]); + return thread; +} + +it("shows a failed decision and clears the error when the user retries", async () => { + const thread = setup(); + await using screen = await render(); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.element(screen.getByRole("alert")).toHaveTextContent("Child environment offline"); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.element(screen.getByRole("alert")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ChildThreadApprovals.tsx b/apps/web/src/components/chat/ChildThreadApprovals.tsx new file mode 100644 index 0000000000000..5a7cb3074b4a6 --- /dev/null +++ b/apps/web/src/components/chat/ChildThreadApprovals.tsx @@ -0,0 +1,95 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope, type ScopedThreadRef } from "@t3tools/contracts"; +import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests"; +import { fullAccessOverrideExplanation } from "@t3tools/client-runtime/state/thread-requests"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; +import { useEnvironmentScope } from "../../state/session"; +import { childThreadApprovalsAtom, threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; + +function ChildApproval({ entry }: { entry: ChildThreadApproval }) { + const canRespond = useEnvironmentScope(entry.thread.environmentId, AuthOrchestrationOperateScope); + const respond = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false }); + const [responding, setResponding] = useState(false); + const [error, setError] = useState(null); + const { approval } = entry; + return ( +
+

Approval needed from {entry.title}

+
+        {approval.detail || approval.requestKind}
+      
+ {approval.appName ?

{approval.appName}

: null} + {approval.responseCapability !== "live" ? ( +

+ Provider process is gone. Interrupt or restart the child thread to respond. +

+ ) : null} + {approval.reason ?

{approval.reason}

: null} + {approval.fullAccessOverride ? ( +

+ {fullAccessOverrideExplanation(approval.fullAccessOverride)} +

+ ) : null} +
+ { + if (!canRespond || responding) return; + setResponding(true); + setError(null); + const result = await respond({ + environmentId: entry.thread.environmentId, + input: { threadId: entry.thread.threadId, requestId, decision }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Failed to submit approval decision.", + ); + } + setResponding(false); + return result; + }} + /> +
+ {error !== null ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +export function ChildThreadApprovals({ threadRef }: { threadRef: ScopedThreadRef | null }) { + const approvals = useAtomValue(childThreadApprovalsAtom(threadRef)); + if (approvals.length === 0) return null; + return ( +
+ {approvals.map((entry) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx index bf83b014b7f6f..4c9702abe5f31 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx @@ -1,6 +1,7 @@ import { InfoIcon } from "lucide-react"; import type { ReactNode } from "react"; import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; @@ -123,3 +124,28 @@ it("shows the details button while text or nested text is cut, until it fits aga await screen.rerender({noticeRow("4rem", "Ready")}); await expect.element(details).not.toBeInTheDocument(); }); + +it("opens the details of a long notice right above its button", async () => { + const viewport = { width: window.innerWidth, height: window.innerHeight }; + await page.viewport(1280, 800); + await using _viewport = { + [Symbol.asyncDispose]: () => page.viewport(viewport.width, viewport.height), + }; + const script = Array.from({ length: 400 }, (_, line) => `echo "line ${line}"`).join("\n"); + const container = document.createElement("div"); + container.style.cssText = "position: fixed; bottom: 4rem; right: 4rem;"; + await using screen = await render(noticeRow("40rem", script), { container }); + const details = screen.getByRole("button", { name: "Show details" }); + await userEvent.hover(details); + const popup = page.getByLabelText("Notice details"); + await expect.element(popup).toBeVisible(); + + // A positioner sized to the unscrolled text flips beside the button and covers it. + await vi.waitFor(() => { + const gap = + details.element().getBoundingClientRect().top - + popup.element().getBoundingClientRect().bottom; + expect(gap).toBeGreaterThanOrEqual(0); + expect(gap).toBeLessThan(8); + }); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 61778f817dc37..ee602dcdac1eb 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -155,9 +155,7 @@ export function NoticeText({ side="top" className="max-w-[min(30rem,calc(100vw-2rem))] whitespace-normal wrap-anywhere [--inline-button-text-align:start] [--inline-button-white-space:normal] [&_[data-slot=inline-button]]:max-w-full" > - - {children} - + {children} ) : null} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index afa058587b553..0b9dda460d8ef 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -152,7 +152,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {

{props.isLoading - ? props.triggerKind === "skill" + ? props.triggerKind === "skill" || props.triggerKind === "slash-command" ? "Searching workspace skills..." : props.triggerKind === "pull-request" ? "Finding pull request..." diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index 139e80d081e4e..9c1e92d93641f 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -12,7 +12,7 @@ function composerControlClassName(size: ComposerControlSize, className?: string) "relative inline-flex shrink-0 cursor-pointer items-center justify-center whitespace-nowrap rounded-(--control-radius) border border-transparent text-base outline-none hover:bg-accent data-pressed:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&:active:not([aria-haspopup])]:scale-[0.97] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:-mx-0.5 [&_svg[data-composer-control-icon]]:mx-0", "[&_:is([data-composer-control-label],[data-chat-provider-model-picker-label])]:[text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:[&_:is([data-composer-control-label],[data-chat-provider-model-picker-label])]:py-1.5", size === "xs" - ? "h-7 gap-1 px-1.75 font-normal text-foreground/70 text-sm hover:text-foreground dark:text-muted-foreground sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5 [&_svg]:mx-0 [&_svg[data-composer-control-chevron]]:ms-0 [&_svg[data-composer-control-chevron]]:-me-1" + ? "h-7 gap-1 has-[[data-composer-label]]:gap-1.5 px-1.75 font-normal text-foreground/70 text-sm hover:text-foreground dark:text-muted-foreground sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5 [&_svg]:mx-0 [&_svg[data-composer-control-chevron]]:ms-0 [&_svg[data-composer-control-chevron]]:-me-1" : "h-7 gap-1.5 px-2 font-medium text-foreground/80 [&_svg:not([class*='text-'])]:text-muted-foreground hover:text-foreground sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4", "aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:hover:bg-accent/80", className, diff --git a/apps/web/src/components/chat/ComposerControlsBar.tsx b/apps/web/src/components/chat/ComposerControlsBar.tsx index 8523c25927fa4..0002beeaf9fef 100644 --- a/apps/web/src/components/chat/ComposerControlsBar.tsx +++ b/apps/web/src/components/chat/ComposerControlsBar.tsx @@ -193,6 +193,8 @@ export function ComposerControlsBar(props: { environmentId={environmentId} compact={false} isComposerOwned + // The trigger grows away from this edge when its model or effort label changes. + popupAlign={composerControlsCollapsed ? "start" : "end"} disabled={providerCatalogPending || isSendBusy} multipleModelsEnabled={multipleModelsEnabled} multipleModelSelections={multipleModelSelections} diff --git a/apps/web/src/components/chat/ComposerFooterControls.tsx b/apps/web/src/components/chat/ComposerFooterControls.tsx index 31c5889db9b22..cb5e78c1e8cfe 100644 --- a/apps/web/src/components/chat/ComposerFooterControls.tsx +++ b/apps/web/src/components/chat/ComposerFooterControls.tsx @@ -21,8 +21,11 @@ import { ProviderModelPicker } from "./ProviderModelPicker"; import { runtimeModeConfig } from "./runtimeModeConfig"; import { useComposerMenuState } from "./useComposerMenuState"; -export type RuntimeModeOption = { mode: RuntimeMode } & (typeof runtimeModeConfig)[RuntimeMode]; -const supervisedRuntimeModeOption = { +export type RuntimeModeOption = { + mode: RuntimeMode; + disabledReason?: string; +} & (typeof runtimeModeConfig)[RuntimeMode]; +const supervisedRuntimeModeOption: RuntimeModeOption = { mode: "approval-required" as const, ...runtimeModeConfig["approval-required"], }; @@ -161,9 +164,7 @@ export const ComposerFooterModeControls = memo(function ComposerFooterModeContro */ export function ComposerPermissionsSection(props: { runtimeMode: RuntimeMode; - runtimeModeOptions: ReadonlyArray< - { mode: RuntimeMode } & (typeof runtimeModeConfig)[RuntimeMode] - >; + runtimeModeOptions: ReadonlyArray; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { // Hover or keyboard focus previews a mode's name and description before a pick. @@ -203,6 +204,7 @@ export function ComposerPermissionsSection(props: { setPreviewMode(null)} onFocus={() => setPreviewMode(option.mode)} onBlur={() => setPreviewMode(null)} - className="flex h-6 flex-1 cursor-pointer items-center justify-center text-muted-foreground outline-none first:rounded-l-md last:rounded-r-md data-checked:bg-input/72 data-checked:text-foreground data-highlighted:text-foreground not-data-checked:data-highlighted:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" + className="flex h-6 flex-1 cursor-pointer items-center justify-center text-muted-foreground outline-none first:rounded-l-md last:rounded-r-md data-disabled:cursor-not-allowed data-disabled:opacity-40 data-checked:bg-input/72 data-checked:text-foreground data-highlighted:text-foreground not-data-checked:data-highlighted:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" > @@ -221,7 +223,7 @@ export function ComposerPermissionsSection(props: { {/* Static per-mode descriptions for screen readers; the preview below is visual only. */} {props.runtimeModeOptions.map((option) => ( - {option.description} + {option.disabledReason ?? option.description} ))}

); diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.tsx index e9de2aa93b302..68158c7151b2f 100644 --- a/apps/web/src/components/chat/ComposerImageThumbnail.tsx +++ b/apps/web/src/components/chat/ComposerImageThumbnail.tsx @@ -1,24 +1,15 @@ -import { memo, Suspense, use, type ReactNode } from "react"; +import { memo, useLayoutEffect, useState, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; import { createComposerImageThumbnail, + isHeicImageFile, + MAX_COMPRESSIBLE_SOURCE_BYTES, readComposerImageThumbnail, } from "../../lib/imageCompression"; -export const ComposerImageThumbnail = memo(function ComposerImageThumbnail(props: { - file: File; - alt: string; - className: string; - fallback: ReactNode; -}) { - return ( - - - - ); -}); - -function ThumbnailImage({ +export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ file, alt, className, @@ -29,8 +20,63 @@ function ThumbnailImage({ className: string; fallback: ReactNode; }) { - // A settled thumbnail paints synchronously instead of suspending for a tick. - const settled = readComposerImageThumbnail(file); - const src = settled === undefined ? use(createComposerImageThumbnail(file)) : settled; - return src ? {alt} : fallback; -} + const [preview, setPreview] = useState(() => ({ + file, + src: readComposerImageThumbnail(file), + })); + const [loadedSrc, setLoadedSrc] = useState(null); + const [failedSrc, setFailedSrc] = useState(null); + const src = preview.file === file ? preview.src : readComposerImageThumbnail(file); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- owns the local blob URL and decodes its replacement before swapping the visible image + useLayoutEffect(() => { + let disposed = false; + let localUrl: string | undefined; + const cached = readComposerImageThumbnail(file); + if ( + file.size <= MAX_COMPRESSIBLE_SOURCE_BYTES && + !isHeicImageFile(file) && + cached === undefined + ) { + localUrl = URL.createObjectURL(file); + // oxlint-disable-next-line react/set-state-in-effect -- the blob URL is created and revoked by this effect + setPreview({ file, src: localUrl }); + } + if (cached === undefined) { + void createComposerImageThumbnail(file).then(async (thumbnail) => { + if (disposed) return; + if (!thumbnail) { + if (!localUrl) setPreview({ file, src: null }); + return; + } + const image = new Image(); + image.src = thumbnail; + try { + await image.decode(); + if (!disposed) setPreview({ file, src: thumbnail }); + } catch { + // Keep the local preview if the thumbnail cannot be decoded. + } + }); + } + return () => { + disposed = true; + if (localUrl) URL.revokeObjectURL(localUrl); + }; + }, [file]); + + return src && failedSrc !== src ? ( + { + if (image?.complete && image.naturalWidth > 0) setLoadedSrc(src); + }} + src={src} + alt={alt} + onLoad={() => setLoadedSrc(src)} + onError={() => setFailedSrc(src)} + className={cn(className, loadedSrc !== src && "invisible")} + /> + ) : src === undefined ? null : ( + fallback + ); +}); diff --git a/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx new file mode 100644 index 0000000000000..3d704b5209305 --- /dev/null +++ b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx @@ -0,0 +1,125 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderOptionSelection, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { useState } from "react"; +import { expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { ComposerEffortColumn, describeComposerEffort } from "./ComposerModelEffort"; +import type { TraitsSelectionInput } from "./TraitsPicker"; + +// Shaped like Devin over ACP: its session mode comes first, and its reasoning id is its own. +const models: ReadonlyArray = [ + { + slug: "fusion", + name: "Fusion (Claude Opus 5.5 Medium + SWE-2 High)", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "mode", + label: "Session Mode", + type: "select", + currentValue: "bypass", + options: [ + { id: "code", label: "Code" }, + { id: "plan", label: "Plan" }, + { id: "bypass", label: "Bypass Permissions" }, + ], + }, + { + id: "thought_level", + label: "Thinking", + type: "select", + role: "reasoning", + currentValue: "medium", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + { + id: "speed", + label: "Speed", + type: "select", + currentValue: "standard", + options: [ + { id: "standard", label: "Standard" }, + { id: "fast", label: "Fast" }, + ], + }, + { id: "auto_approve", label: "Auto-approve tools", type: "boolean", currentValue: false }, + ], + }), + }, +]; + +function traitsInput( + modelOptions: ReadonlyArray, + onModelOptionsChange: (next: ReadonlyArray | undefined) => void, +): TraitsSelectionInput { + return { + provider: ProviderDriverKind.make("acpRegistry"), + instanceId: ProviderInstanceId.make("devin"), + models, + model: "fusion", + modelOptions, + planModeEnabled: false, + onModelOptionsChange, + }; +} + +function Column() { + const [options, setOptions] = useState>([]); + const traits = traitsInput(options, (next) => setOptions(next ?? [])); + return ( + <> +
+ +
+

{describeComposerEffort(traits, "bars", "sm").suffix}

+ + ); +} + +it("draws an agent's reasoning as bars and keeps its mode out of the levels", async () => { + await using screen = await render(, { router: false }); // no routes involved + const status = screen.getByRole("status"); + expect(status.element().querySelector("[data-composer-effort-glyph]")).not.toBeNull(); + await expect.element(status).toHaveTextContent(""); + + const levels = screen.getByRole("radiogroup", { name: "Thinking" }); + await expect.element(levels.getByRole("radio")).toHaveLength(3); + await expect.element(screen.getByRole("radio", { name: "Plan" })).not.toBeInTheDocument(); + + // Up and Down walk from the levels through the option rows without opening their menus. + await userEvent.tab(); + await expect.element(screen.getByRole("radio", { name: "Medium" })).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}"); + const speed = screen.getByRole("button", { name: "Speed: Standard" }); + await expect.element(speed).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); + await userEvent.keyboard("{ArrowRight}"); + await expect.element(page.getByRole("menuitemradio", { name: "Standard" })).toHaveFocus(); + await userEvent.keyboard("{ArrowLeft}"); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); + await expect.element(speed).toHaveFocus(); + + await userEvent.click(screen.getByText("Auto-approve tools")); + await expect + .element(screen.getByRole("switch", { name: "Auto-approve tools" })) + .toHaveAttribute("aria-checked", "true"); + + await userEvent.click(screen.getByRole("button", { name: "Session Mode: Bypass Permissions" })); + await userEvent.click(page.getByRole("menuitemradio", { name: "Plan" })); + await expect + .element(screen.getByRole("button", { name: "Session Mode: Plan" })) + .toBeInTheDocument(); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 5ce77c60b1078..3a863936101b2 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -1,16 +1,25 @@ import { buildTraitsTriggerDisplay, getDescriptorStringValue, - REASONING_SELECT_DESCRIPTOR_IDS, + isReasoningControl, selectableProviderOptionChoices, } from "@t3tools/client-runtime/model-traits"; import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; import type { EffortDisplay } from "@t3tools/contracts/settings"; -import { CheckIcon, ChevronUpIcon } from "lucide-react"; -import { type KeyboardEvent, type ReactNode, use, useRef } from "react"; +import { getProviderOptionCurrentLabel } from "@t3tools/shared/model"; +import { CheckIcon, ChevronRightIcon, ChevronUpIcon } from "lucide-react"; +import { type KeyboardEvent, type ReactNode, use, useRef, useState } from "react"; import { cn } from "~/lib/utils"; -import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { + Menu, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuRadioItemIndicator, + MenuTrigger, +} from "../ui/menu"; +import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { ComposerControlSize } from "./ComposerControl"; import { ComposerPermissionsSection, type RuntimeModeOption } from "./ComposerFooterControls"; @@ -51,8 +60,8 @@ type ColumnControl = | { kind: "boolean"; descriptor: BooleanDescriptor }; /** - * The column lists the model's reasoning control first, under the header that names it, then - * its other options. + * The column lists the model's reasoning levels under the header that names it. Other options, + * such as an agent's session mode, sit below as one row each. */ function columnControls( provider: TraitsSelectionInput["provider"], @@ -64,8 +73,7 @@ function columnControls( ...columnSelects.map((descriptor) => ({ kind: "select" as const, descriptor })), ...columnToggles.map((descriptor) => ({ kind: "boolean" as const, descriptor })), ]; - const effort = - controls.find((control) => REASONING_SELECT_DESCRIPTOR_IDS.has(control.descriptor.id)) ?? null; + const effort = controls.find((control) => isReasoningControl(control.descriptor)) ?? null; return { effort, others: controls.filter((control) => control !== effort) }; } @@ -94,20 +102,40 @@ export function describeComposerEffort( modelSelection, reportedModelSelection: traits.reportedModelSelection, }); + // The same reasoning control the column lists, wherever the agent put it among its options. + const reasoning = + columnControls(traits.provider, visibility.selectDescriptors, visibility.booleanDescriptors) + .effort?.descriptor ?? null; + const ultrathink = visibility.ultrathinkPromptControlled && reasoning === primary; const glyph = - primary && REASONING_SELECT_DESCRIPTOR_IDS.has(primary.id) + reasoning?.type === "select" ? resolveEffortGlyph( - primary, - visibility.ultrathinkPromptControlled + reasoning, + ultrathink ? "ultrathink" - : getDescriptorStringValue(primary, modelSelection, traits.reportedModelSelection), + : getDescriptorStringValue(reasoning, modelSelection, traits.reportedModelSelection), ) : null; - // Without a reasoning select the whole label is the word; it already names speed and the rest. // Workflow levels such as Ultracode always show their name; the setting names every level. const word = glyph ? (glyph.special ?? (effortDisplay === "label" ? glyph.label : null)) - : display.label; + : !reasoning + ? null + : reasoning.type === "boolean" + ? `${reasoning.label} ${reasoning.currentValue === true ? "On" : "Off"}` + : ultrathink + ? "Ultrathink" + : getProviderOptionCurrentLabel(reasoning, modelSelection, traits.reportedModelSelection); + const fast = display.speedLabel === "Fast" || display.speedLabel === "Ultrafast"; + // Agent options such as modes live in the picker; the trigger adds only the context window. + const contextWindow = + visibility.contextWindowDescriptor && + getProviderOptionCurrentLabel( + visibility.contextWindowDescriptor, + modelSelection, + traits.reportedModelSelection, + ); + if (!word && !glyph && !fast && !contextWindow) return { suffix: null, detail: display.label }; return { detail: display.label, suffix: ( @@ -118,22 +146,22 @@ export function describeComposerEffort( size === "sm" && "-ms-0.5", )} > - {word !== null ? ( - {word} + {word ? ( + + {word} + ) : glyph ? ( ) : null} - {glyph && (display.speedLabel === "Fast" || display.speedLabel === "Ultrafast") ? ( - - ) : null} - {glyph && display.otherLabels.length > 0 ? ( + {fast ? : null} + {contextWindow ? ( - {display.otherLabels.join(" · ")} + {contextWindow} ) : null} @@ -150,10 +178,10 @@ function ColumnRow(props: { /** Shown on hover; the column is too narrow to print it under the label. */ description?: string | undefined; selected: boolean; + /** The column's one tab stop: the selected level, else the first when none shows as selected. */ + tabStop: boolean; isDefault?: boolean | undefined; disabled?: boolean; - /** A level of the reasoning control, where focus lands after a model pick. */ - level: boolean; onPick: () => void; }) { const rowProps = { @@ -162,9 +190,10 @@ function ColumnRow(props: { "aria-checked": props.selected, disabled: props.disabled, "data-effort-row": true, - "data-effort-level": props.level || undefined, - // The reasoning control holds the column's one tab stop; Up and Down move between rows. - tabIndex: props.selected && props.level ? 0 : -1, + // Focus lands on a level after a model pick. + "data-effort-level": true, + // Up and Down move between rows. + tabIndex: props.tabStop ? 0 : -1, className: COLUMN_ROW, onClick: props.onPick, } as const; @@ -194,6 +223,8 @@ function ColumnRow(props: { function moveBetweenRows(event: KeyboardEvent) { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + // An option's side menu is a React child here, but it moves through its own items. + if (!event.currentTarget.contains(event.target as Node)) return; const rows = Array.from( event.currentTarget.querySelectorAll("[data-effort-row]:not(:disabled)"), ); @@ -230,66 +261,222 @@ function EffortLevels(props: { traits: TraitsSelectionInput }) { ); } if (!effort && others.length === 0) return ; - const renderControl = (control: ColumnControl) => { - const isEffort = control === effort; - const { descriptor } = control; - if (control.kind === "boolean") { - return ( + // Ultrathink in the prompt locks the levels; the first option then holds the tab stop. + const locked = + effort?.descriptor.id === traits.primarySelectDescriptor?.id && traits.ultrathinkInBodyText; + return ( + <> + {effort ? : null} + {others.length > 0 ? (
- {isEffort ? null :
{descriptor.label}
} - {([true, false] as const).map((value) => ( - traits.changeBoolean(descriptor.id, value)} - /> - ))} + {others.map((control, index) => + control.kind === "boolean" ? ( + + ) : ( + + ), + )}
- ); - } - const locked = - descriptor.id === traits.primarySelectDescriptor?.id && traits.ultrathinkInBodyText; - const value = traits.selectValue(control.descriptor); + ) : null} + + ); +} + +type TraitsSelection = ReturnType; + +function EffortRows(props: { control: ColumnControl; traits: TraitsSelection; locked: boolean }) { + const { control, traits } = props; + const { descriptor } = control; + if (control.kind === "boolean") { return ( -
- {isEffort ? null :
{descriptor.label}
} - {locked ? ( -
- {ULTRATHINK_IN_BODY_NOTE} -
- ) : null} - {selectableProviderOptionChoices(control.descriptor).map((option) => ( +
+ {([true, false] as const).map((value) => ( traits.changeSelect(control.descriptor, option.id)} + key={String(value)} + label={value ? "On" : "Off"} + selected={(control.descriptor.currentValue === true) === value} + tabStop={(control.descriptor.currentValue === true) === value} + onPick={() => traits.changeBoolean(descriptor.id, value)} /> ))}
); - }; + } + const { locked } = props; + const value = traits.selectValue(control.descriptor); + const choices = selectableProviderOptionChoices(control.descriptor); + // An "Ultrathink:" prefix selects a level the column does not offer. + const shown = choices.some((option) => option.id === value); return ( - <> - {effort ? renderControl(effort) : null} - {others.map(renderControl)} - +
+ {locked ? ( +
+ {ULTRATHINK_IN_BODY_NOTE} +
+ ) : null} + {choices.map((option, index) => ( + traits.changeSelect(control.descriptor, option.id)} + /> + ))} +
+ ); +} + +const OPTION_ROW = cn(COLUMN_ROW, "h-auto py-1"); + +/** An option's name over its current value. */ +function OptionRowText(props: { label: string; value: string }) { + return ( + + {props.label} + {props.value} + + ); +} + +/** + * One select besides reasoning, such as an agent's session mode. It names the current choice and + * opens the rest beside the column, so any number of options leaves the levels in place. + */ +function OptionMenuRow(props: { + descriptor: SelectDescriptor; + traits: TraitsSelection; + /** Holds the column's tab stop when the model has no levels. */ + tabStop: boolean; +}) { + const { descriptor, traits } = props; + const [open, setOpen] = useState(false); + const popupRef = useRef(null); + const choices = selectableProviderOptionChoices(descriptor); + const value = traits.selectValue(descriptor); + const current = choices.find((choice) => choice.id === value)?.label ?? ""; + return ( + { + if (!isOpen) return; + const checked = popupRef.current?.querySelector( + '[role="menuitemradio"][aria-checked="true"]', + ); + checked?.focus({ preventScroll: true }); + checked?.scrollIntoView({ block: "nearest" }); + }} + > + {/* No hover opening: an opened menu takes focus, which would steal typing from search. */} + { + if (event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + setOpen(true); + }} + /> + } + > + + + { + if (event.key !== "ArrowLeft") return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + }} + > + { + if (next !== value) traits.changeSelect(descriptor, next); + }} + > + {choices.map((choice) => ( + + + + {choice.label} + {choice.description ? ( + + {choice.description} + + ) : null} + + {choice.isDefault ? ( + Default + ) : null} + + + + ))} + + + + ); +} + +/** A two-state option toggles in place; a menu would add a click for nothing. */ +function OptionSwitchRow(props: { + descriptor: BooleanDescriptor; + traits: TraitsSelection; + tabStop: boolean; +}) { + const { descriptor, traits } = props; + const checked = descriptor.currentValue === true; + return ( + ); } @@ -303,20 +490,22 @@ function NoEffortRow() { export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | null }) { const picker = use(DockedPickerContext); const visibility = props.traits ? getTraitsSectionVisibility(props.traits) : null; - const effort = + const controls = props.traits && visibility ? columnControls( props.traits.provider, visibility.selectDescriptors, visibility.booleanDescriptors, - ).effort + ) : null; return ( <> {/* Matches the search row's box, so one rule runs across the top of the popup. */}
- {effort?.descriptor.label ?? "Effort"} + + {controls?.effort?.descriptor.label ?? (controls?.others.length ? "Options" : "Effort")} + {picker.modelLabel} @@ -325,7 +514,8 @@ export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | nul {/* pt-2.5 puts the first level on the same line as the first model. */}
{props.traits ? : }
diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.direction.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.direction.browser.test.tsx new file mode 100644 index 0000000000000..6e1b387a7412f --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.direction.browser.test.tsx @@ -0,0 +1,100 @@ +import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +vi.mock(import("../ChatMarkdown"), () => ({ + default: (({ text }: { text: string }) =>

{text}

) as never, +})); + +import { render } from "../../../test/render"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; + +type Question = { + header: string; + question: string; + options: { label: string; description: string }[]; +}; + +const renderPanel = (questions: Question[], questionIndex = 0) => + render( + ({ + ...question, + id: `question-${index}`, + multiSelect: false, + })), + }, + ]} + cwd={undefined} + threadRef={{ environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }} + respondingRequestIds={[]} + answers={{}} + questionIndex={questionIndex} + onToggleOption={() => {}} + onAdvance={() => {}} + onDismiss={() => {}} + />, + ); + +it("reads a mostly Hebrew question set right to left", async () => { + await using screen = await renderPanel([ + { + header: "גישה", + question: "איך לבצע את המיגרציה?", + options: [ + { + label: "React Server Components בכל המסכים של האפליקציה", + description: "מסך אחד בכל פעם", + }, + { label: "Keep it on React 18", description: "English only" }, + ], + }, + ]); + + const text = (value: string) => screen.getByText(value, { exact: true }); + await expect.element(text("גישה")).toHaveStyle({ direction: "rtl" }); + await expect + .element(text("React Server Components בכל המסכים של האפליקציה")) + .toHaveStyle({ direction: "rtl" }); + await expect.element(text("מסך אחד בכל פעם")).toHaveStyle({ direction: "rtl" }); + await expect + .element(screen.getByRole("button", { name: /React Server/ })) + .toHaveStyle({ direction: "rtl" }); + await expect.element(text("Keep it on React 18")).toHaveStyle({ direction: "ltr" }); + + await screen.getByTitle("Hide the question and its options").click(); + await expect.element(text("איך לבצע את המיגרציה?")).toHaveStyle({ direction: "rtl" }); +}); + +it("keeps one direction for the whole set when a single question is Hebrew", async () => { + await using screen = await renderPanel( + [ + { + header: "Migration", + question: "How should we move to React Server Components?", + options: [ + { label: "Screen by screen", description: "Lower risk, each step tested alone." }, + { label: "Server Actions for all forms first", description: "Then the rest." }, + ], + }, + { + header: "גרסה", + question: "בחר גרסה", + options: [{ label: "גרסה 3 (חדשה!)", description: "!!!" }], + }, + ], + 1, + ); + + await expect + .element(screen.getByRole("button", { name: /גרסה 3/ })) + .toHaveStyle({ direction: "ltr" }); + await expect + .element(screen.getByText("גרסה 3 (חדשה!)", { exact: true })) + .toHaveStyle({ direction: "rtl" }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index 58ad8e8c0900d..777238f7635e8 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -17,7 +17,9 @@ vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ })); import { act, fakeTimers, render } from "../../../test/render"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { makeThreadProjectionFixture } from "../../test-fixtures"; +import { toastManager } from "../ui/toast"; import { usePendingThreadRequests } from "./usePendingThreadRequests"; const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; @@ -89,7 +91,8 @@ function useProbeRequests({ environmentId, projection, loading = false }: ProbeP serverProjection: loading ? null : projection, supportsQuestionAttachments: false, composerDraftTarget: { environmentId, threadId: projection.thread.id }, - setComposerDraftPrompt: () => {}, + setComposerDraftPrompt: (target, prompt) => + useComposerDraftStore.getState().setPrompt(target, prompt), promptRef, composerRef, setThreadError: () => {}, @@ -141,6 +144,43 @@ it("retains answers, selections and question position after leaving chat, includ expect(requests).toMatchObject(expected); }); +it("tells the user when an option click moves their typed answer into the hidden composer draft", async () => { + using _timers = fakeTimers(); + const environmentId = EnvironmentId.make("displaced-environment"); + const draftTarget = { environmentId, threadId: threadRef.threadId }; + const draftPrompt = () => useComposerDraftStore.getState().getComposerDraft(draftTarget)?.prompt; + using toast = vi.spyOn(toastManager, "add"); + await using _screen = await render( + , + ); + + await act(() => requests.onSelectActivePendingUserInputOption("question-0", "Incremental")); + expect(toast).not.toHaveBeenCalled(); + + await act(() => requests.onChangeActivePendingUserInputCustomAnswer("question-0", "first try")); + await act(() => requests.onSelectActivePendingUserInputOption("question-0", "Incremental")); + expect(draftPrompt()).toBe("first try"); + expect(toast).toHaveBeenCalledTimes(1); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "info", + title: "Your typed text was set aside", + description: "It will be back in the composer once the question is answered.", + data: { threadRef: draftTarget }, + }), + ); + + await act(() => requests.onChangeActivePendingUserInputCustomAnswer("question-0", "second try")); + expect(draftPrompt()).toBe("first try"); + expect(requests.activePendingDraftAnswers["question-0"]).toMatchObject({ + customAnswer: "second try", + }); + expect(toast).toHaveBeenCalledTimes(1); +}); + function QuestionPositionProbe(props: ProbeProps) { const state = useProbeRequests(props); return ( diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 37a002e2c2708..a19e7f205cef9 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,4 +1,5 @@ import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts"; +import { markdownProseDirection, textDirection } from "~/textDirection"; import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; @@ -179,8 +180,20 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( return null; } + const promptDirection = markdownProseDirection( + prompt.questions + .flatMap((question) => [ + question.header, + question.question, + ...question.options.flatMap((option) => [option.label, option.description ?? ""]), + ]) + .join(" "), + ); + const questionTextAlign = promptDirection === "rtl" ? "text-right" : "text-left"; + return ( { setCollapsedQuestionId(open ? null : activeQuestion.id); @@ -197,13 +210,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( {activeQuestion.header} {isCollapsed ? ( <> - + {activeQuestion.question} @@ -245,7 +262,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( text={activeQuestion.question} cwd={cwd} threadRef={threadRef} - className="pt-2 text-foreground/85" + className={cn("pt-2 text-foreground/85", questionTextAlign)} /> {/* Rows span the header row's width so the radios align with the question text and the shortcut column sits under the dismiss button. */}
@@ -257,7 +274,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const isSelected = isOptimisticallySelected || draftSelectedOptions?.[index] === true; const className = cn( - "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 text-left sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", + questionTextAlign, + "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", isSelected ? "text-foreground" : "text-foreground/70 hover:bg-muted/30", (disabled || isResponding) && "opacity-50 cursor-not-allowed", !disabled && !isResponding && "cursor-pointer", @@ -273,9 +291,16 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( )}
- {option.label} + + {option.label} + {option.description && option.description !== option.label ? ( - {option.description} + + {option.description} + ) : null}
{index < 9 ? ( diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 250152c4d8501..91bf642fcb32f 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -27,7 +27,7 @@ import { buildSidebarProjectSnapshots, projectGroupsSpanEnvironments, } from "~/sidebarProjectGrouping"; -import { useProjects, useThreadShells } from "~/state/entities"; +import { useProjects, useVisibleThreadShells } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId, @@ -75,7 +75,7 @@ export function DraftHeroHeadline({ activeProjectTitle, }: DraftHeroHeadlineProps) { const projects = useProjects(); - const threads = useThreadShells(); + const threads = useVisibleThreadShells(); const { environments } = useEnvironments(); const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -140,7 +140,6 @@ export function DraftHeroHeadline({ ), ) ?? null); const activeProjectKey = activeProjectGroup?.projectKey ?? ""; - const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle; const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; @@ -302,12 +301,12 @@ export function DraftHeroHeadline({ } > - {activeProjectDisplayName && !isScratchDraft ? ( - {activeProjectDisplayName} + {activeProjectTitle && !isScratchDraft ? ( + {activeProjectTitle} ) : null} @@ -344,13 +343,6 @@ export function DraftHeroHeadline({ {item.label} - {entry && showProjectEnvironments ? ( - - ) : null} {entry && canPinProjectGroup(entry.group) ? ( toggleProjectGroupPin(entry.group)} /> ) : null} + {entry && showProjectEnvironments ? ( + + ) : null} ); }} @@ -415,7 +414,7 @@ export function DraftHeroHeadline({ return (
{hasResolvedProject && !isScratchDraft ? ( - + {projectSelector} ) : ( diff --git a/apps/web/src/components/chat/EffortGlyph.test.ts b/apps/web/src/components/chat/EffortGlyph.test.ts index 712e4b796ba61..0b73e89e4f833 100644 --- a/apps/web/src/components/chat/EffortGlyph.test.ts +++ b/apps/web/src/components/chat/EffortGlyph.test.ts @@ -45,9 +45,11 @@ describe("resolveEffortGlyph", () => { }); }); - it("falls back to the word when the value is unknown or the scale has one level", () => { + it("falls back to the word when the value is unknown or the scale is too short or long", () => { expect(resolveEffortGlyph(claude, "unknown")).toBeNull(); expect(resolveEffortGlyph(descriptor(["high"]), "high")).toBeNull(); + const levels = Array.from({ length: 8 }, (_, index) => `level${index}`); + expect(resolveEffortGlyph(descriptor(levels), "level0")).toBeNull(); }); }); diff --git a/apps/web/src/components/chat/EffortGlyph.tsx b/apps/web/src/components/chat/EffortGlyph.tsx index 39a46d2564901..7614e41c496a6 100644 --- a/apps/web/src/components/chat/EffortGlyph.tsx +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -14,6 +14,8 @@ type SelectDescriptor = Extract; // Max is named too, so the full scale stops short of it. const NAMED_LEVELS = new Set(["ultracode", "max"]); const OFF_LEVELS = new Set(["off", "none"]); +// More bars than this no longer fit the glyph's height in whole pixels. +const MAX_BARS = 7; /** * Maps a reasoning select to bars: one per ordinary level, filled up to the current one, with an @@ -22,7 +24,7 @@ const OFF_LEVELS = new Set(["off", "none"]); export function resolveEffortGlyph(descriptor: SelectDescriptor, current: string | null) { const special = new Set([...(descriptor.promptInjectedValues ?? []), ...NAMED_LEVELS]); const levels = descriptor.options.filter((option) => !special.has(option.id)); - if (levels.length < 2 || current === null) return null; + if (levels.length < 2 || levels.length > MAX_BARS || current === null) return null; const startsAtOff = OFF_LEVELS.has(levels[0]!.id); const count = startsAtOff ? levels.length - 1 : levels.length; if (special.has(current)) { diff --git a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx index 1ea8d4c558f35..0dc5b367b4e34 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx @@ -16,7 +16,46 @@ const snapShotSource: SnapShotSource = { windowTitle: "Downloads", }; +const D2_SVG = + ''; + describe("ExpandedImageDialog", () => { + it.each(["/work/pipeline.svg", "/work/diagram#v2.svg", "/work/diagram?v2.svg"])( + "fits a markdown SVG without intrinsic size to the viewer: %s", + async (path) => { + const src = URL.createObjectURL(new Blob([D2_SVG], { type: "image/svg+xml" })); + using _src = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + await using screen = await render( + , + // The dialog renders no links and reads no route. + { router: false }, + ); + + const viewer = screen.getByRole("region", { name: "Pipeline, zoomable image" }); + const availableWidth = window.innerWidth * 0.92 - (window.innerWidth >= 640 ? 96 : 0); + await vi.waitFor(() => + expect(viewer.element().getBoundingClientRect().width).toBeCloseTo(availableWidth, 0), + ); + }, + ); + it("Escape closes a popup opened inside the dialog before the dialog", async () => { const onClose = vi.fn(); await using screen = await render( diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index aadbcab79b129..48d961da0ea5e 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -25,6 +25,8 @@ import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { expandedImageKey } from "./ExpandedImagePreview"; import { registerThreadOverlay } from "./threadOverlayRetention"; +import { mediaReferenceFileName } from "@t3tools/client-runtime/media-reference"; +import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/mediaPreview"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; @@ -42,6 +44,16 @@ function ExpandedMediaFailure({ children }: { children: ReactNode }) { ); } +function itemMimeType(item: ExpandedImageItem): string | undefined { + if (item.mimeType) return item.mimeType; + const reference = item.actionsSource?.reference; + const fileName = reference && mediaReferenceFileName(reference); + if (fileName) { + return mediaMimeTypeFromExtension(fileName.slice(fileName.lastIndexOf("."))) ?? undefined; + } + return mediaMimeType(item.src ?? "") ?? undefined; +} + function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { const asset = item.actionsSource?.asset; const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null); @@ -196,7 +208,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ )} - +
{item.type === "video" ? ( @@ -222,7 +234,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ key={`${index}:${item.src}`} src={item.src} name={item.name} - mimeType={item.mimeType} + mimeType={itemMimeType(item)} onError={() => setFailedImageSrc(item.src)} /> )} diff --git a/apps/web/src/components/chat/MarkdownCodeBlock.tsx b/apps/web/src/components/chat/MarkdownCodeBlock.tsx index 6b1822aac14a7..301843c4ebe36 100644 --- a/apps/web/src/components/chat/MarkdownCodeBlock.tsx +++ b/apps/web/src/components/chat/MarkdownCodeBlock.tsx @@ -65,7 +65,7 @@ export function MarkdownCodeBlockFrame({ return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 7fd73739d9195..6b1df4ca16927 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, @@ -217,6 +217,45 @@ const TASK_ID = ScheduledTaskId.make("task-1"); const AUTOMATION_PROMPT = "Triage the inbox\n\nLabel every new issue and close duplicates."; describe("MessagesTimeline work rows", () => { + it("keeps the worked-for heading in place when expanded at the bottom", async () => { + const props = baseProps(); + const runId = RunId.make("completed-run"); + const container = document.createElement("div"); + container.style.cssText = "height: 600px; width: 900px; display: flex; flex-direction: column;"; + await using screen = await render( + + message(`Earlier context ${index}`, {}, `context-${index}`), + ), + message("Progress details\n\n".repeat(50), { role: "assistant", runId }, "progress"), + message("Done", { role: "assistant", runId }, "final"), + ]} + />, + { container }, + ); + const heading = screen.getByRole("button", { name: /^Worked for/ }); + await expect.element(heading).toBeVisible(); + const viewport = props.listRef.current!.getScrollableNode()!; + await vi.waitFor(() => + expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeLessThan(2), + ); + expect(viewport.scrollTop).toBeGreaterThan(0); + const top = heading.element().getBoundingClientRect().top; + const height = viewport.scrollHeight; + using _timers = fakeTimers(); + await heading.click(); + await act(() => vi.advanceTimersByTime(1000)); + await vi.waitFor(() => { + expect(viewport.scrollHeight).toBeGreaterThan(height + 500); + expect(heading.element().getBoundingClientRect().top).toBeCloseTo(top, 0); + expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeGreaterThan( + 500, + ); + }); + }); + it.each(["delegated", "legacy", "missing"] as const)( "uses the reported task for %s completion notifications", async (source) => { @@ -526,6 +565,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: { @@ -859,7 +968,7 @@ describe("MessagesTimeline attachments", () => { onOpen={onOpen} /> ); - await using screen = await renderElement(preview()); + await using screen = await renderElement(
{preview()}
); const audio = screen.container.querySelector("audio")!; const duration = vi.spyOn(audio, "duration", "get").mockReturnValue(Infinity); const play = vi.spyOn(audio, "play").mockResolvedValue(); @@ -884,6 +993,10 @@ describe("MessagesTimeline attachments", () => { audio.dispatchEvent(new Event("durationchange")); }); expect(screen.text()).toContain("0:03 / 0:10"); + expect( + getComputedStyle(screen.container.querySelector("audio")!.previousElementSibling!) + .direction, + ).toBe("ltr"); expect(progress.style.width).toBe("30%"); await act(() => { duration.mockReturnValue(NaN); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4c28163766058..cea2daa111207 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -470,13 +470,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ const normalizedFindQuery = findQuery.trim(); const findActive = findExpanded; - const disclosurePinsEndRef = useRef(false); - const pinDisclosureToEnd = () => { - if (!disclosurePinsEndRef.current) return; - const element = listRef.current?.getScrollableNode(); - if (element) element.scrollTop = element.scrollHeight; - }; - const scheduleDisclosureSettle = () => { if (disclosureSettleFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleFrameRef.current); @@ -485,10 +478,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ cancelAnimationFrame(disclosureSettleSecondFrameRef.current); } disclosureSettleFrameRef.current = requestAnimationFrame(() => { - pinDisclosureToEnd(); disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { - pinDisclosureToEnd(); - disclosurePinsEndRef.current = false; disclosureAnchorKeyRef.current = null; setDisclosureToggleSettling(false); disclosureSettleFrameRef.current = null; @@ -499,13 +489,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string) => { disclosureAnchorKeyRef.current = anchorKey; - disclosurePinsEndRef.current = resolveTimelineIsAtEnd(listRef.current?.getState()) === true; setDisclosureToggleSettling(true); scheduleDisclosureSettle(); }; const shouldRestoreVisibleContentPosition = (row: MessagesTimelineRow) => { - if (disclosurePinsEndRef.current) return false; const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || row.id === disclosureAnchorKey; }; @@ -955,8 +943,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ }; const handleItemSizeChanged = () => { - if (disclosurePinsEndRef.current) { - queueMicrotask(pinDisclosureToEnd); + if (disclosureAnchorKeyRef.current !== null) { scheduleDisclosureSettle(); } scheduleMinimapRefresh(); diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 9928530b97f2a..69da770f1f84b 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -67,11 +67,19 @@ export const ModelListRow = memo(function ModelListRow(props: { badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-5xs" />
-
+
{ + const name = event.currentTarget; + name.title = name.scrollWidth > name.clientWidth ? getDisplayModelName(model) : ""; + }} + > {getDisplayModelName(model)} {/* 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); @@ -126,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/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 742a7cf6b09fd..f040df764cda9 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -366,6 +366,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { docked?: boolean; /** Right at the end of the search text moves into the popup's effort column. */ onFocusEffort?: () => void; + /** Closes the list column, inside the popup's fixed height. */ + listFooter?: ReactNode; }) { const { searchInputRef, @@ -838,16 +840,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ref={pickerContentRef} className={cn( "relative flex w-screen max-w-90 flex-row overflow-hidden", - props.docked - ? "max-sm:h-56 max-sm:min-h-0! max-sm:w-full max-sm:max-w-none" - : "max-h-86.5", + props.docked ? "max-sm:h-56 max-sm:w-full max-sm:max-w-none" : "max-h-86.5", )} + // Docked, the popup sets the height; a minimum here would overflow short windows. style={ - isSearching - ? { height: searchHeight } - : { - minHeight: `min(calc(var(--spacing) * ${tallestTabUnits + LIST_CHROME_UNITS} + 1px), calc(var(--spacing) * 86.5))`, - } + props.docked + ? undefined + : isSearching + ? { height: searchHeight } + : { + minHeight: `min(calc(var(--spacing) * ${tallestTabUnits + LIST_CHROME_UNITS} + 1px), calc(var(--spacing) * 86.5))`, + } } data-model-picker-content="true" > @@ -1072,6 +1075,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onRequestClose={onRequestClose} onOpenProviderSetup={onOpenProviderSetup} /> + {props.listFooter}
diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 1c792cf4cec5a..c07b1f33c5fa9 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -13,7 +13,8 @@ import { function describeUnavailableInstance(entry: ProviderInstanceEntry): string { const label = entry.displayName; - if (!entry.enabled || entry.status === "disabled") { + // An unavailable instance (a driver this build lacks or the policy leaves out) says why instead. + if ((!entry.enabled && entry.isAvailable) || entry.status === "disabled") { return `${label}: Disabled in settings.`; } if (entry.status === "ready" && entry.isAvailable) { diff --git a/apps/web/src/components/chat/NativeChildWorkDialog.tsx b/apps/web/src/components/chat/NativeChildWorkDialog.tsx new file mode 100644 index 0000000000000..7695a0e38c62d --- /dev/null +++ b/apps/web/src/components/chat/NativeChildWorkDialog.tsx @@ -0,0 +1,131 @@ +import type { + EnvironmentId, + OrchestrationV2GetNativeChildWorkResult, + OrchestrationV2NativeChildStep, + OrchestrationV2NativeChildWork, + ThreadId, +} from "@t3tools/contracts"; +import { ListTreeIcon } from "lucide-react"; +import { useState } from "react"; + +import { orchestrationEnvironment } from "../../state/orchestration"; +import { useEnvironmentQuery } from "../../state/query"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; + +const stepLabel = (type: string) => { + const words = type.toLowerCase().replaceAll("_", " "); + return words.charAt(0).toUpperCase() + words.slice(1); +}; + +/** Read-only view of what a provider-native subagent's children did. Loads only while open. */ +export function NativeChildWorkDialog(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + Subagent work + Read only. These subagents cannot take messages. + + {open ? : null} + + + ); +} + +function NativeChildWorkContent(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const query = useEnvironmentQuery( + orchestrationEnvironment.nativeChildWork({ + environmentId: props.environmentId, + input: { threadId: props.threadId }, + }), + ); + const children = query.data?.children; + return ( + <> + + {query.error !== null ? ( +

{query.error}

+ ) : children === undefined ? ( +

Loading…

+ ) : children.length === 0 ? ( +

+ The provider has no record of which subagents this call started. +

+ ) : ( + children.map((child) => ) + )} +
+ + + + + ); +} + +function NativeChild(props: { readonly child: OrchestrationV2NativeChildWork }) { + const { child } = props; + return ( +
+

{child.role || "Subagent"}

+

+ {child.prompt} +

+ {child.truncated ? ( +

Earlier steps are not shown.

+ ) : null} + {child.steps === null ? ( +

No transcript yet.

+ ) : ( +
    + {child.steps.map((step) => ( + + ))} +
+ )} +
+ ); +} + +function NativeChildStep(props: { readonly step: OrchestrationV2NativeChildStep }) { + const { step } = props; + return ( +
  • + + {stepLabel(step.type)} + {step.status !== null && step.status !== "DONE" ? ` · ${stepLabel(step.status)}` : ""} + + {step.toolCalls.length === 0 ? null : ( + + {step.toolCalls.map((call) => `${call.name} ${call.input}`).join("\n")} + + )} + {step.text === null ? null : ( +
    +          {step.text}
    +        
    + )} +
  • + ); +} diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 6750298402942..56d93123d7d67 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -190,9 +190,11 @@ export function WorkspaceTitlebarControls(props: { {props.showMaximizeControl ? ( { return ( target instanceof Element && - target.closest("[data-model-picker-content], [data-model-picker-effort]") + target.closest( + "[data-model-picker-content], [data-model-picker-effort], [data-model-picker-option-menu]", + ) ); }; const preventBackgroundWheel = (event: WheelEvent) => { @@ -90,6 +92,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { disabled?: boolean; terminalOpen?: boolean; open?: boolean; + popupAlign?: "start" | "end"; triggerClassName?: string; triggerLabel?: string; triggerAriaLabel?: string; @@ -153,16 +156,22 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** * Focuses the selected reasoning level, else the first level. With `rows: "any"` it falls back - * to the column's other options. False when nothing took focus. + * to the column's first enabled option. False when nothing took focus. */ const focusEffort = (rows: "levels" | "any") => { const column = popupRef.current?.querySelector("[data-model-picker-effort]"); - const first = (selector: string) => - column?.querySelector(`${selector}[aria-checked="true"]:not(:disabled)`) ?? - column?.querySelector(`${selector}:not(:disabled)`); + const level = + column?.querySelector( + '[data-effort-level][aria-checked="true"]:not(:disabled)', + ) ?? column?.querySelector("[data-effort-level]:not(:disabled)"); + // No aria-checked here: an On switch is checked but is not where focus belongs. const row = - first("[data-effort-level]") ?? (rows === "any" ? first("[data-effort-row]") : null); + level ?? + (rows === "any" + ? column?.querySelector("[data-effort-row]:not(:disabled)") + : null); row?.focus({ preventScroll: true }); + row?.scrollIntoView({ block: "nearest" }); return row != null; }; const openTarget = (target: "effort" | "permissions") => { @@ -229,7 +238,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const triggerText = props.triggerLabel ?? multipleLabel ?? triggerTitle; const triggerTextProps = { - className: "min-w-0 flex-1 overflow-hidden truncate", + className: cn( + "min-w-0 flex-1 overflow-hidden truncate", + // Long names such as "Fusion (Claude Opus 5.5 Medium + SWE-2 High)" would push the composer. + props.isComposerOwned && "max-w-56", + ), "data-chat-provider-model-picker-label": "true", } as const; @@ -261,7 +274,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { : (props.getModelDisabledReason?.(instanceId, model) ?? null) } onInstanceModelChange={handleInstanceModelChange} - {...(docked ? { docked, onFocusEffort: () => void focusEffort("any") } : {})} + {...(docked + ? { + docked, + onFocusEffort: () => void focusEffort("any"), + // Inside the fixed height, so switching to or from a ChatGPT plan never resizes it. + ...(props.selectedModels === undefined + ? { listFooter: } + : {}), + } + : {})} /> ); @@ -392,19 +414,26 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { }; }} initialFocus={searchInputRef} - align="start" + align={props.popupAlign ?? "start"} + // Docked, the popup opens above the composer and slides over it when that space is short, + // so a short window gets its full height (Floating UI sizes to the viewport) instead of a sliver. + {...(docked + ? { side: "top" as const, collisionAvoidance: { side: "shift", align: "shift" } as const } + : {})} className="before:hidden" padding="none" touchBackdrop > {props.effortColumn ? ( -
    -
    +
    + {/* One fixed height, so a model with more options never resizes the popup. Short + windows shrink it and keep the footer. */} +
    {picker}
    { if (event.key !== "ArrowLeft" || event.altKey || event.ctrlKey || event.metaKey) return; @@ -422,7 +451,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : ( picker )} - {props.selectedModels === undefined ? ( + {props.selectedModels === undefined && !docked ? ( ) : null} diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 0e258c10906ac..64afa55ed0d78 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; @@ -86,14 +94,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; } @@ -121,17 +150,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} + +
    +
    + <> + {lifecycle.workNow?.actions} + + ); } @@ -181,3 +185,44 @@ it.each(["a", "b", "navigation"] as const)( expect(focusComposer).toHaveBeenCalledTimes(scenario === "navigation" ? 0 : 1); }, ); + +it.each(["Success", "Failure"] as const)( + "keeps background stop requests per thread after %s", + async (outcome) => { + setup(); + const completions: Array<(result: unknown) => void> = []; + state.switchRef = vi.fn(() => new Promise((resolve) => completions.push(resolve))); + const threadA = threadInput("thread:a", "feature-a"); + const threadB = threadInput("thread:b", "feature-b"); + const currentRouteThreadKeyRef = { current: threadA.routeThreadKey }; + const harness = (thread: ThreadInput) => ( + undefined} + focusComposer={() => undefined} + /> + ); + await using screen = await render(harness(threadA)); + await page.getByRole("button", { name: "Stop", exact: true }).click(); + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + await screen.rerender(harness(threadB)); + await page.getByRole("button", { name: "Stop", exact: true }).click(); + await screen.rerender(harness(threadA)); + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + completions[0]!( + outcome === "Success" + ? { _tag: "Success", value: undefined } + : { _tag: "Failure", cause: Cause.fail(new Error("stop failed")) }, + ); + if (outcome === "Success") { + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + } else { + await expect.element(page.getByRole("button", { name: "Stop", exact: true })).toBeEnabled(); + } + await screen.rerender(harness(threadB)); + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + expect(state.switchRef).toHaveBeenCalledTimes(2); + completions[1]!({ _tag: "Success", value: undefined }); + }, +); diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 915f39a12a24e..b9f8a42d289c3 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -12,7 +12,7 @@ import { makeThreadErrorSetter, } from "../ChatView.logic"; import { useStableCallback } from "../../hooks/useStableCallback"; -import { useNowAfter } from "../../hooks/useDeadline"; +import { useDeadlinePassed, useNowAfter } from "../../hooks/useDeadline"; import type { usePendingThreadRequests } from "./usePendingThreadRequests"; import { AuthOrchestrationOperateScope, @@ -28,6 +28,11 @@ import { snoozeWakeLabel, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; +import { + BACKGROUND_WORK_STOP_RETRY_AFTER_MS, + type BackgroundWorkStopRequest, + isBackgroundWorkStopPending, +} from "@t3tools/client-runtime/state/thread-execution"; import { useAcknowledgeThreadWoke, useThreadActions } from "../../hooks/useThreadActions"; import { settleAndAdvance } from "../../settleAndAdvance"; import type { ChatShortcutCommands } from "./useChatShortcuts"; @@ -51,6 +56,7 @@ import { useEnvironmentScope } from "~/state/session"; import { threadEnvironment } from "../../state/threads"; import { vcsEnvironment } from "../../state/vcs"; import { useChildThreadInputs, readThreadShell, waitForThreadShell } from "../../state/entities"; +import { serverEnvironment } from "../../state/server"; import { environmentShell } from "../../state/shell"; import { ThreadStatusLine } from "./ThreadStatusLine"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -400,21 +406,44 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { // The interrupt path also accepts a completed run while its provider still // has background work. const activeBackgroundTasks = !isWorking && activeThread ? pendingBackgroundTasks : []; - const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState(null); - const isStoppingBackgroundWork = - stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; + const [backgroundWorkStops, setBackgroundWorkStops] = useState< + Record + >({}); + const backgroundWorkStop = backgroundWorkStops[`${environmentId}:${activeThreadId}`] ?? null; + const isStoppingBackgroundWork = isBackgroundWorkStopPending( + backgroundWorkStop, + `${environmentId}:${activeThreadId}`, + pendingBackgroundTasks, + useDeadlinePassed(backgroundWorkStop?.retryAt ?? null), + ); const handleStopBackgroundWork = async () => { - if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + if ( + isStoppingBackgroundWork || + !activeThread || + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) return; - const requestKey = `${environmentId}:${activeThread.id}`; - setStoppingBackgroundWorkKey(requestKey); + const request: BackgroundWorkStopRequest = { + threadKey: `${environmentId}:${activeThread.id}`, + taskIds: pendingBackgroundTasks.map((task) => task.taskId), + retryAt: null, + }; + setBackgroundWorkStops((current) => ({ ...current, [request.threadKey]: request })); const result = await interruptThreadTurn({ environmentId, input: { threadId: activeThread.id }, }); - // Acceptance does not confirm termination. Allow retry while the provider - // finishes stopping the tasks or reports a failure. - setStoppingBackgroundWorkKey((current) => (current === requestKey ? null : current)); + setBackgroundWorkStops((current) => + current[request.threadKey] !== request + ? current + : { + ...current, + [request.threadKey]: + result._tag === "Failure" + ? undefined + : { ...request, retryAt: Date.now() + BACKGROUND_WORK_STOP_RETRY_AFTER_MS }, + }, + ); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); @@ -427,14 +456,20 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { }; const handleStopBackgroundWorkStable = useStableCallback(handleStopBackgroundWork); const openRelatedThreadGenerationRef = useRef(0); - const onOpenRelatedThread = async (threadId: ThreadId) => { + // A task delegated to a linked environment opens there; the default is this thread's own. + const onOpenRelatedThread = async ( + threadId: ThreadId, + threadEnvironmentId: EnvironmentId = environmentId, + ) => { const generation = ++openRelatedThreadGenerationRef.current; - const threadRef = scopeThreadRef(environmentId, threadId); + const threadRef = scopeThreadRef(threadEnvironmentId, threadId); const isEnvironmentLive = () => - appAtomRegistry.get(environmentShell.stateValueAtom(environmentId)).status === "live"; + appAtomRegistry.get(environmentShell.stateValueAtom(threadEnvironmentId)).status === "live"; // The shell can leave idle subagent children out, so absence is not deletion there: the // route decides from the thread's own detail. - const shellOmitsChildren = serverConfig?.shellThreadFamilies === true; + const shellOmitsChildren = + appAtomRegistry.get(serverEnvironment.configValueAtom(threadEnvironmentId)) + ?.shellThreadFamilies === true; // A just-launched child can reach this timeline before its shell syncs. if (!shellOmitsChildren && isEnvironmentLive() && readThreadShell(threadRef) === null) { const location = router.latestLocation; diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 2e6bb927d69dc..70c4f6e8579f2 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -1,6 +1,11 @@ import { useAuth } from "@clerk/react"; import { useAtomValue } from "@effect/atom-react"; -import { AuthRelayReadScope, AuthRelayWriteScope, type AuthSessionState } from "@t3tools/contracts"; +import { + AuthRelayReadScope, + AuthRelayWriteScope, + type AuthSessionState, + managedCapabilityDenial, +} from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -19,6 +24,7 @@ import { usePrimaryEnvironment, usePrimaryEnvironmentId, } from "~/state/environments"; +import { usePrimaryManagedPolicy } from "~/state/managedPolicy"; import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { type T3ConnectAccountPageUrl, @@ -64,7 +70,10 @@ function ConfiguredConnectOnboardingDialog() { ); const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope); const canWriteRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope); - const canManageRelay = canReadRelay && canWriteRelay; + // A policy that turns Connect off leaves nothing to publish; reaching other devices still works. + const connectAllowed = + managedCapabilityDenial(usePrimaryManagedPolicy(), "connect") === undefined; + const canManageRelay = canReadRelay && canWriteRelay && connectAllowed; const sessionScopesKnown = primaryEnvironmentId === null ? environmentsReady : primarySessionState._tag !== "Initial"; diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx index 31ad2028c5f51..1f901a5eae156 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx @@ -329,9 +329,21 @@ it("pairs with Local environment disabled, where there is no primary environment ); }); -it("does not hold a PR link for project discovery that never settles with Local environment disabled", async () => { +it("holds a launch PR link with Local environment disabled until discovery settles", async () => { setup(); state.localEnvironmentDisabled = true; await using _coordinator = await mount(pullRequestUrl); - await vi.waitFor(() => expect(state.openScratchProject).toHaveBeenCalledOnce()); + expect(state.navigate).not.toHaveBeenCalled(); + expect(state.openScratchProject).not.toHaveBeenCalled(); + + state.projects = [project]; + const navigated = new Promise((resolve) => { + state.navigate.mockImplementationOnce(async () => resolve()); + }); + await act(async () => { + appAtomRegistry.set(discoverySettled, true); + await navigated; + }); + expect(state.navigate).toHaveBeenCalledExactlyOnceWith(pullRequestView); + expect(state.openScratchProject).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx index ddd892d02bd12..b138a8232bdb6 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx @@ -237,10 +237,8 @@ export function DesktopWebLinkCoordinator() { // A connected server publishes its config before its project snapshot, so a source control // link delivered at launch would miss its project and fall through to the browser. Hold // those links until discovery settles; an ordinary link never waits for it. - // Discovery never settles without the local environment, so those links do not wait. const hold = openPullRequestsInApp && - !localEnvironmentDisabled && (sourceControlClients.parseChangeRequestUrl(url) !== null || sourceControlClients.parseCommitUrl(url) !== null) ? projectsSettled() diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 5277747ee6306..51b2b92b0a8ba 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -145,17 +145,21 @@ export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { ); } +/** `managedDenial` is this machine's policy refusing updates (`managedUpdatesDenial`); it wins. */ export function getDesktopUpdateDisabledReason( state: Pick | null, + managedDenial?: string, ): string | null { + if (managedDenial !== undefined) return managedDenial; if (!state || (state.enabled && state.status !== "disabled")) return null; return state.message?.trim() || "Automatic updates are not available in this build."; } export function getDesktopUpdateIdleTooltip( state: Pick | null, + managedDenial?: string, ) { - const disabledReason = getDesktopUpdateDisabledReason(state); + const disabledReason = getDesktopUpdateDisabledReason(state, managedDenial); return disabledReason ? { title: "Updates unavailable", detail: disabledReason } : { title: "Check for updates", detail: null }; diff --git a/apps/web/src/components/device/DeviceToolsPanel.browser.test.tsx b/apps/web/src/components/device/DeviceToolsPanel.browser.test.tsx new file mode 100644 index 0000000000000..d1ca0d781e26a --- /dev/null +++ b/apps/web/src/components/device/DeviceToolsPanel.browser.test.tsx @@ -0,0 +1,54 @@ +import type { DeviceSummary } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { act, render } from "../../../test/render"; +import { DeviceToolsPanel } from "./DeviceToolsPanel"; +import type { DeviceControls } from "./useDeviceControls"; + +const device: DeviceSummary = { + hostId: "local", + id: "emulator", + name: "Pixel", + platform: "android", + version: "Android 15", + booted: true, + physical: false, +}; + +it("keeps a typed URL until opening it succeeds", async () => { + const run = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const controls: DeviceControls = { + detail: null, + pending: false, + error: null, + act: run, + disabled: false, + foregroundApp: null, + }; + await using screen = await render( + {}} + onClose={() => {}} + />, + ); + const url = "myapp://settings"; + const field = screen.getByPlaceholder("https://… or myapp://"); + const open = screen.getByRole("button", { name: "Open" }); + + await userEvent.fill(field, url); + await userEvent.click(open); + await act(() => run.mock.results[0]?.value); + await expect.element(field).toHaveValue(url); + + await userEvent.click(open); + await expect.element(field).toHaveValue(""); +}); diff --git a/apps/web/src/components/device/DeviceToolsPanel.tsx b/apps/web/src/components/device/DeviceToolsPanel.tsx index 23c5d0097082c..0d7c5b9f763e8 100644 --- a/apps/web/src/components/device/DeviceToolsPanel.tsx +++ b/apps/web/src/components/device/DeviceToolsPanel.tsx @@ -312,7 +312,7 @@ export function DeviceToolsPanel(props: { onSubmit={(payload) => foregroundApp ? act({ type: "sendPush", appId: foregroundApp.id, payload }) - : Promise.resolve() + : Promise.resolve(false) } /> {!foregroundApp ? ( @@ -349,7 +349,7 @@ function SwitchRow(props: { readonly label: string; readonly checked: boolean | undefined; readonly disabled: boolean; - readonly onChange: (value: boolean) => Promise; + readonly onChange: (value: boolean) => Promise; }) { return ( @@ -370,7 +370,7 @@ function ChoiceSelect(props: { readonly options: ReadonlyArray<{ readonly value: V; readonly label: string }>; readonly disabled: boolean; readonly placeholder?: string; - readonly onChange: (value: V) => Promise; + readonly onChange: (value: V) => Promise; }) { const current = props.options.find((option) => option.value === props.value); return ( @@ -405,13 +405,15 @@ function SubmitRow(props: { readonly placeholder: string; readonly action: string; readonly disabled: boolean; - readonly onSubmit: (value: string) => Promise; + readonly onSubmit: (value: string) => Promise; }) { const [value, setValue] = useState(""); const submit = () => { const trimmed = value.trim(); if (!trimmed) return; - void props.onSubmit(trimmed).then(() => setValue("")); + void props.onSubmit(trimmed).then((sent) => { + if (sent) setValue(""); + }); }; return (
    Promise; - readonly onClear: () => Promise; + readonly onSet: (latitude: number, longitude: number) => Promise; + readonly onClear: () => Promise; }) { const [latitude, setLatitude] = useState(""); const [longitude, setLongitude] = useState(""); @@ -541,7 +543,7 @@ function PermissionsSection(props: { appId: string, permission: DevicePermission, decision: "grant" | "revoke" | "reset", - ) => Promise; + ) => Promise; }) { const [appId, setAppId] = useState(""); const [permission, setPermission] = useState("camera"); diff --git a/apps/web/src/components/device/useDeviceControls.browser.test.tsx b/apps/web/src/components/device/useDeviceControls.browser.test.tsx index 5633369dc1cb2..85c484bfd520c 100644 --- a/apps/web/src/components/device/useDeviceControls.browser.test.tsx +++ b/apps/web/src/components/device/useDeviceControls.browser.test.tsx @@ -116,21 +116,23 @@ it("serializes rapid actions and retains confirmed settings while pending or aft await vi.waitFor(() => expect(controls.detail?.settings.appearance).toBe("light")); const response = deferred(); action.mockReturnValueOnce(response.promise); - let pending!: Promise; + let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); - await controls.act({ type: "setAppearance", value: "light" }); + expect(await controls.act({ type: "setAppearance", value: "light" })).toBe(false); }); expect(action).toHaveBeenCalledOnce(); expect(controls.disabled).toBe(true); expect(controls.detail?.settings.appearance).toBe("light"); await act(async () => { response.resolve(snapshot("dark")); - await pending; + expect(await pending).toBe(true); }); expect(controls.detail?.settings.appearance).toBe("dark"); action.mockResolvedValueOnce({ _tag: "Failure", cause: offline }); - await act(async () => controls.act({ type: "setAppearance", value: "light" })); + await act(async () => { + expect(await controls.act({ type: "setAppearance", value: "light" })).toBe(false); + }); expect(controls.detail?.settings.appearance).toBe("dark"); expect(controls.error).toBe("Device offline"); expect(controls.disabled).toBe(false); @@ -142,7 +144,7 @@ it("confirms settings again after a command sent before the panel reopened", asy await vi.waitFor(() => expect(controls.detail).not.toBeNull()); const response = deferred(); action.mockReturnValueOnce(response.promise); - let pending!: Promise; + let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); }); @@ -174,7 +176,7 @@ it("does not let a read sent before an action overwrite the action's answer", as await screen.rerender(view(true)); await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)); action.mockResolvedValueOnce(snapshot("dark")); - let pending!: Promise; + let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); }); diff --git a/apps/web/src/components/device/useDeviceControls.ts b/apps/web/src/components/device/useDeviceControls.ts index 7b17bc4a624e1..31e67b8f3824f 100644 --- a/apps/web/src/components/device/useDeviceControls.ts +++ b/apps/web/src/components/device/useDeviceControls.ts @@ -39,7 +39,7 @@ export function useDeviceControls(options: { const available = detail !== null && visible; const act = async (body: ActionBody) => { - if (busy.current || !available) return; + if (busy.current || !available) return false; busy.current = true; setPending(true); setView((current) => ({ ...current, error: null })); @@ -47,13 +47,14 @@ export function useDeviceControls(options: { environmentId, input: { hostId, deviceId, ...body } as DeviceActionInput, }) - .then((result) => + .then((result) => { setView((current) => result._tag === "Success" ? { detail: result.value, error: null } : { ...current, error: formatEnvironmentQueryError(result.cause) }, - ), - ) + ); + return result._tag === "Success"; + }) .finally(() => { busy.current = false; setPending(false); diff --git a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx index a2394854b5a8b..b202df0f52f02 100644 --- a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx +++ b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx @@ -3,6 +3,7 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; import { Textarea } from "~/components/ui/textarea"; +import { textDirection } from "~/textDirection"; import { isCommentSubmitShortcut } from "./commentSubmitShortcut"; @@ -65,7 +66,12 @@ export function DiffCommentAnnotation({ onPointerDown={(event) => event.stopPropagation()} >
    {fileTreeOpen ? ( - + ) : null}
    {unstructured} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx index dff498259f888..f5a5b4ee77bd9 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx @@ -191,6 +191,7 @@ export function PullRequestResolveConflictsButton({ export function PullRequestPrimaryAction({ pullRequestKey, primaryAction, + mergeBlockedReason, autoMergeArmed, armedAutoMergeLabel, pendingAutoMergeLabel, @@ -203,6 +204,7 @@ export function PullRequestPrimaryAction({ }: { pullRequestKey: string; primaryAction: ReturnType | null; + mergeBlockedReason: string | null; autoMergeArmed: boolean; armedAutoMergeLabel: string; pendingAutoMergeLabel: string; @@ -263,10 +265,14 @@ export function PullRequestPrimaryAction({ size="xs" variant="default" disabled={actionPending} - {...inlineConfirm.props( - "enable-auto-merge", - () => void perform("enable-auto-merge", selectedMergeMethod), - )} + aria-disabled={mergeBlockedReason !== null || undefined} + aria-description={mergeBlockedReason ?? undefined} + {...(mergeBlockedReason === null + ? inlineConfirm.props( + "enable-auto-merge", + () => void perform("enable-auto-merge", selectedMergeMethod), + ) + : {})} aria-label={ pendingAction === "enable-auto-merge" ? "Enabling..." @@ -299,11 +305,12 @@ export function PullRequestPrimaryAction({ } /> - {pendingAction === "enable-auto-merge" - ? "Enabling..." - : inlineConfirm.isArmed("enable-auto-merge") - ? "Confirm auto-merge?" - : pendingAutoMergeLabel} + {mergeBlockedReason ?? + (pendingAction === "enable-auto-merge" + ? "Enabling..." + : inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : pendingAutoMergeLabel)} ) : primaryAction === "auto-merge-armed" ? ( @@ -329,10 +336,11 @@ export function PullRequestPrimaryAction({ size="xs" variant="default" disabled={actionPending} - {...inlineConfirm.props( - "merge", - () => void perform("merge", selectedMergeMethod), - )} + aria-disabled={mergeBlockedReason !== null || undefined} + aria-description={mergeBlockedReason ?? undefined} + {...(mergeBlockedReason === null + ? inlineConfirm.props("merge", () => void perform("merge", selectedMergeMethod)) + : {})} aria-label={ pendingAction === "merge" ? "Merging..." @@ -365,11 +373,12 @@ export function PullRequestPrimaryAction({ } /> - {pendingAction === "merge" - ? "Merging..." - : inlineConfirm.isArmed("merge") - ? "Confirm merge?" - : selectedMergeMethodLabel} + {mergeBlockedReason ?? + (pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel)} ) : (primaryAction === "merged" || primaryAction === "closed") && diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 1bade1b4e160f..4c8e1e2acbcd6 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -553,6 +553,12 @@ export function PullRequestDetailPanel({ stackPending: !nativeStackQuery.isFresh, stackError: nativeStackQuery.error, }); + const stackOwnsMerge = supportsStackActions && nativeStack !== null; + const mergeBlockedReason = canMergeSinglePullRequest + ? null + : nativeStackQuery.error + ? "Couldn't check stack membership" + : "Checking stack membership…"; const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = () => { @@ -1143,9 +1149,9 @@ export function PullRequestDetailPanel({ checksState, autoMergeEnabled: detail.autoMergeEnabled, hasMergeMethod: allowedMergeMethods.length > 0, - canMerge: canMergeSinglePullRequest && can("merge"), + canMerge: !stackOwnsMerge && can("merge"), canMarkReady: can("ready"), - canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"), + canEnableAutoMerge: !stackOwnsMerge && can("enable-auto-merge"), }) : null; const showsDraftToggle = @@ -1291,6 +1297,15 @@ export function PullRequestDetailPanel({
    {detail ? ( + {context === "page" ? ( + + ) : null} {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( + ); +} + +it("keeps pending merges with their pull request when navigating between threads", async () => { + const first: PullRequestRef = { + projectId: ProjectId.make("project"), + repository: "owner/repo", + number: 1, + }; + const second = { ...first, number: 2 }; + const firstResult = deferred<{ _tag: "Success"; value: void }>(); + const secondResult = deferred<{ _tag: "Success"; value: void }>(); + state.runAction + .mockReset() + .mockReturnValueOnce(firstResult.promise) + .mockReturnValueOnce(secondResult.promise); + await using screen = await render(); + const button = screen.getByRole("button"); + + await userEvent.click(button); + await expect.element(button).toHaveTextContent("Merging..."); + await screen.rerender(); + await expect.element(button).toHaveTextContent("Merge"); + await expect.element(button).toBeEnabled(); + await userEvent.click(button); + expect(state.runAction).toHaveBeenCalledTimes(2); + + await screen.rerender(); + await expect.element(button).toHaveTextContent("Merging..."); + await expect.element(button).toBeDisabled(); + await act(async () => { + firstResult.resolve({ _tag: "Success", value: undefined }); + await firstResult.promise; + }); + await expect.element(button).toHaveTextContent("Merge"); + await screen.rerender(); + await expect.element(button).toHaveTextContent("Merging..."); + await act(async () => { + secondResult.resolve({ _tag: "Success", value: undefined }); + await secondResult.promise; + }); + await expect.element(button).toHaveTextContent("Merge"); +}); diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index d2d2e39ac2e34..82ce86ab8f429 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -140,13 +140,23 @@ export function usePullRequestActionRunner({ const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false, }); - const [actionPending, setActionPending] = useState(false); - const pendingRef = useRef(false); + const pendingRef = useRef(new Set()); + const [pendingKeys, setPendingKeys] = useState>(() => new Set()); + const actionKey = + reference === null + ? null + : JSON.stringify([ + environmentId, + reference.host ?? null, + reference.repository, + reference.number, + ]); + const actionPending = actionKey !== null && pendingKeys.has(actionKey); const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => { - if (pendingRef.current || reference === null) return; - pendingRef.current = true; - setActionPending(true); + if (actionKey === null || reference === null || pendingRef.current.has(actionKey)) return; + pendingRef.current.add(actionKey); + setPendingKeys(new Set(pendingRef.current)); const result = await runAction({ environmentId, input: { @@ -155,8 +165,8 @@ export function usePullRequestActionRunner({ ...(method ? { mergeMethod: method } : resolveMergeMethod ? { resolveMergeMethod } : {}), }, }); - pendingRef.current = false; - setActionPending(false); + pendingRef.current.delete(actionKey); + setPendingKeys(new Set(pendingRef.current)); if (result._tag === "Failure") { toastManager.add({ type: "error", diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 0c9eee9386c73..31e298beec2c8 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -13,6 +13,7 @@ import { type EnvironmentId, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, + isManagedDriverAllowed, } from "@t3tools/contracts"; import { @@ -22,6 +23,7 @@ import { import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; +import { managedInstanceLocks, useManagedPolicy } from "../../state/managedPolicy"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { Button } from "../ui/button"; import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; @@ -167,7 +169,20 @@ export function AddProviderInstanceDialog({ const [wizardStep, setWizardStep] = useState(0); const [addingChatGptAccount, setAddingChatGptAccount] = useState(false); - const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); + // Drivers the administrator's policy leaves out are not offered at all. + const managedPolicy = useManagedPolicy(environmentId); + const featuredDriverOptions = FEATURED_DRIVER_OPTIONS.filter((option) => + isManagedDriverAllowed(managedPolicy, option.driverKind), + ); + const moreDriverOptions = MORE_DRIVER_OPTIONS.filter((option) => + isManagedDriverAllowed(managedPolicy, option.driverKind), + ); + const acpRegistryAllowed = isManagedDriverAllowed(managedPolicy, ACP_REGISTRY_DRIVER_KIND); + const [driver, setDriver] = useState( + () => + (featuredDriverOptions[0] ?? moreDriverOptions[0])?.driverKind ?? + (acpRegistryAllowed ? ACP_REGISTRY_DRIVER_KIND : DEFAULT_DRIVER_KIND), + ); const [identityByDriver, setIdentityByDriver] = useState>( {}, ); @@ -361,6 +376,9 @@ export function AddProviderInstanceDialog({ setHasAttemptedSubmit(false); }; + // A policy that owns this driver's launch settings also rules out ChatGPT's own runtime. + const driverLocks = managedInstanceLocks(managedPolicy, driver); + const handleSave = async () => { if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; if (isSaving || createdInstanceId) return; @@ -467,7 +485,7 @@ export function AddProviderInstanceDialog({ Provider
    - {wizardStep === 0 ? ( + {wizardStep === 0 && (moreDriverOptions.length > 0 || acpRegistryAllowed) ? (
    }> @@ -491,18 +509,20 @@ export function AddProviderInstanceDialog({
    -
    -
    - Or choose from ACP Registry -
    -
    - {isAcpRegistry && isManualAcpConfiguration ? ( + {!acpRegistryAllowed ? null : ( +
    +
    + Or choose from ACP Registry +
    +
    + )} + {!acpRegistryAllowed ? null : isAcpRegistry && isManualAcpConfiguration ? (
    @@ -684,6 +704,9 @@ export function AddProviderInstanceDialog({ idPrefix={`add-provider-${driver}`} variant="settings" onChange={setConfigDraft} + managed={ + driverLocks && { keys: driverLocks.configKeys, reason: driverLocks.reason } + } environmentId={environmentId} /> @@ -716,10 +739,12 @@ export function AddProviderInstanceDialog({ - setAddingChatGptAccount(true)} - /> + {driverLocks?.configKeys.has("setupMode") ? null : ( + setAddingChatGptAccount(true)} + /> + )} ) : wizardStep < (isAcpRegistry ? 1 : 2) ? ( + {pairingDenial !== undefined ? ( + + + + ) : null} { setDialogOpen(open); if (!open) { @@ -646,14 +658,16 @@ export const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHead } }} > - - - Create link - - } - /> + {pairingDenial === undefined ? ( + + + Create link + + } + /> + ) : null} Create pairing link @@ -856,13 +870,14 @@ export const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow const canDisableTailscaleServe = isTailscaleHttpsEndpoint(endpoint) && endpoint.status === "available"; const shouldShowEndpointUrl = !needsTailscaleSetup; + const remoteDenial = managedCapabilityDenial(usePrimaryManagedPolicy(), "remoteClients"); return (
    {isDefault ? ( ) : null} -
    -
    +
    +

    {endpoint.label}

    @@ -879,26 +894,28 @@ export const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow ) : null} {!isAvailable ? ( - + Setup required ) : null}
    -
    +
    {isDefault ? ( - + Default ) : null} {needsTailscaleSetup ? ( - + + + ) : null} {canDisableTailscaleServe ? (
    - + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - automaticGitFetchInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, + { automaticGitFetchInterval: Duration.seconds(seconds) }, ), + options, ) } - > - - - - - - + /> seconds
    @@ -224,32 +207,22 @@ function BackgroundActivityAdvancedDialog({

    - + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, + { providerHealthRefreshInterval: Duration.seconds(seconds) }, ), + options, ) } - > - - - - - - + /> seconds
    @@ -262,32 +235,22 @@ function BackgroundActivityAdvancedDialog({

    - + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - hostPowerMonitorActiveInterval: Duration.seconds( - normalizeIntervalSeconds(value, 5), - ), - }, + { hostPowerMonitorActiveInterval: Duration.seconds(seconds) }, ), + options, ) } - > - - - - - - + /> seconds
    @@ -300,32 +263,22 @@ function BackgroundActivityAdvancedDialog({

    - + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - hostPowerMonitorIdleInterval: Duration.seconds( - normalizeIntervalSeconds(value, 5), - ), - }, + { hostPowerMonitorIdleInterval: Duration.seconds(seconds) }, ), + options, ) } - > - - - - - - + /> seconds
    diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx index 2cbce23e94e66..b12696ce89e43 100644 --- a/apps/web/src/components/settings/BrowserImportWizard.tsx +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -151,8 +151,11 @@ export function BrowserImportWizard({ ) : ( void; readonly onRetry: (() => void) | undefined; }) { @@ -564,7 +569,7 @@ function BlockedStep({ <> Couldn’t import from {source.name} - {BROWSER_IMPORT_FAILURE_COPY[reason]} + {message ?? BROWSER_IMPORT_FAILURE_COPY[reason]} + // 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) => ( @@ -354,7 +444,7 @@ function ProjectDetail({ key={member.physicalProjectKey} title={member.environmentLabel ?? "Environment"} description={member.workspaceRoot} - control={checkoutRemoveButton(member)} + control={checkoutControls(member)} /> )); } @@ -383,26 +473,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)}
    ))}
    @@ -464,6 +554,7 @@ function ProjectDetail({ void renameGroup(event.currentTarget.value, wasEdited); }} onKeyDown={(event) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Enter") event.currentTarget.blur(); }} /> @@ -564,7 +655,7 @@ function ProjectDetail({
    - {hasMultipleCheckouts ? checkoutChoices : null} + {checkoutChoices} ) : null} {!active && signedIn && (provider.auth.canLogout ?? provider.setup?.canAuthenticate) ? ( diff --git a/apps/web/src/components/settings/ProviderInstanceCard.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderInstanceCard.environment.browser.test.tsx new file mode 100644 index 0000000000000..1c9820b5addb5 --- /dev/null +++ b/apps/web/src/components/settings/ProviderInstanceCard.environment.browser.test.tsx @@ -0,0 +1,55 @@ +import { userEvent } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../test/render"; +import { ProviderEnvironmentSection } from "./ProviderInstanceCard"; + +const savedSecret = { + name: "FAKE_API_KEY", + value: "", + sensitive: true, + valueRedacted: true, +} as const; + +describe("ProviderEnvironmentSection saved secrets", () => { + it("requires a replacement before renaming or unlocking a stored secret", async () => { + const onChange = vi.fn(); + await using screen = await render( + , + ); + const name = screen.getByRole("textbox", { name: "Environment variable name 1" }); + const value = screen.getByLabelText("Environment variable value 1"); + const lock = screen.getByRole("button", { + name: "Mark environment variable FAKE_API_KEY as sensitive", + }); + + await expect.element(name).toBeDisabled(); + await expect.element(lock).toBeDisabled(); + await userEvent.fill(value, "unused-draft"); + await userEvent.clear(value); + await userEvent.tab(); + expect(onChange).not.toHaveBeenCalled(); + await expect.element(name).toBeDisabled(); + await expect.element(lock).toBeDisabled(); + + await userEvent.fill(value, "fake-replacement"); + await userEvent.tab(); + expect(onChange).toHaveBeenLastCalledWith([ + { ...savedSecret, value: "fake-replacement", valueRedacted: false }, + ]); + await expect.element(name).toBeEnabled(); + await expect.element(lock).toBeEnabled(); + await userEvent.fill(name, "RENAMED_KEY"); + await userEvent.tab(); + await userEvent.click( + screen.getByRole("button", { name: "Mark environment variable RENAMED_KEY as sensitive" }), + ); + expect(onChange).toHaveBeenLastCalledWith([ + { name: "RENAMED_KEY", value: "fake-replacement", sensitive: false, valueRedacted: false }, + ]); + await userEvent.click( + screen.getByRole("button", { name: "Remove environment variable RENAMED_KEY" }), + ); + expect(onChange).toHaveBeenLastCalledWith([]); + }); +}); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 21fe0fad2d086..b676e64399d97 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -47,6 +47,9 @@ import { DraftInput } from "../ui/draft-input"; import { SecretSourceInput, type SecretSource } from "./SecretSourceInput"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; +import { ManagedControl, ManagedNotice } from "../ManagedPolicy"; +import { managedInstanceLocks, useManagedPolicy } from "../../state/managedPolicy"; +import { disallowedProviderMessage, isManagedDriverAllowed } from "@t3tools/contracts"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { @@ -277,6 +280,7 @@ function ProviderEnvironmentFieldRow(props: { secret: SecretReference | undefined, ) => void; readonly onRemove: (field: ProviderEnvironmentField) => void; + readonly managedReason?: string | undefined; readonly secretSources: ReadonlyArray; }) { const inputId = `${props.idPrefix}-environment-${props.field.name}`; @@ -286,6 +290,7 @@ function ProviderEnvironmentFieldRow(props: { {props.field.label}} description={props.field.description} + managed={props.managedReason} control={
    {props.field.sensitive === false ? ( @@ -334,6 +339,8 @@ function ProviderEnvironmentFieldRow(props: { export function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; + /** Variables an administrator's policy pins: listed by name, never editable or removable. */ + readonly managed?: { readonly names: ReadonlySet; readonly reason: string } | undefined; readonly secretSources?: ReadonlyArray; }) { const [storedDraft, setDraft] = useState(() => @@ -373,6 +380,8 @@ export function ProviderEnvironmentSection(props: { }; const updateVariable = (id: string, patch: Partial>) => { + // A row named like a pinned variable renders as the policy's, with no way to edit or remove it. + if (patch.name !== undefined && props.managed?.names.has(patch.name)) return; const nextRows = rows.map((row) => row.id === id ? { @@ -427,6 +436,16 @@ export function ProviderEnvironmentSection(props: { {rows.length > 0 ? (
    {rows.map((variable, index) => { + if (props.managed?.names.has(variable.name)) { + return ( +
    + + {variable.name} + + {props.managed.reason} +
    + ); + } const nameInput = ( updateVariable(variable.id, { name: name.trim() })} + disabled={variable.valueRedacted === true} placeholder="VARIABLE_NAME" spellCheck={false} aria-label={`Environment variable name ${index + 1}`} @@ -459,6 +479,7 @@ export function ProviderEnvironmentSection(props: { type="button" size="icon-micro" variant="ghost-muted" + disabled={variable.valueRedacted === true} onClick={() => { const sensitive = !variable.sensitive; updateVariable(variable.id, { @@ -539,6 +560,9 @@ export function ProviderEnvironmentSection(props: { })}

    Sensitive values are stored separately and never returned to the app. + {rows.some((row) => row.valueRedacted === true) + ? " Enter a new value to rename a saved one or make it plain text." + : null}

    ) : null} @@ -746,6 +770,14 @@ export function ProviderInstanceCard({ const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); const accentColor = normalizeProviderAccentColor(instance.accentColor); + const managedPolicy = useManagedPolicy(environmentId); + const instanceLocks = managedInstanceLocks(managedPolicy, instance.driver); + const managedDriverReason = isManagedDriverAllowed(managedPolicy, instance.driver) + ? undefined + : disallowedProviderMessage( + managedPolicy?.organization, + driverOption?.label ?? String(instance.driver), + ); const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({ onCopy: ({ providerName }) => { toastManager.add({ @@ -1109,12 +1141,15 @@ export function ProviderInstanceCard({ /> ) : null} - updateEnabled(Boolean(checked))} - aria-label={`Enable ${displayName}`} - /> + {/* The policy refuses switching it on; switching it off stays the user's call. */} + + updateEnabled(Boolean(checked))} + aria-label={`Enable ${displayName}`} + /> +
    ); @@ -1196,6 +1231,7 @@ export function ProviderInstanceCard({ idPrefix={`provider-instance-${instanceId}`} variant="settings" onChange={updateConfig} + managed={instanceLocks && { keys: instanceLocks.configKeys, reason: instanceLocks.reason }} environmentId={environmentId} /> ) : ( @@ -1263,6 +1299,12 @@ export function ProviderInstanceCard({ onCommit={updateEnvironmentField} onSecretChange={updateEnvironmentFieldSecret} onRemove={removeEnvironmentField} + managedReason={ + instanceLocks?.wholeEnvironment || + instanceLocks?.environmentNames.has(field.name) + ? instanceLocks.reason + : undefined + } secretSources={secretSources} /> ))} @@ -1299,15 +1341,26 @@ export function ProviderInstanceCard({ - + {instanceLocks?.wholeEnvironment ? ( + {instanceLocks.reason} + ) : null} +
    + +
    {environmentId !== undefined && liveProvider?.driver === "acpRegistry" ? ( - REASONING_SELECT_DESCRIPTOR_IDS.has(descriptor.id), - ); + return (model.capabilities?.optionDescriptors ?? []).some(isReasoningControl); } // Reasoning is the norm, so only its absence is labeled, and only when other models have it. diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index fbb1ac1503e89..646dfd80edc20 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -206,7 +206,10 @@ function configuredCommandArgs(value: ProviderSettingsFormProps["value"]) { function ProviderCommandArguments({ value, onChange, -}: Pick) { + managedReason, +}: Pick & { + readonly managedReason: string | undefined; +}) { const args = configuredCommandArgs(value); const [storedDraft, setDraft] = useState(() => seedRowsDraft(args, toCommandArgumentRows)); const draft = reconcileRowsDraft(storedDraft, args, commandArgumentsEqual, toCommandArgumentRows); @@ -225,6 +228,7 @@ function ProviderCommandArguments({ {rows.length > 0 ? (
    - {rows.map((argument, index) => ( -
    - - updateArguments( - rows.map((current) => - current.id === argument.id ? { ...current, value: next } : current, - ), - ) - } - aria-label={`Argument ${index + 1}`} - spellCheck={false} - /> - -
    - ))} + {rows.map((argument, index) => + managedReason !== undefined ? ( + + {argument.value} + + ) : ( +
    + + updateArguments( + rows.map((current) => + current.id === argument.id ? { ...current, value: next } : current, + ), + ) + } + aria-label={`Argument ${index + 1}`} + spellCheck={false} + /> + +
    + ), + )}
    ) : null}
    @@ -324,6 +334,8 @@ interface ProviderSettingsFormProps { readonly idPrefix: string; readonly variant: "card" | "dialog" | "settings"; readonly onChange: (nextConfig: Record | undefined) => void; + /** Config keys an administrator's policy pins, shown inert with the reason. */ + readonly managed?: { readonly keys: ReadonlySet; readonly reason: string } | undefined; /** Where the secret providers offered for `SettingsSecretValue` fields are configured. */ readonly environmentId?: EnvironmentId | undefined; } @@ -438,6 +450,7 @@ interface ProviderSettingsFieldRowProps { readonly idPrefix: string; readonly variant: ProviderSettingsFormProps["variant"]; readonly onChange: ProviderSettingsFormProps["onChange"]; + readonly managedReason?: string | undefined; readonly environmentId: EnvironmentId | undefined; } @@ -447,6 +460,7 @@ function ProviderSettingsFieldRow({ idPrefix, variant, onChange, + managedReason, environmentId, }: ProviderSettingsFieldRowProps) { const inputId = `${idPrefix}-${field.key}`; @@ -552,6 +566,7 @@ function ProviderSettingsFieldRow({ field.description ? {field.description} : undefined } control={control} + managed={managedReason} /> ); } @@ -660,6 +675,7 @@ export function ProviderSettingsForm({ idPrefix, variant, onChange, + managed, environmentId, }: ProviderSettingsFormProps) { const fields = deriveProviderSettingsFields(definition, value); @@ -681,10 +697,17 @@ export function ProviderSettingsForm({ idPrefix={idPrefix} variant={variant} onChange={onChange} + managedReason={managed?.keys.has(field.key) ? managed.reason : undefined} environmentId={environmentId} /> ))} - {isLocalAcp ? : null} + {isLocalAcp ? ( + + ) : null} ); } diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index 4ad9b3986bb3a..3e6cbb5fbf79b 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -8,6 +8,7 @@ import { type ServerProvider, type UnifiedSettings, } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; import { AsyncResult, type Atom } from "effect/reactivity"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -35,6 +36,7 @@ const state = vi.hoisted(() => ({ updateModelPreferences: vi.fn(), toggleFavorite: vi.fn(), mutateProviderInstance: vi.fn(), + confirm: vi.fn<() => Promise>(), })); vi.mock(import("../../state/server"), async (original) => { @@ -79,6 +81,14 @@ vi.mock(import("../../state/use-atom-command"), async (original) => ({ "accept-url-auth": state.acceptUrlAuth, })[command.label ?? ""] ?? vi.fn()) as never, })); +vi.mock(import("~/localApi"), async (original) => { + const api = { dialogs: { confirm: state.confirm } }; + return { + ...(await original()), + readLocalApi: (() => api) as never, + ensureLocalApi: (() => api) as never, + }; +}); vi.mock(import("../../state/query"), async (original) => ({ ...(await original()), useEnvironmentQuery: ((query: unknown) => ({ @@ -230,6 +240,7 @@ async function setup() { state.updateModelPreferences.mockReset(); state.toggleFavorite.mockReset(); state.mutateProviderInstance.mockReset().mockResolvedValue(AsyncResult.success({})); + state.confirm.mockReset().mockResolvedValue(false); state.refresh.mockReset().mockResolvedValue(AsyncResult.success(undefined)); state.updateProvider.mockReset().mockResolvedValue(AsyncResult.success(undefined)); state.uninstall.mockReset().mockResolvedValue(AsyncResult.success({})); @@ -357,3 +368,142 @@ describe("EnvironmentProviderSettings routing", () => { ); }); }); + +describe("EnvironmentProviderSettings instance removal", () => { + it("resets a configured default instance only once the user confirms", async () => { + await setup(); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: { binaryPath: "/opt/codex/bin/codex" }, + }, + }, + }; + await setProviders([provider()]); + await using _screen = await render(panel({ targetInstanceId: codexId })); + const reset = page.getByRole("button", { name: "Reset Codex provider settings to default" }); + await userEvent.click(reset); + + expect(state.mutateProviderInstance).not.toHaveBeenCalled(); + expect(state.confirm).toHaveBeenCalledOnce(); + + state.confirm.mockResolvedValue(true); + await userEvent.click(reset); + await vi.waitFor(() => + expect(state.mutateProviderInstance).toHaveBeenCalledWith({ + operation: "remove", + instanceId: codexId, + }), + ); + }); + + it("warns that resetting an enabled default-off provider turns it off", async () => { + await setup(); + const antigravityId = ProviderInstanceId.make("antigravity"); + const antigravity = ProviderDriverKind.make("antigravity"); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [antigravityId]: { + driver: antigravity, + enabled: true, + config: { binaryPath: "/opt/antigravity/bin/agy" }, + }, + }, + }; + await setProviders([{ ...provider(), instanceId: antigravityId, driver: antigravity }]); + await using _screen = await render(panel({ targetInstanceId: antigravityId })); + await userEvent.click(page.getByRole("button", { name: /^Reset .* provider settings/ })); + + expect(state.confirm).toHaveBeenCalledWith( + expect.stringContaining("It will also be turned off."), + { + variant: "destructive", + }, + ); + expect(state.mutateProviderInstance).not.toHaveBeenCalled(); + }); + + it("deletes a custom instance only once the user confirms", async () => { + await setup(); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [customId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + }; + await using _screen = await render(panel({ targetInstanceId: customId })); + const remove = page.getByRole("button", { name: `Delete instance ${customId}` }); + await userEvent.click(remove); + + expect(state.mutateProviderInstance).not.toHaveBeenCalled(); + expect(state.confirm).toHaveBeenCalledOnce(); + + state.confirm.mockResolvedValue(true); + await userEvent.click(remove); + await vi.waitFor(() => + expect(state.mutateProviderInstance).toHaveBeenCalledWith({ + operation: "remove", + instanceId: customId, + }), + ); + }); +}); + +describe("EnvironmentProviderSettings health check interval", () => { + it("commits interval drafts and allows retrying rejected edits", async () => { + await setup(); + await using _screen = await render(panel()); + const input = page.getByRole("textbox", { name: "Provider health check interval in seconds" }); + + await userEvent.type(input, "{ArrowUp}{ArrowDown}"); + expect( + state.updateSettings.mock.calls.map(([patch]) => + Duration.toSeconds(patch.backgroundActivity.overrides.providerHealthRefreshInterval), + ), + ).toEqual([330, 300]); + state.updateSettings.mockClear(); + + // Replacing 300 with 120 passes through 30, 3, an empty field, 1 and 12. + await userEvent.type(input, "{Backspace}{Backspace}{Backspace}120"); + await expect.element(input).toHaveValue("120"); + expect(state.updateSettings).not.toHaveBeenCalled(); + + await userEvent.tab(); + expect( + state.updateSettings.mock.calls.map(([patch]) => + Duration.toSeconds(patch.backgroundActivity.overrides.providerHealthRefreshInterval), + ), + ).toEqual([120]); + + await act(() => state.updateSettings.mock.calls.at(-1)?.[1]?.onFailed?.()); + await expect.element(input).toHaveValue("300"); + await userEvent.type(input, "{Backspace}{Backspace}{Backspace}120"); + await userEvent.tab(); + expect( + state.updateSettings.mock.calls.map(([patch]) => + Duration.toSeconds(patch.backgroundActivity.overrides.providerHealthRefreshInterval), + ), + ).toEqual([120, 120]); + + const rejectPrevious = state.updateSettings.mock.calls.at(-1)?.[1]?.onFailed; + await userEvent.type(input, "{ArrowUp}"); + await act(() => rejectPrevious?.()); + await expect.element(input).toHaveValue("150"); + }); + + it("restores the saved interval when a cleared field loses focus", async () => { + await setup(); + await using _screen = await render(panel()); + const input = page.getByRole("textbox", { name: "Provider health check interval in seconds" }); + + await userEvent.type(input, "{Backspace}{Backspace}{Backspace}"); + await userEvent.tab(); + + await expect.element(input).toHaveValue("300"); + expect(state.updateSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index b5fb648ff97c2..fca8b88925e96 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -11,6 +11,7 @@ import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, + settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { @@ -47,6 +48,7 @@ import { useToggleEnvironmentModelFavorite, } from "../../hooks/useSettings"; import { useStableCallback } from "../../hooks/useStableCallback"; +import { ensureLocalApi } from "../../localApi"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { cn } from "../../lib/utils"; import { resolveAppModelSelectionState } from "../../modelSelection"; @@ -84,19 +86,14 @@ import { EmptyMedia, EmptyTitle, } from "../ui/empty"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "../ui/number-field"; import { ScrollArea } from "../ui/scroll-area"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; +import { useManagedPolicy } from "~/state/managedPolicy"; import { ExpandableText } from "./ExpandableText"; +import { IntervalSecondsField } from "./IntervalSecondsField"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { useSecretSources } from "./SecretSourceInput"; import { UsageProviderSettings } from "./UsageProviderSettings"; @@ -111,7 +108,6 @@ import { backgroundActivityOverrideSettings, buildProviderInstanceUpdatePatch, durationToSeconds, - normalizeIntervalSeconds, PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, } from "./SettingsPanels.logic"; import { @@ -133,6 +129,25 @@ import { resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; +function providerInstanceName(instance: ProviderInstanceConfig): string { + return ( + instance.displayName?.trim() || + providerClients.get(instance.driver)?.label || + String(instance.driver) + ); +} + +async function confirmProviderInstanceRemoval( + question: string, + consequence = "", +): Promise { + 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)); @@ -548,6 +563,10 @@ export function EnvironmentProviderSettings({ readonly readOnly?: boolean; }) { const settings = useEnvironmentSettings(environmentId); + const managedPolicy = useManagedPolicy(environmentId); + // A policy that allows no provider leaves nothing to add. + const canAddProvider = + !readOnly && managedPolicy?.limits?.providers?.allowedDrivers?.length !== 0; const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope); const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope); const updateSettings = useUpdateEnvironmentSettings(environmentId); @@ -832,6 +851,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, @@ -868,10 +891,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({ @@ -962,7 +992,7 @@ export function EnvironmentProviderSettings({ {...searchableSetting("providers")} variant="plain" titleAction={ - !readOnly ? ( + canAddProvider ? (
    {rows.map((row) => renderProviderInstance(row, "list"))} - {!readOnly ? ( + {canAddProvider ? (
    } diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index fee5b9857a6ae..ec888267c78cc 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -12,6 +12,7 @@ import { type EnvironmentId, connectReachabilityHelpUrl, formatConnectReachable, + managedCapabilityDenial, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { @@ -60,6 +61,7 @@ import { usePrimaryEnvironmentId, } from "~/state/environments"; import { APP_VERSION } from "~/branding"; +import { usePrimaryManagedPolicy } from "~/state/managedPolicy"; import { useAtomCommand } from "../../state/use-atom-command"; import { serverEnvironment } from "~/state/server"; import { @@ -531,6 +533,8 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b reconcileCloudState, } = useCloudLinkController(); const { openAuthPrompt } = useT3ConnectAuthPrompt(); + // The server unlinks and refuses relay changes under such a policy; unlinking stays possible. + const connectDenial = managedCapabilityDenial(usePrimaryManagedPolicy(), "connect"); const [isUpdating, setIsUpdating] = useState(false); const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); @@ -652,21 +656,24 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b - {signedOut ? null : } + {signedOut || connectDenial !== undefined ? null : ( + + )} ) : linkRevoked ? ( ) : null} @@ -675,7 +691,7 @@ function ScheduledTaskEnvironmentSection({ {taskId && !linkedTask ? ( ) : null} @@ -752,7 +768,9 @@ function ScheduledTaskRow({
    {scheduleLabel(task.schedule)} ·{" "} - {!task.enabled ? ( + {task.blockedByPolicy !== undefined ? ( + task.blockedByPolicy + ) : !task.enabled ? ( "Paused" ) : isWebhook ? ( "Listening" @@ -1171,6 +1189,10 @@ function ScheduledTaskEditorForm({ const { scope, connectedEnvironments } = useSettingsScope(); const [environmentId, setEnvironmentId] = useState(initialEnvironmentId); const environment = useEnvironment(environmentId); + // Older servers reject one-time schedules, so offer Once only where it can save. + const offersOnce = + environment?.serverConfig?.environment.capabilities.oneTimeSchedules === true || + task?.schedule.type === "once"; const connected = environment?.connection.phase === "connected" && environment.serverConfig !== null; const tasksQuery = useEnvironmentQuery( @@ -1191,6 +1213,7 @@ function ScheduledTaskEditorForm({ const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, { label: "scheduled task upsert", }); + const webhookDenial = managedCapabilityDenial(useManagedPolicy(environmentId), "webhookTasks"); const [draft, setDraft] = useState(() => task ? taskToDraft(task) @@ -1278,7 +1301,7 @@ function ScheduledTaskEditorForm({ } const schedule = scheduleFromDraft(draft); if (schedule === null) { - for (const error of webhookDraftErrors(draft)) reportFailure(error.title, error.description); + for (const error of scheduleDraftErrors(draft)) reportFailure(error.title, error.description); return; } if ( @@ -1298,6 +1321,13 @@ function ScheduledTaskEditorForm({ reportFailure("Invalid interval", "Enter an interval of at least one minute."); return; } + if (schedule.type === "once" && !offersOnce) { + reportFailure( + "Once is not available here", + "This environment runs an older T3 Code. Update it to schedule a one-time task, or pick another schedule.", + ); + return; + } if (draft.workspaceMode === "existing_worktree" && !draft.existingWorktreePath.trim()) { reportFailure("Checkout path is required", "Enter the path of the checkout to run in."); return; @@ -1328,7 +1358,10 @@ function ScheduledTaskEditorForm({ threadId: draft.threadId ? (draft.threadId as ThreadId) : null, workspaceStrategy, modelSelection, - runtimeMode: draft.runtimeMode, + // Tasks have no mode picker; a mode above the administrator's ceiling would fail every run. + runtimeMode: + clampManagedRuntimeMode(environment?.serverConfig?.managedPolicy, draft.runtimeMode) ?? + draft.runtimeMode, interactionMode: draft.interactionMode, creationSource: "web", }; @@ -1352,7 +1385,8 @@ function ScheduledTaskEditorForm({ {draft.editingId ? "Edit task" : "New task"} - Run a prompt automatically on an interval, at a fixed time, or when a webhook is called. + Run a prompt automatically on an interval, at a fixed time, once at a set date, or when a + webhook is called. @@ -1526,7 +1560,10 @@ function ScheduledTaskEditorForm({ onChange={(event) => setDraft((current) => ({ ...current, - existingWorktreePath: event.target.value, + existingWorktreePath: resolvePastedPath( + current.existingWorktreePath, + event.target.value, + ), })) } /> @@ -1580,24 +1617,60 @@ function ScheduledTaskEditorForm({ value={[draft.scheduleMode]} onValueChange={(values) => { const mode = values[0] as ScheduleMode | undefined; - if (mode === "fixed" || mode === "interval" || mode === "webhook") - setDraft((current) => ({ - ...current, - scheduleMode: mode, - prompt: - mode === "webhook" && !current.prompt.trim() - ? DEFAULT_WEBHOOK_PROMPT - : current.prompt, - })); + if ( + mode === "fixed" || + mode === "once" || + mode === "interval" || + mode === "webhook" + ) + setDraft((current) => { + const suggested = + mode === "once" && !current.runAt + ? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000) + : null; + return { + ...current, + scheduleMode: mode, + // Keep the instant too, so an hour that repeats when clocks go back stays exact. + ...(suggested === null + ? {} + : { + runAt: toDateTimeLocalValue(suggested), + savedRunAt: suggested.toISOString(), + }), + prompt: + mode === "webhook" && !current.prompt.trim() + ? DEFAULT_WEBHOOK_PROMPT + : current.prompt, + }; + }); }} > At a time + {offersOnce || draft.scheduleMode === "once" ? ( + Once + ) : null} Every interval - On webhook + + On webhook +
    - {draft.scheduleMode === "webhook" ? ( + {webhookDenial !== undefined ? ( + + {draft.scheduleMode === "webhook" + ? `${webhookDenial} Switch this task to a time or an interval, or turn it off, to save it.` + : webhookDenial} + + ) : null} + {/* A disabled task still saves, so keep its fields reachable for fixing. */} + {draft.scheduleMode === "webhook" && + webhookDenial !== undefined && + draft.enabled ? null : draft.scheduleMode === "webhook" ? (

    @@ -1631,7 +1704,7 @@ function ScheduledTaskEditorForm({ onChange={(filters) => setDraft((current) => ({ ...current, filters }))} /> @@ -1644,6 +1717,9 @@ function ScheduledTaskEditorForm({ } /> +

    + Requests with the same key run only once within 7 days. +

    - Replay keys suppress repeat deliveries for 7 days. Coalescing runs the latest - payload once when the window from the first delivery closes. Empty keys run - normally. + Coalescing runs the latest payload once when the window from the first delivery + closes. Empty keys run normally.

    @@ -1767,6 +1842,20 @@ function ScheduledTaskEditorForm({
    ) : null}
    + ) : draft.scheduleMode === "once" ? ( +
    + + + setDraft((current) => ({ ...current, runAt: event.target.value })) + } + /> +
    ) : draft.scheduleMode === "fixed" ? (
    @@ -1847,7 +1936,14 @@ function ScheduledTaskEditorForm({ +
    + + ); +} + +describe("useResizableWidth", () => { + it("follows a responsive default until the user saves a width", async () => { + const previous = localStorage.getItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + try { + // This hook only needs browser storage and React; no navigation is involved. + await using screen = await render(, { router: false }); + expect(screen.container.textContent).toBe("320"); + await screen.rerender(); + expect(screen.container.textContent).toBe("200"); + + await act(() => screen.container.querySelector("button")!.click()); + await screen.rerender(); + expect(screen.container.textContent).toBe("350"); + + await screen.unmount(); + await using restored = await render(, { router: false }); + expect(restored.container.textContent).toBe("350"); + } finally { + if (previous === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, previous); + } + }); + + it("keeps the default responsive after a stationary click and persists an actual drag", async () => { + const previous = localStorage.getItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + try { + // This hook only needs browser storage and React; no navigation is involved. + await using screen = await render(, { router: false }); + await screen.getByRole("separator").click(); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + await screen.rerender(); + expect(screen.container.textContent).toBe("256"); + + const handle = screen.container.querySelector('[role="separator"]')!; + // Synthetic drag events have no active browser pointer to capture. + using _capture = vi.spyOn(handle, "setPointerCapture").mockImplementation(() => {}); + const pointer = { bubbles: true, pointerId: 1, button: 0 }; + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientX: 500 })); + }); + await screen.rerender(); + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: 500 })); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(screen.container.style.getPropertyValue(RESIZABLE_WIDTH_PROPERTY)).toBe("280px"); + + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientX: 500 })); + handle.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: 450 })); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBe("330"); + await screen.rerender(); + expect(screen.container.textContent).toBe("330"); + } finally { + if (previous === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, previous); + } + }); +}); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 50fdba38a9fc5..dc5fe8569d03e 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -3,6 +3,7 @@ import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useState } fro import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; import { useResizeDrag } from "./useResizeDrag"; +import { useStableCallback } from "./useStableCallback"; const WidthSchema = Schema.Finite; @@ -26,7 +27,7 @@ export interface ResizableWidthHandlers { readonly onLostPointerCapture: (event: ReactPointerEvent) => void; } -/** Persist on drag end to avoid a storage write on every animation frame. */ +/** Uses the current default until a width is chosen; persists on drag end, not every frame. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; readonly setWidth: (width: number) => void; @@ -39,14 +40,14 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return Math.max(minWidth, Math.min(maxWidth, value)); }; - const readWidth = () => { - if (typeof window === "undefined") return defaultWidth; + const readWidth = (): number | null => { + if (typeof window === "undefined") return null; try { const stored = getLocalStorageItem(storageKey, WidthSchema); - return clamp(stored ?? defaultWidth); + return stored === null ? null : clamp(stored); } catch (error) { console.error("Could not read persisted panel width.", error); - return defaultWidth; + return null; } }; const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() })); @@ -54,7 +55,10 @@ export function useResizableWidth(options: UseResizableWidthOptions): { setWidthState({ storageKey, width: readWidth() }); } - const clampedWidth = clamp(widthState.width); + const clampedWidth = clamp(widthState.width ?? defaultWidth); + const restoreWidth = useStableCallback((host: HTMLElement | null) => { + host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${clampedWidth}px`); + }); const commitWidth = (nextStorageKey: string, nextWidth: number) => { // Resizing remains usable for this session when browser storage is unavailable. @@ -82,8 +86,9 @@ export function useResizableWidth(options: UseResizableWidthOptions): { host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`); return nextWidth; }, - finish(finalWidth) { - commitWidth(startStorageKey, finalWidth); + finish(finalWidth, moved) { + if (moved) commitWidth(startStorageKey, finalWidth); + else restoreWidth(host); }, cleanup(committed) { host?.style.removeProperty("transition-property"); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 2c24f13760092..43dfe3e045bb7 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -588,8 +588,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { reportFailure: false, }); const { environments } = useEnvironments(); - const persistServerSettings = async (request: Parameters[0]) => { + const persistServerSettings = async ( + request: Parameters[0], + onFailed?: () => void, + ) => { const result = await persist(request); + if (result._tag === "Failure") onFailed?.(); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const label = environments.find((environment) => environment.environmentId === request.environmentId) @@ -604,7 +608,10 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { return result; }; const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true); - const updateSettings = (patch: UnifiedSettingsPatch) => { + const updateSettings = ( + patch: UnifiedSettingsPatch, + options?: { readonly onFailed?: () => void }, + ) => { const { serverPatch, clientPatch } = splitPatch(patch); const canWriteServerPatch = @@ -613,6 +620,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { readEnvironmentScope(environmentId, scope), ); if (Object.keys(serverPatch).length > 0 && !canWriteServerPatch) { + options?.onFailed?.(); toastManager.add({ type: "warning", title: "Setting not saved", @@ -622,18 +630,20 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) { const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); // Dropping the write silently leaves the control looking saved. - const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => - toastManager.add({ + const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => { + options?.onFailed?.(); + return toastManager.add({ type: "warning", title: "Setting not saved", description, }); + }; if (Object.keys(localPatch).length > 0) { if (environmentId) { - void persistServerSettings({ - environmentId, - input: { patch: localPatch }, - }); + void persistServerSettings( + { environmentId, input: { patch: localPatch } }, + options?.onFailed, + ); } else { warnUnsaved(); } @@ -688,6 +698,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { result: await persist(request), })), ).then((outcomes) => { + if ( + deniedLabels.length > 0 || + outcomes.some(({ result }) => result._tag === "Failure") + ) { + options?.onFailed?.(); + } const failures = outcomes.flatMap(({ label, result }) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return []; const error = squashAtomCommandFailure(result); diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index fd27270f3380b..de5056c4af696 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -10,6 +10,11 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { useDiffPanelStore } from "../diffPanelStore"; +import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../previewMiniPlayerStore"; +import { useRightPanelStore } from "../rightPanelStore"; + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((complete) => { @@ -163,6 +168,23 @@ vi.mock("./useSettings", () => ({ import { useThreadActions } from "./useThreadActions"; +function seedThreadPanelState(ref: ScopedThreadRef) { + useRightPanelStore.getState().show(ref); + useDiffPanelStore.getState().selectGitScope(ref, "branch"); + usePreviewMiniPlayerStore.getState().open(ref, browserMiniPlayerSource("tab")); +} + +function threadPanelStatePresence(ref: ScopedThreadRef) { + const key = scopedThreadKey(ref); + return { + rightPanel: key in useRightPanelStore.getState().byThreadKey, + diffPanel: key in useDiffPanelStore.getState().byThreadKey, + miniPlayer: key in usePreviewMiniPlayerStore.getState().byThreadKey, + }; +} +const panelStateKept = { rightPanel: true, diffPanel: true, miniPlayer: true }; +const panelStateCleared = { rightPanel: false, diffPanel: false, miniPlayer: false }; + const primary = EnvironmentId.make("primary"); const secondary = EnvironmentId.make("secondary"); const target = { environmentId: secondary, threadId: ThreadId.make("thread") }; @@ -226,6 +248,9 @@ beforeEach(() => { state.toasts = []; state.afterRequest = undefined; state.sessionLookupFails = false; + useRightPanelStore.setState(useRightPanelStore.getInitialState(), true); + useDiffPanelStore.setState(useDiffPanelStore.getInitialState(), true); + usePreviewMiniPlayerStore.setState(usePreviewMiniPlayerStore.getInitialState(), true); }); describe("thread action permissions", () => { @@ -284,23 +309,28 @@ describe("thread action permissions", () => { it("deletes an archived thread only with its own environment's grant", async () => { state.threads = []; + seedThreadPanelState(target); const actions = useThreadActions(); await actions.deleteThread(target); expect(state.requests).toEqual([]); + expect(threadPanelStatePresence(target)).toEqual(panelStateKept); state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); expect((await actions.deleteThread(target))._tag).toBe("Success"); expect(state.requests).toEqual([ expect.objectContaining({ action: "delete", environmentId: secondary }), ]); + expect(threadPanelStatePresence(target)).toEqual(panelStateCleared); }); it("stops before delete and local cleanup when permission is revoked during session stop", async () => { state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); state.threads[0]!.session = { status: "ready" }; state.afterRequest = () => state.scopes.get(secondary)!.clear(); + seedThreadPanelState(target); expect((await useThreadActions().deleteThread(target))._tag).toBe("Failure"); expect(state.requests.map((request) => request.action)).toEqual(["stopSession"]); expect(state.localEffects).toEqual([]); + expect(threadPanelStatePresence(target)).toEqual(panelStateKept); }); it.each([ @@ -310,10 +340,12 @@ describe("thread action permissions", () => { state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); state.threads[0]!.worktreePath = "/worktrees/thread"; state.sessionLookupFails = sessionLookupFails; + seedThreadPanelState(target); expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); expect(state.confirm).not.toHaveBeenCalled(); expect(state.requests.map((request) => request.action)).toEqual(["stopSession", "delete"]); expect(state.localEffects).toContain("clear-terminal-ui"); + expect(threadPanelStatePresence(target)).toEqual(panelStateCleared); }); it("does not request worktree removal after its grant is revoked during delete", async () => { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index c41873d59400f..e5eedffc16698 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -45,7 +45,10 @@ import { readThreadShells, } from "../state/entities"; import { useUiStateStore } from "../uiStateStore"; +import { useDiffPanelStore } from "../diffPanelStore"; +import { usePreviewMiniPlayerStore } from "../previewMiniPlayerStore"; import { clearThreadPreviewState } from "../previewStateStore"; +import { useRightPanelStore } from "../rightPanelStore"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -236,6 +239,13 @@ function expireThreadUndos(target: ScopedThreadRef, kinds: readonly string[]) { for (const kind of kinds) ThreadUndo.invalidate(kind, scopedThreadKey(target)); } +function clearDeletedThreadPanelState(ref: ScopedThreadRef) { + clearThreadPreviewState(ref); + useRightPanelStore.getState().removeThread(ref); + useDiffPanelStore.getState().removeThread(ref); + usePreviewMiniPlayerStore.getState().removeThread(ref); +} + /** A No project thread that already has its own folder, on a server that can promote it. */ export function canTurnThreadIntoProject(threadRef: ScopedThreadRef): boolean { const thread = readThreadShell(threadRef); @@ -437,7 +447,7 @@ export function useThreadActions() { if (result._tag === "Success") { expireThreadUndos(target, [...LIST_PLACEMENT_UNDO_KINDS, "archive"]); refreshArchivedThreadsForEnvironment(target.environmentId); - clearThreadPreviewState(target); + clearDeletedThreadPanelState(target); } return result; } @@ -561,7 +571,7 @@ export function useThreadActions() { threadRef, ); clearTerminalUiState(threadRef); - clearThreadPreviewState(threadRef); + clearDeletedThreadPanelState(threadRef); if (shouldNavigateToFallback) { const fallbackThread = fallbackThreadId diff --git a/apps/web/src/hooks/useThreadVisitedMigration.test.ts b/apps/web/src/hooks/useThreadVisitedMigration.test.ts new file mode 100644 index 0000000000000..e0bd84855cc33 --- /dev/null +++ b/apps/web/src/hooks/useThreadVisitedMigration.test.ts @@ -0,0 +1,103 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { environmentThreadShells } from "../state/threads"; +import { makeThreadFixture } from "../test-fixtures"; +import { startThreadVisitedMigration } from "./useThreadVisitedMigration"; + +const state = vi.hoisted(() => ({ + localVisits: {} as Record, +})); + +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ runAtomCommand: vi.fn() })); +vi.mock("../uiStateStore", () => ({ + useUiStateStore: { getState: () => ({ threadLastVisitedAtById: state.localVisits }) }, +})); +vi.mock("../state/threads", async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentThreadShells: { + threadShellsAtom: Atom.make>([]), + }, + threadEnvironment: { visit: {} }, + }; +}); + +const localVisit = "2026-10-27T10:00:00.000Z"; + +function setup(threads: ReadonlyArray, local = localVisit) { + state.localVisits = Object.fromEntries( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + local, + ]), + ); + vi.mocked(runAtomCommand).mockReset().mockResolvedValue(AsyncResult.success(undefined)); + const tasks: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + tasks.push(task); + return () => {}; + }, + }); + const shells = environmentThreadShells.threadShellsAtom; + if (!Atom.isWritable(shells)) throw new Error("Expected writable test shell state"); + registry.set(shells, threads); + const stop = startThreadVisitedMigration(registry); + return { + setThreads(next: ReadonlyArray) { + registry.set(shells, next); + for (let task = tasks.shift(); task !== undefined; task = tasks.shift()) task(); + }, + [Symbol.dispose]() { + stop(); + registry.dispose(); + }, + }; +} + +describe("startThreadVisitedMigration", () => { + it("preserves mark unread when a client with a newer local visit reloads", () => { + using _migration = setup([makeThreadFixture({ lastVisitedAt: "2026-10-27T09:59:59.999Z" })]); + + expect(runAtomCommand).not.toHaveBeenCalled(); + }); + + it("seeds unset visits once per environment and thread", () => { + const thread = makeThreadFixture({ lastVisitedAt: null }); + const remote = { ...thread, environmentId: EnvironmentId.make("remote") }; + using migration = setup([thread, remote]); + + expect(vi.mocked(runAtomCommand).mock.calls.map((call) => call[2])).toEqual([ + { + environmentId: thread.environmentId, + input: { threadId: thread.id, visitedAt: localVisit }, + }, + { + environmentId: remote.environmentId, + input: { threadId: remote.id, visitedAt: localVisit }, + }, + ]); + migration.setThreads([{ ...thread }, { ...remote }]); + expect(runAtomCommand).toHaveBeenCalledTimes(2); + }); + + it("waits for older servers to support visits before migrating", () => { + const thread = makeThreadFixture(); + expect(thread.lastVisitedAt).toBeUndefined(); + using migration = setup([thread]); + expect(runAtomCommand).not.toHaveBeenCalled(); + + migration.setThreads([{ ...thread, lastVisitedAt: null }]); + expect(runAtomCommand).toHaveBeenCalledTimes(1); + }); + + it.each(["", "not-a-date"])("ignores invalid local visits: %s", (local) => { + using _migration = setup([makeThreadFixture({ lastVisitedAt: null })], local); + expect(runAtomCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useThreadVisitedMigration.ts b/apps/web/src/hooks/useThreadVisitedMigration.ts index 2d01d800360ea..fb90b6d4f2e1d 100644 --- a/apps/web/src/hooks/useThreadVisitedMigration.ts +++ b/apps/web/src/hooks/useThreadVisitedMigration.ts @@ -15,8 +15,8 @@ export function startThreadVisitedMigration(registry: AtomRegistry.AtomRegistry) const migratedThreadKeys = new Set(); const migrate = (threads: ReadonlyArray) => { for (const thread of threads) { - // Missing means unsupported; retain the local value until the server upgrades. - if (thread.lastVisitedAt === undefined) continue; + // An established server watermark may be deliberately older after Mark unread. + if (thread.lastVisitedAt !== null) continue; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); if (migratedThreadKeys.has(threadKey)) continue; migratedThreadKeys.add(threadKey); @@ -24,8 +24,6 @@ export function startThreadVisitedMigration(registry: AtomRegistry.AtomRegistry) if (!local) continue; const localMs = Date.parse(local); if (!Number.isFinite(localMs)) continue; - const serverMs = thread.lastVisitedAt === null ? null : Date.parse(thread.lastVisitedAt); - if (serverMs !== null && Number.isFinite(serverMs) && serverMs >= localMs) continue; void runAtomCommand( registry, threadEnvironment.visit, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e186516791252..15dd5e14d33fe 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1569,6 +1569,7 @@ html[data-theme-id] [data-app-sidebar] { --accent-foreground: var(--app-theme-accent-surface-foreground); --muted: var(--app-theme-muted); --muted-foreground: var(--app-theme-muted-foreground); + --secondary-label: var(--app-theme-secondary-label); --border: var(--app-theme-border); --input: var(--app-theme-input); --sidebar: var(--app-theme-sidebar); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 18c1b4c53c5a9..fc96aa1f85896 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -15,15 +15,20 @@ import { matchesKeybindingShortcutModifiers, normalizeEventKey, resolveEventKeys, - type ShortcutEventLike, + type ShortcutEventLike as SharedShortcutEventLike, type ShortcutModifierStateLike, } from "@t3tools/shared/keybindings"; import { isElectron } from "./env"; +import { isEditableFocused } from "./lib/editableFocus"; import { projectScriptIdFromCommand } from "./projectScripts"; -export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings"; +export type { ShortcutModifierStateLike } from "@t3tools/shared/keybindings"; export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings"; +export interface ShortcutEventLike extends SharedShortcutEventLike { + target?: EventTarget | null; +} + export interface ShortcutMatchContext { terminalFocus: boolean; terminalOpen: boolean; @@ -177,6 +182,9 @@ export function resolveShortcutCommand( ): KeybindingCommand | null { const platform = resolvePlatform(options); const context = resolveContext(options); + if (options?.context?.editableFocus === undefined && typeof document !== "undefined") { + context.editableFocus = isEditableFocused(event.target ?? document.activeElement); + } for (let index = keybindings.length - 1; index >= 0; index -= 1) { const binding = keybindings[index]; diff --git a/apps/web/src/keybindingsFocus.test.ts b/apps/web/src/keybindingsFocus.test.ts new file mode 100644 index 0000000000000..34be53f1709e9 --- /dev/null +++ b/apps/web/src/keybindingsFocus.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import type { KeybindingCommand } from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; +import { resolveShortcutCommand } from "./keybindings"; +import * as editableFocus from "./lib/editableFocus"; + +function setupFocus() { + const editor = new EventTarget(); + const other = new EventTarget(); + vi.stubGlobal("document", { activeElement: editor }); + const focus = vi + .spyOn(editableFocus, "isEditableFocused") + .mockImplementation((target) => target === editor); + return { + editor, + other, + [Symbol.dispose]() { + focus.mockRestore(); + vi.unstubAllGlobals(); + }, + }; +} + +function keydown(target?: EventTarget) { + return { + key: "k", + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + ...(target ? { target } : {}), + }; +} + +function bindings(command: KeybindingCommand, when = "!editableFocus") { + return compileResolvedKeybindingsConfig([{ key: "mod+k", command, when }]); +} + +describe("editable shortcut conditions", () => { + it.each(["commandPalette.toggle", "sidebar.toggle", "thread.next", "thread.jump.1"] as const)( + "honors !editableFocus for %s without a caller-provided focus context", + (command) => { + using focus = setupFocus(); + const rules = bindings(command); + + expect( + resolveShortcutCommand(keydown(focus.editor), rules, { + platform: "Linux", + context: { terminalFocus: false }, + }), + ).toBeNull(); + expect(resolveShortcutCommand(keydown(focus.other), rules, { platform: "Linux" })).toBe( + command, + ); + }, + ); + + it("uses the active element when a forwarded shortcut has no target", () => { + using _focus = setupFocus(); + expect( + resolveShortcutCommand(keydown(), bindings("commandPalette.toggle"), { platform: "Linux" }), + ).toBeNull(); + }); + + it("honors a positive editableFocus condition", () => { + using focus = setupFocus(); + const rules = bindings("commandPalette.toggle", "editableFocus"); + expect(resolveShortcutCommand(keydown(focus.editor), rules, { platform: "Linux" })).toBe( + "commandPalette.toggle", + ); + expect(resolveShortcutCommand(keydown(focus.other), rules, { platform: "Linux" })).toBeNull(); + }); + + it("preserves explicit focus overrides", () => { + using focus = setupFocus(); + const rules = bindings("commandPalette.toggle"); + + expect( + resolveShortcutCommand(keydown(focus.editor), rules, { + platform: "Linux", + context: { editableFocus: false }, + }), + ).toBe("commandPalette.toggle"); + expect( + resolveShortcutCommand(keydown(focus.other), rules, { + platform: "Linux", + context: { editableFocus: true }, + }), + ).toBeNull(); + }); + + it("keeps shortcuts without an editable-focus condition active while editing", () => { + using focus = setupFocus(); + expect( + resolveShortcutCommand(keydown(focus.editor), bindings("commandPalette.toggle", "true"), { + platform: "Linux", + }), + ).toBe("commandPalette.toggle"); + }); +}); diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index a84e072255b5b..fbd17226cefc8 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -7,6 +7,8 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import { orchestrationEnvironment } from "../state/orchestration"; +import { serverEnvironment } from "../state/server"; +import { environmentThreadShells } from "../state/threads"; import { appAtomRegistry } from "../rpc/atomRegistry"; function archivedSnapshotAtom(environmentId: EnvironmentId) { @@ -18,6 +20,10 @@ function archivedSnapshotAtom(environmentId: EnvironmentId) { const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ getSnapshotAtom: archivedSnapshotAtom, + hiddenThreadTagsAtom: serverEnvironment.hiddenThreadTagsAtom, + hiddenThreadKeysAtom: environmentThreadShells.hiddenThreadKeysAtom, + liveThreadRefsAtom: environmentThreadShells.threadRefsAtom, + anyEnvironmentHidingAtom: environmentThreadShells.anyEnvironmentHidingAtom, labelPrefix: "web:archived-thread-snapshots", }); diff --git a/apps/web/src/lib/backgroundActivityReporter.test.ts b/apps/web/src/lib/backgroundActivityReporter.test.ts index ae4b980afe0a0..bbb24ce1b8516 100644 --- a/apps/web/src/lib/backgroundActivityReporter.test.ts +++ b/apps/web/src/lib/backgroundActivityReporter.test.ts @@ -111,4 +111,29 @@ describe("layer", () => { expect(calls).toBe(2); }).pipe(Effect.scoped), ); + + it.effect("keeps reporting after a report never settles", () => + Effect.gen(function* () { + const entries = yield* SubscriptionRef.make( + new Map([[EnvironmentId.make("environment-reporter-test"), {}]]), + ); + let calls = 0; + const registry = EnvironmentRegistry.EnvironmentRegistry.of({ + entries, + run: () => { + calls += 1; + return calls === 1 ? Effect.never : Effect.void; + }, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + + yield* Layer.build( + layer.pipe(Layer.provide(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registry))), + ); + yield* TestClock.adjust("1 second"); + expect(calls).toBe(1); + + yield* TestClock.adjust("25 seconds"); + expect(calls).toBe(2); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/web/src/lib/backgroundActivityReporter.ts b/apps/web/src/lib/backgroundActivityReporter.ts index 70b34f9140cdc..a9f90b1718140 100644 --- a/apps/web/src/lib/backgroundActivityReporter.ts +++ b/apps/web/src/lib/backgroundActivityReporter.ts @@ -23,6 +23,8 @@ import { randomUUID } from "./utils"; const CLIENT_ID_STORAGE_KEY = "t3.backgroundActivity.clientId"; const REPORT_INTERVAL_MS = 25_000; +// Shorter than the interval, so a report that never settles cannot hold up the next one. +const REPORT_TIMEOUT_MS = 15_000; const LEASE_TTL_MS = 45_000; const RECENT_INTERACTION_WINDOW_MS = LEASE_TTL_MS; const BASELINE_SCOPES: ReadonlyArray = [{ type: "provider-status" }]; @@ -207,7 +209,8 @@ export const layer = Layer.effectDiscard( ) // Not `ignore`: tearing down an environment mid-request interrupts its report, // and an interrupt leaking out here would end the reporting loop for good. - .pipe(Effect.ignoreCause), + // The timeout covers a live connection that never answers, which would stall it. + .pipe(Effect.timeout(`${REPORT_TIMEOUT_MS} millis`), Effect.ignoreCause), { concurrency: "unbounded", discard: true }, ); }).pipe(Effect.withSpan("web.backgroundActivity.report")); 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/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts index 582dba9569a8a..bf792a803238c 100644 --- a/apps/web/src/lib/turnDiffTree.test.ts +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -87,6 +87,21 @@ describe("buildTurnDiffTree", () => { }); it("compacts only single-directory chains and stops at branch points", () => { + expect( + buildTurnDiffTree([ + { path: "src/a/b/first.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "src/c/d/second.ts", kind: "modified", additions: 2, deletions: 0 }, + ]), + ).toMatchObject([ + { + name: "src", + children: [ + { name: "a/b", path: "src/a/b", children: [{ name: "first.ts" }] }, + { name: "c/d", path: "src/c/d", children: [{ name: "second.ts" }] }, + ], + }, + ]); + const tree = buildTurnDiffTree([ { path: "apps/server/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, { path: "apps/server/main.ts", kind: "modified", additions: 4, deletions: 0 }, diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts index e841e18ce24d2..7d491e15ccfb5 100644 --- a/apps/web/src/lib/turnDiffTree.ts +++ b/apps/web/src/lib/turnDiffTree.ts @@ -56,14 +56,7 @@ function readStat(file: TurnDiffFileChange): TurnDiffStat | null { } function compactDirectoryNode(node: TurnDiffTreeDirectoryNode): TurnDiffTreeDirectoryNode { - const compactedChildren = node.children.map((child) => - child.kind === "directory" ? compactDirectoryNode(child) : child, - ); - - let compactedNode: TurnDiffTreeDirectoryNode = { - ...node, - children: compactedChildren, - }; + let compactedNode = node; while (compactedNode.children.length === 1 && compactedNode.children[0]?.kind === "directory") { const onlyChild = compactedNode.children[0]; diff --git a/apps/web/src/lib/workspaceFocus.browser.test.ts b/apps/web/src/lib/workspaceFocus.browser.test.ts index f0064630f08c2..f97ac2e5408d3 100644 --- a/apps/web/src/lib/workspaceFocus.browser.test.ts +++ b/apps/web/src/lib/workspaceFocus.browser.test.ts @@ -226,6 +226,29 @@ describe("workspace area focus", () => { await vi.waitFor(() => expect(document.activeElement).toBe(composer.button)); }); + it("keeps focus on the control a panel tab switch came from", () => { + using fixture = setup(); + const panel = fixture.area("panel", 3); + const content = document.createElement("div"); + content.setAttribute("data-right-panel-surface-content", ""); + const row = (name: string) => { + const button = document.createElement("button"); + button.textContent = name; + content.append(button); + return button; + }; + const earlierRow = row("reference-analysis.md"); + const clickedRow = row("print-guide.md"); + panel.element.append(content); + panel.element.dataset.workspaceFocusKey = "panel:file:print-guide.md"; + earlierRow.focus(); + panel.element.dataset.workspaceFocusKey = "panel:file:reference-analysis.md"; + clickedRow.focus(); + panel.element.dataset.workspaceFocusKey = "panel:file:print-guide.md"; + expect(fixture.controller.focus("panel")).toBe(true); + expect(document.activeElement).toBe(clickedRow); + }); + it("waits for a requested area to mount", async () => { using fixture = setup(); fixture.area("composer", 2); diff --git a/apps/web/src/lib/workspaceFocus.ts b/apps/web/src/lib/workspaceFocus.ts index e612a8ff54974..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( @@ -186,6 +186,7 @@ export function createWorkspaceFocusController(doc: Document) { // A lazy surface's Suspense fallback is empty. Wait for its content instead // of claiming focus on the tab strip before the useful controls mount. if (surface && !surface.hasChildNodes()) return false; + if (!selector && surface?.contains(doc.activeElement)) return true; if (selector) { const target = area.querySelector(selector); if (!target || !isWorkspaceElementVisible(target)) return false; diff --git a/apps/web/src/localEnvironment.ts b/apps/web/src/localEnvironment.ts index d9f4be0e29518..bccffd64ce23d 100644 --- a/apps/web/src/localEnvironment.ts +++ b/apps/web/src/localEnvironment.ts @@ -1,3 +1,5 @@ export function isLocalEnvironmentDisabled(): boolean { - return window.desktopBridge?.getLocalEnvironmentEnabled() === false; + return ( + typeof window !== "undefined" && window.desktopBridge?.getLocalEnvironmentEnabled() === false + ); } 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/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index b5e9be7d9e011..435854d54b393 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -1,11 +1,17 @@ import { ANTIGRAVITY_DEFAULT_MODEL, DEFAULT_TEXT_GENERATION_MODEL, + DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; -import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, + type UnifiedSettings, +} from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; import { createModelSelection } from "@t3tools/shared/model"; import { deriveEffectiveComposerModelState } from "./composerDraftStore"; @@ -17,6 +23,7 @@ import { providerInstanceEntriesFor, } from "./providerInstances"; import { + defaultModelSelectionsAreMixed, getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, @@ -73,6 +80,142 @@ function settingsWithProviderInstances(): UnifiedSettings { }; } +describe("scoped default model selections", () => { + // regression: #27997 equal automatic defaults can resolve to different providers + it("compares automatic defaults using each environment's models", () => { + const codex = { + projectId: null, + settings: DEFAULT_UNIFIED_SETTINGS, + providers: [provider({ instanceId: "codex", models: ["gpt-6-astra"] })], + }; + const claude = { + projectId: null, + settings: DEFAULT_UNIFIED_SETTINGS, + providers: [ + provider({ + instanceId: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + models: ["claude-sonnet-4-6"], + }), + ], + }; + + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, claude])).toBe(true); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [claude, codex])).toBe(true); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, codex])).toBe(false); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [claude])).toBe(false); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [])).toBe(false); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, { ...codex, providers: [] }]), + ).toBe(true); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + codex, + { ...codex, providers: [provider({ instanceId: "codex", models: ["gpt-6-sol"] })] }, + ]), + ).toBe(true); + + const projectId = ProjectId.make("claude-only"); + const project = { + projectId, + settings: { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [ProviderInstanceId.make("codex")]: { enabled: false } }, + }, + }, + }, + providers: [...codex.providers, ...claude.providers], + }; + const remoteProject = { ...claude, projectId: ProjectId.make("remote-project") }; + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [project, remoteProject])).toBe( + false, + ); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [remoteProject, project])).toBe( + false, + ); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + project, + { ...project, projectId: ProjectId.make("other-project") }, + ]), + ).toBe(true); + + const configured = { + projectId: null, + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerModelPreferences: { + [ProviderInstanceId.make("codex")]: { + hiddenModels: ["gpt-6-astra"], + modelOrder: [], + }, + }, + }, + providers: [provider({ instanceId: "codex", models: ["gpt-6-astra", "gpt-6-sol"] })], + }; + const inherited = { ...configured, settings: DEFAULT_SERVER_SETTINGS }; + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [configured, inherited])).toBe( + true, + ); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [inherited, configured])).toBe( + true, + ); + const clientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + providerModelPreferences: configured.settings.providerModelPreferences, + }; + expect(defaultModelSelectionsAreMixed(clientSettings, [configured, inherited])).toBe(false); + expect(defaultModelSelectionsAreMixed(clientSettings, [inherited, configured])).toBe(false); + expect( + defaultModelSelectionsAreMixed(clientSettings, [ + { ...inherited, settings: { ...DEFAULT_SERVER_SETTINGS, providerModelPreferences: {} } }, + inherited, + ]), + ).toBe(true); + }); + + it("keeps automatic, explicit, and different saved options distinct", () => { + const providers = [provider({ instanceId: "codex", models: ["gpt-6-astra"] })]; + const selected = { + projectId: null, + providers, + settings: { + ...DEFAULT_UNIFIED_SETTINGS, + defaultModelSelection: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-6-astra", + ), + }, + }; + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [selected, { ...selected }]), + ).toBe(false); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + selected, + { projectId: null, providers, settings: DEFAULT_UNIFIED_SETTINGS }, + ]), + ).toBe(true); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + selected, + { + ...selected, + settings: { + ...selected.settings, + defaultModelSelection: { + ...selected.settings.defaultModelSelection, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }, + }, + ]), + ).toBe(true); + }); +}); + describe("instance-scoped model selection", () => { it("keeps custom models on the provider instance that declared them", () => { const providers = [ @@ -609,6 +752,37 @@ describe("instance-scoped model selection", () => { ).toEqual(createModelSelection(instanceId, "openai/gpt-5.5")); }); + it("keeps the default instance's draft model off a custom instance of the same driver", () => { + const driver = ProviderDriverKind.make("opencode"); + const defaultInstanceId = ProviderInstanceId.make("opencode"); + const kimiInstanceId = ProviderInstanceId.make("kimi_api"); + const kimiModel = "kimi-code-plan-global/k3.1-pro"; + const providers = [ + provider({ + provider: driver, + instanceId: defaultInstanceId, + models: ["opencode-go/kimi-k3.1-pro"], + }), + provider({ provider: driver, instanceId: kimiInstanceId, models: [kimiModel] }), + ]; + const state = deriveEffectiveComposerModelState({ + draft: { + activeProvider: kimiInstanceId, + modelSelectionByProvider: { + [defaultInstanceId]: createModelSelection(defaultInstanceId, "opencode-go/kimi-k3.1-pro"), + }, + }, + providers, + selectedProvider: driver, + selectedInstanceId: kimiInstanceId, + threadModelSelection: null, + projectModelSelection: null, + settings: DEFAULT_UNIFIED_SETTINGS, + }); + + expect(state.selectedModel).toBe(kimiModel); + }); + it("self-heals a persisted selection pointing at a text-generation-incapable instance", () => { const providers = [ provider({ @@ -829,6 +1003,7 @@ describe("composer fallback to another instance of the same driver", () => { readonly providers: ReadonlyArray; readonly draft?: Parameters[0]["draft"]; readonly threadModelSelection: ReturnType; + readonly customModels?: Partial>>; }) { const settings = { ...DEFAULT_UNIFIED_SETTINGS, @@ -838,7 +1013,13 @@ describe("composer fallback to another instance of the same driver", () => { { driver: provider.driver, enabled: provider.enabled, - config: {}, + config: { + customModels: (input.customModels?.[provider.instanceId] ?? []).map((slug) => ({ + slug, + name: slug, + capabilities: null, + })), + }, }, ]), ), @@ -923,6 +1104,138 @@ describe("composer fallback to another instance of the same driver", () => { expect(traits.modelOptionsForDispatch).toBeUndefined(); }); + describe("with model aliases", () => { + const reasoningModel = ( + slug: string, + id: string, + extra: Partial = {}, + ): ServerProvider["models"][number] => ({ + slug, + name: slug, + isCustom: false, + ...extra, + capabilities: { + optionDescriptors: [ + { + id, + label: id, + type: "select", + options: ["medium", "high", "xhigh"].map((value) => ({ + id: value, + label: value, + isDefault: value === "medium", + })), + }, + ], + }, + }); + + it.each([ + ["5.4", "gpt-5.4"], + ["gpt-5-codex", "gpt-5.4"], + ["gpt-5.4", "5.4"], + ])("keeps the effort from %s on fallback %s", (savedModel, fallbackModel) => { + const disabledCodex = ProviderInstanceId.make("codex"); + const fallbackCodex = ProviderInstanceId.make("codex_team"); + const models = [reasoningModel(fallbackModel, "reasoningEffort")]; + const { selectedInstanceId, state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: disabledCodex }), enabled: false, models }, + { ...provider({ provider: codex, instanceId: fallbackCodex }), models }, + ], + threadModelSelection: createModelSelection(disabledCodex, savedModel, [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(selectedInstanceId).toBe(fallbackCodex); + expect(state.selectedModel).toBe(fallbackModel); + expect(traits.promptEffort).toBe("xhigh"); + }); + + it.each([ + { + // A disabled Codex instance reports only its custom models. + state: "disabled", + requested: { + enabled: false, + models: [reasoningModel("5.4", "reasoningEffort", { isCustom: true })], + }, + }, + { + // An unavailable instance reports no models; its custom models are only in settings. + state: "unavailable", + requested: { enabled: false, availability: "unavailable" as const, models: [] }, + }, + ])( + "does not alias-expand a custom model named 5.4 when the requested instance is $state", + ({ requested }) => { + const disabledCodex = ProviderInstanceId.make("codex"); + const { state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: disabledCodex }), ...requested }, + { + ...provider({ provider: codex, instanceId: "codex_team" }), + models: [reasoningModel("gpt-5.4", "reasoningEffort")], + }, + ], + customModels: { [disabledCodex]: ["5.4"] }, + threadModelSelection: createModelSelection(disabledCodex, "5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(state.selectedModel).toBe("gpt-5.4"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }, + ); + + it("does not alias-expand the fallback instance's custom model", () => { + const { state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: "codex" }), enabled: false }, + { + ...provider({ provider: codex, instanceId: "codex_team" }), + models: [reasoningModel("5.4", "reasoningEffort", { isCustom: true })], + }, + ], + customModels: { codex_team: ["5.4"] }, + threadModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(state.selectedModel).toBe("5.4"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }); + + it("does not carry options through an alias that names another model per instance", () => { + // "antigravity-default" is each Antigravity instance's current model. + const antigravity = ProviderDriverKind.make("antigravity"); + const disabledAntigravity = ProviderInstanceId.make("antigravity"); + const current = { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] }; + const { state, traits } = composerFor({ + providers: [ + { + ...provider({ provider: antigravity, instanceId: disabledAntigravity }), + enabled: false, + models: [reasoningModel("gemini-3-pro", "thinking", current)], + }, + { + ...provider({ provider: antigravity, instanceId: "antigravity_work" }), + models: [reasoningModel("claude-sonnet-4-6", "thinking", current)], + }, + ], + threadModelSelection: createModelSelection(disabledAntigravity, ANTIGRAVITY_DEFAULT_MODEL, [ + { id: "thinking", value: "high" }, + ]), + }); + + expect(state.selectedModel).toBe("claude-sonnet-4-6"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }); + }); + it("does not carry options to another driver", () => { const codexOnly: ReadonlyArray = [ { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index db52028c397e0..ec2816cc2849a 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -4,9 +4,11 @@ import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, type ModelSelection, + type ProjectId, ProviderDriverKind, ProviderInstanceId, type ServerProvider, + type ServerSettings, } from "@t3tools/contracts"; import { type CustomModelDefinition, @@ -16,8 +18,10 @@ import { resolveSelectableModel, } from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderState"; -import { UnifiedSettings } from "@t3tools/contracts/settings"; +import { type ClientSettings, UnifiedSettings } from "@t3tools/contracts/settings"; +import { resolveModelPreferences } from "@t3tools/shared/serverSettings"; import * as Arr from "effect/Array"; +import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; import { getDefaultServerModel, @@ -29,6 +33,8 @@ import { type ProviderInstanceEntry, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + resolveDefaultProviderModelSelection, + resolveProjectDefaultProviderModelSelection, } from "./providerInstances"; import { sortModelsForProviderInstance } from "./modelOrdering"; import { resolveModelShortName } from "@t3tools/client-runtime/provider-clients"; @@ -37,7 +43,7 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; const DEFAULT_TEXT_GENERATION_INSTANCE_ID = ProviderInstanceId.make("codex"); // Legacy per-driver buckets would leak custom-instance models into stock instances. -function readInstanceCustomModels( +export function readInstanceCustomModels( settings: UnifiedSettings, instanceId: ProviderInstanceId, driverKind: ProviderDriverKind, @@ -308,6 +314,38 @@ export function resolveEffectiveDefaultModelSelection( return model === null || model === selection.model ? selection : { ...selection, model }; } +export function defaultModelSelectionsAreMixed( + clientSettings: ClientSettings, + targets: ReadonlyArray<{ + projectId: ProjectId | null; + settings: ServerSettings; + providers: ReadonlyArray; + }>, +): boolean { + const selections = targets.map(({ projectId, settings, providers }) => { + const stored = settings.defaultModelSelection; + const selection = + stored === null + ? resolveProjectDefaultProviderModelSelection(providers, settings, projectId) + : resolveDefaultProviderModelSelection(providers, stored); + return { + stored, + effective: selection + ? resolveEffectiveDefaultModelSelection( + { + ...clientSettings, + ...settings, + ...resolveModelPreferences(settings, clientSettings), + }, + providers, + selection, + ) + : null, + }; + }); + return selections.some((selection) => !Equal.equals(selection, selections[0])); +} + export function getCustomModelOptionsByInstance( settings: UnifiedSettings, providers: ReadonlyArray, diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index a07ff9ae9b17e..891f3312ae833 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { applyProviderInstanceSettings, + brandProviderInstanceEntries, deriveProviderEntriesByEnvironment, deriveProviderInstanceEntries, getDefaultProviderInstanceModel, @@ -254,6 +255,32 @@ describe("deriveProviderEntriesByEnvironment", () => { }); }); +describe("brandProviderInstanceEntries", () => { + it("keeps an instance a project turns on as a sibling for the instance badge", () => { + const codex = ProviderDriverKind.make("codex"); + const settings = { + providerInstances: { + [ProviderInstanceId.make("codex_work")]: { driver: codex, enabled: false }, + }, + projectSettingsOverrides: { + [ProjectId.make("project-work")]: { + providerInstances: { [ProviderInstanceId.make("codex_work")]: { enabled: true } }, + }, + }, + }; + const entries = brandProviderInstanceEntries( + deriveProviderInstanceEntries([ + provider({ provider: codex, instanceId: "codex" }), + provider({ provider: codex, instanceId: "codex_work" }), + ]), + settings, + ); + const personal = entries.find((entry) => entry.instanceId === "codex"); + + expect(personal && shouldShowInstanceBadge(personal, entries)).toBe(true); + }); +}); + describe("resolveSelectableProviderInstance", () => { it("falls back to the first enabled and available instance", () => { const disabled = ProviderInstanceId.make("codex"); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index f9e711c40e7fc..c2120f60e31cc 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -93,10 +93,7 @@ export function deriveProviderEntriesByEnvironment( const byEnvironment = new Map>(); for (const [environmentId, providers, settings] of providersByEnvironment) { const derived = deriveProviderInstanceEntries(providers); - // The snapshot includes instances enabled by any project; machine settings alone do not. - const entries = settings - ? derived.map((entry) => applyProviderInstanceBranding(entry, settings)) - : derived; + const entries = settings ? brandProviderInstanceEntries(derived, settings) : derived; byEnvironment.set( environmentId, new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)), @@ -105,6 +102,14 @@ export function deriveProviderEntriesByEnvironment( return byEnvironment; } +// The snapshot includes instances enabled by any project; machine settings alone do not. +export function brandProviderInstanceEntries( + entries: ReadonlyArray, + settings: Pick, +): ReadonlyArray { + return entries.map((entry) => applyProviderInstanceBranding(entry, settings)); +} + /** projectId applies project overrides; explicit null uses machine settings. */ export function applyProviderInstanceSettings( entries: ReadonlyArray, 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/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 6d9c692d64564..1f736d4f119ae 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -166,6 +166,8 @@ interface RightPanelStoreState { activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; + /** Agent-driven activation. Not a user action, so it does not move keyboard focus. */ + revealSurface: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; @@ -451,6 +453,11 @@ const userActionOnThread = ( : {}), }); +const activated = (current: ThreadRightPanelState, surfaceId: string): ThreadRightPanelState => + current.surfaces.some((surface) => surface.id === surfaceId) + ? { ...current, isOpen: true, activeSurfaceId: surfaceId } + : current; + const closeAction = ( state: RightPanelStoreState, threadKey: string, @@ -928,11 +935,11 @@ export const useRightPanelStore = create()( ), activateSurface: (ref, surfaceId) => set((state) => - userAction(state, scopedThreadKey(ref), (current) => - current.surfaces.some((surface) => surface.id === surfaceId) - ? { ...current, isOpen: true, activeSurfaceId: surfaceId } - : current, - ), + userAction(state, scopedThreadKey(ref), (current) => activated(current, surfaceId)), + ), + revealSurface: (ref, surfaceId) => + set((state) => + automaticUpdate(state, scopedThreadKey(ref), (current) => activated(current, surfaceId)), ), closeSurface: (ref, surfaceId) => set((state) => diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 75bd468f7520d..b511be0fa9255 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -17,7 +17,7 @@ import { readProject, useAllEnvironmentShellsBootstrapped, useProjects, - useThreadShells, + useVisibleThreadShells, } from "../state/entities"; import { useEnvironments } from "../state/environments"; @@ -35,7 +35,7 @@ function ChatIndexRouteView() { function IndexDraftLanding() { const projects = useProjects(); - const threads = useThreadShells(); + const threads = useVisibleThreadShells(); const bootstrapped = useAllEnvironmentShellsBootstrapped(); const handleNewThread = useNewThreadHandler(); const router = useRouter(); 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 5ddb93f30e8b2..cb9474c9a9237 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -123,12 +123,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/entities.ts b/apps/web/src/state/entities.ts index 0f94280768d4b..19c0736779897 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -107,6 +107,23 @@ export function useThreadShells(enabled = true): ReadonlyArray { + return useAtomValue(environmentThreadShells.hiddenThreadKeysAtom); +} + +/** Thread shells minus those the tag filter hides, for lists that show threads to the user. */ +export function useVisibleThreadShells(enabled = true): ReadonlyArray { + return useAtomValue( + enabled ? environmentThreadShells.visibleThreadShellsAtom : EMPTY_THREAD_SHELLS_ATOM, + ); +} + export function useAllEnvironmentShellsBootstrapped(): boolean { return useAtomValue(allEnvironmentShellsBootstrappedAtom); } diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 0b2cab287d223..a35af5e094a00 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -114,3 +114,7 @@ export function useEnvironmentMachines() { export function useConnectedEnvironmentIds() { return useAtomValue(environmentSummaries.connectedEnvironmentIdsAtom); } + +export function useEnvironmentConnectionSummaries() { + return useAtomValue(environmentSummaries.environmentsAtom); +} diff --git a/apps/web/src/state/managedPolicy.test.ts b/apps/web/src/state/managedPolicy.test.ts new file mode 100644 index 0000000000000..2fde6442a93c4 --- /dev/null +++ b/apps/web/src/state/managedPolicy.test.ts @@ -0,0 +1,45 @@ +import { ProviderDriverKind, type ManagedPolicySummary } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { managedInstanceLocks } from "./managedPolicy"; + +const codex = ProviderDriverKind.make("codex"); +const claude = ProviderDriverKind.make("claudeAgent"); + +const policy: ManagedPolicySummary = { + organization: "Example Corp", + lockedSettings: [], + limits: { + providers: { + lockedInstanceFields: { + [codex]: ["config.binaryPath", "environment.OPENAI_BASE_URL", "environment.HTTPS_PROXY"], + }, + }, + }, +}; + +describe("managedInstanceLocks", () => { + it("splits locked instance paths into config keys and environment names", () => { + const locks = managedInstanceLocks(policy, codex); + expect(locks && [...locks.configKeys]).toEqual(["binaryPath"]); + expect(locks && [...locks.environmentNames]).toEqual(["OPENAI_BASE_URL", "HTTPS_PROXY"]); + expect(locks?.reason).toBe("Managed by Example Corp."); + }); + + it("treats a bare environment path as every variable", () => { + const ceilingPolicy = { + ...policy, + limits: { + providers: { lockedInstanceFields: { [codex]: ["config.binaryPath", "environment"] } }, + }, + }; + const locks = managedInstanceLocks(ceilingPolicy, codex); + expect(locks?.wholeEnvironment).toBe(true); + expect(managedInstanceLocks(policy, codex)?.wholeEnvironment).toBe(false); + }); + + it("locks nothing for drivers the policy does not pin, or without a policy", () => { + expect(managedInstanceLocks(policy, claude)).toBeUndefined(); + expect(managedInstanceLocks(undefined, codex)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/state/managedPolicy.ts b/apps/web/src/state/managedPolicy.ts new file mode 100644 index 0000000000000..4e588b3796012 --- /dev/null +++ b/apps/web/src/state/managedPolicy.ts @@ -0,0 +1,67 @@ +import type { EnvironmentId, ManagedPolicySummary, ProviderDriverKind } from "@t3tools/contracts"; + +import { useServerConfigs } from "./entities"; +import { usePrimaryEnvironmentId } from "./environments"; + +/** The policy an environment's administrator applies, or undefined when it is unmanaged. */ +export function useManagedPolicy( + environmentId: EnvironmentId | null | undefined, +): ManagedPolicySummary | undefined { + const serverConfigs = useServerConfigs(); + return environmentId == null ? undefined : serverConfigs.get(environmentId)?.managedPolicy; +} + +export function usePrimaryManagedPolicy(): ManagedPolicySummary | undefined { + return useManagedPolicy(usePrimaryEnvironmentId()); +} + +/** + * Why a control acting on several environments is unavailable: the first denial any target's + * policy gives, since one refusing target refuses the whole action. + */ +export function firstManagedDenial( + policies: Iterable, + deny: (policy: ManagedPolicySummary | undefined) => string | undefined, +): string | undefined { + for (const policy of policies) { + const denial = deny(policy); + if (denial !== undefined) return denial; + } + return undefined; +} + +/** What usage-data notices say when the policy turns product analytics off. */ +export function managedTelemetryOffMessage( + policy: ManagedPolicySummary | undefined, +): string | undefined { + return policy?.limits?.telemetry === false + ? `${policy.organization} turned off anonymous usage data.` + : undefined; +} + +export interface ManagedInstanceLocks { + /** Keys inside the instance's `config` users cannot change. */ + readonly configKeys: ReadonlySet; + /** Environment variables users cannot edit, rename, or remove. */ + readonly environmentNames: ReadonlySet; + /** Under a runtime-mode ceiling every variable is the administrator's, not only named ones. */ + readonly wholeEnvironment: boolean; + readonly reason: string; +} + +/** What the policy pins on instances of `driver`, or undefined when nothing is. */ +export function managedInstanceLocks( + policy: ManagedPolicySummary | undefined, + driver: ProviderDriverKind, +): ManagedInstanceLocks | undefined { + const paths = policy?.limits?.providers?.lockedInstanceFields?.[driver]; + if (policy === undefined || paths === undefined || paths.length === 0) return undefined; + const fieldsUnder = (prefix: string) => + new Set(paths.flatMap((path) => (path.startsWith(prefix) ? [path.slice(prefix.length)] : []))); + return { + configKeys: fieldsUnder("config."), + environmentNames: fieldsUnder("environment."), + wholeEnvironment: paths.includes("environment"), + reason: `Managed by ${policy.organization}.`, + }; +} diff --git a/apps/web/src/state/olderSettledShelf.browser.test.tsx b/apps/web/src/state/olderSettledShelf.browser.test.tsx new file mode 100644 index 0000000000000..a99ba97e480b6 --- /dev/null +++ b/apps/web/src/state/olderSettledShelf.browser.test.tsx @@ -0,0 +1,39 @@ +import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { useOlderSettledShelf } from "./olderSettledShelf"; + +const environmentId = EnvironmentId.make("env-a"); +const first = ProjectId.make("project-a"); +const second = ProjectId.make("project-b"); + +function Shelf(props: { readonly scopes: ReadonlyArray }) { + const shelf = useOlderSettledShelf({ scopes: props.scopes, expanded: true, visibleCount: 0 }); + return ( + <> + {shelf.pages.get(environmentId) ?? 0} + + + ); +} + +describe("useOlderSettledShelf", () => { + it("keeps requested pages when the scope's projects reorder", async () => { + await using screen = await render( + , + // A bare hook host; nothing here links or navigates. + { router: false }, + ); + await userEvent.click(screen.getByRole("button", { name: "Grow" })); + await expect.element(screen.getByRole("status")).toHaveTextContent("1"); + + // Loading a page can reorder the sidebar's projects; the shelf must not drop its pages. + await screen.rerender(); + await expect.element(screen.getByRole("status")).toHaveTextContent("1"); + }); +}); diff --git a/apps/web/src/state/olderSettledShelf.ts b/apps/web/src/state/olderSettledShelf.ts index a903fd6d99b23..e313c805dd3cc 100644 --- a/apps/web/src/state/olderSettledShelf.ts +++ b/apps/web/src/state/olderSettledShelf.ts @@ -3,6 +3,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/model import { isListedSettledThread, settledShelfFrontier, + sortOlderSettledScopes, unloadedOlderSettledCount, wantedOlderSettledPages, type OlderSettledScope, @@ -27,10 +28,11 @@ export function useOlderSettledShelf(input: { /** Rows the shelf shows; pages are read until it lists that many. */ readonly visibleCount: number; }) { - const scopeKey = JSON.stringify(input.scopes); + const scopes = sortOlderSettledScopes(input.scopes); + const scopeKey = JSON.stringify(scopes); const [requested, setRequested] = useState({ scopeKey, pages: NO_PAGES }); const pages = requested.scopeKey === scopeKey && input.expanded ? requested.pages : NO_PAGES; - const shelfScopes = input.scopes.map((scope) => ({ + const shelfScopes = scopes.map((scope) => ({ scope, pages: pages.get(scope.environmentId) ?? 0, })); @@ -67,7 +69,7 @@ export function useOlderSettledShelf(input: { isListed: (thread: EnvironmentThreadShell) => isListedSettledThread(thread.source, frontier), /** Older settled threads in scope that are not local; add it to the local Settled rows. */ unloadedCount: (threads: ReadonlyArray) => - unloadedOlderSettledCount({ olderSettled: olderOf, scopes: input.scopes, threads }), + unloadedOlderSettledCount({ olderSettled: olderOf, scopes, threads }), loading: loaded.some(({ scope }) => pagesOf(scope.environmentId)?.status === "pending"), failed: loaded.some(({ scope }) => pagesOf(scope.environmentId)?.status === "failed"), retry: () => { diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index 13dffb510b93f..00c38aec3df76 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -25,12 +25,13 @@ export function useEnvironmentScope( environmentId: EnvironmentId | null, scope: AuthEnvironmentScope, ): boolean { - const result = useAtomValue( + // Select the grant: every revalidation emits a new result, and most consumers are list rows. + return useAtomValue( environmentId === null ? EMPTY_SESSION_STATE_ATOM : environmentSession.sessionStateAtom(environmentId), + (result) => sessionHasScope(result, scope), ); - return sessionHasScope(result, scope); } export function sessionHasScope( diff --git a/apps/web/src/state/shell.test.ts b/apps/web/src/state/shell.test.ts index 4d5920a94ad85..19a88cfbb19f9 100644 --- a/apps/web/src/state/shell.test.ts +++ b/apps/web/src/state/shell.test.ts @@ -7,9 +7,9 @@ import { EMPTY_SHELL_STATE, type EnvironmentShellState } from "@t3tools/client-r import { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { createAllEnvironmentProjectSnapshotsReadyAtom } from "./shell"; +import { createAllEnvironmentProjectSnapshotsReadyAtom, expectsPrimaryEnvironment } from "./shell"; const LOCAL = EnvironmentId.make("local"); const REMOTE = EnvironmentId.make("remote"); @@ -135,3 +135,16 @@ describe("project snapshot readiness", () => { registry.dispose(); }); }); + +it("expects a primary environment only on desktops with the local environment enabled", () => { + const desktop = (enabled: boolean) => { + vi.stubGlobal("window", { desktopBridge: { getLocalEnvironmentEnabled: () => enabled } }); + try { + return expectsPrimaryEnvironment(); + } finally { + vi.unstubAllGlobals(); + } + }; + expect(desktop(true)).toBe(true); + expect(desktop(false)).toBe(false); +}); diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index c4675d3d01301..ef46e49839808 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -21,6 +21,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); @@ -123,12 +124,17 @@ export const allEnvironmentProjectSnapshotsReadyAtom = createAllEnvironmentProjectSnapshotsReadyAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, shellStateValueAtom: environmentShell.stateValueAtom, - requiresPrimaryEnvironment: !isHostedStaticApp(), + requiresPrimaryEnvironment: expectsPrimaryEnvironment(), }); +/** Hosted clients and desktops with the local environment disabled never discover a primary. */ +export function expectsPrimaryEnvironment(): boolean { + return !isHostedStaticApp() && !isLocalEnvironmentDisabled(); +} + export const allReachableShellsLiveAtom = Atom.make((get) => { const catalog = get(environmentCatalog.catalogValueAtom); - if (!catalogIsComplete(catalog, !isHostedStaticApp())) return false; + if (!catalogIsComplete(catalog, expectsPrimaryEnvironment())) return false; for (const environmentId of enabledEnvironmentIds(catalog)) { const shell = get(environmentShell.stateValueAtom(environmentId)); if (shell.status === "live" && Option.isSome(shell.snapshot)) continue; diff --git a/apps/web/src/state/terminalSessions.test.ts b/apps/web/src/state/terminalSessions.test.ts index c4b55772b3403..2c39a36bec486 100644 --- a/apps/web/src/state/terminalSessions.test.ts +++ b/apps/web/src/state/terminalSessions.test.ts @@ -1,11 +1,23 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts"; import { selectRunningSubprocessTerminalIds } from "@t3tools/client-runtime/state/terminal"; -import { AtomRegistry } from "effect/reactivity"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, AtomRegistry } from "effect/reactivity"; -import { attachedTerminalSessionAtom, selectKnownTerminalSessions } from "./terminalSessions"; +import { + attachedTerminalSessionAtom, + knownTerminalSessionsAtom, + selectKnownTerminalSessions, +} from "./terminalSessions"; const attachCalls = vi.hoisted(() => [] as unknown[]); +const metadataAtom = await vi.hoisted(async () => { + const { AsyncResult, Atom } = await import("effect/reactivity"); + return Atom.make>>( + AsyncResult.initial(true), + ); +}); vi.mock("./terminal", async () => { const { AsyncResult, Atom } = await import("effect/reactivity"); return { @@ -14,7 +26,7 @@ vi.mock("./terminal", async () => { attachCalls.push(request); return Atom.make(AsyncResult.initial(true)); }, - metadata: () => Atom.make(AsyncResult.initial(true)), + metadata: () => metadataAtom, }, }; }); @@ -100,6 +112,37 @@ describe("selectKnownTerminalSessions", () => { }); }); +describe("knownTerminalSessionsAtom", () => { + const metadataResult = (...terminals: ReadonlyArray) => + AsyncResult.success>(terminals); + + it("notifies only when the thread's own terminals change", () => { + const registry = AtomRegistry.make(); + const sessions = knownTerminalSessionsAtom(environmentA, threadA); + expect(registry.get(sessions)).toBeNull(); + + const first = summary(threadA, "terminal-1"); + const other = summary(threadB, "terminal-1"); + registry.set(metadataAtom, metadataResult(first)); + const notified: unknown[] = []; + const unsubscribe = registry.subscribe(sessions, (value) => notified.push(value)); + const before = registry.get(sessions); + + registry.set(metadataAtom, metadataResult(first, other)); + registry.set(metadataAtom, metadataResult(first, { ...other, label: "Renamed" })); + expect(notified).toEqual([]); + expect(registry.get(sessions)).toBe(before); + + registry.set(metadataAtom, metadataResult({ ...first, label: "Renamed" }, other)); + expect(notified).toHaveLength(1); + + const previousSuccess = Option.some(metadataResult(first)); + registry.set(metadataAtom, AsyncResult.failure(Cause.die("unavailable"), { previousSuccess })); + expect(registry.get(sessions)).toBeNull(); + unsubscribe(); + }); +}); + describe("attachedTerminalSessionAtom", () => { it("attaches with the launch input it was keyed by", () => { attachCalls.length = 0; diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts index a426212cb9a44..56fa59b794daf 100644 --- a/apps/web/src/state/terminalSessions.ts +++ b/apps/web/src/state/terminalSessions.ts @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { combineTerminalSessionState, EMPTY_TERMINAL_BUFFER_STATE, @@ -19,7 +20,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; -import { formatEnvironmentQueryError, useEnvironmentQuery } from "./query"; +import { formatEnvironmentQueryError } from "./query"; import { terminalEnvironment } from "./terminal"; import { environmentSession, sessionHasScope, useEnvironmentScope } from "./session"; @@ -194,22 +195,40 @@ export function attachedTerminalSessionAtom( return attachedTerminalSessionFamily(JSON.stringify([environmentId, terminal])); } +const NO_KNOWN_TERMINAL_SESSIONS_ATOM = Atom.make | null>(null); + +const decodeKnownSessionsKey = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Tuple([EnvironmentId, Schema.NullOr(ThreadId)])), +); + +// Derived per thread, so a terminal update elsewhere in the environment notifies no one here. +const knownTerminalSessionsFamily = Atom.family((key: string) => { + const [environmentId, threadId] = Option.getOrThrow(decodeKnownSessionsKey(key)); + return Atom.make((get) => { + const metadata = get(terminalEnvironment.metadata({ environmentId, input: null })); + return metadata._tag === "Success" + ? selectKnownTerminalSessions(metadata.value, environmentId, threadId) + : null; + }).pipe(Atom.withLabel(`web-known-terminal-sessions:${key}`)); +}); + +export function knownTerminalSessionsAtom( + environmentId: EnvironmentId, + threadId: ThreadId | null, +): Atom.Atom | null> { + return knownTerminalSessionsFamily(JSON.stringify([environmentId, threadId])); +} + export function useKnownTerminalSessions(input: { readonly environmentId: EnvironmentId | null; readonly threadId: ThreadId | null; }): ReadonlyArray | null { const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope); - const metadata = useEnvironmentQuery( + return useAtomValue( input.environmentId === null || !canRead - ? null - : terminalEnvironment.metadata({ - environmentId: input.environmentId, - input: null, - }), + ? NO_KNOWN_TERMINAL_SESSIONS_ATOM + : knownTerminalSessionsAtom(input.environmentId, input.threadId), ); - return metadata.data === null || metadata.error !== null - ? null - : selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId); } export function useThreadRunningTerminalIds(input: { diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index c7919d658ccf6..6d2ce2a07c90b 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -1,3 +1,4 @@ +import { createChildThreadApprovalsAtom } from "@t3tools/client-runtime/state/thread-requests"; import { useAtomValue } from "@effect/atom-react"; import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections"; import { arrayElementsEqual } from "@t3tools/client-runtime/state/entities"; @@ -17,6 +18,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { serverEnvironment } from "./server"; import { environmentShell } from "./shell"; export const threadFamilies = createThreadFamilyAtoms( @@ -38,6 +40,11 @@ export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: threadEnvironment.snapshotAtom, + hiddenThreadTagsAtom: serverEnvironment.hiddenThreadTagsAtom, +}); +export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({ + childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom, + pendingRequestsAtom: environmentThreadDetails.pendingRequestsAtom, }); const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 9a68a5fa724f7..f47a65cd08268 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -8,7 +8,11 @@ import { type UsageProviderKind, type UsageSummaryInput, } from "@t3tools/contracts"; -import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { + needsCursorKeychainAccess, + refreshUsage, + usageSummaryInput, +} from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -55,7 +59,15 @@ const usageByWindowAtom = Atom.family((windowKey: string) => }); continue; } - const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const result = get( + serverEnvironment.usageSummary({ + environmentId, + input: usageSummaryInput( + input, + get(serverEnvironment.usageModelFilterAtom(environmentId)), + ), + }), + ); const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index f6df5912ecdd1..dafb07f354437 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -187,6 +187,25 @@ describe("extractTerminalLinks", () => { ]); }); + it.each([ + "connected to redis://127.0.0.1:6379/0", + "connected to postgres://localhost:5432/app", + "uploaded s3://bucket/reports/today.csv", + " at main (file:///Users/jane/app/src/index.mjs:3:7)", + "+redis://localhost:6379/0", + "-redis://localhost:6379/0", + ])("does not turn part of a non-web URL into an editor link: %s", (line) => { + expect(extractTerminalLinks(line)).toEqual([]); + }); + + it("keeps file links beside a non-web URL", () => { + const line = "src/db.ts:4 redis://localhost:6379/0 C:/app/src/db.ts:9"; + expect(extractTerminalLinks(line)).toEqual([ + { kind: "path", text: "src/db.ts:4", start: 0, end: 11 }, + { kind: "path", text: "C:/app/src/db.ts:9", start: 37, end: 55 }, + ]); + }); + it("keeps a question mark inside a filesystem path", () => { const path = "/tmp/report?draft"; expect(extractTerminalLinks(`wrote ${path}`)).toEqual([ diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 1c078d86cd20f..68bd4c8d5a835 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -41,6 +41,9 @@ const TRAILING_PATH_PUNCTUATION_PATTERN = /[.,;:!?]+$/; // A question mark is legal in filenames; only exclude query-bearing request targets. const REQUEST_ROUTE_PATTERN = /(?:^|[\s"'`])(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE|proxying)\s+["'`]?(?:\/|[A-Za-z0-9_-]+\/)[^\s?]*\?\S*/g; +// The tail of `redis://host/0` or `file:///a.mjs` is not a path. One-letter schemes stay drives, +// a diff's `+` or `-` may lead, and the lookbehind keeps runs from being rescanned at every suffix. +const SCHEME_URL_PATTERN = /(?]+/gu; function trimClosingDelimiters(value: string, kind: TerminalLinkKind): string { let output = value.replace( @@ -110,10 +113,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { const promptMatches: TerminalLinkMatch[] = promptPath ? [{ kind: "path", text: promptPath, start: 3, end: 3 + promptPath.length }] : []; - const requestRoutes = Array.from(line.matchAll(REQUEST_ROUTE_PATTERN), (match) => ({ - start: match.index, - end: match.index + match[0].length, - })); + const nonPathRanges = Array.from( + [...line.matchAll(REQUEST_ROUTE_PATTERN), ...line.matchAll(SCHEME_URL_PATTERN)], + (match) => ({ start: match.index, end: match.index + match[0].length }), + ); const urlMatches = collectMatches(line, "url", URL_PATTERN, promptMatches); const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, [ ...promptMatches, @@ -126,7 +129,7 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { ]); return [...promptMatches, ...urlMatches, ...quotedMatches, ...pathMatches] .filter( - (match) => match.kind === "url" || !requestRoutes.some((route) => overlaps(match, route)), + (match) => match.kind === "url" || !nonPathRanges.some((range) => overlaps(match, range)), ) .toSorted((a, b) => a.start - b.start); } diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index dcae81b4fb9ff..b8ca5a624636f 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -177,15 +177,19 @@ function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { } /** Bounds String.fromCodePoint arguments for cells with enormous combining-mark sequences. */ -export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string { - if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(0, true)); +export function ghosttyCellText( + codepointView: DataView, + graphemeLength: number, + byteOffset = 0, +): string { + if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(byteOffset, true)); const CHUNK_SIZE = 4_096; let text = ""; for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) { const count = Math.min(CHUNK_SIZE, graphemeLength - start); const codes = new Array(count); for (let index = 0; index < count; index += 1) { - codes[index] = codepointView.getUint32((start + index) * 4, true); + codes[index] = codepointView.getUint32(byteOffset + (start + index) * 4, true); } text += String.fromCodePoint(...codes); } @@ -853,12 +857,12 @@ export class GhosttyTerminalCore { if (rowDirty) { this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background); dirtyRows.add(rowIndex); - this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.memoryBytes()[this.scratch] = 0; this.runtime.call("ghostty_render_state_row_set", iterator, 0, this.scratch); } rowIndex += 1; } - this.runtime.view(this.scratch, 4).setUint32(0, 0, true); + this.runtime.memoryDataView().setUint32(this.scratch, 0, true); this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch); } @@ -1009,19 +1013,19 @@ export class GhosttyTerminalCore { "ghostty_render_state_row_get(raw)", this.runtime.call("ghostty_render_state_row_get", iterator, ROW_DATA.raw, this.scratch), ); - const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true); - this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + const rawRow = this.runtime.memoryDataView().getBigUint64(this.scratch, true); + this.runtime.memoryBytes()[this.scratch + 8] = 0; this.assertSuccess( "ghostty_row_get(wrap continuation)", this.runtime.call("ghostty_row_get", rawRow, 2, this.scratch + 8), ); - const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; - this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + const isWrapContinuation = this.runtime.memoryBytes()[this.scratch + 8] !== 0; + this.runtime.memoryBytes()[this.scratch + 8] = 0; this.assertSuccess( "ghostty_row_get(wrap)", this.runtime.call("ghostty_row_get", rawRow, 1, this.scratch + 8), ); - const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + const wrapsToNext = this.runtime.memoryBytes()[this.scratch + 8] !== 0; this.assertSuccess( "ghostty_render_state_row_get(cells)", @@ -1034,15 +1038,13 @@ export class GhosttyTerminalCore { ); const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); const { size: styleSize, fields: styleFields } = this.runtime.layout("GhosttyStyle"); + const cellsNext = this.runtime.fn("ghostty_render_state_row_cells_next"); + const cellsGetMulti = this.runtime.fn("ghostty_render_state_row_cells_get_multi"); const cells: GhosttyCell[] = []; - while ( - cells.length < cols && - this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0 - ) { - this.runtime.bytes(this.style, styleSize).fill(0); + while (cells.length < cols && cellsNext(cellsIterator) !== 0) { + this.runtime.memoryBytes().fill(0, this.style, this.style + styleSize); this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); - const result = this.runtime.call( - "ghostty_render_state_row_cells_get_multi", + const result = cellsGetMulti( cellsIterator, 5, this.cellQuery + CELL_QUERY.keys, @@ -1081,8 +1083,7 @@ export class GhosttyTerminalCore { ) === GHOSTTY_SUCCESS ) { // Byte allocations need not be 4-byte aligned; use DataView instead of Uint32Array. - const codepointView = this.runtime.view(this.graphemes, bufferSize); - text = ghosttyCellText(codepointView, graphemeLength); + text = ghosttyCellText(this.runtime.memoryDataView(), graphemeLength, this.graphemes); } } let wide = 0; @@ -1097,13 +1098,13 @@ export class GhosttyTerminalCore { this.scratch, ), ); - const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true); - this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true); + const rawCell = this.runtime.memoryDataView().getBigUint64(this.scratch, true); + this.runtime.memoryDataView().setUint32(this.scratch + 8, 0, true); this.assertSuccess( "ghostty_cell_get(wide)", this.runtime.call("ghostty_cell_get", rawCell, RAW_CELL_DATA.wide, this.scratch + 8), ); - wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); + wide = this.runtime.memoryDataView().getUint32(this.scratch + 8, true); } // Read the style after allocation and ABI calls, which can grow WASM memory. const styleView = this.runtime.memoryDataView(); @@ -1228,34 +1229,34 @@ export class GhosttyTerminalCore { } private getU16(data: number): number { - this.runtime.bytes(this.scratch, 2).fill(0); + this.runtime.memoryDataView().setUint16(this.scratch, 0, true); this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); - return this.runtime.view(this.scratch, 2).getUint16(0, true); + return this.runtime.memoryDataView().getUint16(this.scratch, true); } private getU32(data: number): number { - this.runtime.bytes(this.scratch, 4).fill(0); + this.runtime.memoryDataView().setUint32(this.scratch, 0, true); this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); - return this.runtime.view(this.scratch, 4).getUint32(0, true); + return this.runtime.memoryDataView().getUint32(this.scratch, true); } private getBool(data: number): boolean { - this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.memoryBytes()[this.scratch] = 0; this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); - return this.runtime.bytes(this.scratch, 1)[0] !== 0; + return this.runtime.memoryBytes()[this.scratch] !== 0; } private getColor(data: number, fallback: GhosttyColor): GhosttyColor { - this.runtime.bytes(this.scratch, 3).fill(0); + this.runtime.memoryBytes().fill(0, this.scratch, this.scratch + 3); const result = this.runtime.call( "ghostty_render_state_get", this.renderState, @@ -1266,15 +1267,15 @@ export class GhosttyTerminalCore { } private getRowBool(iterator: number, data: number): boolean { - this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.memoryBytes()[this.scratch] = 0; return ( this.runtime.call("ghostty_render_state_row_get", iterator, data, this.scratch) === - GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + GHOSTTY_SUCCESS && this.runtime.memoryBytes()[this.scratch] !== 0 ); } private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { - this.runtime.bytes(this.scratch, 3).fill(0); + this.runtime.memoryBytes().fill(0, this.scratch, this.scratch + 3); const result = this.runtime.call( "ghostty_render_state_row_cells_get", iterator, diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts index 22c9c13e50722..762fb0f6ef13d 100644 --- a/apps/web/src/terminal/ghostty/runtime.ts +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -24,6 +24,7 @@ export class GhosttyRuntime { readonly layouts: TypeLayouts; private readonly exports: WebAssembly.Exports; private memoryView: DataView; + private memoryU8: Uint8Array; private readonly ptyWriters = new Map void>(); private nextPtyWriterId = 1; private writePtyFunctionIndex = 0; @@ -36,6 +37,7 @@ export class GhosttyRuntime { } this.memory = memory; this.memoryView = new DataView(memory.buffer); + this.memoryU8 = new Uint8Array(memory.buffer); const jsonPointer = this.call("ghostty_type_json"); const bytes = new Uint8Array(memory.buffer); let end = jsonPointer; @@ -68,11 +70,15 @@ export class GhosttyRuntime { } call(name: string, ...args: Array): number { + return this.fn(name)(...args); + } + + fn(name: string): WasmFunction { const fn = this.exports[name]; if (typeof fn !== "function") { throw new Error(`libghostty-vt export is unavailable: ${name}`); } - return (fn as WasmFunction)(...args); + return fn as WasmFunction; } layout(name: string): TypeLayout { @@ -126,18 +132,29 @@ export class GhosttyRuntime { } view(pointer: number, size?: number): DataView { - return new DataView(this.memory.buffer, pointer, size); + return new DataView(this.memoryDataView().buffer, pointer, size); } bytes(pointer: number, size: number): Uint8Array { - return new Uint8Array(this.memory.buffer, pointer, size); + return new Uint8Array(this.memoryDataView().buffer, pointer, size); } memoryDataView(): DataView { - if (this.memoryView.buffer !== this.memory.buffer) { + this.refreshMemoryViews(); + return this.memoryView; + } + + memoryBytes(): Uint8Array { + this.refreshMemoryViews(); + return this.memoryU8; + } + + private refreshMemoryViews(): void { + // Growing memory detaches the old buffer. Reading `memory.buffer` costs far more than this check. + if (this.memoryU8.byteLength === 0) { this.memoryView = new DataView(this.memory.buffer); + this.memoryU8 = new Uint8Array(this.memory.buffer); } - return this.memoryView; } setField(pointer: number, structName: string, fieldName: string, value: number): void { diff --git a/apps/web/src/textDirection.test.ts b/apps/web/src/textDirection.test.ts index a104133799c52..0aa7b3c9e1f96 100644 --- a/apps/web/src/textDirection.test.ts +++ b/apps/web/src/textDirection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { textDirection } from "./textDirection"; +import { markdownProseDirection, textDirection } from "./textDirection"; describe("textDirection", () => { it.each([ @@ -13,9 +13,34 @@ describe("textDirection", () => { "rtl", ], ["English quoting one Arabic word", "translate the label مرحبا for the login screen", "ltr"], + [ + "tied sentences use the whole-text word majority", + "Done. تم إصلاح المشكلة في الملف الرئيسي", + "rtl", + ], ["a tie, by its first word", "React مكتبة", "ltr"], ["a tie that opens in Arabic", "مكتبة React", "rtl"], ["Arabic around numbers and punctuation", "عدّل السطر 42، ثم شغّل الاختبارات (3)", "rtl"], + [ + "mostly Arabic sentences beside one long English one", + "هذا مثال على paragraph يحتوي على نص عربي و English words معًا. Arabic text should flow right-to-left while English words like JavaScript, React, and API should remain left-to-right within the same line. الأرقام 1234567890 ورموز مثل @username و #hashtag و 100$ تظهر في الاتجاه الصحيح. هذه جملة لاختبار RTL rendering في المتصفح أو المحرر.", + "rtl", + ], + [ + "mostly English sentences beside one Arabic one", + "Run the migration first. ثم شغّل الاختبارات كلها. Then deploy to staging.", + "ltr", + ], + [ + "Arabic with inline abbreviations", + "هذه جملة عربية تتحدث عن e.g. React i.e. TypeScript JavaScript", + "rtl", + ], + [ + "an abbreviation at a sentence boundary", + "This long English sentence describes the technology sector in the U.S. هذه جملة عربية. وهذه جملة عربية أخرى.", + "rtl", + ], ])("reads %s", (_, text, direction) => { expect(textDirection(text)).toBe(direction); }); @@ -24,3 +49,39 @@ describe("textDirection", () => { expect(textDirection("42 — 7 ✓")).toBeUndefined(); }); }); + +describe("markdownProseDirection", () => { + it.each([ + [ + "Hebrew around a code block", + "תתקן את זה:\n```ts\nexport function useCheckout(props: CheckoutProps) {\n return props.items.length;\n}\n```\nותריץ שוב", + "rtl", + ], + ["Hebrew around inline code", "לערוך את `apps/web/src/chat.tsx` או להשאיר `useMemo`?", "rtl"], + ["Hebrew around double-backtick code", "תבדוק ``const a = `b c d e` + f`` עכשיו", "rtl"], + [ + "Hebrew after a four-backtick fence", + "````md\none two three four five six\n````\nשלום עולם, זה עובד", + "rtl", + ], + [ + "Hebrew after a tilde fence", + "~~~\nnpm run build --filter web --force\n~~~\nזה נכשל אצלי", + "rtl", + ], + [ + "Hebrew before an unclosed fence", + "שלום, הנה השגיאה:\n```\nError: cannot find module react dom client", + "rtl", + ], + ["Hebrew around a URL", "ראה https://github.com/pingdotgg/t3code/pull/24050 לפני", "rtl"], + ["English around inline code", "Should I edit `useMemo` or keep it?", "ltr"], + ["English quoting one Hebrew word", "Please check the שלום label before the release", "ltr"], + ])("reads %s", (_, markdown, direction) => { + expect(markdownProseDirection(markdown)).toBe(direction); + }); + + it("leaves markdown without prose letters to its surroundings", () => { + expect(markdownProseDirection("```\nconst a = 1;\n```")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/textDirection.ts b/apps/web/src/textDirection.ts index 9f0adedd30e3c..8b1a9e8c2a357 100644 --- a/apps/web/src/textDirection.ts +++ b/apps/web/src/textDirection.ts @@ -1,27 +1 @@ -export type TextDirection = "ltr" | "rtl"; - -// Hebrew through Arabic Extended-A, their presentation forms, and the -// supplementary right-to-left blocks. -const RTL_LETTER = /[֐-ࣿיִ-﷿ﹰ-\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u; -const LETTER = /\p{L}/u; - -/** - * `dir="auto"` misreads Arabic prose that opens with an English term. - * Count words so mixed prose follows its main language; break ties with the first word. - */ -export function textDirection(text: string): TextDirection | undefined { - if (!RTL_LETTER.test(text)) return LETTER.test(text) ? "ltr" : undefined; - let rtlWords = 0; - let ltrWords = 0; - let first: TextDirection | undefined; - for (const word of text.split(/\s+/)) { - const letter = LETTER.exec(word)?.[0]; - if (!letter) continue; - const direction = RTL_LETTER.test(letter) ? "rtl" : "ltr"; - first ??= direction; - if (direction === "rtl") rtlWords += 1; - else ltrWords += 1; - } - if (rtlWords === ltrWords) return first; - return rtlWords > ltrWords ? "rtl" : "ltr"; -} +export * from "@t3tools/shared/textDirection"; diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index f6903655449b5..5069cf4f8187b 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -61,6 +61,17 @@ function contrastRatio(first: string, second: string): number { } describe("theme files", () => { + it("gives non-Latin theme names distinct stable ids", () => { + const file = { + version: THEME_FILE_VERSION, + appearance: "dark", + colors: { canvas: "#101820" }, + }; + const night = parseThemeFile({ ...file, name: "夜" }); + expect(parseThemeFile({ ...file, name: "朝" }).id).not.toBe(night.id); + expect(parseThemeFile({ ...file, name: " 夜 " }).id).toBe(night.id); + }); + it("keeps built-in and standard search pairs readable and distinct from inline code", () => { const toOklab = converter("oklab"); const distance = (first: string, second: string) => { diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index e7d9c5d5bf2fa..3c561e5417eac 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1220,13 +1220,20 @@ export function getThemePreferenceMode(theme: ThemePreference): ThemeAppearance } export function themeIdFromName(name: string): string { - const normalized = name - .trim() - .toLowerCase() + const label = name.trim().toLowerCase(); + const normalized = label .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); - return normalized || "custom-theme"; + if (normalized || !label) return normalized || "custom-theme"; + + // Names without ASCII letters or digits still need independent, stable ids. + let hash = 2_166_136_261; + for (let index = 0; index < label.length; index += 1) { + hash ^= label.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return `theme-${(hash >>> 0).toString(36)}`; } export class ThemeLibraryStorageError extends Schema.TaggedError()( diff --git a/apps/web/src/threadNotifications.test.ts b/apps/web/src/threadNotifications.test.ts index 412edfbb43309..f48267c16f318 100644 --- a/apps/web/src/threadNotifications.test.ts +++ b/apps/web/src/threadNotifications.test.ts @@ -49,6 +49,31 @@ afterEach(() => { }); describe("playNotificationSound", () => { + it("preloads both sounds on unlock and reuses them for playback", async () => { + const { unlockNotificationAudio, playNotificationSound } = await loadUnlocked(); + expect(fetch).toHaveBeenCalledTimes(2); + unlockNotificationAudio(); + await playNotificationSound("completion", () => true); + await playNotificationSound("input", () => true); + expect(fetch).toHaveBeenCalledTimes(2); + expect(audio.started).toBe(2); + }); + + it("retries failed preloads on an alert rather than every gesture", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("offline")); + const { unlockNotificationAudio, playNotificationSound } = await loadUnlocked(); + await playNotificationSound("completion", () => true); + expect(fetch).toHaveBeenCalledTimes(2); + + unlockNotificationAudio(); + expect(fetch).toHaveBeenCalledTimes(2); + + vi.mocked(fetch).mockResolvedValue(new Response(new ArrayBuffer(0))); + await playNotificationSound("completion", () => true); + expect(fetch).toHaveBeenCalledTimes(3); + expect(audio.started).toBe(1); + }); + it("plays one sound for threads that complete together", async () => { const { playNotificationSound } = await loadUnlocked(); diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 22ec58e0d7af0..8784f79baceef 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -85,10 +85,31 @@ const buffers = new Map>(); /** When each sound's current playback ends, on the audio context's clock. */ const playingUntil = new Map(); +function loadNotificationBuffer(context: AudioContext, url: string): Promise { + let buffer = buffers.get(url); + if (!buffer) { + buffer = fetch(url) + .then((response) => response.arrayBuffer()) + .then((data) => context.decodeAudioData(data)) + .catch((error: unknown) => { + buffers.delete(url); + throw error; + }); + buffers.set(url, buffer); + } + return buffer; +} + /** Called from a gesture so browsers allow later background playback. */ export function unlockNotificationAudio() { + const firstUnlock = audioContext === undefined; audioContext ??= new AudioContext(); void audioContext.resume().catch(() => undefined); + if (!firstUnlock) return; + // Fetch and decode during interaction so the first alert is ready when the thread finishes. + for (const url of [completionUrl, inputUrl]) { + void loadNotificationBuffer(audioContext, url).catch(() => undefined); + } } export async function playNotificationSound( @@ -99,14 +120,7 @@ export async function playNotificationSound( const context = audioContext; const url = kind === "completion" ? completionUrl : inputUrl; try { - let buffer = buffers.get(url); - if (!buffer) { - buffer = fetch(url) - .then((response) => response.arrayBuffer()) - .then((data) => context.decodeAudioData(data)); - buffers.set(url, buffer); - } - const decoded = await buffer; + const decoded = await loadNotificationBuffer(context, url); if (!shouldPlay() || context.state !== "running") return; // Coalesce same-sound bursts after decode to avoid clipping; input and completion alerts keep separate slots. if (context.currentTime < (playingUntil.get(url) ?? 0)) return; @@ -116,6 +130,6 @@ export async function playNotificationSound( source.start(); playingUntil.set(url, context.currentTime + decoded.duration); } catch { - buffers.delete(url); + // A failed load is retried on the next alert. } } 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 b1a8121677a5e..abad91401ccbe 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -73,17 +73,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; } @@ -136,58 +137,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/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index 93792b815d852..f0172c2354d03 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { getThemeColorsForMode, + parseThemeFile, themeColorToHex, THEME_FILE_VERSION, toCanonicalThemeColor, @@ -9,6 +10,7 @@ import { import { isVsCodeThemeFile, pairVsCodeThemes, + parseThemeImport, parseVsCodeThemeFile, resolveThemeLabelCollisions, } from "./vscodeThemeImport"; @@ -63,6 +65,59 @@ const VSCODE_DARK = { tokenColors: [], }; +describe("theme import text", () => { + it("preserves conversion of ordinary VS Code JSON", () => { + expect(parseThemeImport(JSON.stringify(VSCODE_DARK))).toEqual( + parseVsCodeThemeFile(VSCODE_DARK), + ); + }); + + it.each([ + '{ // line comment\n "name": "Test", "colors": { "editor.background": "#002b36" } }', + '{ /* block comment */ "name": "Test", "colors": { "editor.background": "#002b36" } }', + '{ "name": "Test", "colors": { "editor.background": "#002b36", }, "tokenColors": [ { "scope": ["comment",], "settings": {}, }, ], }', + ])("imports VS Code JSONC without changing conversion: %s", (source) => { + const theme = parseThemeImport(source); + expect(theme.label).toBe("Test"); + expect(theme.appearance).toBe("dark"); + expect(asHex(theme.colors.canvas)).toBe("#002b36"); + }); + + it.each([ + "", + '{ "name": "Test", "colors": { "editor.background": "#002b36" }', + '{ "name": "Test" "colors": { "editor.background": "#002b36" } }', + '{ "name": "Test", "colors": { "editor.background": "#002b36" } } /* unterminated', + '{ "name": "Test", "colors": { "editor.background": "#002b36" } } extra', + ])("rejects malformed input rather than using parser recovery: %s", (source) => { + expect(() => parseThemeImport(source)).toThrow("That theme file is not valid JSON."); + }); + + it("preserves native T3 theme parsing and strict JSON syntax", () => { + const nativeTheme = { + version: THEME_FILE_VERSION, + name: "Native theme", + appearance: "dark", + colors: { canvas: "#002b36" }, + }; + const source = JSON.stringify(nativeTheme); + expect(parseThemeImport(source)).toEqual(parseThemeFile(nativeTheme)); + expect(() => parseThemeImport(source.replace("{", "{ /* native theme */"))).toThrow(); + expect(() => parseThemeImport(source.slice(0, -1) + ",}")).toThrow(); + }); + + it("still validates theme structure after parsing JSONC", () => { + expect(() => + parseThemeImport('{ /* not a theme */ "colors": { "editor.foreground": "#fff" } }'), + ).toThrow('no "editor.background"'); + expect(() => + parseThemeImport( + '{ "version": 1, "name": "Bad native", "appearance": "dark", "colors": { "unknown": "#fff" } }', + ), + ).toThrow('"unknown" is not a supported theme color role.'); + }); +}); + describe("VS Code theme import", () => { it("recognises workbench themes and rejects our own files", () => { expect(isVsCodeThemeFile(VSCODE_DARK)).toBe(true); diff --git a/apps/web/src/vscodeThemeImport.ts b/apps/web/src/vscodeThemeImport.ts index 57e3d245b212b..416351b16edfb 100644 --- a/apps/web/src/vscodeThemeImport.ts +++ b/apps/web/src/vscodeThemeImport.ts @@ -1,3 +1,5 @@ +import { parse, type ParseError } from "jsonc-parser"; + import { createVividThemeColors, getStandardThemeColors, @@ -133,6 +135,15 @@ export function isVsCodeThemeFile(value: unknown): boolean { return hasWorkbenchColors || Array.isArray(value.tokenColors); } +export function parseThemeImport(source: string): ThemeDefinition { + const errors: ParseError[] = []; + const value: unknown = parse(source, errors, { allowTrailingComma: true }); + if (errors.length > 0) throw new Error("That theme file is not valid JSON."); + return isVsCodeThemeFile(value) + ? parseVsCodeThemeFile(value) + : parseThemeFile(JSON.parse(source)); +} + function resolveAppearance(value: Record, canvas: VsCodeRgb): ThemeAppearance { const type = typeof value.type === "string" ? value.type.toLowerCase() : null; if (type === "light" || type === "hc-light") return "light"; diff --git a/apps/web/test/appScenarios.renders.tsx b/apps/web/test/appScenarios.renders.tsx index 459f3b9791d7f..0667a9f4950b4 100644 --- a/apps/web/test/appScenarios.renders.tsx +++ b/apps/web/test/appScenarios.renders.tsx @@ -3,6 +3,7 @@ // chat and settings routes, the sidebar and the right panel, through the same stores and // atoms the app reads. See renderScenarios.renders.tsx for the counting rules. import { createMemoryHistory } from "@tanstack/react-router"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; @@ -13,11 +14,14 @@ import "~/index.css"; import { AppRoot } from "~/AppRoot"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { useRightPanelStore } from "~/rightPanelStore"; import { getRouter, type AppRouter } from "~/router"; import { ENVIRONMENT_ID, assistantItem, + emitTerminalMetadata, emitThreadEvent, + emitVcsStatus, installFakeEnvironment, isThreadSubscribed, runningRun, @@ -47,9 +51,11 @@ vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ trackRpcRequestSent: () => {}, })); +const GIT_STATUS_UPDATES = 20; const HISTORY_TURNS = 20; const STREAM_DELTAS = 30; const TYPED_TEXT = "fix the flaky tests!"; +const TERMINAL_UPDATES = 20; // The stream scenario leaves a turn on its thread, so it streams into one no other scenario opens. const THREADS = ["thread-warm", "thread-a", "thread-b", "thread-live"].map((name) => threadWithHistory(name, HISTORY_TURNS), @@ -61,7 +67,9 @@ const [WARM, A, B, LIVE] = THREADS.map((thread) => thread.id) as [ ThreadId, ]; -installFakeEnvironment(THREADS); +const SIDE_CHAT = { ...threadWithHistory("thread-side", HISTORY_TURNS), sideChatOf: WARM }; + +installFakeEnvironment([...THREADS, SIDE_CHAT]); // Desktop-sized, so the sidebar, composer and right panel lay out as they do in the app. await page.viewport(1400, 900); // A returning user: the first-run gate would otherwise wait on onboarding evidence. @@ -211,6 +219,37 @@ it("app-stream-assistant-turn", async () => { await report("app-stream-assistant-turn", [counted], { commits: false }); }); +it("app-terminal-updates-in-other-thread", async () => { + using _clock = frozenClock(); + await using _app = (await openThread(A)).screen; + const counted = await measure(async () => { + for (let update = 0; update < TERMINAL_UPDATES; update++) { + await act(() => + emitTerminalMetadata({ + type: "upsert", + terminal: { + threadId: B, + terminalId: "default", + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 42, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: true, + label: `command ${update}`, + updatedAt: `2026-03-18T09:01:${String(update).padStart(2, "0")}.000Z`, + }, + }), + ); + } + }); + // Only thread B's sidebar row shows these updates. + expect(counted.components.ChatView).toBeUndefined(); + expect(counted.components.SidebarThreadRow).toBe(TERMINAL_UPDATES); + await report("app-terminal-updates-in-other-thread", [counted]); +}); + it("app-type-in-composer", async () => { using _clock = frozenClock(); await using _app = (await openThread(A)).screen; @@ -287,3 +326,33 @@ it("app-switch-settings-section", async () => { await measure(openSettings(router, "/settings/appearance")), ]); }); + +// ChatView re-renders when the tree goes dirty or clean; the composer and side chat timeline should not. +it("app-git-status-updates-with-side-chat", async () => { + using _clock = frozenClock(); + useRightPanelStore.getState().openSideChat(scopeThreadRef(ENVIRONMENT_ID, WARM), SIDE_CHAT.id); + await using _app = (await openThread(WARM)).screen; + await expect.element(lastAnswer(SIDE_CHAT.id)).toBeVisible(); + const local = (insertions: number) => ({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", + hasWorkingTreeChanges: insertions % 2 === 1, + workingTree: { + files: [{ path: "src/app.ts", insertions, deletions: 0 }], + insertions, + deletions: 0, + }, + }); + await act(() => emitVcsStatus({ _tag: "snapshot", local: local(0), remote: null })); + const counted = await measure(async () => { + for (let update = 1; update <= GIT_STATUS_UPDATES; update++) { + await act(() => emitVcsStatus({ _tag: "localUpdated", local: local(update) })); + } + }); + expect(counted.components.ChatView).toBe(GIT_STATUS_UPDATES * 2); + expect(counted.components.ChatComposer).toBeUndefined(); + expect(counted.components.ConversationTimeline).toBeUndefined(); + await report("app-git-status-updates-with-side-chat", [counted]); +}); diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index c586ad51c2c5f..44cd45d127e11 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -17,6 +17,7 @@ import { TurnItemId, WS_METHODS, WsRpcGroup, + type VcsStatusStreamEvent, type OrchestrationProjectShell, type OrchestrationV2ProjectedTurnItem, type OrchestrationV2Run, @@ -25,6 +26,7 @@ import { type OrchestrationV2ThreadShell, type OrchestrationV2ThreadStreamItem, type OrchestrationV2TurnItem, + type TerminalMetadataStreamEvent, } from "@t3tools/contracts"; import type { ConnectionAttemptError, @@ -181,6 +183,8 @@ function run(threadId: ThreadId, id: RunId, ordinal: number, userMessageId: stri } export interface FakeThread { + /** Makes this a side chat of that thread. */ + readonly sideChatOf?: ThreadId; readonly id: ThreadId; readonly title: string; readonly runs: ReadonlyArray; @@ -223,7 +227,12 @@ function shellOf(thread: FakeThread): OrchestrationV2ThreadShell { branch: "main", worktreePath: null, activeProviderThreadId: null, - lineage: { rootThreadId: thread.id, parentThreadId: null, relationshipToParent: null }, + lineage: { + rootThreadId: thread.sideChatOf ?? thread.id, + parentThreadId: thread.sideChatOf ?? null, + relationshipToParent: null, + ...(thread.sideChatOf ? { sideChat: true } : {}), + }, forkedFrom: null, createdBy: "user", creationSource: "web", @@ -307,8 +316,10 @@ const json = (body: unknown, status = 200) => // Keyed by thread id: a live thread subscription's queue, for pushing events to it. const threadSubscribers = new Map>(); +let terminalMetadataSubscriber: Queue.Queue | null = null; let threads: ReadonlyArray = []; let sequence = 1; +let vcsStatusSubscriber: Queue.Queue | null = null; const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue) => () => void) => Stream.unwrap( @@ -324,6 +335,13 @@ const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue ); const handlers: Record unknown> = { + [WS_METHODS.subscribeTerminalMetadata]: () => + subscribe([{ type: "snapshot", terminals: [] }], (queue) => { + terminalMetadataSubscriber = queue; + return () => { + if (terminalMetadataSubscriber === queue) terminalMetadataSubscriber = null; + }; + }), [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => subscribe([ { @@ -356,6 +374,13 @@ const handlers: Record unknown> = { }, ); }, + [WS_METHODS.subscribeVcsStatus]: () => + subscribe([], (queue) => { + vcsStatusSubscriber = queue; + return () => { + if (vcsStatusSubscriber === queue) vcsStatusSubscriber = null; + }; + }), [WS_METHODS.subscribeServerConfig]: () => subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig }]), }; @@ -437,6 +462,11 @@ export function installFakeEnvironment(fixtures: ReadonlyArray): Dis type ThreadEvent = Extract["event"]; +export async function emitTerminalMetadata(event: TerminalMetadataStreamEvent): Promise { + if (terminalMetadataSubscriber === null) throw new Error("No terminal metadata subscription"); + await Effect.runPromise(Queue.offer(terminalMetadataSubscriber, event)); +} + /** Whether the app holds a live subscription to the thread, so events reach it. */ export function isThreadSubscribed(threadId: ThreadId): boolean { return threadSubscribers.has(threadId); @@ -468,3 +498,8 @@ export function runningRun(threadId: ThreadId, id: string, ordinal: number, user } export { userItem }; + +export async function emitVcsStatus(event: VcsStatusStreamEvent): Promise { + if (vcsStatusSubscriber === null) throw new Error("No VCS status subscription"); + await Effect.runPromise(Queue.offer(vcsStatusSubscriber, event)); +} diff --git a/apps/web/test/fixtures/terminal-layout/capture.mjs b/apps/web/test/fixtures/terminal-layout/capture.mjs new file mode 100644 index 0000000000000..b1afa18051aa6 --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/capture.mjs @@ -0,0 +1,52 @@ +export const frozenTime = Date.UTC(2026, 9, 21, 22, 30); + +// Freeze wall-clock labels without changing animation or recording timing. +export function freezeClock() { + const RealDate = globalThis.__evidenceRealDate ?? Date; + globalThis.__evidenceRealDate = RealDate; + globalThis.Date = class extends RealDate { + constructor(...args) { + super(...(args.length ? args : [frozenTime])); + } + static now() { + return frozenTime; + } + }; +} + +export async function metadata(provenance) { + await document.fonts.ready; + const box = (selector) => + [...document.querySelectorAll(selector)].map((el) => { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }); + return { + ...provenance, + userAgent: navigator.userAgent, + platform: navigator.platform, + locale: navigator.language, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + frozenTime: new Date().toISOString(), + devicePixelRatio, + viewport: { width: innerWidth, height: innerHeight }, + visualViewportScale: visualViewport.scale, + rootFontSize: getComputedStyle(document.documentElement).fontSize, + rootClass: document.documentElement.className, + fonts: [...document.fonts] + .filter((f) => f.status === "loaded") + .map((f) => ({ family: f.family, style: f.style, weight: f.weight })), + terminal: box("[data-thread-terminal-drawer]"), + canvases: box("canvas"), + composer: box('[role="textbox"][contenteditable="true"]'), + scroll: [...document.querySelectorAll("*")] + .filter((e) => e.scrollTop || e.scrollLeft) + .map((e) => ({ + tag: e.tagName, + className: e.className, + top: e.scrollTop, + left: e.scrollLeft, + })), + reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, + }; +} diff --git a/apps/web/test/fixtures/terminal-layout/driver.mjs b/apps/web/test/fixtures/terminal-layout/driver.mjs new file mode 100644 index 0000000000000..04b93e1a07cc5 --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/driver.mjs @@ -0,0 +1,85 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +// The caller supplies the harness's T3 preview tool dispatcher, not Playwright. +export async function createDriver( + callPreview, + { tabId, outputDir, repoDir, run, phase, sourceTreeSha }, +) { + await NodeFSP.mkdir(outputDir, { recursive: true }); + await NodeFSP.mkdir("/tmp/t3-terminal-evidence", { recursive: true }); + const events = []; + const captures = []; + const started = Date.now(); + const git = (...args) => + NodeChildProcess.execFileSync("git", args, { cwd: repoDir, encoding: "utf8" }).trim(); + const provenance = { + headSha: git("rev-parse", "HEAD"), + sourceTreeSha, + terminalBlob: git("hash-object", "apps/web/src/components/ThreadTerminalDrawer.tsx"), + workingTreeStatus: git("status", "--short"), + phase, + }; + let milestones; + let pendingMilestones; + const decode = (result) => + result.structuredContent ?? + JSON.parse(result.content.find((item) => item.type === "text").text); + // Watch before the action that starts a shell, so fast prompts cannot race the watcher. + const arm = async () => { + await milestones?.return(); + milestones = NodeFSP.watch("/tmp/t3-terminal-evidence"); + pendingMilestones = milestones.next(); + }; + const call = async (name, args = {}) => { + if (name === "press" && args.key === "Enter") await arm(); + const result = await callPreview(name, { ...args, tabId }); + events.push({ run, ms: Date.now() - started, op: name, args, isError: !!result.isError }); + if (result.isError) throw new Error(JSON.stringify(result)); + return decode(result); + }; + const evaluate = async (expression) => (await call("evaluate", { expression })).value; + const driver = { + call, + evaluate, + arm, + ready: async (pane) => { + if (!milestones) throw new Error(`Terminal readiness for ${pane} was not armed`); + for (;;) { + const event = await pendingMilestones; + if (event.done) throw new Error("Terminal readiness watcher ended"); + if (event.value.filename === `ready-${pane}`) break; + pendingMilestones = milestones.next(); + } + await milestones.return(); + milestones = undefined; + pendingMilestones = undefined; + }, + columns: async () => + Number( + (await NodeFSP.readFile("/tmp/t3-terminal-evidence/input-size", "utf8")) + .trim() + .split(/\s+/)[1], + ), + note: async (note, data) => events.push({ run, ms: Date.now() - started, note, data }), + checkpoint: async (checkpoint) => { + const meta = await evaluate( + `(async()=>{const m=await import('/test/fixtures/terminal-layout/capture.mjs');return m.metadata(${JSON.stringify(provenance)})})()`, + ); + const snapshot = await call("snapshot", { save: true }); + const target = NodePath.join(outputDir, `${run}-${checkpoint}.png`); + await NodeFSP.copyFile(snapshot.screenshotPath, target); + captures.push({ run, checkpoint, path: target, meta }); + events.push({ run, ms: Date.now() - started, checkpoint, path: target }); + }, + finish: async () => { + await milestones?.return(); + await NodeFSP.writeFile( + NodePath.join(outputDir, `${run}.json`), + JSON.stringify({ provenance, events, captures }, null, 2), + ); + }, + }; + return driver; +} diff --git a/apps/web/test/fixtures/terminal-layout/manifest.json b/apps/web/test/fixtures/terminal-layout/manifest.json new file mode 100644 index 0000000000000..bc45d58077942 --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/manifest.json @@ -0,0 +1,38 @@ +{ + "frozenUtc": "2026-10-21T22:30:00.000Z", + "viewports": { + "W": [1440, 900], + "N": [390, 900] + }, + "themes": ["light", "dark"], + "static": ["P1-W-L", "P1-W-D", "P1-N-L", "P1-N-D", "D1-W-L", "D1-W-D", "D1-N-L", "D1-N-D"], + "recordings": ["RP-W-D", "RP-N-L", "RD-W-D", "RD-N-L"], + "permissionUnavailable": ["Q1-N-L", "Q2-W-D"], + "shellCommand": "bash /apps/web/test/fixtures/terminal-layout/shell.sh A", + "drawerHeight": 280, + "diff": { + "zero": { + "threshold": 0, + "gain": 8 + }, + "thresholded": { + "threshold": 8, + "gain": 8 + }, + "raw": { + "threshold": 0, + "gain": 1 + } + }, + "historical": { + "baselineApp": "70e5e384dab96a1d1e4a253ee5a9415ed5d8ce30", + "afterApp": "ec679087f9029cc9be36fc76e1f95e2ffb692dce" + }, + "baselineApp": "b5a226e49c52ecab4ddc2d678a7012d97e651a6a", + "afterApp": "Resolve HEAD at capture time; includes finalized fixtures", + "terminalBlobs": { + "before": "0a12a6535c2d56325ef3193068b44c5ef08eb4b2", + "after": "9626c2640b7d20fc38c972ed6fd3af250a434c16" + }, + "setup": "Pair an isolated dev server; createDriver dispatches T3 preview tools for one owned tab; setup creates a fresh draft and shell. Call replay with the returned threadRef/threadKey. Use typeFirstLine for pending-input captures. Stop the server before seed.mjs." +} diff --git a/apps/web/test/fixtures/terminal-layout/replay.mjs b/apps/web/test/fixtures/terminal-layout/replay.mjs new file mode 100644 index 0000000000000..752a06a7ab361 --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/replay.mjs @@ -0,0 +1,196 @@ +// The driver invokes only T3 preview tools. Each checkpoint saves the whole viewport. +export async function replay(driver, { mode, width, shellPath, threadRef, threadKey }) { + const owner = mode === "RP" ? "right-panel" : "drawer"; + const surface = `[data-terminal-owner="${owner}"]`; + const action = (name) => `${surface} button[aria-label^="${name}"]`; + const call = driver.call; + const read = driver.evaluate; + const checkpoint = driver.checkpoint; + await read( + `globalThis.driverThreadRef = ${JSON.stringify(threadRef)}; globalThis.driverThreadKey = ${JSON.stringify(threadKey)}`, + ); + const press = (key, modifiers) => call("press", { key, modifiers }); + const click = (locator) => call("click", { locator }); + const quotedShellPath = `'${shellPath.replaceAll("'", "'\\''")}'`; + const command = async (text) => { + await press("c", ["Control"]); + await call("type", { locator: `${surface} textarea:focus`, text }); + await press("Enter"); + }; + const seed = async (pane, index) => { + await click(`${surface} canvas >> nth=${index}`); + await command(`bash ${quotedShellPath} ${pane}`); + await driver.ready(pane); + }; + const reset = async () => { + const n = await read(`document.querySelectorAll('${surface} canvas').length`); + for (let i = 0; i < n; i++) { + await click(`${surface} canvas >> nth=${i}`); + await command("fixture_reset"); + await driver.ready(String.fromCharCode(65 + i)); + } + }; + const close = async () => { + await click(action("Close Terminal (")); + await call("wait_for", { locator: '[role="alertdialog"]' }); + await click('role=button[name="Confirm"]'); + await call("wait_for", { locator: `${surface} canvas` }); + }; + await reset(); + await checkpoint("single"); + for (const name of [ + "Split Terminal Horizontally", + "Split Terminal Vertically", + "New Terminal", + "Close Terminal (", + ]) { + await call("hover", { locator: action(name) }); + await call("wait_for", { locator: '[role="dialog"]' }); + } + await checkpoint("tooltip"); + const hit = await read( + `(()=>{const b=document.querySelector('${action("Split Terminal Horizontally")}');const r=b.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+2,label:b.getAttribute('aria-label'),hit:document.elementFromPoint(r.x+r.width/2,r.y+2)?.closest('button')?.getAttribute('aria-label')}})()`, + ); + await driver.note("top2px horizontal split hit test", hit); + await call("click", { x: hit.x, y: hit.y }); + await seed("B", 1); + await reset(); + await checkpoint("S-H"); + for (const [pane, index] of [ + ["C", 2], + ["D", 3], + ]) { + await click(action("Split Terminal Horizontally")); + await seed(pane, index); + } + await reset(); + await call("hover", { locator: action("Split Terminal Horizontally") }); + await call("wait_for", { text: "max 4 per group" }); + try { + await click(action("Split Terminal Horizontally")); + } catch (error) { + await driver.note("Limit control rejected the pointer attempt", { error: String(error) }); + } + const count = await read(`document.querySelectorAll('${surface} canvas').length`); + if (count !== 4) throw Error(`Expected four panes, received ${count}`); + await checkpoint("L"); + for (let i = 0; i < 3; i++) await close(); + await reset(); + await checkpoint("single-after-horizontal"); + await click(action("Split Terminal Vertically")); + await seed("B", 1); + await reset(); + await checkpoint("S-V"); + await close(); + await reset(); + await click(action("New Terminal")); + await seed("B", 0); + await checkpoint(mode === "RP" ? "new-tab" : "G"); + if (mode === "RP") { + const tab = (index) => + `[data-right-panel-tab-list] [data-active-tab] button:not([aria-label]):visible >> nth=${index}`; + await click(tab(0)); + await reset(); + await click(tab(1)); + await close(); + if (width > 980) { + await call("drag", { + source: '[role="separator"].cursor-col-resize:visible', + target: '[role="textbox"][contenteditable="true"]', + }); + await checkpoint("width-drag"); + await click('button[aria-label="Maximize panel"]:visible'); + await checkpoint("maximized"); + await click('button[aria-label="Restore panel size"]:visible'); + await call("drag", { + source: '[role="separator"].cursor-col-resize:visible', + target: 'button[aria-label="Toggle right panel"]:visible', + }); + } + await click('button[aria-label="Toggle right panel"]:visible'); + await checkpoint("hidden"); + await click('button[aria-label="Toggle right panel"]:visible'); + } else { + const session = (index) => + `${surface} [class~="group/tab"] > button:not([aria-label]) >> nth=${index}`; + await click(session(0)); + await reset(); + await click(session(1)); + await close(); + await click('button[aria-label="Toggle thread details panel"]:visible'); + await driver.arm(); + await click( + 'button[aria-label="Run Evidence deterministic terminal with deliberately long label"]', + ); + await call("wait_for", { locator: `${surface} canvas` }); + await click('button[aria-label="Toggle thread details panel"]:visible'); + await driver.ready("G"); + await checkpoint("G-long-label"); + await close(); + await click(`${surface} canvas`); + await press("d", ["Control"]); + await seed("B", 1); + await checkpoint("shortcut-split"); + await close(); + await call("drag", { source: `${surface} > .cursor-row-resize`, target: `${surface} canvas` }); + await reset(); + await checkpoint("minimum-height"); + await read( + `(async()=>{const m=await import('/src/terminalUiStateStore.ts');m.useTerminalUiStateStore.getState().setTerminalHeight(driverThreadRef,280)})()`, + ); + await click('button[aria-label="Toggle terminal drawer"]:visible'); + await click('button[aria-label="Toggle terminal drawer"]:visible'); + await call("wait_for", { locator: `${surface} canvas` }); + await driver.note("restore preferred height through existing store action", { height: 280 }); + await call("type", { + locator: '[role="textbox"][contenteditable="true"]', + text: Array.from({ length: 12 }, (_, i) => "Composer fixture line " + (i + 1)).join("\n"), + }); + await read( + `(async()=>{const m=await import('/src/hooks/useSettings.ts');await m.updateClientSettings({fontSizeTerminal:18})})()`, + ); + await reset(); + await checkpoint("constrained-font18"); + await call("type", { + locator: '[role="textbox"][contenteditable="true"]', + text: "", + clear: true, + }); + await read( + `(async()=>{const m=await import('/src/hooks/useSettings.ts');await m.updateClientSettings({fontSizeTerminal:12})})()`, + ); + await click(`${surface} canvas`); + await command("fixture_scrollback"); + await driver.ready("A"); + await read( + `document.querySelector('${surface} canvas').dispatchEvent(new WheelEvent('wheel',{deltaY:-1000,bubbles:true,cancelable:true}))`, + ); + await checkpoint("scroll-up"); + await read( + `document.querySelector('${surface} canvas').dispatchEvent(new WheelEvent('wheel',{deltaY:1000,bubbles:true,cancelable:true}))`, + ); + await checkpoint("scroll-down"); + await click('button[aria-label="Toggle terminal drawer"]:visible'); + await checkpoint("hidden"); + await click('button[aria-label="Toggle terminal drawer"]:visible'); + } + await reset(); + await checkpoint("reopened"); + await click(action("Close Terminal (")); + await click('role=button[name="Cancel"]'); + await checkpoint("close-cancel"); + await click(action("Close Terminal (")); + await click('role=button[name="Confirm"]'); + await checkpoint("E-last-close"); + if (mode === "RP") { + const open = await read( + `JSON.parse(localStorage.getItem('t3code:right-panel-state:v2')).state.byThreadKey[driverThreadKey]?.isOpen === true`, + ); + if (!open) await click('button[aria-label="Toggle right panel"]:visible'); + await click('role=button[name="Terminal T"]'); + } else { + await click('button[aria-label="Toggle terminal drawer"]:visible'); + } + await seed("A", 0); + await checkpoint("new-usable-single"); +} diff --git a/apps/web/test/fixtures/terminal-layout/seed.mjs b/apps/web/test/fixtures/terminal-layout/seed.mjs new file mode 100644 index 0000000000000..37978c0a8891d --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/seed.mjs @@ -0,0 +1,36 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +// Run with the isolated server stopped: node seed.mjs . +// The app creates projects/drafts normally; this adds only the named script. +const [home, projectId] = process.argv.slice(2); +if (!home || !projectId) throw new Error("Usage: node seed.mjs "); +const resolvedHome = await NodeFSP.realpath(home); +const liveHome = await NodeFSP.realpath(NodePath.join(NodeOS.homedir(), ".t3")); +if (resolvedHome === liveHome || resolvedHome.startsWith(liveHome + NodePath.sep)) { + throw new Error("Refusing to write live T3 state"); +} +const target = NodePath.join(resolvedHome, "userdata/settings.json"); +const settings = JSON.parse( + await NodeFSP.readFile(target, "utf8").catch((error) => { + if (error.code === "ENOENT") return "{}"; + throw error; + }), +); +const shellPath = NodeURL.fileURLToPath(new URL("./shell.sh", import.meta.url)); +const script = { + id: "evidence-deterministic-t", + name: "Evidence deterministic terminal with deliberately long label", + command: `bash '${shellPath.replaceAll("'", "'\\''")}' G`, + icon: "play", + runOnWorktreeCreate: false, +}; +settings.projectScriptOverrides ??= {}; +const previous = settings.projectScriptOverrides[projectId] ?? []; +settings.projectScriptOverrides[projectId] = [ + ...previous.filter((item) => item.id !== script.id), + script, +]; +await NodeFSP.writeFile(target, JSON.stringify(settings, null, 2) + "\n"); diff --git a/apps/web/test/fixtures/terminal-layout/setup.mjs b/apps/web/test/fixtures/terminal-layout/setup.mjs new file mode 100644 index 0000000000000..521bbf146e41d --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/setup.mjs @@ -0,0 +1,62 @@ +// Start an isolated dev server and pair its Browser panel before calling this. +// callPreview dispatches names to T3 preview_* tools; it must keep one owned tab. +export async function setup(driver, { width, theme, mode, shellPath, projectName = "server" }) { + const { call, evaluate } = driver; + await call("resize", { mode: "freeform", width, height: 900 }); + await call("set_appearance", { colorScheme: theme }); + await evaluate(`(async()=>{ + const capture=await import('/test/fixtures/terminal-layout/capture.mjs'); + capture.freezeClock(); + const settings=await import('/src/hooks/useSettings.ts'); + await settings.updateClientSettings({fontSizeTerminal:12}); + localStorage.setItem('t3code:theme',${JSON.stringify(theme)}); + window.dispatchEvent(new StorageEvent('storage',{key:'t3code:theme',newValue:${JSON.stringify(theme)}})); + })()`); + await call("click", { + locator: `button[aria-label=${JSON.stringify(`New thread in ${projectName}`)}]`, + }); + await call("wait_for", { locator: '[role="textbox"][contenteditable="true"]' }); + const context = await evaluate(`(()=>{ + const draftId=location.pathname.split('/').at(-1); + const draft=JSON.parse(localStorage['t3code:composer-drafts:v1']).state.draftThreadsByThreadKey[draftId]; + if(!draft) throw Error('Expected a newly created draft'); + const threadRef={environmentId:draft.environmentId,threadId:draft.threadId}; + return {route:location.pathname,threadRef,threadKey:draft.environmentId+':'+draft.threadId}; + })()`); + const sidebarOpen = await evaluate( + `document.querySelector('button[aria-label="Toggle main sidebar"]')?.getAttribute('aria-pressed')==='true'`, + ); + if (sidebarOpen) await call("click", { locator: 'button[aria-label="Toggle main sidebar"]' }); + if (mode === "RP") { + await call("click", { locator: 'button[aria-label="Toggle right panel"]' }); + await call("click", { locator: 'role=button[name="Terminal T"]' }); + } else { + await call("click", { locator: 'button[aria-label="Toggle terminal drawer"]' }); + } + const surface = `[data-terminal-owner="${mode === "RP" ? "right-panel" : "drawer"}"]`; + await call("wait_for", { locator: `${surface} canvas` }); + await call("click", { locator: `${surface} canvas` }); + await call("press", { key: "c", modifiers: ["Control"] }); + const quotedPath = `'${shellPath.replaceAll("'", "'\\''")}'`; + await call("type", { locator: `${surface} textarea:focus`, text: `bash ${quotedPath} A` }); + await call("press", { key: "Enter" }); + await driver.ready("A"); + return context; +} + +// This leaves real unexecuted shell input and its cursor on the first row. +export async function typeFirstLine(driver, { mode, columns }) { + const surface = `[data-terminal-owner="${mode === "RP" ? "right-panel" : "drawer"}"]`; + await driver.call("click", { locator: `${surface} canvas` }); + await driver.call("press", { key: "c", modifiers: ["Control"] }); + await driver.call("type", { locator: `${surface} textarea:focus`, text: "fixture_input" }); + await driver.call("press", { key: "Enter" }); + await driver.ready("input"); + const terminalColumns = columns ?? (await driver.columns()); + const prefix = "printf '%s\\n' '"; + const suffix = "RIGHT'"; + const text = + prefix + "typed-".padEnd(terminalColumns - 2 - prefix.length - suffix.length, "x") + suffix; + await driver.call("type", { locator: `${surface} textarea:focus`, text }); + return text; +} diff --git a/apps/web/test/fixtures/terminal-layout/shell.sh b/apps/web/test/fixtures/terminal-layout/shell.sh new file mode 100644 index 0000000000000..833fe434b7100 --- /dev/null +++ b/apps/web/test/fixtures/terminal-layout/shell.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + set -eu + mkdir -p /tmp/t3-terminal-evidence + exec env -i HOME=/tmp/t3-terminal-evidence PATH=/usr/bin:/bin TERM=xterm-256color LANG=C.UTF-8 LC_ALL=C.UTF-8 EVIDENCE_PANE="${1:-A}" bash --noprofile --rcfile "$0" -i +fi +cd /tmp/t3-terminal-evidence +HISTFILE=/dev/null +set +o history +fixture_prompt() { + local width fill + width=$(stty size); width=${width##* } + printf -v fill '%*s' "$((width > 12 ? width - 12 : 1))" '' + PS1="\[\e[36m\]LEFT-${fill// /-}-RIGHT\[\e[0m\]\n\[\e[32m\]pane ${EVIDENCE_PANE} READY\[\e[0m\] ANSI blue: \[\e[34m\]0123456789\[\e[0m\]\nevidence:/fixture $ " + printf '\033[?25l' + stty size > /tmp/t3-terminal-evidence/input-size + printf '%s\n' "$EVIDENCE_PANE" > "/tmp/t3-terminal-evidence/ready-${EVIDENCE_PANE}" +} +fixture_reset() { printf '\033[3J\033[H\033[2J'; } +fixture_scrollback() { + local n + for ((n=1;n<=80;n++)); do printf 'SCROLL %03d | deterministic terminal output | RIGHT\n' "$n"; done +} +PROMPT_COMMAND=fixture_prompt +fixture_reset + +# Leave the next command on the first physical line with a steady visible cursor. +fixture_input() { + PROMPT_COMMAND= + PS1= + printf '\033[3J\033[H\033[2J\033[?25h\033[2 q' + stty size > /tmp/t3-terminal-evidence/input-size + printf 'input\n' > /tmp/t3-terminal-evidence/ready-input +} diff --git a/docs/README.md b/docs/README.md index af3c45ce567b1..f332da947f55f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,7 +25,7 @@ - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) - [Triage an issue](./user/triage.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Pi](./user/providers-pi.md) · [Muse Code](./user/providers-muse.md) · [fx](./user/providers-fx.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Kiro](./user/providers-kiro.md) · [Pi](./user/providers-pi.md) · [Muse Code](./user/providers-muse.md) · [fx](./user/providers-fx.md) --- diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 28fa103feb5cb..3fddb7c4d7a02 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -141,12 +141,7 @@ binary WebSocket; Android multiplexes SEMU-framed H.264 and JSON gestures over one WebSocket. [`DeviceStreamView.tsx`](../../apps/web/src/components/device/DeviceStreamView.tsx) speaks both so one panel covers both platforms. -Simulators encode H.264 High 5.1 without frame reordering, but the hub's fallback -encoder can omit the SPS VUI that declares its reorder limits. Chromium's hardware -decoder then retains frames for seconds despite `optimizeForLatency` and an empty -`decodeQueueSize`. The iOS AVCC viewer restores the missing metadata before -configuring the decoder so hardware decoding can remain enabled. WebCodecs is -secure-context only, so the viewer probes `isConfigSupported` and falls back to +WebCodecs is secure-context only, so the viewer probes `isConfigSupported` and falls back to MJPEG on iOS when decoding is unavailable. Android's SEMU socket has no MJPEG; there the panel reports that it cannot decode. diff --git a/docs/internals/product-analytics.md b/docs/internals/product-analytics.md index 3369d5832c493..b6dc3f245390a 100644 --- a/docs/internals/product-analytics.md +++ b/docs/internals/product-analytics.md @@ -53,22 +53,63 @@ A command whose id was already accepted is a retry, so it records nothing again. One identity can appear in several client groups during a period; adding those groups double-counts users. -Provider send and completion counts need not match. Most completions start -without a person: provider continuations, agent sends, and delegated subagents. -`provider.turn.completed` carries the starting message's `createdBy` and -`creationSource`, plus `scheduled` and `subagent`; filter on them to count user -turns. Collection is best effort, with no scan or backfill of provider history. - -Token totals cover the main agent. Child agents and model rerouting prevent a turn -from representing one provider/model combination's full cost. For provider -comparisons, require complete usage, no observed subagents, and no mixed models; -compare matching model, effort, interaction mode, and terminal status. Aggregate -output/input ratios should divide the summed totals. Averaging per-turn ratios -lets small-input turns dominate. - -Unknown counts stay absent. Partial usage contains valid observed counts but -cannot establish a whole-turn total. Keep these distinctions when changing token -normalization or building reports. +Provider send and completion counts need not match. `provider.turn.completed` +is one provider turn, and `segment` says what started it: the run's first +attempt (`initial`), a `steering_restart`, a provider `continuation` such as a +Codex goal turn, or a `native_child` subagent. `createdBy`, `creationSource`, +`scheduled`, and `subagent` describe the run's starting message, and `childKind` +separates native subagents from T3-managed child threads. Use this event for +per-turn measurements, not for counting requests. + +`provider.request.completed` is one run, recorded when the run settles in the +server process that ran it. It adds each root turn once, so continuations, +steering, and replayed provider events do not add rows. `requestKind: "user"` +counts what people asked for. Native subagent turns are summed apart from the +root (`childUsageStatus`, `childInputTokens`); T3-managed child threads report +their own requests as `t3_child`. A child still running at settlement is never +added later, and the summary sets `childrenRunning`. A server restart loses the +summary of a run in progress. A run that continues after a restart reports its +own usage with `restartContinuation` and `lifecycleObserved: false`; it cannot +recover the original request's count or kind. A run whose provider session never +opens has no summary, and a start the provider rejects reports +`providerTurns: 0`. A Stop that lands while a steering restart is starting ends +the run without a summary. Collection is best effort, with no scan or backfill of +provider history. + +Counts cover the main agent. `otherInputTokens` and `otherOutputTokens` are what +a provider's own ledger recorded beyond it during the turn: Claude's per-process +`modelUsage` growth, which covers native subagents, compaction, and helper +calls. They need a baseline from the same process, so they stay absent when it +is unknown or the ledger goes backwards, and background work that ends between +turns lands in the next one. Claude subagents have no turns of their own, so +this never overlaps child turns. + +`model`, `effort`, and `serviceTier` are what the run selected. `serviceTier` +is `standard`, `flex`, `fast`, or `ultrafast`, from Codex's tier option or +Claude's fast mode, and is absent when the selection does not set one. `observedModel` and +`mixedModels` appear only when the provider reported the models for the counted +work (Codex thread settings and reroutes, Claude root messages, OpenCode steps). +`mixedModels: false` without `reportVersion` was never checked. Model labels +come from the bundled model manifest and the Cursor and Grok catalogs, also for +routed ids such as `anthropic/claude-opus-5-5`, and efforts come from a closed +list, so user-configured names stay out. + +Status and counters answer different questions. `usageReported: false` means +the adapter attached no usage; a harness where every row says so is unsupported, +not idle. `partial` keeps valid counts that cannot establish a whole-turn total, +such as an OpenCode step that ended without a report, an OpenCode stream +reconnect, or an interrupted segment. +Explicit zeros are sent and unknown counts stay absent. `localCommand` marks a +provider-local command such as Claude's `/compact`, whose result counters are not +model work. Reasoning tokens are a subset of output. + +Charts built on these events should say "output tokens per measured provider +completion", compare matching provider, observed model, effort, interaction mode, +and terminal status, and show eligible and measured counts by harness, origin, +and status so unknown models and unsupported adapters stay in the denominator. +Divide summed totals for output/input ratios; averaging per-turn ratios lets +small-input turns dominate. Rows without `reportVersion` keep their missing +fields unknown; a missing origin is never a person. `feature.used` counts WebSocket requests that start a feature. RPCs are observed in the RPC authorization middleware after the request succeeds; dispatched commands diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 42f0a70101b3a..3a31a9dac93cd 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -93,7 +93,10 @@ manual-only because their version is pinned in mise's config. A Grok cask whose cannot be verified also stays manual-only: `grok update` would install a second copy. npm updates pin `--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew compares against `brew info` since casks trail npm by hours; native installs share npm's version -train, so the registry stays authoritative for them. +train, so the registry stays authoritative for them. A Chocolatey install of Claude stays +manual-only: its package directory is writable only by administrators, and `claude update` through +the shim installs a native copy elsewhere. The latest version comes from `choco search`, and the +card shows `choco upgrade claude-code` without running it. See the [resolver](../../apps/server/src/provider/providerMaintenanceRunner.ts). Ownership is cached per instance and re-read immediately before an update runs. The diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index 4c9bbcb780bee..1d577d7f0d7dd 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -13,22 +13,30 @@ of its traces and leaves token and signature verification to the environment By default the forwarder stores nothing. An environment can opt in to having the relay hold requests while it is offline -(`hold_webhooks_while_offline` on its link). Only then does the relay store the -raw request, including the hook token in the path, in a Durable Object for -that environment, with SQLite storage. The object pushes held requests back -through the tunnel from its alarm, oldest first, and backs off while the -environment stays away. When the tunnel reconnects, the environment asks the -relay to deliver right away. Requests are deleted once the environment -answers, after 24 hours, or when no user has the environment linked. The relay -still never checks the token; delivery goes through the same environment route. +(`hold_webhooks_while_offline` on its links). Only then does the relay store the +raw request, including the hook token in the path, in a Durable Object with +SQLite storage. There is one object per managed link, keyed by its endpoint +key: each account that links the environment gets its own held requests, +order, and caps. The object pushes held requests back through the tunnel from +its alarm, oldest first within each hook, and backs off while the environment +stays away. A hook that answers busy is skipped for the rest of that run, so +other hooks on the link can overtake it. When +the tunnel reconnects, the environment asks the relay to deliver right away to +each of its links. Requests are deleted once the environment answers, after 24 +hours, when their link is unlinked, or when the environment turns holding off, +which clears every link it proved. Both clears are best effort: unlinking logs a +failed clear and continues, and turning holding off stops at the first link it +cannot clear, so those requests wait for the 24-hour expiry. The relay still +never checks the token; +delivery goes through the same environment route. Every forward carries `x-t3-relay-delivery-id`, so a request that reached the environment before a timeout and is delivered again later runs once ([inbox object](../../infra/relay/src/hooks/HookInboxObject.ts)). A Durable Object, not Postgres or Queues, because held requests are write-once, -read-once bodies of up to 1 MiB that need per-environment order, caps, and -retry timing. Queues cap messages at 128 KB and cannot hold one environment's -requests back while it is away. +read-once bodies of up to 1 MiB that need per-link order, caps, and retry +timing. Queues cap messages at 128 KB and cannot hold one link's requests back +while its environment is away. Clerk, deployment, and native authentication setup live in the [Connect setup runbook](../operations/connect-setup.md). diff --git a/docs/internals/web-ui.md b/docs/internals/web-ui.md index 13b7d9df99fd0..c54a3802be941 100644 --- a/docs/internals/web-ui.md +++ b/docs/internals/web-ui.md @@ -31,8 +31,8 @@ The composer banners are the reference. renders `Button`. - [`ComposerBannerStack.tsx`](../../apps/web/src/components/chat/ComposerBannerStack.tsx) composes the slots and owns only stack behavior: priority, expand and collapse, dismiss transitions. -- Consumers are a few lines of slots, and the slots carry the styling: - [`ComposerActivityStatus.tsx`](../../apps/web/src/components/chat/ComposerActivityStatus.tsx). +- Consumers are a few lines of slots, and the slots carry the banner styling: + [`ComposerPlanFollowUpBanner.tsx`](../../apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx). Build new UI the same way. Compose or extend a component instead of copying its markup and classes. When a call site repeats a treatment another one already uses, make it a variant or a slot on the diff --git a/docs/operations/development.md b/docs/operations/development.md index 3a56a23f63daf..131b279fab683 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -86,6 +86,34 @@ To add a scenario, point a `SCENARIOS` entry at a generic ACP replay fixture The agent replays session updates, permission requests, and questions. It drops session setup and vendor frames, so Grok and fx recordings lose their vendor-only behavior. +### Profiling dev startup + +Run `vp run profile:startup` to capture the first 20 seconds of inherited Node processes under +`.t3/startup-profiles/`. Workers that execute the preload get separate profiles. +It starts the normal dev runner and accepts its flags. +Stop it with Ctrl+C after the capture. Set `T3CODE_STARTUP_PROFILE_DIR` to choose another output +directory. Profiling adds overhead, so compare readiness with an ordinary `vp run dev` run. + +Open a `.cpuprofile` in Chrome DevTools' Performance panel for sampled function stacks and source +lines. Each `.modules.json` is a Chrome trace containing timed module resolution, loading, +synchronous subprocess calls, and startup measures. Per-file records include importer URLs, +source loading, preparation, recursive dependency linking, and top-level evaluation. Preparation +uses synchronous loader boundaries; linking includes dependencies, and CommonJS evaluation can +include nested `require` calls. Some ESM/CommonJS interop reports a combined stage. `.process.json` records the process, Node version, and capture window. +The server's nested Effect and SQL spans remain in `.t3/userdata/logs/server.trace.ndjson`. + +The preload temporarily adds module-body timing markers and uses one automatic debugger pause +to instrument the live Node loader. The launcher adds `--no-node-snapshot` to inherited +`NODE_OPTIONS` because Node 26's snapshotted loader cannot accept that breakpoint. Include the +same flag when using the preload directly. This disables Node's built-in startup snapshot, +can change compilation-cache hits, and adds overhead. +Treat per-file captures as diagnostic runs; use uninstrumented runs for baseline startup latency. +The first entry module can compile before loader instrumentation is installed. + +A fresh process still uses filesystem, Node compilation, provider, and Vite dependency caches. +Measure a missing Vite dependency cache separately from a restart with an existing database. +Neither the Vite ready message nor the HTTP listener proves that a browser has rendered the app. + ### Sharing and remote debugging `vp run dev --share` publishes the web port over the machine's tailnet and prints a pairing URL @@ -233,6 +261,30 @@ DMGs default to the host architecture. Use `--arch` to choose another target and to retain packaging files for inspection. Run `vp run dist:desktop:artifact --help` for other options. +### Prepared server bytecode + +Native macOS and Windows desktop artifact builds generate server bytecode with the packaged +Electron executable, after ASAR creation and before signing. Linux builds skip generation until +native packaging and relocated AppImage reuse have been validated. macOS x64 builds on ARM64 +hosts run the packaged x64 Electron under Rosetta to generate the cache. Other cross-platform and +cross-architecture builds skip generation explicitly. CLI, SEA and WSL runtimes keep their +existing behavior; only desktop backend imports covered by generation receive prepared bytecode. +Worker-only modules compile normally. + +Electron's Node 24 cache has no read-only option and includes the producer UID in its directory +name. The backend links individual prepared files into its writable cache with keys for the +installed module paths. Node replaces files atomically, so regeneration cannot write through those +links into signed resources. Windows uses hard links and falls back to normal caching when volumes +differ or resource permissions prevent linking. Protected installations therefore may not reuse +prepared bytecode. No cache bytes are copied during startup. + +The key calculation follows Node's internal portable ESM format. Generation checks that the +packaged engine produced the expected CLI entry and fails the build if that format changes. +The manifest also checks Electron, Node, OS, architecture and V8's flags-sensitive version tag. +Missing or rejected entries compile from the shipped JavaScript. Prepared backends skip the +explicit startup flush; Node can persist missing entries on normal exit. The writable fallback +keeps the explicit flush needed when Windows force-kills the backend. + ### Linux AppImage prerequisites Build on Linux because the browser-secret helper links against the host's libsecret. Install diff --git a/docs/operations/release.md b/docs/operations/release.md index 18daadc5f2787..4dd489bfde064 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -42,7 +42,7 @@ the version. - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - - The release job's **Write release notes** step (`scripts/generate-release-notes.ts`) builds the body: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
    `. It needs the `OPENAI_API_KEY` secret. The summary leaves out commits whose scopes are all mobile; the header counts every commit. The desktop update popover, on every channel, shows the highlights paragraph and the header counts as a short note per release, with the offered release's age from the update feed's `releaseDate`. The nightly Discord post shows the part above `
    `. If the step fails or there is nothing to summarize, it writes no file and the release uses GitHub's generated notes as before. + - The release job's **Write release notes** step (`scripts/generate-release-notes.ts`) builds the body: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
    `. It needs the `OPENAI_API_KEY` secret. The desktop update popover shows up to seven highlights and the number of other fixes and performance improvements. Mobile-only commits count toward release totals but cannot be highlights. The offered release's age comes from the update feed's `releaseDate`. The nightly Discord post shows the part above `
    `. If summary generation fails, the release uses GitHub's generated notes. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. - Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but the Windows archives is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. @@ -106,9 +106,13 @@ The relay is a shared control plane versioned separately from client releases. S client builds must point at the same relay so users see the same linked environments when switching release channels. -`.github/workflows/deploy-relay.yml` deploys Alchemy stage `prod` on every push to `main`. The -release workflow reads the relay URL and Clerk client configuration from the existing `production` -GitHub Actions environment before building desktop, CLI, or hosted web artifacts. +`.github/workflows/deploy-relay.yml` deploys Alchemy stage `prod` on every push to `main`. Stable +and nightly releases read the relay URL and Clerk client configuration from the existing +`production` GitHub Actions environment before building desktop, CLI, or hosted web artifacts. That +environment admits only `main` and `v*` tags, so preview releases, which may run from any branch, +take the same public identifiers from `.env.example`; keep it in step with the `production` values. +Every channel reads the relay client tracing config from Alchemy state with the repository-level +`CLOUDFLARE_API_TOKEN`, so moving that token into `production` would break preview releases. Required repository variables shared by relay deployments: diff --git a/docs/orchestration-v2/feature-lifecycles.md b/docs/orchestration-v2/feature-lifecycles.md index 4e1e1a0568ae5..76818736bc758 100644 --- a/docs/orchestration-v2/feature-lifecycles.md +++ b/docs/orchestration-v2/feature-lifecycles.md @@ -410,7 +410,7 @@ rollback.toRun(threadId, targetRunOrdinal) -> delete or mark stale later checkpoint refs ``` -Codex `thread/rollback` returns an authoritative provider thread snapshot. Other providers may require a synthetic provider thread restart from context. +Codex `thread/revert` returns updated loaded thread metadata with an empty `turns` array; retained history must be hydrated through `thread/turns/list`. Other providers may require a synthetic provider thread restart from context. Nested rollback: diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index f46803f9d67b6..41146da1ed4c1 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -11,8 +11,9 @@ agent can use this endpoint to: - create one or more ordinary top-level T3 threads; - list a project's threads and incrementally read any thread; - rename threads, regenerate titles, and link or unlink pull requests; -- send or steer follow-up messages; and -- wait for or interrupt ordinary thread runs. +- send or steer follow-up messages; +- wait for or interrupt ordinary thread runs; and +- schedule recurring work that runs without an active turn. These are T3 orchestration operations, not provider-native sub-agent APIs. Delegated tasks always create a T3 child thread and run. The child receives @@ -58,17 +59,18 @@ check the `orchestration` capability before reading or mutating state. ### Codex V2 -Codex app-server receives the remote MCP server through command-line config -overrides: +Codex gets T3's MCP server in each thread's `thread/start`, `thread/resume`, +and `thread/fork` config, with the provider-session token in an +`Authorization` header. The same server is registered twice: -```text --c mcp_servers.t3-code.url=http://127.0.0.1:/mcp --c mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN" -``` +- `t3-code`: every tool except `t3_thread_wait`. +- `t3-code-direct`: only `t3_thread_wait`, with `omit_tools_from = ["code_mode", "deferred"]`. -The provider-session token is placed in `T3_MCP_BEARER_TOKEN`. Both the -production Codex launcher and the injectable test launcher use the same -projection helper. +Code Mode runs a nested call as part of a script, so the model keeps working +while a blocking wait runs. The second entry makes the wait a direct tool call, +like Codex's own wait tools. Codex reports calls as `.`, so +`packages/shared/src/t3McpToolPresentation.ts` must resolve both server names. +Tool display and pending-work detection depend on it. ### Claude Agent SDK V2 @@ -98,8 +100,11 @@ server headers or token. ### Cursor Agent SDK V2 Cursor receives the same authenticated HTTP MCP endpoint through the SDK's -`mcpServers` agent and send options. The adapter passes the authorization header -to the SDK but projects only redacted option metadata into protocol diagnostics. +`mcpServers` on `Agent.create` / `Agent.resume` and each `send`. With +`@cursor/sdk` 1.0.39+, send-level servers replace only the create-time inline +set; ambient servers from `settingSources` (`project`, `user`, `team`, `mdm`, +`plugins`) stay available. The adapter passes the authorization header to the +SDK but projects only redacted option metadata into protocol diagnostics. ### Grok ACP V2 @@ -192,7 +197,12 @@ provider selection model-visible without allowing a request that cannot run. ## Tool Surface -The server exposes eleven orchestration tools. +`OrchestratorToolkit` in +[`toolkits/orchestrator/tools.ts`](../../apps/server/src/mcp/toolkits/orchestrator/tools.ts) +is the authoritative list of orchestration tools. The sections below cover +delegation and thread tools. `t3_thread_launch` is documented with them but +registered by the project toolkit in +[`toolkits/project/tools.ts`](../../apps/server/src/mcp/toolkits/project/tools.ts). ### `orchestrator_capabilities` @@ -454,6 +464,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 @@ -489,8 +515,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/orchestration-v2/thread-lineage-and-context-transfer.md b/docs/orchestration-v2/thread-lineage-and-context-transfer.md index 2bdb17e4234df..7d76582b4cb61 100644 --- a/docs/orchestration-v2/thread-lineage-and-context-transfer.md +++ b/docs/orchestration-v2/thread-lineage-and-context-transfer.md @@ -227,9 +227,9 @@ This hook checks pending context transfers targeting the thread/run and chooses Provider adapters own native details. The orchestrator owns the relationship, source point, durable transfer record, and command receipts. -For Codex, native `thread/fork` accepts an inclusive `lastTurnId` boundary. When the app source point is a completed provider turn with a native turn reference, the Codex adapter passes that native id so the provider creates the fork at the requested point directly. This is the only viable path on paginated Codex threads, which reject `thread/rollback`. If no native turn reference is available, the adapter falls back to forking the latest native state and rolling back the fork by the number of later terminal provider turns — and reports an explicit failure when the forked thread uses paginated history, since that fallback cannot be honored there. The orchestrator still passes a provider-neutral source point and source provider-turn history; it does not encode Codex boundary or rollback policy. +For Codex, native `thread/fork` accepts an inclusive `lastTurnId` boundary. When the app source point is a completed provider turn with a native turn reference, the Codex adapter passes that native id so the provider creates the fork at the requested point directly. If no native turn reference is available, the adapter falls back to forking the latest native state and rolling back the fork by the number of later terminal provider turns, using `thread/revert` on paginated history and reporting an explicit failure on legacy history, which Codex 0.156 cannot revert. The orchestrator still passes a provider-neutral source point and source provider-turn history; it does not encode Codex boundary or rollback policy. -The same paginated-history constraint applies to direct checkpoint rollback: `thread/rollback` only works on legacy-history Codex threads. The V2 adapter probes `historyMode` before rolling back and fails explicitly on paginated threads rather than sending a request Codex will reject. The paginated equivalent (page `thread/turns/list` to find the first removed turn, then `thread/revert` with `beforeTurnId`) is not implemented yet. +Direct checkpoint rollback also requires paginated Codex history. The V2 adapter checks `historyMode` before rolling back and fails explicitly on legacy history, which Codex 0.156 cannot revert. On paginated history, it pages `thread/turns/list` to find the first removed turn, then sends `thread/revert` with `beforeTurnId`. ## Data Ownership diff --git a/docs/user/composer.md b/docs/user/composer.md index db41f6cc013fe..a3e06c9aed6f7 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -37,7 +37,8 @@ Attach up to 100 files per message. Each image can be up to 10 MiB, with at most 50 MiB each, subject to the environment's upload support and limit. The agent receives them on the environment's machine. Provider and model limits still apply, including images already in the conversation. A video attachment gives -the agent a file path; it does not enable native video input. +the agent a file path; only [Antigravity](./providers-antigravity.md#skills-and-attachments) +sends supported videos to Gemini models as video. Uploads begin when you add an attachment. All uploads must finish before the message can send. Retry or remove a failed upload. On web and desktop, reloading @@ -47,6 +48,8 @@ You can drag or paste images into the web or desktop composer. HEIC and HEIF photos are converted to JPEG there and when selected from the mobile photo library; photos over the image limit are also resized to fit. On mobile, you can also send files to T3 Code through another app's system share sheet. +On Android, you can also drag images from another app into the message input +in split screen. See [images and videos](#images-and-videos-in-messages) for previewing and saving media. @@ -180,6 +183,10 @@ On supported iPhones with iOS 26 or later, use the composer's microphone to reco then confirm to transcribe. Text is inserted where your selection was when recording started, ready for you to review and edit before sending. +Voice input depends on Apple's on-device speech transcription. On a device without +it, the composer has no microphone button and **Settings → Microphone** says voice +input isn't available. + The first use may download Apple's speech model and needs a network connection. Later transcription works offline for that language. Recordings can be up to five minutes long. Canceling, leaving the screen, or an audio interruption discards the @@ -211,8 +218,8 @@ images. **Send now** sends a message right away, into the running turn if there also edit or remove it. If you stop the agent or the server restarts, queued messages keep their order and are held. -Press **Resume** in an empty composer on web and desktop, or **Resume queue** on mobile, to -continue. Sending a new message also resumes the queue, unless it steers a turn that is already +Press **Resume** in an empty composer to continue. **Resume** also continues a stopped +run or one that hit a usage limit, even with nothing queued. Sending a new message also resumes the queue, unless it steers a turn that is already running. You can edit or remove held messages without starting them. The pencil on a queued message opens it in the composer for editing. The original message @@ -240,7 +247,12 @@ Provider commands must start the message to run. T3 Code commands such as `/model` and `/plan`, and skill mentions, work on any line. Send `/compact` in an existing conversation to reduce context usage when the -provider supports it. Web and desktop also offer compaction from the context meter. +provider supports it, even with the context meter turned off. + +On web and desktop, turn on **Settings → Labs → Legacy → Context +window indicator (legacy)** to see context usage. The meter is off by default +and appears once the provider reports usage. Open it for usage details and +**Compact context** when the provider supports compaction. Claude instances can also compact idle threads for you. See [compact long conversations](./providers-claude.md#compact-long-conversations). diff --git a/docs/user/cursor.md b/docs/user/cursor.md index 6a6b1c8f284b3..4263ceb14fd8d 100644 --- a/docs/user/cursor.md +++ b/docs/user/cursor.md @@ -15,6 +15,17 @@ The adapter currently uses the SDK's local-agent runtime so runs operate in the workspace. Cursor cloud agents need repository and cloud-environment configuration that T3 Code does not expose yet. +Custom Cursor MCP servers and plugins are not configured in T3 Code. They load from the environment +machine's Cursor config when the agent opens: project `.cursor/mcp.json`, user `~/.cursor/mcp.json`, +and installed plugins under `~/.cursor/plugins`. Plugin tools +can appear without a login; OAuth MCP servers still need credentials. Run `agent mcp login ` +with the cwd set to the T3 project root (tokens land in +`~/.cursor/projects//mcp-auth.json`). Cursor desktop Connections auth does not write +that file, and the local SDK cannot complete interactive MCP OAuth itself—auth attempts fail with +messages such as "MCP authentication is not supported in local SDK runs" or that the auth server +does not support dynamic client registration. After you add or change skills, plugins, or MCP +servers, or after a successful CLI login, use **Restart agent session**. + ## Sign in Choose **Sign in**, then open the sign-in page and complete it in your browser. T3 Code @@ -36,7 +47,7 @@ The adapter supports: - creating and resuming local Cursor agent threads; - changing the model and model parameters between turns; - assistant text, reasoning, tool activity, plans, and todo streaming; -- thread-scoped T3 Code MCP tools; +- thread-scoped T3 Code MCP tools alongside ambient Cursor MCP/plugins from disk; - image attachments; - interruption, queued app messages, and orchestrator-owned interrupt/restart steering; - provider conversation snapshots through `Agent.messages.list()`; diff --git a/docs/user/install.md b/docs/user/install.md index 3f44f5b596b80..fde265746004e 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -164,6 +164,7 @@ computer. | Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | | Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | | Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | +| Kiro | Install [Kiro CLI](https://kiro.dev/docs/cli/), then run `kiro-cli login`. | | OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | | Antigravity | Install and sign in with Google from T3 Code's provider settings. | | Pi | Install [Pi](./providers-pi.md#set-up-pi) with Node.js 22.19 or newer, then run `pi` and `/login`. | @@ -186,9 +187,10 @@ When a provider CLI is behind its latest release, its provider card shows the available version. **Update now** runs the installer that owns the CLI (Homebrew, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or the CLI's own update command when T3 Code cannot tell. Update a CLI installed with -mise through mise. Cursor and Antigravity update with T3 Code. Homebrew installs -compare against the version Homebrew offers, which can trail the npm release by -a few hours. +mise through mise. Cursor and Antigravity update with T3 Code. Homebrew and +Chocolatey installs compare against the version their package manager offers, +which can trail the npm release; update Claude installed with Chocolatey +through Chocolatey. Add another provider instance for a separate account or configuration. Each instance can have its own environment variables, such as API keys or a custom @@ -197,8 +199,8 @@ their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), -[Antigravity](./providers-antigravity.md), [Pi](./providers-pi.md), -[Muse Code](./providers-muse.md), and [fx](./providers-fx.md). +[Antigravity](./providers-antigravity.md), [Kiro](./providers-kiro.md), +[Pi](./providers-pi.md), [Muse Code](./providers-muse.md), and [fx](./providers-fx.md). ## Next steps @@ -207,3 +209,4 @@ For provider-specific setup and accounts, see [Codex](./providers-codex.md), - [Remote access](./remote-access.md): connect from another device. - [Running in the background](./background-service.md): keep a Linux or macOS host available. - [Updating T3 Code](./updating.md): update the app and connected servers. +- [Managed policy](./managed-policy.md): configure T3 Code for everyone on a machine. diff --git a/docs/user/keyboard-focus.md b/docs/user/keyboard-focus.md index b51262b6d69bf..8eb80a012f669 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/managed-policy.md b/docs/user/managed-policy.md new file mode 100644 index 0000000000000..e1ba501e6fa3d --- /dev/null +++ b/docs/user/managed-policy.md @@ -0,0 +1,274 @@ +# Managed policy + +A managed policy lets an administrator configure T3 Code for every user on a machine. It sets +defaults and locks for settings, and limits such as the broadest permission mode or which +providers may run. The T3 Code server on the machine enforces it for every app that connects: +the desktop app, a browser, or the mobile app. + +## Roll out a policy + +1. Write the policy as JSON, starting from the [example](#write-a-policy). +2. Run `t3 policy check policy.json`. It prints what the policy will do, or what is wrong with it. +3. Deploy the policy to each machine with your device management tool, in + [one of the supported places](#where-the-policy-goes). +4. Restart T3 Code on the machine. The server reads the policy only when it starts. +5. Run `t3 policy show` on the machine to + [check what the server loaded](#check-what-the-server-loaded). + +The desktop app includes the `t3` command; see [The `t3` command](./install.md#the-t3-command). + +## Write a policy + +```json +{ + "schemaVersion": 1, + "organization": "Example Corp", + "supportUrl": "https://it.example.com/t3", + "settings": { + "defaultRuntimeMode": { "value": "approval-required", "locked": false }, + "enableAgentBrowserAccess": { "value": false, "locked": true } + }, + "limits": { + "maxRuntimeMode": "auto-accept-edits", + "clients": ["desktop", "local-browser"], + "telemetry": false + } +} +``` + +`schemaVersion` and `organization` are required. Users see the organization's name in Settings +and beside each control the policy locks, with a link to `supportUrl` if you set one. [`settings`](#settings) +sets defaults and locks, and [`limits`](#limits) restricts what T3 Code may do. + +Every user on the machine can read the policy, so do not put credentials in it. + +## Where the policy goes + +Use one of these per machine: + +| OS | Native policy | Policy file | +| ------- | ------------------------------------------------------------------- | ------------------------------------------------- | +| macOS | Configuration profile for `com.t3tools.t3code` | `/Library/Application Support/T3Code/policy.json` | +| Windows | `Policy` string value under `HKLM\SOFTWARE\Policies\T3Tools\T3Code` | `C:\Program Files\T3Code\policy.json` | +| Linux | | `/etc/t3code/policy.json` | + +T3 Code does not merge sources. If a machine has both a native policy and a policy file, T3 Code +refuses to start until you remove one. + +**Policy file.** Copy the JSON to the path for the OS. Make it and its folder writable only by +administrators. + +**macOS configuration profile.** Add a custom settings payload for the preference domain +`com.t3tools.t3code`. The payload's keys are the policy's top-level keys (`schemaVersion`, +`organization`, `supportUrl`, `settings`, `limits`); JSON objects become dictionaries and lists +become arrays. To make the payload from a policy file you checked, run +`plutil -convert xml1 -o com.t3tools.t3code.plist policy.json` and upload the result. Install it as +a device profile, since T3 Code ignores user profiles, and deploy only one profile for this domain. +A profile cannot hold `null`, so use a policy file if a setting's value must be `null`. + +**Windows registry.** Set the `Policy` value (`REG_SZ`) to the whole policy as one line of JSON, +from Group Policy Preferences or an Intune script. T3 Code reads it through the Windows registry +API, so it works where registry tools such as `reg.exe` are blocked for users. In PowerShell, as an +administrator: + +```powershell +$json = (Get-Content -Raw -Encoding UTF8 policy.json) -replace '\r?\n\s*', '' +New-Item -Path HKLM:\SOFTWARE\Policies\T3Tools\T3Code -Force | Out-Null +Set-ItemProperty -Path HKLM:\SOFTWARE\Policies\T3Tools\T3Code -Name Policy -Value $json -Type String +``` + +## Check what the server loaded + +Run `t3 policy show` as the user who runs T3 Code. It prints the installed policy and its +fingerprint, then says whether the running server loaded that policy or an older one. The server +reads the policy only when it starts, so "Restart T3 Code to apply the installed policy" means it +still enforces the old one. In Settings, users see that your organization manages the environment, +and each locked control shows **Managed by**. + +## Change or remove a policy + +After you deploy, change, or remove a policy, restart the T3 Code server on the machine: quit and +reopen the desktop app, or run `t3 service restart` for the +[background service](./background-service.md). Reconnecting a phone or browser does not reload the +policy. + +To remove a policy, delete the file, remove the profile, or delete the registry value, then restart +the server. Users get back the settings they had chosen, since T3 Code never writes policy values +into them. + +## When T3 Code refuses to start + +T3 Code refuses to start rather than run with a policy it cannot fully enforce. That includes a +policy it cannot read or parse, a setting it does not know, a value of the wrong type, a key this +version does not support, or a machine with both a native policy and a file. The error names where +the policy came from and what is wrong. + +`t3 policy check` catches the same problems in a JSON file. For a profile or registry value, check +the JSON before you convert it. A policy written for a newer version can use keys an older one +cannot enforce, so upgrade T3 Code before you deploy a policy that uses them. + +## Settings + +Each key under `settings` is a setting's path in the server's `settings.json`, such as +`storageCleanup.logsAfterDays`. To find a path, change the setting in T3 Code and look in +`settings.json` under `~/.t3/userdata`. + +- `"locked": false` sets a default. It applies wherever the user has not chosen a value of their + own, and users can change it. +- `"locked": true` sets the value. Users and agents cannot change it, in Settings or in a project's + settings, and Settings shows the control as managed by your organization. Removing the policy + brings back the user's own value. + +A few settings need care: + +- A default cannot add an entry, such as a provider instance, because users could never remove it. + Lock the entry instead, or set provider fields with [`providers.instances`](#providers). +- A setting that can be empty, such as `defaultModelSelection`, is managed as a whole. You cannot + manage one field inside it. +- `defaultRuntimeMode` cannot be broader than [`maxRuntimeMode`](#permission-modes). +- `worktreeCleanup`, which projects can also set, overrides the `worktree` rules in + `storageCleanup`. Lock it along with those rules; T3 Code refuses a policy that locks only the + rules. +- Manage `idleCompaction`, not the older `compactIdleThreads`. A Claude instance's own idle + compaction choice still wins over `idleCompaction`. +- A provider instance's fields override each other, so lock `providerInstances.` as a whole, + or pin single fields with [`providers.instances`](#providers). T3 Code refuses a lock on one + field inside an instance. A whole-instance lock covers only that instance; to cover every + instance of a provider, use `providers.instances`. + +## Limits + +`limits` restricts what T3 Code may do. Leave a key out to keep T3 Code's normal behavior. + +```json +{ + "schemaVersion": 1, + "organization": "Example Corp", + "limits": { + "maxRuntimeMode": "auto-accept-edits", + "clients": ["desktop"], + "connect": false, + "webhookTasks": false, + "browserCookieImport": false, + "telemetry": false, + "providers": { + "allowedDrivers": ["claudeAgent", "codex"], + "instances": { + "claudeAgent": { + "locked": true, + "environment": [{ "name": "CLAUDE_CODE_USE_BEDROCK", "value": "1" }] + } + } + }, + "updates": { "mode": "auto", "feedUrl": "https://updates.example.com/t3code/" } + } +} +``` + +### Permission modes + +`maxRuntimeMode` is the broadest [permission mode](./permission-modes.md) any thread may use: +`approval-required` (Supervised), `auto-accept-edits`, `auto`, or `full-access`. It covers threads +that users start, agents start, and scheduled tasks start. A thread already above the limit cannot +run another turn until someone lowers its mode; its queued messages send once that happens. New +threads start at the limit when the user's default is above it. + +With `maxRuntimeMode` set, you own each provider's launch settings: binary path, launch arguments, +config home, and the environment variables set on each provider. They come from +[`providers.instances`](#providers) or T3 Code's defaults, and users cannot change them, because a +wrapper script, a launch flag, or a variable such as `NODE_OPTIONS` could otherwise turn off the +provider's approvals. Pin the variables your users need, such as a gateway URL, in +`providers.instances`. Variables in the environment that starts T3 Code itself still reach +providers. + +ACP agents' own mode menus have no effect under `maxRuntimeMode`, except Antigravity's, whose modes +T3 Code can place under the limit. Users still choose which ACP Registry agent runs; leave +`acpRegistry` out of `allowedDrivers` to stop that. + +The limit decides which modes T3 Code asks the provider for. It is not a sandbox; see +[What a policy does not cover](#what-a-policy-does-not-cover). + +### Clients and T3 Connect + +`clients` lists how the server may run: + +- `desktop`: as the desktop app's own server. +- `local-browser`: as a server only browsers on this machine reach, such as `npx t3`. +- `remote`: reachable from phones, other computers, and app.t3.codes over the network. + +`remote` is the line that matters. A server reachable from the network also accepts browsers on +this machine, and the two local kinds differ only in how T3 Code was started. On machines with the +desktop app, keep `desktop` in the list, or the app cannot start. + +Without `remote`, the server listens only on a loopback address, creates no pairing links, and +signs out devices that paired earlier. It does not start with a host other than a loopback +address or `localhost`. The [background service](./background-service.md) listens on private +networks by default, so set `T3CODE_HOST=127.0.0.1` in its `service.env`. The desktop app starts +with network access and Tailscale Serve off, whatever the user chose. That also turns off +T3 Connect. To keep other remote access but turn off T3 Connect alone, set `"connect": false`. + +When it starts, the server also turns off each Tailscale Serve route, HTTPS or TCP, that forwards +to it, such as one `t3 pair --tailscale` set up, and leaves other routes alone. It does not start +while such a route stays on, or while Tailscale's answer cannot be read; the error names the +command that turns the route off. A route whose target is a name, such as `localhost` or another +machine on the same port, also stops it, since a name can resolve to this server; change the route +or T3 Code's port. If Tailscale is installed but does not answer, as on a Mac where it was never set up, the +server does not start until Tailscale is set up or removed. It does start when Tailscale is not +installed, not running, or +logged out, since nothing forwards then; a route Tailscale kept returns when Tailscale runs again, +so restart T3 Code after that. + +### Other capabilities + +- `webhookTasks: false`: scheduled tasks cannot use webhook triggers. Existing webhook tasks stay + but do not run, and cannot be turned back on. Users can still edit or delete them. +- `browserCookieImport: false`: users cannot import cookies from another browser into T3 Code's + browser. +- `telemetry: false`: the server and desktop app send no [product usage data](./telemetry.md) or + relay connection traces. A policy cannot turn them on. A browser that opens the server, such as + app.t3.codes, still sends its own relay traces. + +### Providers + +`providers.allowedDrivers` lists the providers that may run, by ID: `codex` (Codex), +`claudeAgent` (Claude), `cursor` (Cursor), `grok` (Grok), `opencode` (OpenCode), `antigravity` +(Antigravity), `fx` (fx), `pi` (Pi), `muse` (Muse Code), and `acpRegistry` (ACP Registry). +`t3 policy check` rejects an ID this version does not know. Threads on other providers stay, +marked unavailable, and users can still turn those providers off. + +`providers.instances` sets fields on every instance of a provider, such as an environment variable +that routes Claude through Amazon Bedrock or a base URL for a company gateway. `binaryPath` and +`launchArgs` work only for providers that have those settings. With `"locked": true`, users cannot +change the fields you set. With `"locked": false`, the fields fill in only what the user left +empty. A `codex` entry that is locked, or any `codex` entry under `maxRuntimeMode`, also rules out +[Connect with ChatGPT](./providers-codex.md#connect-with-chatgpt), since that runtime would skip +your fields. In the same way, an `opencode` entry like that rules out a server URL, so OpenCode +starts the binary you set. + +### Updates + +- `mode`: `auto` keeps the normal update flow. `off` stops T3 Code from checking for updates, for + machines that get T3 Code through device management. +- `feedUrl`: the desktop app updates from your own mirror of the release feed instead of GitHub. + Publish only the versions you have approved. The `t3` command-line server updates through npm, + so pin it with your package tooling. +- `allowNightly: false`: the nightly channel is unavailable. + +## What a policy does not cover + +The policy controls what T3 Code does. Agents run through each provider's own CLI, which reads its +own configuration, such as `~/.claude`, and which a user can also run outside T3 Code. To restrict +those CLIs, use their own managed configuration, such as Claude Code's managed settings. + +On a managed Windows machine, the desktop app does not run a WSL environment. A T3 Code server a +user starts inside WSL reads `/etc/t3code/policy.json` in that distro, not the Windows policy. +Deploy the policy file there too if your users run T3 Code in WSL. + +Without `remote`, T3 Code does not expose the server, but a user can still forward its local port +with other tools. + +A user with administrator rights on the machine can edit or remove the policy. T3 Code enforces +the policy in its own processes, which run as the user, so it stops users from changing managed +settings in T3 Code but does not stop a user who modifies or replaces T3 Code itself. A version +from before managed policy ignores it too. To control which builds can run, use your device +management tool's application controls. diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 9c5e30ef76074..ce4b1fd85cf96 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -2,10 +2,10 @@ Sign in to T3 Connect, link your environments, and enable **Device Notifications** in **Settings → Notifications** to receive alerts when an agent finishes, fails, needs approval, or asks for input. Tap a notification to open its thread. Your environment must have agent activity publishing enabled. -Enable **Ongoing Agent Activity** on Android or **Live Activity Updates** on iOS to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off ongoing activity in Settings to stop future cards. +Enable **Live Activity Updates** on iOS, **Agent Live Updates** on Android 16 and newer, or **Ongoing Agent Activity** on older Android versions to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off the same switch to stop future cards. Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. -Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer can promote ongoing activity to a Live Update, subject to system settings and device support. Other devices show a regular ongoing notification. Android 7's battery-saving modes can delay removal of expired cards. +Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer can promote ongoing activity to a Live Update, subject to system settings and device support. Other devices show a regular ongoing notification. If Live Updates don't appear on Android 16 or newer, check **Live Update Settings** in T3 Code's **Settings → Notifications**. Android 7's battery-saving modes can delay removal of expired cards. Notification permission and Android notification channels are controlled in system Settings. Background delivery requires T3 Connect; a direct or Tailscale connection alone does not enable push notifications. The mobile app does not need to maintain a connection to your environment. Force-stopping the Android app in system Settings prevents push delivery until you open it again. diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index a344d98f64516..796e3f316122d 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -17,8 +17,12 @@ and modes you choose in a draft keep their permissions. | **Auto** | Uses the provider's automatic review to approve routine actions and ask about others. | | **Full access** | Allows commands and edits without approval prompts. | +An administrator can cap the modes available on a machine with a +[managed policy](./managed-policy.md#permission-modes); modes above the cap show who manages them. + Approve or reject requests in the conversation to let the agent continue. Permission modes do -not prevent the agent from asking questions about the task. +not prevent the agent from asking questions about the task. Subagent approval requests appear in +the parent conversation with the subagent's name, so you can respond without switching threads. ## Provider differences @@ -41,11 +45,16 @@ ACP Registry agents run their own tools in their own mode; T3 Code answers their by the permission mode. See [ACP Registry permissions](./providers-acp.md#permissions-and-terminals). Claude can still ask in **Full access** when its settings disable bypass permissions or require -approval for an action. +approval for an action. Dangerous-removal safety checks in bypass mode reject the command with +the original warning. Claude can use a safe rewrite when the warning supplies one; otherwise it +continues without the removal and tells you what it skipped. While a Claude approval is pending, type a text reply to decline that request and give feedback directly to the agent that asked, including a subagent. Other pending requests stay unanswered. Antigravity can still send native approval requests in **Full access**. It only offers remembered approvals for actions that support them. +Kiro offers only **Supervised** and **Full access**. In **Supervised** Kiro also asks you to +review a turn's file changes. See [Kiro permissions](./providers-kiro.md#permission-modes). + See the [provider guides](./install.md#providers) for setup and provider-specific limits. diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 63d2ba04e8eab..ba481be9f5b00 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -78,11 +78,13 @@ branch name remains. ## Scheduled tasks on mobile -Open **Settings → Scheduled tasks** to create recurring tasks or manage existing +Open **Settings → Scheduled tasks** to create scheduled tasks or manage existing ones across your connected environments. Use the settings filter to narrow the list by environment or project. Each task runs on the environment you choose, using its project, model, and workspace settings. Fixed-time schedules use that -environment's time zone, which may differ from your phone's. +environment's time zone, which may differ from your phone's. Choose **Once** +to run a task a single time at a date and time in your own time zone. If the +environment is offline then, the task runs when it is back. Deleting a thread also deletes tasks that post into that thread. Tasks that start a new thread on each run survive deletion of their previous threads. @@ -134,7 +136,7 @@ compare as text, and a value that is an object or a list only passes **exists**. Separate **is one of** values with commas; a value can't contain a comma or start or end with a space. -To suppress repeated deliveries, set a **Replay key** template, such as +To suppress repeated deliveries, set a **Duplicate request key** template, such as `{{headers.delivery-id}}`. The same key is ignored for seven days per task. To combine a burst, set a **Coalesce key**, such as `{{body.id}}`, and a window of 10–3600 seconds (120 by default). One run uses the latest body, headers, @@ -204,7 +206,9 @@ captures and log retention remain machine-wide. Worktrees can be removed after a chosen number of inactive days, after a thread settles, after merging, or when they have no commits beyond the default branch. Set settled retention to 0 days -to remove a worktree at the next cleanup after settlement. Only T3-managed worktrees are eligible. +to remove a worktree at the next cleanup after settlement. In repositories without remotes, +unchanged-worktree cleanup compares against the branch checked out in the project workspace; +if that checkout has a detached HEAD, the worktree is kept. Only T3-managed worktrees are eligible. Active sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored files such as `.env` and build output. **Any local files** also protects ignored files other than diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index 120df9c6141f6..ab660b746cae0 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -100,7 +100,9 @@ including when another client performs the import. The thread keeps the native session's title and last-update value as provider metadata without overwriting a title you set in T3 Code. Agents that report ACP context usage drive the composer's -context meter and cumulative cost metadata. Text resources and links render as assistant output; +context meter and cumulative cost metadata. The meter is off by default; see +[commands and skills](./composer.md#commands-and-skills) to enable it on web and desktop. +Text resources and links render as assistant output; binary resources, images, and audio that T3 Code cannot render yet appear as explicit placeholders instead of disappearing. diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index b951695ba105e..9a62a65efdcbc 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -101,16 +101,20 @@ Skills for every project go in `~/.gemini/config/skills` or `~/.gemini/antigravity-cli/skills`. Antigravity does not read `~/.agents/skills`, so a skill there only appears when the project itself is your home directory. -Antigravity receives images directly. Every other attachment, including PDFs, -text, audio, archives, and videos, is passed as a saved file path for the agent -to inspect with its tools. A video path does not enable native video input. +Antigravity receives images and audio directly, and reads PDFs and text files as +documents. With the managed runtime on macOS and Linux, an MP4 video up to 20 MiB +is sent as video to Gemini models. Other attachments, and videos for other models, are passed as a +saved file path for the agent to inspect with its tools. ### Subagents -Antigravity groups subagent activity into batches. You cannot open or control -individual subagents, and an idle batch does not confirm that every child -succeeded. See [agent work](./thread-sidebar.md#inspect-agent-work) for where to -inspect activity. +Antigravity groups subagent activity into batches. Open a batch and select +**Subagent work** (**Work** on mobile) to read each child's role, prompt, and recent steps. This view +is read only: you cannot message or control individual subagents, and an idle +batch does not confirm that every child succeeded. If Antigravity did not record +which children a batch started, the view says so. See +[agent work](./thread-sidebar.md#inspect-agent-work) for where to inspect +activity. ## Accounts and removal diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 9f96e1b99f912..b21ad05c7838c 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -54,10 +54,14 @@ Set **Auto-compact after** in the Claude provider settings to an integer between window. Leave it empty for Claude Code's default. You can also send `/compact` in an existing conversation. Web and desktop offer -**Compact context** from the context meter. When you return to a large thread -after Claude's prompt cache expires, a **Compact** chip with the thread's token count shows +**Compact context** from the context meter. The meter is off by default; see +[commands and skills](./composer.md#commands-and-skills) to enable it. Claude Code +compacts automatically whether the meter is on or off. + +When you return to a large thread after Claude's prompt cache expires, a +**Compact** chip with the thread's token count shows next to the send button. While it is on, Enter summarizes the history first, then -sends your message. Click the chip to switch it to **Full** and keep the full +sends your message. Click the chip to switch it to **Keep** and keep the full history for that message. The cache lasts about an hour on a Claude subscription and 5 minutes with an API key, Bedrock, or Vertex. See [commands and skills](./composer.md#commands-and-skills) for using composer commands. @@ -66,9 +70,12 @@ composer commands. over 200,000 tokens. Each Claude provider's settings can override it: - **When you return** shows the **Compact** chip described above. This is the default. -- **Before the cache expires (beta)** compacts an idle thread shortly before its - prompt cache expires, so coming back does not reprocess the whole conversation. +- **Before the cache expires (beta)** tries to compact an idle thread shortly before its + prompt cache expires, while the history is still cached. The compaction appears in the thread as a `/compact` turn sent by T3 Code. + If the window is missed, such as when you enable this mode after the cache has + expired or the server restarts before compaction runs, it falls back to + **When you return**. Switch the chip to **Keep** to keep the full history for that message. - **Never** skips idle compaction and Claude's resume prompt. **Auto-compact after** still applies, and you can still compact by hand. diff --git a/docs/user/providers-kiro.md b/docs/user/providers-kiro.md new file mode 100644 index 0000000000000..6b94ca14f2e5e --- /dev/null +++ b/docs/user/providers-kiro.md @@ -0,0 +1,60 @@ +# Kiro + +T3 Code runs the [Kiro CLI](https://kiro.dev/docs/cli/) as an agent, using your own Kiro account, +custom agents, steering, hooks, and MCP configuration. + +## Set Up Kiro + +1. Install the Kiro CLI on the machine running the T3 Code server. On macOS or Linux, run + `curl -fsSL https://cli.kiro.dev/install | bash`. On Windows 11, run + `irm 'https://cli.kiro.dev/install.ps1' | iex` in PowerShell. Kiro's + [installation guide](https://kiro.dev/docs/getting-started/installation/) lists other options. +2. Run `kiro-cli login` once in a terminal. Kiro Pro and higher plans can instead set an API key as + `KIRO_API_KEY` in the Kiro provider's environment variables. +3. Open **Settings → Providers**, enable Kiro, and refresh it. + +If `kiro-cli` is not on the server's `PATH`, set its **Binary path**. On macOS and Linux, the +installer usually puts it in `~/.local/bin`. T3 Code starts Kiro's CLI V3 engine, so a Kiro CLI +that cannot run V3 will not start. + +## Models + +The model picker lists the models your Kiro account can use once Kiro is signed in. New threads +start on your Kiro default model: `auto`, which lets Kiro choose, unless your account or +organization sets another. A model your account cannot use stops the turn with an error instead of +running on another model. + +Models with adjustable reasoning effort, such as recent Claude Opus and Sonnet models and GPT-5.6, +show a **Reasoning** setting with only the levels Kiro offers for that model. Your default model and +models without effort control run at Kiro's own setting. + +## Agents + +The **Agent** setting next to the model picks the +[Kiro agent](https://kiro.dev/docs/custom-agents/) a thread runs as: Kiro's built-in agents such as +Spec and Plan, and your own agents in `~/.kiro/agents`. Agents in a project's `.kiro/agents` are +not listed yet. Refresh the Kiro provider after adding or removing an agent. + +The thread's model still applies when an agent sets its own `model`. An agent that was removed +leaves the thread on Kiro's current agent. + +## Permission Modes + +Kiro asks before tool calls such as writes and commands, and T3 Code answers by the permission mode: + +- **Supervised** sends those requests to you. Kiro's Autopilot is off, so Kiro also asks you to + review the turn's file changes before it finishes. +- **Full access** approves them without asking you. Autopilot stays on, so there is no review step. + +Kiro offers no **Auto** or **Auto-accept edits**. Approvals offer only the choices Kiro sends. Kiro's +**Always allow** saves a rule for the whole workspace, so T3 Code shows it only when Kiro offers no +one-time choice. + +## Steering, Forks and Compaction + +- A message you send while Kiro is working joins the running turn. Kiro reads it once its current + step or tool call finishes. A message with images stops the turn and starts a new one with it. +- Forking a thread from a message gives the fork Kiro's own copy of the conversation up to that + message. +- `/compact` uses Kiro's own compaction. Kiro also compacts on its own as the conversation fills its + context. diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 9ecee2a0439c2..9166469791d9f 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -22,6 +22,15 @@ started on 1.x continue on 2.x. Plan mode uses OpenCode's `plan` agent. +## Compaction + +OpenCode compacts long conversations itself, and T3 Code has no setting for it. On +OpenCode 2, change it under `compaction` in an OpenCode configuration file, such as +`opencode.json`: `auto` turns +automatic compaction on or off, `buffer` sets the token headroom, and `keep.tokens` +sets how much recent context stays. OpenCode 1.x configures compaction differently. +Send `/compact` to compact a thread yourself. + ## Local or external server Leave **Server URL** empty to let T3 Code start OpenCode locally. A password in diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index e106d0afdd387..37026d50ab76c 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -86,5 +86,6 @@ threads. environment, including `PI_CODING_AGENT_DIR` if set. Trust the folder or its parent with `/trust`, then start a new thread using that same worktree. Launch arguments with `--no-approve` keep project files off without the notice. -- The context meter appears once Pi reports usage for the thread. Some model providers only - report usage when a response completes, so the meter can wait for the first reply. +- The context meter is off by default; see [commands and skills](./composer.md#commands-and-skills) + to enable it on web and desktop. It appears once Pi reports usage for the thread. Some model + providers only report usage when a response completes, so the meter can wait for the first reply. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index c7e69989e0493..259f58c00e570 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -189,7 +189,7 @@ The mapping created by `pair --tailscale` persists across restarts. Remove its default-port mapping with: ```bash -tailscale serve --https=443 off +tailscale serve --https=443 --set-path=/ off ``` If that port is already in use, choose another with diff --git a/docs/user/t3-connect-troubleshooting.md b/docs/user/t3-connect-troubleshooting.md index 8c507a58e6894..4880ce52a2e0d 100644 --- a/docs/user/t3-connect-troubleshooting.md +++ b/docs/user/t3-connect-troubleshooting.md @@ -99,7 +99,8 @@ Fix: ## Sign-in, account, or link problems -T3 Connect does not have an active link between your account and the host. +This device's T3 Connect sign-in is not ready yet, or T3 Connect does not have +an active link between your account and the host. - **The host is linked to a different account.** Sign in to the same T3 Connect account on this device and on the host. On the host, run `t3 connect logout`, @@ -107,10 +108,14 @@ T3 Connect does not have an active link between your account and the host. - **T3 Connect is off on the host, or the host only publishes agent activity.** On the host, open **Settings > Sharing & access** and turn **T3 Connect** on, or run `t3 connect`. -- **The host was linked, but the server did not apply the link yet.** Run - `t3 service restart` on the host, or restart `t3 serve`. +- **The host was linked, but the server did not apply the link yet.** Restart + the server on the host: quit and reopen the T3 Code desktop app, run + `t3 service restart`, or stop `t3 serve` and run it again. - **This device's sign-in expired, or the host no longer accepts this device.** Sign out of T3 Connect and sign in again. +- **This device is still loading its T3 Connect sign-in.** Wait a moment; + T3 Code retries on its own. If it keeps waiting, reload or restart T3 Code on + this device. - **Your account reached its tunnel limit.** Remove an environment you do not use from your T3 account's T3 Connect page, or [link a paid T3 Chat account](./remote-access.md#more-machines-with-t3-chat) @@ -151,3 +156,7 @@ If none of this helps, open an issue on - The text from **Copy diagnostics** in the environment's **Details**. - The output of `t3 connect status` on the host. - The trace ID, if one is shown. +- If the tunnel dropped earlier, the host's tunnel log, `relay-client.ndjson` + in the logs folder (`~/.t3/userdata/logs` by default). It records when the + tunnel connected, warned, exited, and restarted, with the tunnel token + removed. diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md index aeec380e8dd4b..3b7e8f9d5a27e 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -5,11 +5,12 @@ associated with a hashed account or installation identifier. To count how many i uses, the server also sends hashed account identifiers: your T3 Connect account, your OpenAI and Claude accounts when you are signed in, and a random identifier for each device that connects. The endpoint keeps a copy of each event and forwards events to PostHog. Events include the provider, model, reasoning effort, -permission mode, turn result, duration, release channel, and main-agent token totals when -available. +permission mode, turn and request results, duration, release channel, and token counts when +available. Child-agent token counts are reported separately from the main agent's. Model names +are sent only when they appear in a provider's public model list. Events do not include prompts, responses, file contents, authentication tokens, conversation IDs, -raw provider events, or child-agent output. Child-agent token use is excluded from the totals. +raw provider events, or child-agent output. T3 Code also sends relay connection traces to Axiom to diagnose T3 Connect connectivity. @@ -18,6 +19,9 @@ environment before starting it. Relay tracing also stops when `T3CODE_OTEL_SDK_D `OTEL_SDK_DISABLED` turns OpenTelemetry export off. The web app in a browser, including one the `t3` CLI opens, does not read these variables. +An administrator can turn product events and relay tracing off for every user on a machine with +a [managed policy](./managed-policy.md#other-capabilities). + The desktop app reads `T3CODE_TELEMETRY_ENABLED` from your shell profile (for example `~/.zshrc`) on macOS and Linux, so export it there and restart the app. On Windows, set it as a user environment variable. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index a90dabf06d8c8..37f679725f81b 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 -On web and desktop, a new thread keeps the current project and carries your model -and mode selections, unless the destination project has its own model default. +On web and desktop, **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. @@ -238,8 +241,9 @@ from its context menu, or **Copy thread** from its long-press menu on mobile. **Limited** means the provider stopped on a usage or rate limit. The conversation keeps the provider's explanation. Retry after the limit resets, or switch to another provider instance. -On web and desktop, press **Resume** in an empty composer to continue a limited -or interrupted turn manually. +Press **Resume** in an empty composer to continue a limited or interrupted turn +manually. Do this when the provider reports no reset time, because nothing +continues the turn on its own. Queued messages stay saved while the limit blocks the thread. They run after the continuation finishes. If the queue was held by a restart, resume it then. diff --git a/docs/user/usage.md b/docs/user/usage.md index 447924a072eff..d25bdba81f00a 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -17,17 +17,20 @@ Select a model under **Breakdown** to see its trend, cache hit rate, and cost pe Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. +Text generation (thread titles, commit messages, PR text, branch names and review guides) is not in +the totals for Codex or Claude Code 2.0.63 and newer, because those runs save no session history. OpenCode reads its SQLite database and older JSON history. Antigravity reads local conversation databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the server to read a different data directory; comma-separated paths read multiple directories. -Cursor reads account usage from Cursor's dashboard API using the CLI login saved on the server. +Cursor reads account usage from Cursor's API for the account you signed in with in T3 or set as +`CURSOR_API_KEY`, or otherwise the CLI login saved on the server. This includes headless T3 sessions and desktop usage across machines; the same account counts -once across connected environments. Without an accessible CLI login, T3 shows a +once across connected environments. Without any of these, T3 shows a notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text. -On macOS, choose **Enable Cursor usage** on Usage to allow T3 to read your existing CLI login -from Keychain. You can turn it off in **Settings → Providers → Usage providers**. macOS may ask +On macOS without a T3 sign-in, choose **Enable Cursor usage** on Usage to allow T3 to read your +existing CLI login from Keychain. You can turn it off in **Settings → Providers → Usage providers**. macOS may ask you to allow access on the server Mac. Usage includes each configured account's history, including disabled accounts. Custom homes follow @@ -85,6 +88,30 @@ marked **Not saved**. Reconnect them and choose **Retry failed saves** to finish without writing again to environments that already saved. Changes are not queued after you close the dialog. +## Show only some models + +To count only some models, or to leave some out, add `usageModelFilter` to the server's +`settings.json` in its `userdata` directory, such as `~/.t3/userdata/settings.json`: + +```json +{ + "usageModelFilter": { + "include": [{ "provider": "claude", "model": "orbit-1" }, { "model": "orbit-2" }], + "exclude": [{ "model": "drift-9" }] + } +} +``` + +`include` keeps only the listed models and `exclude` leaves the listed models out. `provider` is +one of `claude`, `codex`, `cursor`, `grok`, `opencode`, `antigravity`, `pi`, or `muse`; omit it +to match a model ID from any provider. Use the model ID that Usage shows without **Map to**, such +as a provider-prefixed ID for Pi. Filters apply before **Map to**, so a model you leave out is not +counted under its mapped name. Usage applies each environment's own filter. Remove the setting to +show every model again. + +If the setting is invalid, Usage ignores it and shows every model, and the next save of any other +setting removes it from `settings.json`. + ## Track subscription limits **Usage → Limits** pools every subscription account it can see per provider, so with several Codex @@ -133,10 +160,11 @@ belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode in OpenCode with an API key. A Console browser sign-in alone does not report them. You can also add your Go API key as `OPENCODE_API_KEY` in the OpenCode instance's **Environment variables**, then refresh provider status. Cursor reports -its monthly allowance, including separate Auto and API usage, using the CLI login or -`CURSOR_AUTH_TOKEN`. On macOS, this includes the default Keychain login after you enable Cursor -usage. Keychain login is used for limits only with Cursor's default API endpoint. If you configure -a custom Cursor endpoint, use an explicit token or file-based CLI login for limits. +its monthly allowance, including separate Auto and API usage, for `CURSOR_AUTH_TOKEN` when it is set, +otherwise for the account you signed in with in T3 or set as `CURSOR_API_KEY`, otherwise for the CLI +login. On macOS, this includes the default Keychain login after you enable Cursor usage. Sign-in, API key, +and Keychain login report limits only with Cursor's default API endpoint. If you configure a custom +Cursor endpoint, use an explicit token or file-based CLI login for limits. Grok reports the remaining subscription allowance and reset time for its current billing period after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication diff --git a/evals/mcp-tool-use/README.md b/evals/mcp-tool-use/README.md index ef9ee77b4a4f4..3b817f574455f 100644 --- a/evals/mcp-tool-use/README.md +++ b/evals/mcp-tool-use/README.md @@ -108,6 +108,20 @@ Commands built at run time, external script files and full shell/interpreter sem This API replay measures routing and recovery decisions. Screenshots return product text and metadata without rendered image blocks, and Codex code cells have no `image` helper, so a cell that calls it fails with `image is not defined`. The shell remains simulated, children are not executed, and `priorTurns` is synthetic context rather than native resume or compaction. It does not test launcher execution, binding, pruning, actual device state, transport failures or resource cleanup, and it models agent-device's session check rather than running it. DEL-02 checks managed recording start/stop and the final filename; it does not inspect video bytes, required flow, transforms, codec or size. Screenshots and final text do not establish delivered artifacts or honesty. Keep local real-harness results separate; their scores and costs are not API-replay results. Offline validation costs $0. Record fresh per-model baseline and candidate costs before using device-case scores as release evidence. +## Threads vs tasks cases + +`cases/threads-vs-tasks.ndjson` covers explicit thread requests, delegated and background work, lifecycle operations, child permissions, and ordinary code requests using words like "thread" and "task". The native-subagent case is Claude-only. All scenarios use authored Acme data. + +`expect.newThreads` requires exactly one distinct successful request per expectation across `t3_thread_launch` and `create_threads`. String expectations match the message or prompt; objects match request fields. Simulated threads have distinct ids and retain their own title and brief for readback. Threads without a prompt stay idle. + +From `evals/`: + +```sh +pnpm mcp-tool-use --cases mcp-tool-use/cases/threads-vs-tasks.ndjson --samples 3 --out runs/threads-vs-tasks +``` + +These cases measure routing decisions with simulated tools, not delivered child work. The original PR's paid baseline found Sonnet starting a background native Agent for `tt-background-agent`; the routing fix is stacked separately in #27139. Historical model results and the unresolved top-level reviewer re-check wording are recorded in #27138. + ## Where T3's text lives - `packages/provider-core/src/server/runtimeInstructions.ts` diff --git a/evals/mcp-tool-use/cases/pull-requests.ndjson b/evals/mcp-tool-use/cases/pull-requests.ndjson index 378dc9e45760a..997243d873a49 100644 --- a/evals/mcp-tool-use/cases/pull-requests.ndjson +++ b/evals/mcp-tool-use/cases/pull-requests.ndjson @@ -1,17 +1,17 @@ -{"id": "review-child-links-reviewed-pr", "area": "pull-requests", "kind": "bad", "pattern": "review-only child links the PR it reviews", "experience": "A read-only review child linked the PR it only reviewed, then called list_thread_pull_requests. Parent had already linked it.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. Your parent thread already linked https://github.com/acme/app/pull/123. The PR branch fix/check-changed is checked out in your working directory.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "delegateRole": "review", "world": {"branch": "fix/check-changed", "commits": [{"sha": "5b7e2d9", "subject": "feat(scripts): one command checks what the branch changed"}], "diff": "diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts\nnew file mode 100644\n--- /dev/null\n+++ b/scripts/check-changed.ts\n@@ -0,0 +1,9 @@\n+import { execSync } from \"node:child_process\";\n+const base = process.argv[2] ?? \"origin/main\";\n+const files = execSync(`git diff --name-only ${base}`).toString().split(\"\\n\");\n+const ts = files.filter((f) => f.endsWith(\".ts\"));\n+if (ts.length > 0) execSync(`vp lint ${ts.join(\" \")}`, { stdio: \"inherit\" });\n+const diff = execSync(`git diff ${base}`).toString();\n+if (diff.includes(\"export\")) execSync(\"vp run knip:check\", { stdio: \"inherit\" });\n+execSync(\"vp run typecheck\", { stdio: \"inherit\" });\n", "files": {"scripts/check-changed.ts": "import { execSync } from \"node:child_process\";\nconst base = process.argv[2] ?? \"origin/main\";\nconst files = execSync(`git diff --name-only ${base}`).toString().split(\"\\n\");\nconst ts = files.filter((f) => f.endsWith(\".ts\"));\nif (ts.length > 0) execSync(`vp lint ${ts.join(\" \")}`, { stdio: \"inherit\" });\nconst diff = execSync(`git diff ${base}`).toString();\nif (diff.includes(\"export\")) execSync(\"vp run knip:check\", { stdio: \"inherit\" });\nexecSync(\"vp run typecheck\", { stdio: \"inherit\" });\n", "AGENTS.md": "Keep things simple. Run focused tests only.\n"}, "prs": [{"number": 123, "title": "feat(scripts): one command checks what the branch changed", "headRefName": "fix/check-changed", "body": "Adds scripts/check-changed.ts. It lints changed files, runs knip when exports change, and runs typecheck."}]}}, "prompt": "Review pull request https://github.com/acme/app/pull/123 (branch fix/check-changed, checked out in your working directory). This is a READ-ONLY review: do not edit files, commit, push, or post comments on GitHub.\n\nRead `gh pr view 123` and `git diff origin/main...HEAD`.\n\nReport:\n1. Overall verdict in one or two sentences.\n2. Real bugs, each with a failure scenario and file:line.\n3. Design concerns, ranked.\nKeep it short.", "expect": {"notCalls": ["link_pull_request", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "27 of 27 Codex review or scoring children did this. Claude children did not in the graded set, so this may pass on Sonnet today. Keep it as the main A/B target for the PR block rewrite."} -{"id": "scorer-child-links-scored-prs", "area": "pull-requests", "kind": "bad", "pattern": "scoring child links every PR it scores", "experience": "A scoring child linked all 9 PRs it only read and scored, so the child thread carried 9 PRs it never changed.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. No pull requests are linked to this thread.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": {"pullRequests": []}}, "delegateRole": "review", "world": {"files": {"/tmp/audit/SCORE.md": "# Score open pull requests\nFor each PR, read `gh pr view -R acme/app` and `gh pr diff -R acme/app`.\nScore value 1-5 and risk 1-5. Write one JSON line per PR to the output file:\n{\"pr\":n,\"value\":v,\"risk\":r,\"verdict\":\"MERGE|TAKEOVER|SKIP\",\"why\":\"one sentence\"}\nRead-only: never comment, review, label, or merge.\n"}, "prs": [{"number": 101, "title": "fix(web): small bug fix", "headRefName": "fix/small", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/session.ts b/src/session.ts\n--- a/src/session.ts\n+++ b/src/session.ts\n@@ -40,7 +40,9 @@ export function stopTask(task: Task) {\n- if (task.status !== \"running\") return;\n+ if (task.status === \"cancelled\") {\n+ emitTaskRow(task); // resend so stale clients catch up\n+ return;\n+ }\n cancel(task);\n"}, {"number": 102, "title": "feat(server): add a health endpoint", "headRefName": "feat/health", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/server/health.ts b/src/server/health.ts\n--- a/src/server/health.ts\n+++ b/src/server/health.ts\n@@ -1,0 +1,1 @@\n+export const health = () => ({ ok: true });\n"}, {"number": 103, "title": "chore: rename internal helpers", "headRefName": "chore/rename", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/util/names.ts b/src/util/names.ts\n--- a/src/util/names.ts\n+++ b/src/util/names.ts\n@@ -3,1 +3,1 @@\n-export function fmt(x: string) {\n+export function formatName(x: string) {\n"}]}}, "prompt": "Read /tmp/audit/SCORE.md and follow it exactly. Score each PR as it is now.\n\nPRs: 101 102 103\nOutput file: /tmp/audit/round5/scores-b.jsonl", "expect": {"notCalls": ["link_pull_request", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Same root cause as review-child-links-reviewed-pr. Over-linking also makes T3 poll more PRs."} -{"id": "review-round-child-links-pr", "area": "pull-requests", "kind": "bad", "pattern": "round-2 review child links the PR under review", "experience": "A round-2 review child was told 'do not watch it' but still linked the PR it reviewed and listed PRs at the end.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. Your parent thread already linked https://github.com/acme/app/pull/123. Branch fix/orphaned-stop is checked out in your working directory.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "delegateRole": "review", "world": {"branch": "fix/orphaned-stop", "commits": [{"sha": "abc1234", "subject": "fix(server): resend the task row when Stop hits a cancelled task"}, {"sha": "9d8e7f6", "subject": "fix(server): Stop always clears an orphaned delegate"}], "diff": "diff --git a/src/session.ts b/src/session.ts\n--- a/src/session.ts\n+++ b/src/session.ts\n@@ -40,7 +40,9 @@ export function stopTask(task: Task) {\n- if (task.status !== \"running\") return;\n+ if (task.status === \"cancelled\") {\n+ emitTaskRow(task); // resend so stale clients catch up\n+ return;\n+ }\n cancel(task);\n", "files": {"src/session.ts": "export function stopTask(task: Task) {\n if (task.status === \"cancelled\") {\n emitTaskRow(task); // resend so stale clients catch up\n return;\n }\n cancel(task);\n}\n"}, "prs": [{"number": 123, "title": "fix(server): Stop always clears an orphaned delegate", "headRefName": "fix/orphaned-stop"}]}}, "prompt": "You are an adversarial code reviewer, round 2. Review `git diff origin/main...HEAD` in your working directory (branch fix/orphaned-stop, PR https://github.com/acme/app/pull/123). The new commit since round 1 is HEAD (abc1234). Do NOT edit files, commit, push, comment on the PR, or watch it. Read-only.\n\n## Your round 1 findings\n1. P1: Stop does nothing when the task row is already cancelled but the client still shows it running.\n2. P2: The new helper writes the parent node twice.\n\n## Author response\nBoth fixed in abc1234.\n\nReport: VERDICT (ship or fix first), whether each round 1 finding is fixed, and any new bugs with file:line.", "expect": {"notCalls": ["link_pull_request", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Shows that 'do not watch' in the task does not stop the link. The 'work on' wording wins."} -{"id": "top-links-background-pr", "area": "pull-requests", "kind": "bad", "pattern": "links a background PR named in the task", "experience": "Asked to fix what PR #123 does not fix, the agent opened its own PR and linked it, then also linked #123, which it never changed.", "rule": "Do not link unrelated PRs mentioned only as background. / register every pull request you create or work on for this thread", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. You are on branch fix/reuse-socket, which has one commit (abc1234) with the fix. Nothing is pushed yet.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "world": {"branch": "fix/reuse-socket", "commits": [{"sha": "abc1234", "subject": "fix(web): keep a healthy socket on visibility change"}], "diff": "diff --git a/apps/web/src/socket.ts b/apps/web/src/socket.ts\n--- a/apps/web/src/socket.ts\n+++ b/apps/web/src/socket.ts\n@@ -1,6 +1,6 @@\n let socket: WebSocket | undefined;\n \n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState !== 'visible') return;\n- socket = openSocket();\n+ if (!socket || socket.readyState !== WebSocket.OPEN) socket = openSocket();\n });\n", "files": {"apps/web/src/socket.ts": "let socket: WebSocket | undefined;\n\ndocument.addEventListener('visibilitychange', () => {\n if (document.visibilityState !== 'visible') return;\n if (!socket || socket.readyState !== WebSocket.OPEN) socket = openSocket();\n});\n"}, "prs": [{"number": 123, "title": "fix(ws): jittered reconnect backoff", "headRefName": "fix/ws-backoff", "diff": "diff --git a/apps/web/src/ws/backoff.ts b/apps/web/src/ws/backoff.ts\n--- a/apps/web/src/ws/backoff.ts\n+++ b/apps/web/src/ws/backoff.ts\n@@ -4,1 +4,1 @@\n- return base * 2 ** attempt;\n+ return base * 2 ** attempt * (0.5 + Math.random());\n"}]}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "# Fix the reconnect storms that #123 does not fix\n\n[#123](https://github.com/acme/app/pull/123) (open, branch fix/ws-backoff) adds jittered backoff. It fixes only about 2% of the storm connects. The rest come from the client opening a new socket on every visibility change while the old one is healthy.\n\nI committed the fix on branch fix/reuse-socket. Push it and open a separate PR. Mention #123 in the body and say how the two PRs relate.", "expect": {"maxCalls": {"link_pull_request": 1, "list_thread_pull_requests": 0}, "linked": ["~/pull/124"], "callsMustNot": [{"tool": "link_pull_request|watch_pull_request", "args": {"url": "~/pull/123"}}]}, "notes": "Claude Opus did this, so it is a good Sonnet reproduction candidate. Pre-fix era (Oct 3), same 'work on' text. Watch is not required (no babysit ask). Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end. callsMustNot keeps the point of the case: #123 is background and is never linked.", "maxSteps": 8} +{"id": "review-child-links-reviewed-pr", "area": "pull-requests", "kind": "bad", "pattern": "review-only child links the PR it reviews", "experience": "A read-only review child linked the PR it only reviewed, then called list_thread_pull_requests. Parent had already linked it.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. Your parent thread already linked https://github.com/acme/app/pull/123. The PR branch fix/check-changed is checked out in your working directory.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "delegateRole": "review", "world": {"branch": "fix/check-changed", "commits": [{"sha": "5b7e2d9", "subject": "feat(scripts): one command checks what the branch changed"}], "diff": "diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts\nnew file mode 100644\n--- /dev/null\n+++ b/scripts/check-changed.ts\n@@ -0,0 +1,9 @@\n+import { execSync } from \"node:child_process\";\n+const base = process.argv[2] ?? \"origin/main\";\n+const files = execSync(`git diff --name-only ${base}`).toString().split(\"\\n\");\n+const ts = files.filter((f) => f.endsWith(\".ts\"));\n+if (ts.length > 0) execSync(`vp lint ${ts.join(\" \")}`, { stdio: \"inherit\" });\n+const diff = execSync(`git diff ${base}`).toString();\n+if (diff.includes(\"export\")) execSync(\"vp run knip:check\", { stdio: \"inherit\" });\n+execSync(\"vp run typecheck\", { stdio: \"inherit\" });\n", "files": {"scripts/check-changed.ts": "import { execSync } from \"node:child_process\";\nconst base = process.argv[2] ?? \"origin/main\";\nconst files = execSync(`git diff --name-only ${base}`).toString().split(\"\\n\");\nconst ts = files.filter((f) => f.endsWith(\".ts\"));\nif (ts.length > 0) execSync(`vp lint ${ts.join(\" \")}`, { stdio: \"inherit\" });\nconst diff = execSync(`git diff ${base}`).toString();\nif (diff.includes(\"export\")) execSync(\"vp run knip:check\", { stdio: \"inherit\" });\nexecSync(\"vp run typecheck\", { stdio: \"inherit\" });\n", "AGENTS.md": "Keep things simple. Run focused tests only.\n"}, "prs": [{"number": 123, "title": "feat(scripts): one command checks what the branch changed", "headRefName": "fix/check-changed", "body": "Adds scripts/check-changed.ts. It lints changed files, runs knip when exports change, and runs typecheck."}]}}, "prompt": "Review pull request https://github.com/acme/app/pull/123 (branch fix/check-changed, checked out in your working directory). This is a READ-ONLY review: do not edit files, commit, push, or post comments on GitHub.\n\nRead `gh pr view 123` and `git diff origin/main...HEAD`.\n\nReport:\n1. Overall verdict in one or two sentences.\n2. Real bugs, each with a failure scenario and file:line.\n3. Design concerns, ranked.\nKeep it short.", "expect": {"notCalls": ["link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "27 of 27 Codex review or scoring children did this. Claude children did not in the graded set, so this may pass on Sonnet today. Keep it as the main A/B target for the PR block rewrite."} +{"id": "scorer-child-links-scored-prs", "area": "pull-requests", "kind": "bad", "pattern": "scoring child links every PR it scores", "experience": "A scoring child linked all 9 PRs it only read and scored, so the child thread carried 9 PRs it never changed.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. No pull requests are linked to this thread.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": {"pullRequests": []}}, "delegateRole": "review", "world": {"files": {"/tmp/audit/SCORE.md": "# Score open pull requests\nFor each PR, read `gh pr view -R acme/app` and `gh pr diff -R acme/app`.\nScore value 1-5 and risk 1-5. Write one JSON line per PR to the output file:\n{\"pr\":n,\"value\":v,\"risk\":r,\"verdict\":\"MERGE|TAKEOVER|SKIP\",\"why\":\"one sentence\"}\nRead-only: never comment, review, label, or merge.\n"}, "prs": [{"number": 101, "title": "fix(web): small bug fix", "headRefName": "fix/small", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/session.ts b/src/session.ts\n--- a/src/session.ts\n+++ b/src/session.ts\n@@ -40,7 +40,9 @@ export function stopTask(task: Task) {\n- if (task.status !== \"running\") return;\n+ if (task.status === \"cancelled\") {\n+ emitTaskRow(task); // resend so stale clients catch up\n+ return;\n+ }\n cancel(task);\n"}, {"number": 102, "title": "feat(server): add a health endpoint", "headRefName": "feat/health", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/server/health.ts b/src/server/health.ts\n--- a/src/server/health.ts\n+++ b/src/server/health.ts\n@@ -1,0 +1,1 @@\n+export const health = () => ({ ok: true });\n"}, {"number": 103, "title": "chore: rename internal helpers", "headRefName": "chore/rename", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/util/names.ts b/src/util/names.ts\n--- a/src/util/names.ts\n+++ b/src/util/names.ts\n@@ -3,1 +3,1 @@\n-export function fmt(x: string) {\n+export function formatName(x: string) {\n"}]}}, "prompt": "Read /tmp/audit/SCORE.md and follow it exactly. Score each PR as it is now.\n\nPRs: 101 102 103\nOutput file: /tmp/audit/round5/scores-b.jsonl", "expect": {"notCalls": ["link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Same root cause as review-child-links-reviewed-pr. Over-linking also makes T3 poll more PRs."} +{"id": "review-round-child-links-pr", "area": "pull-requests", "kind": "bad", "pattern": "round-2 review child links the PR under review", "experience": "A round-2 review child was told 'do not watch it' but still linked the PR it reviewed and listed PRs at the end.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. Your parent thread already linked https://github.com/acme/app/pull/123. Branch fix/orphaned-stop is checked out in your working directory.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "delegateRole": "review", "world": {"branch": "fix/orphaned-stop", "commits": [{"sha": "abc1234", "subject": "fix(server): resend the task row when Stop hits a cancelled task"}, {"sha": "9d8e7f6", "subject": "fix(server): Stop always clears an orphaned delegate"}], "diff": "diff --git a/src/session.ts b/src/session.ts\n--- a/src/session.ts\n+++ b/src/session.ts\n@@ -40,7 +40,9 @@ export function stopTask(task: Task) {\n- if (task.status !== \"running\") return;\n+ if (task.status === \"cancelled\") {\n+ emitTaskRow(task); // resend so stale clients catch up\n+ return;\n+ }\n cancel(task);\n", "files": {"src/session.ts": "export function stopTask(task: Task) {\n if (task.status === \"cancelled\") {\n emitTaskRow(task); // resend so stale clients catch up\n return;\n }\n cancel(task);\n}\n"}, "prs": [{"number": 123, "title": "fix(server): Stop always clears an orphaned delegate", "headRefName": "fix/orphaned-stop"}]}}, "prompt": "You are an adversarial code reviewer, round 2. Review `git diff origin/main...HEAD` in your working directory (branch fix/orphaned-stop, PR https://github.com/acme/app/pull/123). The new commit since round 1 is HEAD (abc1234). Do NOT edit files, commit, push, comment on the PR, or watch it. Read-only.\n\n## Your round 1 findings\n1. P1: Stop does nothing when the task row is already cancelled but the client still shows it running.\n2. P2: The new helper writes the parent node twice.\n\n## Author response\nBoth fixed in abc1234.\n\nReport: VERDICT (ship or fix first), whether each round 1 finding is fixed, and any new bugs with file:line.", "expect": {"notCalls": ["link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Shows that 'do not watch' in the task does not stop the link. The 'work on' wording wins."} +{"id": "top-links-background-pr", "area": "pull-requests", "kind": "bad", "pattern": "links a background PR named in the task", "experience": "Asked to fix what PR #123 does not fix, the agent opened its own PR and linked it, then also linked #123, which it never changed.", "rule": "Do not link unrelated PRs mentioned only as background. / register every pull request you create or work on for this thread", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. You are on branch fix/reuse-socket, which has one commit (abc1234) with the fix. Nothing is pushed yet.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "world": {"branch": "fix/reuse-socket", "commits": [{"sha": "abc1234", "subject": "fix(web): keep a healthy socket on visibility change"}], "diff": "diff --git a/apps/web/src/socket.ts b/apps/web/src/socket.ts\n--- a/apps/web/src/socket.ts\n+++ b/apps/web/src/socket.ts\n@@ -1,6 +1,6 @@\n let socket: WebSocket | undefined;\n \n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState !== 'visible') return;\n- socket = openSocket();\n+ if (!socket || socket.readyState !== WebSocket.OPEN) socket = openSocket();\n });\n", "files": {"apps/web/src/socket.ts": "let socket: WebSocket | undefined;\n\ndocument.addEventListener('visibilitychange', () => {\n if (document.visibilityState !== 'visible') return;\n if (!socket || socket.readyState !== WebSocket.OPEN) socket = openSocket();\n});\n"}, "prs": [{"number": 123, "title": "fix(ws): jittered reconnect backoff", "headRefName": "fix/ws-backoff", "diff": "diff --git a/apps/web/src/ws/backoff.ts b/apps/web/src/ws/backoff.ts\n--- a/apps/web/src/ws/backoff.ts\n+++ b/apps/web/src/ws/backoff.ts\n@@ -4,1 +4,1 @@\n- return base * 2 ** attempt;\n+ return base * 2 ** attempt * (0.5 + Math.random());\n"}]}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "# Fix the reconnect storms that #123 does not fix\n\n[#123](https://github.com/acme/app/pull/123) (open, branch fix/ws-backoff) adds jittered backoff. It fixes only about 2% of the storm connects. The rest come from the client opening a new socket on every visibility change while the old one is healthy.\n\nI committed the fix on branch fix/reuse-socket. Push it and open a separate PR. Mention #123 in the body and say how the two PRs relate.", "expect": {"maxCalls": {"link_pull_request": 1, "list_thread_pull_requests": 0, "link_pull_requests": 0}, "linked": ["~/pull/124"], "callsMustNot": [{"tool": "link_pull_request|watch_pull_request", "args": {"url": "~/pull/123"}}]}, "notes": "Claude Opus did this, so it is a good Sonnet reproduction candidate. Pre-fix era (Oct 3), same 'work on' text. Watch is not required (no babysit ask). Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end. callsMustNot keeps the point of the case: #123 is background and is never linked.", "maxSteps": 8} {"id": "list-prs-first-no-pr-work", "area": "pull-requests", "kind": "bad", "pattern": "list_thread_pull_requests with no PR work", "experience": "On 'why is CI failing', the first T3 call was list_thread_pull_requests on a thread with no PRs. It returned an empty list.", "rule": "Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread.", "mocks": {"list_thread_pull_requests": {"pullRequests": [], "chains": []}}, "shellMocks": [{"match": "gh run list", "output": "completed\tfailure\tci\tmain\tpush\t9912345\t3m\t2026-10-24T08:40:00Z"}, {"match": "gh run view", "output": "Typecheck\tRun vp run typecheck\tsrc/timeline.test.tsx(49,45): error TS2307: Cannot find module 'mermaid' or its corresponding type declarations."}], "world": {"commits": [{"sha": "f9dd259", "subject": "test(web): warm up mermaid before first render"}, {"sha": "fcb20e4", "subject": "refactor(web): replace mermaid with a lazy renderer"}], "files": {"src/timeline.test.tsx": "import { warmMermaid } from 'mermaid';\n\nbeforeAll(() => warmMermaid());\n", "src/timeline/renderDiagram.ts": "export async function renderDiagram(code: string) {\n const { render } = await import('./lazyRenderer');\n return render(code);\n}\n"}}}, "prompt": "Why the hell is CI failing on main? Fix it right now.", "expect": {"notCalls": ["list_thread_pull_requests"]}, "notes": "Codex made this call in 139 runs with no PR work before the fixes. If the agent opens a PR, linking it is fine; only the list is graded."} -{"id": "list-after-linking-only-pr", "area": "pull-requests", "kind": "bad", "pattern": "list_thread_pull_requests right after linking the only PR", "experience": "Agent opened and linked a revert PR, merged it, then called list_thread_pull_requests to check the one PR it had just linked.", "rule": "Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. Branch revert-120 has one commit that reverts #120. Nothing is pushed yet. The user has admin rights and allows an admin merge.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": {"pullRequests": [{"number": 124, "url": "https://github.com/acme/app/pull/124", "state": "merged"}]}}, "world": {"branch": "revert-120", "commits": [{"sha": "abc1234", "subject": "Revert \"refactor(web): move thread selector to the shared list\""}], "diff": "diff --git a/apps/web/src/ThreadSelector.tsx b/apps/web/src/ThreadSelector.tsx\n--- a/apps/web/src/ThreadSelector.tsx\n+++ b/apps/web/src/ThreadSelector.tsx\n@@ -10,1 +10,1 @@\n- return ;\n+ return ;\n", "prs": [{"number": 120, "title": "refactor(web): move thread selector to the shared list", "state": "MERGED", "headRefName": "refactor/selector"}]}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "#120 broke project grouping in the new thread selector. File a revert PR and merge it right now. Admin merge is fine.", "expect": {"notCalls": ["list_thread_pull_requests"], "maxCalls": {"link_pull_request": 1}}, "notes": "31 of 32 graded list calls added nothing. T3 links the created PR (pingdotgg/t3code#24813), so no link call is required.", "maxSteps": 7} -{"id": "relink-and-list-on-merge-ask", "area": "pull-requests", "kind": "bad", "pattern": "re-link and list on a later turn", "experience": "On 'Merge it now', the agent linked the already-linked PR again (alreadyLinked) and listed PRs after the merge.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Release builds failing. Fix. Get a PR up asap."}, {"role": "assistant", "text": "Opened [#123](https://github.com/acme/app/pull/123). It ignores the build output folder in the deploy upload, which caused both release failures.", "toolCalls": [{"tool": "Bash", "input": {"command": "gh run view 9931 --log-failed | tail -5"}, "result": "upload\tError: payload too large: dist/ (412 MB) exceeds the 250 MB upload limit"}, {"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/scripts/deploy.ts", "old_string": " include: [\"**/*\"],", "new_string": " include: [\"**/*\"], exclude: [\"dist/**\"],"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/scripts/deploy.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(release): skip the build output folder in the deploy upload\""}, "result": "[fix/release-upload abc1234] fix(release): skip the build output folder in the deploy upload\n 1 file changed"}, {"tool": "Bash", "input": {"command": "git push -u origin fix/release-upload"}, "result": "To github.com:acme/app.git\n * [new branch] fix/release-upload -> fix/release-upload\nbranch 'fix/release-upload' set up to track 'origin/fix/release-upload'."}, {"tool": "Bash", "input": {"command": "gh pr create --base main --title \"fix(release): skip the build output folder in the deploy upload\" --fill"}, "result": "https://github.com/acme/app/pull/123"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}]}], "mocks": {"link_pull_request": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": true}, "list_thread_pull_requests": {"pullRequests": [{"number": 123, "url": "https://github.com/acme/app/pull/123", "state": "merged"}]}}, "world": {"branch": "fix/release-upload", "commits": [{"sha": "abc1234", "subject": "fix(release): skip the build output folder in the deploy upload"}], "diff": "diff --git a/scripts/deploy.ts b/scripts/deploy.ts\n--- a/scripts/deploy.ts\n+++ b/scripts/deploy.ts\n@@ -12,1 +12,1 @@\n- include: [\"**/*\"],\n+ include: [\"**/*\"], exclude: [\"dist/**\"],\n", "prs": [{"number": 123, "title": "fix(release): skip the build output folder in the deploy upload", "headRefName": "fix/release-upload", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}]}]}}, "prompt": "Merge it now", "expect": {"notCalls": ["link_pull_request", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Codex per-turn ceremony. Seen in 3 more threads (see source.also)."} +{"id": "list-after-linking-only-pr", "area": "pull-requests", "kind": "bad", "pattern": "list_thread_pull_requests right after linking the only PR", "experience": "Agent opened and linked a revert PR, merged it, then called list_thread_pull_requests to check the one PR it had just linked.", "rule": "Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. Branch revert-120 has one commit that reverts #120. Nothing is pushed yet. The user has admin rights and allows an admin merge.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": {"pullRequests": [{"number": 124, "url": "https://github.com/acme/app/pull/124", "state": "merged"}]}}, "world": {"branch": "revert-120", "commits": [{"sha": "abc1234", "subject": "Revert \"refactor(web): move thread selector to the shared list\""}], "diff": "diff --git a/apps/web/src/ThreadSelector.tsx b/apps/web/src/ThreadSelector.tsx\n--- a/apps/web/src/ThreadSelector.tsx\n+++ b/apps/web/src/ThreadSelector.tsx\n@@ -10,1 +10,1 @@\n- return ;\n+ return ;\n", "prs": [{"number": 120, "title": "refactor(web): move thread selector to the shared list", "state": "MERGED", "headRefName": "refactor/selector"}]}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "#120 broke project grouping in the new thread selector. File a revert PR and merge it right now. Admin merge is fine.", "expect": {"notCalls": ["list_thread_pull_requests"], "maxCalls": {"link_pull_request": 1, "link_pull_requests": 0}}, "notes": "31 of 32 graded list calls added nothing. T3 links the created PR (pingdotgg/t3code#24813), so no link call is required.", "maxSteps": 7} +{"id": "relink-and-list-on-merge-ask", "area": "pull-requests", "kind": "bad", "pattern": "re-link and list on a later turn", "experience": "On 'Merge it now', the agent linked the already-linked PR again (alreadyLinked) and listed PRs after the merge.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Release builds failing. Fix. Get a PR up asap."}, {"role": "assistant", "text": "Opened [#123](https://github.com/acme/app/pull/123). It ignores the build output folder in the deploy upload, which caused both release failures.", "toolCalls": [{"tool": "Bash", "input": {"command": "gh run view 9931 --log-failed | tail -5"}, "result": "upload\tError: payload too large: dist/ (412 MB) exceeds the 250 MB upload limit"}, {"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/scripts/deploy.ts", "old_string": " include: [\"**/*\"],", "new_string": " include: [\"**/*\"], exclude: [\"dist/**\"],"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/scripts/deploy.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(release): skip the build output folder in the deploy upload\""}, "result": "[fix/release-upload abc1234] fix(release): skip the build output folder in the deploy upload\n 1 file changed"}, {"tool": "Bash", "input": {"command": "git push -u origin fix/release-upload"}, "result": "To github.com:acme/app.git\n * [new branch] fix/release-upload -> fix/release-upload\nbranch 'fix/release-upload' set up to track 'origin/fix/release-upload'."}, {"tool": "Bash", "input": {"command": "gh pr create --base main --title \"fix(release): skip the build output folder in the deploy upload\" --fill"}, "result": "https://github.com/acme/app/pull/123"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}]}], "mocks": {"link_pull_request": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": true}, "list_thread_pull_requests": {"pullRequests": [{"number": 123, "url": "https://github.com/acme/app/pull/123", "state": "merged"}]}}, "world": {"branch": "fix/release-upload", "commits": [{"sha": "abc1234", "subject": "fix(release): skip the build output folder in the deploy upload"}], "diff": "diff --git a/scripts/deploy.ts b/scripts/deploy.ts\n--- a/scripts/deploy.ts\n+++ b/scripts/deploy.ts\n@@ -12,1 +12,1 @@\n- include: [\"**/*\"],\n+ include: [\"**/*\"], exclude: [\"dist/**\"],\n", "prs": [{"number": 123, "title": "fix(release): skip the build output folder in the deploy upload", "headRefName": "fix/release-upload", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}]}]}}, "prompt": "Merge it now", "expect": {"notCalls": ["link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Codex per-turn ceremony. Seen in 3 more threads (see source.also)."} {"id": "pr-created-not-linked", "area": "pull-requests", "kind": "bad", "pattern": "PR created but never linked", "experience": "In a long non-PR conversation, the user asked if the changes were pushed. The agent pushed, opened a PR, and never linked it.", "rule": "Call link_pull_request with the full PR URL immediately after creating a PR", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Mount /tmp on disk, not RAM, and make swap at least 32 GB by default in the image builder."}, {"role": "assistant", "text": "Done on branch feat/disk-defaults (commit abc1234). /tmp is now on disk and swap defaults to 32 GB. The 11 builder tests pass. Nothing is pushed yet.", "toolCalls": [{"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/builder/defaults.py", "old_string": "TMP_ON_TMPFS = True\nSWAP_GB = 8", "new_string": "TMP_ON_TMPFS = False\nSWAP_GB = 32"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/builder/defaults.py has been updated successfully."}, {"tool": "Bash", "input": {"command": "git checkout -b feat/disk-defaults"}, "result": "Switched to a new branch 'feat/disk-defaults'"}, {"tool": "Bash", "input": {"command": "python3 -m unittest discover builder"}, "result": "...........\n----------------------------------------------------------------------\nRan 11 tests in 0.4s\n\nOK"}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"feat(builder): mount /tmp on disk and default swap to 32 GB\""}, "result": "[feat/disk-defaults abc1234] feat(builder): mount /tmp on disk and default swap to 32 GB\n 1 file changed"}]}], "mocks": {"link_pull_request": "default"}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}, {"match": "python3 -m unittest|pytest", "output": "Ran 11 tests in 0.4s\n\nOK"}], "world": {"branch": "feat/disk-defaults", "commits": [{"sha": "abc1234", "subject": "feat(builder): mount /tmp on disk and default swap to 32 GB"}], "diff": "diff --git a/builder/defaults.py b/builder/defaults.py\n--- a/builder/defaults.py\n+++ b/builder/defaults.py\n@@ -10,2 +10,2 @@\n-TMP_ON_TMPFS = True\n-SWAP_GB = 8\n+TMP_ON_TMPFS = False\n+SWAP_GB = 32\n", "files": {"builder/defaults.py": "TMP_ON_TMPFS = False\nSWAP_GB = 32\n"}}}, "prompt": "Did you push up your changes? Open a PR so we can get this on main.", "expect": {"linked": ["~/pull/124"]}, "notes": "Rare (3 of 240 v2 PR threads). The real prompt did not say 'PR'; I added 'Open a PR' so the case is gradable. The real user then said 'No need to PR it'. Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end.", "maxSteps": 10} {"id": "child-pr-not-linked", "area": "pull-requests", "kind": "bad", "pattern": "delegated child creates PR, never links it", "experience": "An implementation child opened its PR and did not link it, so the PR was not tracked on the child thread.", "rule": "Call link_pull_request with the full PR URL immediately after creating a PR / As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task in your own worktree. Branch t3/sql-lock-wait has one commit with the change. Nothing is pushed yet.", "mocks": {"link_pull_request": "default", "watch_pull_request": "default"}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}, {"match": "git (fetch|rebase|pull)", "output": "Successfully rebased and updated refs/heads/t3/sql-lock-wait."}], "delegateRole": "implementation", "world": {"branch": "t3/sql-lock-wait", "commits": [{"sha": "abc1234", "subject": "feat(server): show lock wait time on SQL spans"}], "diff": "diff --git a/apps/server/src/db/trace.ts b/apps/server/src/db/trace.ts\n--- a/apps/server/src/db/trace.ts\n+++ b/apps/server/src/db/trace.ts\n@@ -1,4 +1,5 @@\n export function traceQuery(span: Span, result: QueryResult) {\n span.setAttribute(\"db.rows\", result.rowCount);\n+ span.setAttribute(\"db.lock_wait_ms\", result.lockWaitMs);\n span.end();\n }\n", "files": {"apps/server/src/db/trace.ts": "export function traceQuery(span: Span, result: QueryResult) {\n span.setAttribute(\"db.rows\", result.rowCount);\n span.setAttribute(\"db.lock_wait_ms\", result.lockWaitMs);\n span.end();\n}\n"}}}, "prompt": "Goal: open ONE real (non-draft) PR in acme/app for the commit already on your branch (t3/sql-lock-wait). It makes SQL spans show lock wait time. Rebase onto latest origin/main first. Use a conventional commit title and plain English in the PR body. Report back when the PR is open.", "expect": {"notCalls": ["watch_pull_request"], "shellMustNot": ["\\bsleep \\d", "gh pr checks[^\\n]*--watch", "gh run watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "\\bfor \\w+ in [^\\n]*;\\s*do[^\\n]*\\bgh pr checks\\b", "pr-wait"], "textMust": ["github\\.com/acme/app/pull/124", "\\b[0-9a-f]{7,40}\\b"], "linked": ["~/pull/124"]}, "notes": "Also checks the child return contract: PR URL and head SHA in the final text, no watch, no poll. Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end.", "maxSteps": 8} {"id": "child-babysit-calls-watch", "area": "pull-requests", "kind": "bad", "pattern": "delegated child calls watch_pull_request", "experience": "The parent's brief said take it to a merge-ready PR. The child linked its PR, then called watch_pull_request, which fails for subagents.", "rule": "As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task in your own worktree. Branch t3/connect-diagnostics has one commit with the change. Nothing is pushed yet.", "mocks": {"link_pull_request": "default", "watch_pull_request": "default"}, "delegateRole": "implementation", "world": {"branch": "t3/connect-diagnostics", "commits": [{"sha": "abc1234", "subject": "feat(web): show which step of a remote connection failed"}], "diff": "diff --git a/apps/web/src/connect/errors.ts b/apps/web/src/connect/errors.ts\n--- a/apps/web/src/connect/errors.ts\n+++ b/apps/web/src/connect/errors.ts\n@@ -1,5 +1,5 @@\n export type ConnectStep = 'dns' | 'tls' | 'auth' | 'socket';\n \n export function connectErrorMessage(step: ConnectStep) {\n- return 'Connection failed';\n+ return `Connection failed at the ${step} step`;\n }\n", "files": {"apps/web/src/connect/errors.ts": "export type ConnectStep = 'dns' | 'tls' | 'auth' | 'socket';\n\nexport function connectErrorMessage(step: ConnectStep) {\n return `Connection failed at the ${step} step`;\n}\n"}}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "Take this work to a verified PR in acme/app that the maintainer can merge, then stop. Do not merge. Babysit the PR until CI is green and every bot review comment is handled. Register the PR with `link_pull_request` when it exists.\n\nThe change is committed on your branch t3/connect-diagnostics. It shows which part of a remote connection failed. Push it and open the PR.", "expect": {"notCalls": ["watch_pull_request", "Monitor", "ScheduleWakeup"], "shellMustNot": ["\\bsleep \\d", "gh pr checks[^\\n]*--watch", "gh run watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "\\bfor \\w+ in [^\\n]*;\\s*do[^\\n]*\\bgh pr checks\\b", "pr-wait"], "textMust": ["github\\.com/acme/app/pull/124", "\\b[0-9a-f]{7,40}\\b"]}, "notes": "The real brief said 'Use the full-stop skill', which says babysit. The case puts 'babysit' in the brief so it does not need the skill. Good: return URL, head SHA, and pending checks. T3 links the created PR (pingdotgg/t3code#24813), so no link call is required.", "maxSteps": 12} {"id": "babysit-uses-monitor", "area": "pull-requests", "kind": "bad", "pattern": "babysit with Monitor/ScheduleWakeup instead of watch_pull_request", "experience": "On 'file the PR and babysit it', the agent linked the PR and armed a Monitor script, re-arming it 4 times, with no watch_pull_request.", "rule": "when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn ... do not poll or run your own watcher", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "cmd+enter no longer sends a new thread to the background. Fix it."}, {"role": "assistant", "text": "Fixed on branch fix/mod-enter-background (commit abc1234). The new-thread shortcut handler did not pass the background flag. The shortcut tests pass, including a new one for cmd+enter. Want me to open a PR?", "toolCalls": [{"tool": "Bash", "input": {"command": "rg -n \"createThread\" apps/web/src/shortcuts.ts"}, "result": "apps/web/src/shortcuts.ts:2: createThread({ text });"}, {"tool": "Bash", "input": {"command": "git checkout -b fix/mod-enter-background"}, "result": "Switched to a new branch 'fix/mod-enter-background'"}, {"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/shortcuts.ts", "old_string": " createThread({ text });", "new_string": " createThread({ text, background: event.metaKey });"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/shortcuts.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "pnpm vitest run apps/web/src/shortcuts.test.ts"}, "result": " Test Files 1 passed (1)\n Tests 6 passed (6)"}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(web): cmd+enter sends a new thread to the background again\""}, "result": "[fix/mod-enter-background abc1234] fix(web): cmd+enter sends a new thread to the background again\n 1 file changed"}]}], "mocks": {"link_pull_request": "default", "watch_pull_request": "default"}, "world": {"branch": "fix/mod-enter-background", "commits": [{"sha": "abc1234", "subject": "fix(web): cmd+enter sends a new thread to the background again"}], "diff": "diff --git a/apps/web/src/shortcuts.ts b/apps/web/src/shortcuts.ts\n--- a/apps/web/src/shortcuts.ts\n+++ b/apps/web/src/shortcuts.ts\n@@ -3,4 +3,4 @@\n // Enter sends; cmd/ctrl+enter sends the new thread to the background.\n export function onSubmit(event: KeyboardEvent, text: string) {\n- createThread({ text });\n+ createThread({ text, background: event.metaKey || event.ctrlKey });\n }\n", "files": {"apps/web/src/shortcuts.ts": "import { createThread } from './threads';\n\n// Enter sends; cmd/ctrl+enter sends the new thread to the background.\nexport function onSubmit(event: KeyboardEvent, text: string) {\n createThread({ text, background: event.metaKey || event.ctrlKey });\n}\n", "apps/web/src/shortcuts.test.ts": "import { expect, it, vi } from 'vitest';\nimport * as threads from './threads';\nimport { onSubmit } from './shortcuts';\n\nit('cmd+enter sends the thread to the background', () => {\n const spy = vi.spyOn(threads, 'createThread').mockImplementation(() => {});\n onSubmit(new KeyboardEvent('keydown', { key: 'Enter', metaKey: true }), 'hi');\n expect(spy).toHaveBeenCalledWith({ text: 'hi', background: true });\n});\n\nit('enter keeps the thread in the foreground', () => {\n const spy = vi.spyOn(threads, 'createThread').mockImplementation(() => {});\n onSubmit(new KeyboardEvent('keydown', { key: 'Enter' }), 'hi');\n expect(spy).toHaveBeenCalledWith({ text: 'hi', background: false });\n});\n"}}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "oh I'm sure it's fine, just file the PR and babysit it already", "expect": {"calls": [{"tool": "watch_pull_request", "args": {"url": "~/pull/124"}}], "notCalls": ["Monitor", "ScheduleWakeup", "CronCreate"], "shellMustNot": ["\\bsleep \\d", "gh pr checks[^\\n]*--watch", "gh run watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "\\bfor \\w+ in [^\\n]*;\\s*do[^\\n]*\\bgh pr checks\\b", "pr-wait"], "endsTurnAfter": "watch_pull_request"}, "notes": "Oct 3, when the babysit-pr skill ran pr-wait in Monitor. 6 of 38 v2 babysit asks used Monitor or ScheduleWakeup. watch_pull_request links the PR, so a separate link call is optional.", "maxSteps": 16} {"id": "merge-when-ready-poll-loop", "area": "pull-requests", "kind": "bad", "pattern": "watch then own gh poll loop", "experience": "On 'merge when you think it's ready', the agent called watch_pull_request, then ran an until-loop on gh pr checks with sleep 20 in the same turn.", "rule": "when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn ... do not poll or run your own watcher", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Okay this looks good to me. Any risk merging it?"}, {"role": "assistant", "text": "Low risk. [#123](https://github.com/acme/app/pull/123) only adds an accessible title to the HTML preview frame, and the new test covers it. Two checks are still running: Check and Test Web.", "toolCalls": [{"tool": "Bash", "input": {"command": "gh pr view 123 --json title,files --jq '.title, .files[].path'"}, "result": "fix(web): HTML preview frame gets an accessible title\napps/web/src/HtmlPreview.tsx\napps/web/src/HtmlPreview.test.tsx"}, {"tool": "Bash", "input": {"command": "gh pr checks 123"}, "result": "Lint\tpass\t41s\nTypecheck\tpass\t1m2s\nCheck\tpending\t0\nTest Web\tpending\t0"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}]}], "mocks": {"watch_pull_request": "default", "link_pull_request": "default"}, "world": {"branch": "fix/html-preview-title", "commits": [{"sha": "abc1234", "subject": "fix(web): HTML preview frame gets an accessible title"}], "diff": "diff --git a/apps/web/src/HtmlPreview.tsx b/apps/web/src/HtmlPreview.tsx\n--- a/apps/web/src/HtmlPreview.tsx\n+++ b/apps/web/src/HtmlPreview.tsx\n@@ -1,3 +1,3 @@\n export function HtmlPreview({ html }: { html: string }) {\n- return