diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 2979c4ccad393..2705791372666 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -1,6 +1,6 @@ # T3 Code triage playbook -You are a support engineer for T3 Code (https://github.com/pingdotgg/t3code), working +You are a support engineer for T3 Code (https://github.com/RTVision/t3code), working inside a coding-agent session on the machine of a user whose install is misbehaving: crashes, auth failures, broken setups, slow launches, or anything else. Your job is to find out what went wrong, unblock the user if you can, and turn what you learned into @@ -24,13 +24,13 @@ the database. Its triage CLI version (called "Installed version" in older context files) is only the version of the `t3` that ran `t3 triage`. It is often not the build the bug -happened on: `npx t3 triage` runs whatever `t3` npm resolves, while the user may +happened on: `npx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 triage` runs whatever `t3` npm resolves, while the user may run the desktop app, the background service, or a nightly. The server process line gives the local server's version when the server recorded one. ## 3. Check for a newer playbook -Fetch https://raw.githubusercontent.com/pingdotgg/t3code/main/.github/triage/PLAYBOOK.md. +Fetch https://raw.githubusercontent.com/RTVision/t3code/rtvision/.github/triage/PLAYBOOK.md. If it is reachable and its content differs from this text, follow that version instead of this one. The user may be on an old release with an old copy. @@ -48,9 +48,9 @@ such as `v0.0.42`), into the source cache directory named in the context file, one subdirectory per commit hash: git clone --depth 1 --filter=blob:none --branch \ - https://github.com/pingdotgg/t3code / + https://github.com/RTVision/t3code / -If the tag does not exist (nightly builds), clone `main` instead, and treat file +If the tag does not exist (nightly builds), clone `rtvision` instead, and treat file and line references as approximate: the user's build may not match `main` exactly. If the target directory already exists from an earlier triage run, reuse it instead of cloning again. Before cloning, delete other entries in the @@ -94,7 +94,7 @@ comes from this repo's `main` branch. ## 6. Check upstream -Search existing issues in pingdotgg/t3code (use `gh`, or the public GitHub search +Search existing issues in RTVision/t3code (use `gh`, or the public GitHub search API if `gh` is missing or not logged in). Then check whether the problem is already fixed in a release newer than the version the bug happened on: compare versions, read release notes and recent commits touching the relevant code. @@ -126,7 +126,7 @@ of `main` for that work, never the tag-pinned diagnosis clone. posting. Never post without it. - Note at the end of the issue which model and agent produced it. - If `gh` is not authenticated, offer `gh auth login`, or build a prefilled - https://github.com/pingdotgg/t3code/issues/new URL with title and body query + https://github.com/RTVision/t3code/issues/new URL with title and body query parameters; print the URL, and open it in their browser only after they approve. - If the user pasted screenshots, remind them to drag the images into the issue diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 507e013bf1c2a..6877f5c831a08 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -215,7 +215,7 @@ jobs: git config user.name "${APP_SLUG}[bot]" git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" git add apps/mobile/app.config.ts - git commit \ + git commit --no-verify \ -m "chore(mobile): bump app version to $RELEASE_VERSION" \ -m "Co-authored-by: codex " git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d89b7c7abe7a..c36d51bdadb1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1388,7 +1388,7 @@ jobs: git config user.email "${{ steps.app_bot.outputs.email }}" git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml - git commit -m "chore(release): prepare $RELEASE_TAG" + git commit --no-verify -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main announce_discord: diff --git a/AGENTS.md b/AGENTS.md index 493f7fc0be715..d99f1b5fa96a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ The most common defect in this repo is a change that works on the path you teste An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: - The first `vp run dev` in a worktree does this for you when its database is missing and the source is at most 500 MiB. Larger sources warn and dev starts with an empty database. `--no-seed` or `T3CODE_DEV_SEED=0` skips seeding. Manual migration still accepts larger sources and may use substantial disk space before pruning. -- Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. +- Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, auth sessions, and links to other environments, so your dev server never runs the developer's agents or reaches their other machines. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. - Copy in, never symlink. Data flows one way: into your sandbox, never back out. diff --git a/apps/desktop/gnome-extension/metadata.json b/apps/desktop/gnome-extension/metadata.json index ce9d435c593d8..664c269ef9955 100644 --- a/apps/desktop/gnome-extension/metadata.json +++ b/apps/desktop/gnome-extension/metadata.json @@ -4,5 +4,5 @@ "description": "Allows T3 Code to capture the focused window, identify it for optional accessibility text, and animate it into T3 Code. Disable this extension to revoke access.", "shell-version": ["45", "46", "47", "48", "49", "50", "51"], "version": 3, - "url": "https://github.com/pingdotgg/t3code" + "url": "https://github.com/RTVision/t3code" } diff --git a/apps/desktop/resources/installer.nsh b/apps/desktop/resources/installer.nsh index 092f641c3359f..7f277fdcbf9c2 100644 --- a/apps/desktop/resources/installer.nsh +++ b/apps/desktop/resources/installer.nsh @@ -1,3 +1,35 @@ +!include "StrFunc.nsh" +!ifdef BUILD_UNINSTALLER + ${UnStrRep} + !define T3StrRep '${UnStrRep}' +!else + ${StrRep} + !define T3StrRep '${StrRep}' +!endif + +!macro customAppRunningMessages + Push $0 + Push $1 + ${If} $IsPowerShellAvailable == 0 + System::Call 'kernel32::SetEnvironmentVariable(t "T3CODE_INSTALL_DIR", t "$INSTDIR")' + nsExec::ExecToStack `"$PowerShellPath" -NoProfile -NonInteractive -C "Get-CimInstance -ClassName Win32_Process | Where-Object { $$_.ExecutablePath -and [IO.Path]::GetDirectoryName($$_.ExecutablePath) -eq $$env:T3CODE_INSTALL_DIR } | ForEach-Object { [IO.Path]::GetFileNameWithoutExtension($$_.ExecutablePath) } | Where-Object { $$_ -in 'T3 Code (Alpha)', 'T3 Code (Beta)', 'T3 Code (Nightly)' } | Select-Object -First 1 | ForEach-Object { [Console]::Write($$_) }"` + Pop $0 + Pop $1 + System::Call 'kernel32::SetEnvironmentVariable(t "T3CODE_INSTALL_DIR", p 0)' + ${If} $0 == 0 + ${If} $1 == "T3 Code (Alpha)" + ${OrIf} $1 == "T3 Code (Beta)" + ${OrIf} $1 == "T3 Code (Nightly)" + ${T3StrRep} $appRunningMessage $appRunningMessage "${PRODUCT_NAME}" $1 + ${T3StrRep} $appClosingMessage $appClosingMessage "${PRODUCT_NAME}" $1 + ${T3StrRep} $appCannotBeClosedMessage $appCannotBeClosedMessage "${PRODUCT_NAME}" $1 + ${EndIf} + ${EndIf} + ${EndIf} + Pop $1 + Pop $0 +!macroend + !macro customInstall !ifndef DO_NOT_CREATE_START_MENU_SHORTCUT ${IfNot} ${FileExists} "$newStartMenuLink" diff --git a/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml b/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml index 0ae54a9849fcb..e11b0c68f1c60 100644 --- a/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml +++ b/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml @@ -27,6 +27,6 @@ Development https://t3.codes - https://github.com/pingdotgg/t3code/issues - https://github.com/pingdotgg/t3code + https://github.com/RTVision/t3code/issues + https://github.com/RTVision/t3code diff --git a/apps/desktop/src/app/ClerkTokenStore.test.ts b/apps/desktop/src/app/ClerkTokenStore.test.ts new file mode 100644 index 0000000000000..b7ac0e38d5142 --- /dev/null +++ b/apps/desktop/src/app/ClerkTokenStore.test.ts @@ -0,0 +1,189 @@ +// @effect-diagnostics nodeBuiltinImport:off -- These tests assert real POSIX modes on a temporary directory. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { assert, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +vi.mock("node:fs", { spy: true }); + +import { openClerkTokenStorage, type ClerkTokenStorage } from "./ClerkTokenStore.ts"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- Unix mode bits are the assertion; Windows reports them through ACLs. +const describePosix = describe.skipIf(process.platform === "win32"); + +const modeOf = (path: string): number => NodeFS.statSync(path).mode & 0o777; + +function writeLoose(path: string, contents: string): void { + NodeFS.writeFileSync(path, contents, { mode: 0o666 }); + NodeFS.chmodSync(path, 0o666); +} + +async function withStateDir(run: (stateDir: string) => Promise | void): Promise { + const stateDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-clerk-tokens-")); + try { + await run(stateDir); + } finally { + NodeFS.rmSync(stateDir, { recursive: true, force: true }); + } +} + +const idleStorage = (): ClerkTokenStorage => ({ + getItem: async () => null, + setItem: async () => undefined, + removeItem: () => undefined, +}); + +describePosix("ClerkTokenStore", () => { + it("keeps clerk tokens at 0600 inside a 0700 directory", async () => { + await withStateDir(async (stateDir) => { + NodeFS.chmodSync(stateDir, 0o755); + const directory = NodePath.join(stateDir, "clerk"); + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + const tokenPath = NodePath.join(directory, "clerk-tokens.json"); + NodeFS.mkdirSync(directory, { mode: 0o755 }); + NodeFS.chmodSync(directory, 0o755); + writeLoose(legacyPath, '{"__clerk_client_jwt":"enc:existing"}\n'); + + const opened: string[] = []; + const storage = openClerkTokenStorage(stateDir, (cwd) => { + opened.push(cwd); + return { + getItem: async () => { + writeLoose(tokenPath, "from-get"); + return "from-get"; + }, + setItem: (_key, value) => { + writeLoose(tokenPath, value); + }, + removeItem: () => { + NodeFS.rmSync(tokenPath, { force: true }); + }, + }; + }); + + assert.deepEqual(opened, [directory]); + assert.equal(modeOf(stateDir), 0o755); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal( + NodeFS.readFileSync(tokenPath, "utf8"), + '{"__clerk_client_jwt":"enc:existing"}\n', + ); + + assert.equal(await storage.getItem("client"), "from-get"); + assert.equal(modeOf(tokenPath), 0o600); + await storage.setItem("client", "from-set"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "from-set"); + storage.removeItem("client"); + assert.equal(NodeFS.existsSync(tokenPath), false); + + writeLoose(tokenPath, "kept"); + writeLoose(legacyPath, "stale"); + NodeFS.chmodSync(directory, 0o755); + openClerkTokenStorage(stateDir, () => idleStorage()); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept"); + }); + }); + + it("removes the legacy token file when the private copy already exists", async () => { + await withStateDir((stateDir) => { + const directory = NodePath.join(stateDir, "clerk"); + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + const tokenPath = NodePath.join(directory, "clerk-tokens.json"); + NodeFS.mkdirSync(directory, { mode: 0o755 }); + writeLoose(legacyPath, "stale-credential"); + writeLoose(tokenPath, "kept-credential"); + + openClerkTokenStorage(stateDir, () => idleStorage()); + + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept-credential"); + }); + }); + + it("resolves a relative state directory before opening storage", async () => { + await withStateDir(async (stateDir) => { + const relativeStateDir = NodePath.relative(process.cwd(), stateDir); + const opened: string[] = []; + openClerkTokenStorage(relativeStateDir, (directory) => { + opened.push(directory); + return idleStorage(); + }); + + const directory = NodePath.join(stateDir, "clerk"); + assert.equal(NodePath.isAbsolute(relativeStateDir), false); + assert.deepEqual(opened, [directory]); + assert.equal(modeOf(directory), 0o700); + }); + }); + + it("migrates relative state paths from Electron userData, not the working directory", async () => { + await withStateDir((stateDir) => { + const relativeStateDir = NodePath.relative(process.cwd(), stateDir); + const userData = NodePath.join( + stateDir, + "electron", + ...relativeStateDir.split(NodePath.sep).map(() => "profile"), + ); + const legacyStateDir = NodePath.join(userData, relativeStateDir); + const legacyPath = NodePath.join(legacyStateDir, "clerk-tokens.json"); + NodeFS.mkdirSync(legacyStateDir, { recursive: true }); + writeLoose(legacyPath, "existing-session"); + const unrelatedPath = NodePath.join(stateDir, "clerk-tokens.json"); + writeLoose(unrelatedPath, "not-the-electron-session"); + + openClerkTokenStorage(relativeStateDir, idleStorage, userData); + + const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json"); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(NodeFS.readFileSync(unrelatedPath, "utf8"), "not-the-electron-session"); + }); + }); + + it("migrates tokens when the legacy directory is on another filesystem", async () => { + await withStateDir((stateDir) => { + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + writeLoose(legacyPath, "existing-session"); + using _rename = vi.spyOn(NodeFS, "renameSync").mockImplementation(() => { + throw Object.assign(new Error("cross-device link"), { code: "EXDEV" }); + }); + + openClerkTokenStorage(stateDir, idleStorage); + + const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json"); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + }); + }); + + it("does not follow a symlinked clerk directory or token file", async () => { + await withStateDir(async (stateDir) => { + const outsideDirectory = NodePath.join(stateDir, "outside-dir"); + NodeFS.mkdirSync(outsideDirectory, { mode: 0o755 }); + NodeFS.chmodSync(outsideDirectory, 0o755); + NodeFS.symlinkSync(outsideDirectory, NodePath.join(stateDir, "clerk")); + + assert.throws(() => openClerkTokenStorage(stateDir, idleStorage), /must be a real directory/); + assert.equal(modeOf(outsideDirectory), 0o755); + NodeFS.unlinkSync(NodePath.join(stateDir, "clerk")); + + const outsideFile = NodePath.join(stateDir, "outside.json"); + NodeFS.writeFileSync(outsideFile, "secret", { mode: 0o644 }); + NodeFS.chmodSync(outsideFile, 0o644); + NodeFS.symlinkSync(outsideFile, NodePath.join(stateDir, "clerk-tokens.json")); + assert.throws(() => openClerkTokenStorage(stateDir, idleStorage), /must be a regular file/); + assert.equal(modeOf(outsideFile), 0o644); + }); + }); +}); diff --git a/apps/desktop/src/app/ClerkTokenStore.ts b/apps/desktop/src/app/ClerkTokenStore.ts new file mode 100644 index 0000000000000..005736c97cb38 --- /dev/null +++ b/apps/desktop/src/app/ClerkTokenStore.ts @@ -0,0 +1,117 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The Clerk bridge must lock the token file down before startup can yield to Electron's ready event. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +// electron-store chmods every write to 0666; the private directory protects tokens during writes. +const CLERK_TOKEN_DIRECTORY_MODE = 0o700; +const CLERK_TOKEN_FILE_MODE = 0o600; +const CLERK_TOKEN_DIRECTORY_NAME = "clerk"; +const CLERK_TOKEN_FILE_NAME = "clerk-tokens.json"; + +type Awaitable = T | Promise; + +export interface ClerkTokenStorage { + readonly getItem: (key: string) => Awaitable; + readonly setItem: (key: string, value: string) => Awaitable; + readonly removeItem: (key: string) => Awaitable; +} + +function isErrno(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + +function lstatIfExists(path: string): NodeFS.Stats | undefined { + try { + return NodeFS.lstatSync(path); + } catch (error) { + if (isErrno(error, "ENOENT")) return undefined; + throw error; + } +} + +function assertRegularFile(path: string, stat: NodeFS.Stats): void { + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`Clerk token file "${path}" must be a regular file.`); + } +} + +function restrictClerkTokenFile(path: string): void { + const stat = lstatIfExists(path); + if (stat === undefined) return; + assertRegularFile(path, stat); + if ((stat.mode & 0o777) === CLERK_TOKEN_FILE_MODE) return; + NodeFS.chmodSync(path, CLERK_TOKEN_FILE_MODE); +} + +function migrateLegacyClerkTokenFile(stateDir: string, directory: string): void { + const legacyPath = NodePath.join(stateDir, CLERK_TOKEN_FILE_NAME); + const tokenPath = NodePath.join(directory, CLERK_TOKEN_FILE_NAME); + const legacy = lstatIfExists(legacyPath); + if (legacy !== undefined) { + assertRegularFile(legacyPath, legacy); + NodeFS.chmodSync(legacyPath, CLERK_TOKEN_FILE_MODE); + } + + const destination = lstatIfExists(tokenPath); + if (destination === undefined) { + if (legacy === undefined) return; + try { + NodeFS.copyFileSync(legacyPath, tokenPath, NodeFS.constants.COPYFILE_EXCL); + NodeFS.chmodSync(tokenPath, CLERK_TOKEN_FILE_MODE); + NodeFS.unlinkSync(legacyPath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } + return; + } + + assertRegularFile(tokenPath, destination); + NodeFS.chmodSync(tokenPath, CLERK_TOKEN_FILE_MODE); + if (legacy === undefined) return; + try { + NodeFS.unlinkSync(legacyPath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } +} + +function restrictAfter(run: () => Awaitable, tighten: () => void): Awaitable { + let result: Awaitable; + try { + result = run(); + } catch (error) { + tighten(); + throw error; + } + if (result instanceof Promise) return result.finally(tighten); + tighten(); + return result; +} + +/** Opens Clerk token storage in a private directory and keeps the file at `0600`. */ +export function openClerkTokenStorage( + stateDir: string, + createStorage: (directory: string) => ClerkTokenStorage, + userDataPath = ".", +): ClerkTokenStorage { + const absoluteStateDir = NodePath.resolve(stateDir); + const directory = NodePath.join(absoluteStateDir, CLERK_TOKEN_DIRECTORY_NAME); + NodeFS.mkdirSync(directory, { recursive: true, mode: CLERK_TOKEN_DIRECTORY_MODE }); + const stat = NodeFS.lstatSync(directory); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Clerk token directory "${directory}" must be a real directory.`); + } + NodeFS.chmodSync(directory, CLERK_TOKEN_DIRECTORY_MODE); + // electron-store rebases relative paths under Electron userData, not the working directory. + migrateLegacyClerkTokenFile(NodePath.resolve(userDataPath, stateDir), directory); + const tokenPath = NodePath.join(directory, CLERK_TOKEN_FILE_NAME); + restrictClerkTokenFile(tokenPath); + + const storage = createStorage(directory); + const tighten = () => restrictClerkTokenFile(tokenPath); + return { + getItem: (key) => restrictAfter(() => storage.getItem(key), tighten), + setItem: (key, value) => restrictAfter(() => storage.setItem(key, value), tighten), + removeItem: (key) => restrictAfter(() => storage.removeItem(key), tighten), + }; +} diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 34b7286c335ed..8a823cde8db79 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,13 +1,21 @@ // @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account. +import * as NodeFS from "node:fs"; import * as NodeHttp from "node:http"; -import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodePathLayer from "@effect/platform-node/NodePath"; import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { beforeEach, vi } from "vite-plus/test"; +import { afterAll, beforeEach, vi } from "vite-plus/test"; + +const clerkStateDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-clerk-")); +afterAll(() => { + NodeFS.rmSync(clerkStateDir, { recursive: true, force: true }); +}); const { createClerkBridgeMock, requestSingleInstanceLockMock, storageAdapter, storageMock } = vi.hoisted(() => ({ @@ -58,7 +66,7 @@ const layerDesktopClerk = ( listeners = new Map void>(), ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ - stateDir: "/tmp/t3-state", + stateDir: clerkStateDir, isDevelopment, appDataDirectory: "/tmp/app-data", platform, @@ -92,7 +100,7 @@ const layerDesktopClerk = ( return DesktopClerk.layer.pipe( Layer.provide( Layer.mergeAll( - NodePath.layerPosix, + NodePathLayer.layerPosix, Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), Layer.succeed(ElectronShell.ElectronShell, shell), @@ -159,6 +167,10 @@ describe("DesktopClerk", () => { "on:open-url", "createClerkBridge", ]); + assert.deepEqual(storageMock.mock.calls, [ + [{ path: NodePath.join(NodePath.resolve(clerkStateDir), "clerk") }], + ]); + assert.notStrictEqual(createClerkBridgeMock.mock.calls[0]?.[0]?.storage, storageAdapter); }, ); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 992e1fe056937..ef9d2aad56795 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -21,6 +21,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopUserData from "./DesktopUserData.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { openClerkTokenStorage } from "./ClerkTokenStore.ts"; import * as DesktopWebLinks from "./DesktopWebLinks.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -84,9 +85,13 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); -function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { +function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean, userDataPath: string) { return createClerkBridge({ - storage: storage({ path: stateDir }), + storage: openClerkTokenStorage( + stateDir, + (directory) => storage({ path: directory }), + userDataPath, + ), manageSingleInstanceLock: false, passkeys: true, renderer: { @@ -131,7 +136,8 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ - try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), + try: () => + createDesktopClerkBridge(environment.stateDir, environment.isDevelopment, userDataPath), catch: (cause) => new DesktopClerkBridgeInitializationError({ stateDir: environment.stateDir, diff --git a/apps/desktop/src/app/DesktopStartupRecovery.ts b/apps/desktop/src/app/DesktopStartupRecovery.ts index 4d2dbcfb14409..163e4f6f51ff3 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.ts @@ -18,7 +18,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopState from "./DesktopState.ts"; -const RELEASES_URL = "https://github.com/pingdotgg/t3code/releases"; +const RELEASES_URL = "https://github.com/RTVision/t3code/releases"; const LAST_STARTED_VERSION_FILE = "desktop-last-started-version"; // Buttons are plain links to this host; will-navigate cancels them and runs the action. const ACTION_ORIGIN = "https://startup-recovery.invalid"; diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 77a4aa998d7bc..da2d1c71036df 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -12,6 +12,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as FileSystem from "effect/FileSystem"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import { T3_NPX_COMMAND } from "@t3tools/shared/releasePackage"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; @@ -177,7 +178,7 @@ export const layer = Layer.effect( electronDialog .showErrorBox( "This T3 home is unavailable", - "Another T3 Code server, such as the background service, is using this data directory, or an older background service is set up for it. Stop that server or try `t3 update`. If the older launcher cannot update, run `t3 service uninstall` and optionally reinstall with `t3 service install` from a current CLI. Use `npx t3@latest` if `t3` is missing or outdated, and pass `--base-dir` with the service's T3 home to each command. Then reopen T3 Code. You can also set a separate T3CODE_HOME and pair with the running server.", + `Another T3 Code server, such as the background service, is using this data directory, or an older background service is set up for it. Stop that server or try \`t3 update\`. If the older launcher cannot update, run \`t3 service uninstall\` and optionally reinstall with \`t3 service install\` from a current CLI. Use \`${T3_NPX_COMMAND}@latest\` if \`t3\` is missing or outdated, and pass \`--base-dir\` with the service's T3 home to each command. Then reopen T3 Code. You can also set a separate T3CODE_HOME and pair with the running server.`, ) .pipe(Effect.andThen(electronApp.quit)), }); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index f53cde3e451d7..67ac6a51e65c7 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -238,6 +238,34 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("advertises the current LAN address after the network changes", () => { + const networkInterfaces: Record< + string, + readonly DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[] | undefined + > = { ...lanNetworkInterfaces }; + + return withHarness( + networkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + networkInterfaces.en0 = [{ address: "10.0.0.5", family: "IPv4", internal: false }]; + + const state = yield* serverExposure.getState; + assert.equal(state.advertisedHost, "10.0.0.5"); + assert.equal(state.endpointUrl, "http://10.0.0.5:4173"); + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://10.0.0.5:4173/"], + ); + assert.equal((yield* serverExposure.backendConfig).bindHost, "0.0.0.0"); + }), + ); + }); + it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => withHarness( emptyNetworkInterfaces, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 8d178c605c4a6..288d969d6626c 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -409,6 +409,30 @@ function resolveRuntimeState(input: { }; } +// A network-accessible backend binds every interface, so a new Wi-Fi network +// or DHCP lease changes only the address worth advertising, not the binding. +const withCurrentAdvertisedHost = ( + state: RuntimeState, + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, + advertisedHostOverride: Option.Option, +): RuntimeState => { + if (state.mode !== "network-accessible") { + return state; + } + const override = Option.getOrUndefined(advertisedHostOverride); + const exposure = resolveDesktopServerExposure({ + mode: state.mode, + port: state.port, + networkInterfaces, + ...(override ? { advertisedHostOverride: override } : {}), + }); + return { + ...state, + endpointUrl: Option.fromNullishOr(exposure.endpointUrl), + advertisedHost: Option.fromNullishOr(exposure.advertisedHost), + }; +}; + const tailscaleIpv4AddressKey = ( networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, ): string => @@ -458,7 +482,17 @@ export const make = Effect.gen(function* () { const readNetworkInterfaces = networkInterfaces.read; - const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); + const readCurrentState = (currentNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces) => + Ref.get(stateRef).pipe( + Effect.map((state) => + withCurrentAdvertisedHost(state, currentNetworkInterfaces, config.desktopLanHostOverride), + ), + ); + + const getState = readNetworkInterfaces.pipe( + Effect.flatMap(readCurrentState), + Effect.map(toContractState), + ); const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")( @@ -555,8 +589,8 @@ export const make = Effect.gen(function* () { ); const getAdvertisedEndpoints = Effect.gen(function* () { - const state = yield* Ref.get(stateRef); const currentNetworkInterfaces = yield* readNetworkInterfaces; + const state = yield* readCurrentState(currentNetworkInterfaces); const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: state.port, exposure: toResolvedExposure(state), diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts new file mode 100644 index 0000000000000..30b7f2d7387e3 --- /dev/null +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -0,0 +1,89 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vite-plus/test"; + +const { getSystemLocaleMock, getUserDefaultMock } = vi.hoisted(() => ({ + getSystemLocaleMock: vi.fn(() => "en-GB"), + getUserDefaultMock: vi.fn((_key: string) => false), +})); + +vi.mock("electron", () => ({ + app: { + getSystemLocale: getSystemLocaleMock, + }, + systemPreferences: { + getUserDefault: getUserDefaultMock, + }, +})); + +import * as ElectronApp from "./ElectronApp.ts"; + +describe("ElectronApp", () => { + beforeEach(() => { + getSystemLocaleMock.mockReset(); + getUserDefaultMock.mockReset(); + }); + + it("keeps the macOS region override and hour cycle in the locale tag", () => { + assert.strictEqual(ElectronApp.toSystemLocaleTag("en_GB"), "en-GB"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en-US@rg=nlzzzz", "h12"), "en-NL-u-hc-h12"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en_US@rg=nlzzzz"), "en-NL"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en-GB", "h23"), "en-GB-u-hc-h23"); + }); + + it("drops ICU keywords Intl cannot read instead of losing the whole locale", () => { + assert.strictEqual(ElectronApp.toSystemLocaleTag("de_DE@calendar=buddhist"), "de-DE"); + }); + + it.effect("follows LC_TIME on Linux when it differs from the language", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const systemLocale = yield* electronApp.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, { + LANG: "en_US.UTF-8", + LC_TIME: "de_DE.UTF-8", + }), + ); + + assert.strictEqual(systemLocale, "de-DE"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("falls back to Chromium's locale on Linux when the time locale is C", () => + Effect.gen(function* () { + getSystemLocaleMock.mockImplementationOnce(() => "fr-FR"); + const electronApp = yield* ElectronApp.ElectronApp; + const systemLocale = yield* electronApp.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, { LANG: "C.UTF-8" }), + ); + + assert.strictEqual(systemLocale, "fr-FR"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); +}); + +it.effect("reads both macOS hour preferences without overriding explicit formats", () => + Effect.gen(function* () { + for (const [preference, expected] of [ + ["AppleICUForce12HourTime", "en-NL-u-hc-h12"], + ["AppleICUForce24HourTime", "en-NL-u-hc-h23"], + ["", "en-NL"], + ]) { + getSystemLocaleMock.mockReturnValue("en_US@rg=nlzzzz"); + getUserDefaultMock.mockImplementation((key) => key === preference); + const locale = yield* ElectronApp.make.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + ); + assert.strictEqual(locale, expected); + for (const hour12 of [true, false]) { + assert.strictEqual( + new Intl.DateTimeFormat(locale, { hour: "numeric", hour12 }).resolvedOptions().hour12, + hour12, + ); + } + } + }), +); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 04f26b1bced84..d677baa56dce3 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -1,3 +1,4 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -93,6 +94,21 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); +export function toSystemLocaleTag(identifier: string, hourCycle?: "h12" | "h23"): string { + const [base = "", keywords = ""] = identifier.split("@"); + const languageTag = base.replace(/_/g, "-"); + // Intl ignores rg extensions, so the macOS region override must replace the region subtag. + const region = /(?:^|;)rg=([a-z]{2}|\d{3})zzzz(?:;|$)/i.exec(keywords)?.[1]?.toUpperCase(); + try { + return new Intl.Locale(languageTag, { + ...(region ? { region } : {}), + ...(hourCycle ? { hourCycle } : {}), + }).toString(); + } catch { + return languageTag; + } +} + /** @public */ export const make = ElectronApp.of({ metadata: Effect.gen(function* () { @@ -122,8 +138,23 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), - // NSLocale returns en_GB-style tags; Intl requires hyphens. - systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), + systemLocale: Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + let identifier = Electron.app.getSystemLocale(); + let hourCycle: "h12" | "h23" | undefined; + if (platform === "linux") { + const env = yield* HostProcess.Environment; + const timeLocale = (env.LC_ALL || env.LC_TIME || env.LANG)?.split(".")[0]; + if (timeLocale && timeLocale !== "C" && timeLocale !== "POSIX") identifier = timeLocale; + } else if (platform === "darwin") { + if (Electron.systemPreferences.getUserDefault("AppleICUForce24HourTime", "boolean")) { + hourCycle = "h23"; + } else if (Electron.systemPreferences.getUserDefault("AppleICUForce12HourTime", "boolean")) { + hourCycle = "h12"; + } + } + return toSystemLocaleTag(identifier, hourCycle); + }), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; yield* Effect.tryPromise({ diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts new file mode 100644 index 0000000000000..4239bfe4c5bca --- /dev/null +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -0,0 +1,81 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +import type { LinuxColorScheme } from "./LinuxColorScheme.ts"; + +const { nativeTheme } = vi.hoisted(() => ({ + nativeTheme: { themeSource: "system" as "system" | "light" | "dark" }, +})); + +vi.mock("electron", () => ({ nativeTheme })); + +import * as ElectronTheme from "./ElectronTheme.ts"; + +function setup() { + nativeTheme.themeSource = "system"; + const portal: { report: (scheme: LinuxColorScheme) => void; stopped: boolean } = { + report: () => undefined, + stopped: false, + }; + const watch = (onChange: (scheme: LinuxColorScheme) => void) => { + portal.report = onChange; + return { + stop: () => { + portal.stopped = true; + }, + }; + }; + return { portal, watch }; +} + +describe("ElectronTheme", () => { + it.effect("resolves the system appearance from the desktop color scheme as it changes", () => + Effect.gen(function* () { + const { portal, watch } = setup(); + const theme = yield* ElectronTheme.makeWith(watch); + yield* theme.setSource("system"); + + portal.report("dark"); + assert.equal(nativeTheme.themeSource, "dark"); + portal.report("light"); + assert.equal(nativeTheme.themeSource, "light"); + portal.report(null); + assert.equal(nativeTheme.themeSource, "system"); + }).pipe(Effect.scoped), + ); + + it.effect("keeps an explicit appearance over the desktop color scheme", () => + Effect.gen(function* () { + const { portal, watch } = setup(); + const theme = yield* ElectronTheme.makeWith(watch); + portal.report("dark"); + + yield* theme.setSource("light"); + portal.report("dark"); + assert.equal(nativeTheme.themeSource, "light"); + + yield* theme.setSource("system"); + assert.equal(nativeTheme.themeSource, "dark"); + }).pipe(Effect.scoped), + ); + + it.effect("stops following the desktop when the layer is released", () => + Effect.gen(function* () { + const { portal, watch } = setup(); + yield* ElectronTheme.makeWith(watch).pipe(Effect.scoped); + assert.isTrue(portal.stopped); + }), + ); + + it.effect("leaves the system appearance to Electron when the portal is unavailable", () => + Effect.gen(function* () { + setup(); + const theme = yield* ElectronTheme.makeWith(() => { + throw new Error("no session bus"); + }); + yield* theme.setSource("system"); + assert.equal(nativeTheme.themeSource, "system"); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ab0673b817ae2..d31939ba761ab 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -1,4 +1,5 @@ import { DesktopThemeSchema, type DesktopTheme } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -7,6 +8,8 @@ import * as Scope from "effect/Scope"; import * as Electron from "electron"; +import { watchLinuxColorScheme, type LinuxColorScheme } from "./LinuxColorScheme.ts"; + export class ElectronThemeSetSourceError extends Schema.TaggedError()( "ElectronThemeSetSourceError", { @@ -28,30 +31,68 @@ export class ElectronTheme extends Context.Service< } >()("@t3tools/desktop/electron/ElectronTheme") {} -/** @public */ -export const make = ElectronTheme.of({ - shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), - setSource: Effect.fn("ElectronTheme.setSource")(function (theme) { - return Effect.try({ - try: () => { - Electron.nativeTheme.themeSource = theme; - }, - catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), - }); - }), - onUpdated: Effect.fn("ElectronTheme.onUpdated")(function (listener) { - return Effect.acquireRelease( - Effect.suspend(() => { - Electron.nativeTheme.on("updated", listener); - return Effect.void; +type WatchSystemColorScheme = (onChange: (scheme: LinuxColorScheme) => void) => { + readonly stop: () => void; +}; + +export const makeWith = Effect.fn("ElectronTheme.make")(function* ( + watchSystemColorScheme: WatchSystemColorScheme | undefined, +) { + let source: DesktopTheme = Electron.nativeTheme.themeSource; + let systemScheme: LinuxColorScheme = null; + // On Linux, Electron reads the desktop color scheme once and then ignores the settings + // portal (electron/electron#48736), so "system" is resolved from the portal directly. + const apply = () => { + Electron.nativeTheme.themeSource = source === "system" ? (systemScheme ?? source) : source; + }; + + if (watchSystemColorScheme) { + yield* Effect.acquireRelease( + Effect.sync(() => { + try { + return watchSystemColorScheme((scheme) => { + systemScheme = scheme; + apply(); + }); + } catch { + return undefined; + } }), - () => + (watcher) => Effect.sync(() => watcher?.stop()), + ); + } + + return ElectronTheme.of({ + shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), + setSource: Effect.fn("ElectronTheme.setSource")(function (theme) { + return Effect.try({ + try: () => { + source = theme; + apply(); + }, + catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), + }); + }), + onUpdated: Effect.fn("ElectronTheme.onUpdated")(function (listener) { + return Effect.acquireRelease( Effect.suspend(() => { - Electron.nativeTheme.removeListener("updated", listener); + Electron.nativeTheme.on("updated", listener); return Effect.void; }), - ); - }), + () => + Effect.suspend(() => { + Electron.nativeTheme.removeListener("updated", listener); + return Effect.void; + }), + ); + }), + }); +}); + +/** @public */ +export const make = Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + return yield* makeWith(platform === "linux" ? watchLinuxColorScheme : undefined); }); -export const layer = Layer.succeed(ElectronTheme, make); +export const layer = Layer.effect(ElectronTheme, make); diff --git a/apps/desktop/src/electron/LinuxColorScheme.test.ts b/apps/desktop/src/electron/LinuxColorScheme.test.ts new file mode 100644 index 0000000000000..47225f0611b72 --- /dev/null +++ b/apps/desktop/src/electron/LinuxColorScheme.test.ts @@ -0,0 +1,100 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Event-driven fake of the portal transport. +import * as NodeEvents from "node:events"; +import { DBusError, Message, MessageType, Variant, type MessageBus } from "dbus-next"; +import { expect, it, vi } from "vite-plus/test"; + +import { watchLinuxColorScheme, type LinuxColorScheme } from "./LinuxColorScheme.ts"; + +const PATH = "/org/freedesktop/portal/desktop"; +const SETTINGS = "org.freedesktop.portal.Settings"; + +class FakeBus extends NodeEvents.EventEmitter { + calls: Message[] = []; + disconnect = vi.fn(); + readonly settings: { readOne?: unknown; read?: unknown }; + constructor(settings: { readOne?: unknown; read?: unknown }) { + super(); + this.settings = settings; + } + + readonly readOneCalled = Promise.withResolvers(); + + async call(message: Message) { + this.calls.push(message); + if (message.member === "AddMatch") + return new Message({ type: MessageType.METHOD_RETURN, replySerial: "1" }); + if (message.member === "ReadOne") this.readOneCalled.resolve(); + const value = await (message.member === "ReadOne" ? this.settings.readOne : this.settings.read); + if (value === undefined) throw new DBusError("org.freedesktop.DBus.Error.UnknownMethod", ""); + return new Message({ + type: MessageType.METHOD_RETURN, + replySerial: "1", + signature: "v", + body: [value], + }); + } + + changed(namespace: string, key: string, value: number) { + this.emit( + "message", + new Message({ + type: MessageType.SIGNAL, + path: PATH, + interface: SETTINGS, + member: "SettingChanged", + body: [namespace, key, new Variant("u", value)], + }), + ); + } +} + +function watch(bus: FakeBus) { + const schemes: LinuxColorScheme[] = []; + const { ready, stop } = watchLinuxColorScheme( + (scheme) => schemes.push(scheme), + bus as unknown as MessageBus, + ); + return { schemes, ready, stop }; +} + +it("reads the current color scheme and follows portal changes", async () => { + const bus = new FakeBus({ readOne: new Variant("u", 1) }); + const { schemes, ready, stop } = watch(bus); + await ready; + + bus.changed("org.freedesktop.appearance", "color-scheme", 2); + bus.changed("org.freedesktop.appearance", "accent-color", 1); + bus.changed("org.gnome.desktop.interface", "color-scheme", 1); + bus.changed("org.freedesktop.appearance", "color-scheme", 1); + // GNOME's light mode reports "no preference". + bus.changed("org.freedesktop.appearance", "color-scheme", 0); + bus.changed("org.freedesktop.appearance", "color-scheme", 3); + expect(schemes).toEqual(["dark", "light", "dark", "light", null]); + + stop(); + bus.changed("org.freedesktop.appearance", "color-scheme", 1); + expect(schemes).toEqual(["dark", "light", "dark", "light", null]); + expect(bus.disconnect).toHaveBeenCalledOnce(); +}); + +it("falls back to the nested Read reply on portals without ReadOne", async () => { + const bus = new FakeBus({ read: new Variant("v", new Variant("u", 2)) }); + const { schemes, ready } = watch(bus); + await ready; + + expect(schemes).toEqual(["light"]); + expect(bus.calls.map((call) => call.member)).toEqual(["AddMatch", "ReadOne", "Read"]); +}); + +it("keeps a change that arrives while the initial read is pending", async () => { + const reply = Promise.withResolvers(); + const bus = new FakeBus({ readOne: reply.promise }); + const { schemes, ready } = watch(bus); + + await bus.readOneCalled.promise; + bus.changed("org.freedesktop.appearance", "color-scheme", 1); + reply.resolve(new Variant("u", 0)); + await ready; + + expect(schemes).toEqual(["dark"]); +}); diff --git a/apps/desktop/src/electron/LinuxColorScheme.ts b/apps/desktop/src/electron/LinuxColorScheme.ts new file mode 100644 index 0000000000000..8b88fd5655aa8 --- /dev/null +++ b/apps/desktop/src/electron/LinuxColorScheme.ts @@ -0,0 +1,91 @@ +import { Message, MessageType, Variant, sessionBus, type MessageBus } from "dbus-next"; + +const PORTAL = "org.freedesktop.portal.Desktop"; +const PATH = "/org/freedesktop/portal/desktop"; +const SETTINGS = "org.freedesktop.portal.Settings"; +const DBUS = "org.freedesktop.DBus"; +const NAMESPACE = "org.freedesktop.appearance"; +const KEY = "color-scheme"; + +export type LinuxColorScheme = "dark" | "light" | null; + +// The portal reports 1 for prefer-dark, 2 for prefer-light and 0 for no preference. GNOME's +// light mode is 0, and the spec asks apps to use their default then, so only 1 is dark. +// The deprecated Read method nests the value in a second variant. +function decodeColorScheme(value: unknown): LinuxColorScheme { + let scheme = value; + while (scheme instanceof Variant) scheme = scheme.value; + return scheme === 1 ? "dark" : scheme === 0 || scheme === 2 ? "light" : null; +} + +const settingsCall = (member: "ReadOne" | "Read") => + new Message({ + destination: PORTAL, + path: PATH, + interface: SETTINGS, + member, + signature: "ss", + body: [NAMESPACE, KEY], + }); + +/** + * Follows the desktop color scheme through the XDG settings portal. Without a session bus or + * portal it reports nothing, so Chromium's own resolution stays in charge. + */ +export function watchLinuxColorScheme( + onChange: (scheme: LinuxColorScheme) => void, + bus: MessageBus = sessionBus(), +) { + let closed = false; + let changed = false; + const report = (value: unknown) => { + if (!closed) onChange(decodeColorScheme(value)); + }; + const onMessage = (message: Message) => { + if ( + message.type !== MessageType.SIGNAL || + message.path !== PATH || + message.interface !== SETTINGS || + message.member !== "SettingChanged" + ) + return; + const [namespace, key, value] = message.body; + if (namespace !== NAMESPACE || key !== KEY) return; + changed = true; + report(value); + }; + const ignore = () => undefined; + + bus.on("error", ignore); + bus.on("message", onMessage); + const ready = (async () => { + await bus.call( + new Message({ + destination: DBUS, + path: "/org/freedesktop/DBus", + interface: DBUS, + member: "AddMatch", + signature: "s", + body: [ + `type='signal',interface='${SETTINGS}',member='SettingChanged',path='${PATH}',arg0='${NAMESPACE}',arg1='${KEY}'`, + ], + }), + ); + // ReadOne needs Settings portal version 2; older portals only offer Read. + const reply = await bus + .call(settingsCall("ReadOne")) + .catch(() => bus.call(settingsCall("Read"))); + // The portal answers reads through its backend asynchronously, so a change signal can + // overtake this reply; the reply then carries the older value. + if (reply && !changed) report(reply.body[0]); + })().catch(ignore); + + return { + ready, + stop: () => { + closed = true; + bus.removeListener("message", onMessage); + bus.disconnect(); + }, + }; +} diff --git a/apps/desktop/src/permissions/MacSettingsWindow.test.ts b/apps/desktop/src/permissions/MacSettingsWindow.test.ts index d07b4f5f9a76d..ce0d3509d68bd 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.test.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.test.ts @@ -1,4 +1,5 @@ import * as NodeEvents from "node:events"; +import * as NodeVM from "node:vm"; import { beforeEach, expect, it, vi } from "vite-plus/test"; import { watchMacSettingsWindow } from "./MacSettingsWindow.ts"; @@ -42,3 +43,29 @@ it("distinguishes unavailable tracking from a valid absent window", () => { expect(unavailable).toHaveBeenCalledTimes(2); stop(); }); +it("polls while its parent lives and stops after reparenting to launchd", () => { + watchMacSettingsWindow(vi.fn(), vi.fn())(); + const script = mocks.spawn.mock.lastCall?.[1][3]; + const imports = new Set(); + const writeData = vi.fn(); + const poll = vi.fn(() => ({ count: 0 })); + const sleep = vi.fn(() => { + if (sleep.mock.calls.length > 2) throw new Error("tracker kept polling after parent exit"); + }); + const bridge = Object.assign((line: string) => ({ dataUsingEncoding: () => line }), { + getppid: () => { + if (!imports.has("unistd")) throw new Error("unistd bridge unavailable"); + return sleep.mock.calls.length < 2 ? 42 : 1; + }, + NSRunningApplication: { runningApplicationsWithBundleIdentifier: poll }, + NSFileHandle: { fileHandleWithStandardOutput: { writeData } }, + NSThread: { sleepForTimeInterval: sleep }, + NSUTF8StringEncoding: 4, + }); + NodeVM.runInNewContext(script + "\nrun();", { + ObjC: { import: (name: string) => imports.add(name) }, + $: bridge, + }); + expect(poll).toHaveBeenCalledTimes(2); + expect(writeData.mock.calls).toEqual([["null\n"]]); +}); diff --git a/apps/desktop/src/permissions/MacSettingsWindow.ts b/apps/desktop/src/permissions/MacSettingsWindow.ts index e5acc09abbf92..5967d15de97a4 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.ts @@ -20,9 +20,10 @@ const decodeSettingsWindow = Schema.decodeUnknownSync(Schema.fromJsonString(Sett const SETTINGS_WINDOW_SCRIPT = ` ObjC.import("CoreGraphics"); ObjC.import("AppKit"); +ObjC.import("unistd"); function run() { let previous = ""; - while (true) { + while ($.getppid() !== 1) { const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.systempreferences"); let result = null; if (apps.count > 0) { diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts index 6fc5b7ae9faa1..69f21285e1965 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts @@ -17,6 +17,7 @@ import { unwrapWindowsDpapiKey, } from "./ChromiumKeys.ts"; import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; +import { BROWSER_IMPORT_SOURCES } from "./Sources.ts"; const { getPassword } = vi.hoisted(() => ({ getPassword: vi.fn<() => string | null>() })); @@ -146,12 +147,24 @@ describe("Linux Chromium secrets", () => { ), ); - it.effect("preserves trailing whitespace in the stored secret", () => - Effect.gen(function* () { - const secret = yield* readLinuxSecret("chrome"); - expect(secret).toBe("linux-secret \t\n"); - }).pipe(Effect.provide(layerHelper({ stdout: "linux-secret \t\n" }))), - ); + it.effect("derives Vivaldi's Linux key while preserving whitespace in the stored secret", () => { + let captured: CapturedCommand | undefined; + return Effect.gen(function* () { + const source = BROWSER_IMPORT_SOURCES.find((source) => source.id === "vivaldi")!; + const keys = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: source.keychainService, + keychainAccount: source.keychainAccount, + linuxSecretApplication: source.linuxSecretApplication, + }); + expect(captured?.args).toEqual(["chrome"]); + expect(keys.cbcV11?.toString("hex")).toBe("95aa769d6a5beca14d5d90ae655db112"); + }).pipe( + Effect.provide( + layerHelper({ stdout: "linux-secret \t\n", capture: (value) => (captured = value) }), + ), + ); + }); it.effect("drains stdout and stderr concurrently", () => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 14c4bca77b7e6..3f94269bfe273 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -109,7 +109,8 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray { }).pipe(Effect.provide(layer)), ); - it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => + it.effect("grants preview permissions through both the request and check handlers", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; const partition = yield* browserSessions.getPartition("scope-a"); @@ -168,7 +168,12 @@ describe("BrowserSession", () => { return granted; }; - for (const permission of ["clipboard-sanitized-write", "geolocation", "fullscreen"]) { + for (const permission of [ + "clipboard-sanitized-write", + "geolocation", + "fullscreen", + "loopback-network", + ]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); assert.isTrue( checkHandler(null, permission) as boolean, @@ -179,7 +184,13 @@ describe("BrowserSession", () => { // `clipboard-write` is not a real Electron permission — the async write API // uses `clipboard-sanitized-write` — so the stale name must not be granted, // and unrelated permissions stay denied. - for (const permission of ["clipboard-write", "notifications", "midi"]) { + for (const permission of [ + "clipboard-write", + "notifications", + "midi", + "local-network", + "local-network-access", + ]) { assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`); assert.isFalse( checkHandler(null, permission) as boolean, diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index e467c370d0f5b..f8f23aca6afa7 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -24,6 +24,8 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "geolocation", // DesktopWindow confines HTML fullscreen to the guest's webview. "fullscreen", + // Loopback requests already succeed in Electron 44; permission-aware sign-in widgets query first. + "loopback-network", // Deny local-fonts to untrusted previews: it exposes installed-font fingerprints and file bytes. // Deny notifications: pages could impersonate T3 Code; app notifications use the default session. ]); diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 1c1f9dc078ca0..a2589edf8f7b2 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -14,6 +14,9 @@ const makeTarget = ( setDownloadDirectory, }); +const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) => + createCdpRelayConnection(target, write, () => {}); + // Each relay reply resolves after a few microtasks; one macrotask lets them all land. // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -21,9 +24,7 @@ const settle = () => new Promise((resolve) => setImmediate(resolve)); describe("CDP relay", () => { it("announces only the tab's page, under its real target id", async () => { const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(vi.fn()), (raw) => written.push(JSON.parse(raw))); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); relay.receive(JSON.stringify({ id: 2, method: "Target.createTarget", params: {} })); await settle(); @@ -51,9 +52,7 @@ describe("CDP relay", () => { new Promise((resolve) => pending.push(() => resolve({ method }))), ); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive( JSON.stringify({ id: 5, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }), ); @@ -76,7 +75,7 @@ describe("CDP relay", () => { it("resumes paused child frames, so cross-site iframes run", async () => { const send = vi.fn(async () => ({})); - const relay = createCdpRelayConnection(makeTarget(send), () => {}); + const relay = makeRelay(makeTarget(send), () => {}); relay.receive( JSON.stringify({ id: 1, @@ -102,9 +101,7 @@ describe("CDP relay", () => { it("acknowledges Playwright's focus emulation without forcing it onto the guest", async () => { const send = vi.fn(async () => ({})); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive( JSON.stringify({ id: 1, @@ -120,9 +117,7 @@ describe("CDP relay", () => { it("routes tab events to the page session once the page is announced", async () => { const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(vi.fn()), (raw) => written.push(JSON.parse(raw))); relay.event("Page.loadEventFired", {}, undefined); expect(written).toEqual([]); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); @@ -140,9 +135,7 @@ describe("CDP relay extra sessions", () => { it("gives a second page session the page's commands and events", async () => { const send = vi.fn(async () => ({})); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); relay.receive(JSON.stringify({ id: 2, method: "Target.attachToBrowserTarget" })); await settle(); @@ -182,7 +175,7 @@ describe("CDP relay downloads", () => { const send = vi.fn(async () => ({})); const directories: Array = []; const written: Array> = []; - const relay = createCdpRelayConnection( + const relay = makeRelay( makeTarget(send, (directory) => directories.push(directory)), (raw) => written.push(JSON.parse(raw)), ); @@ -213,3 +206,58 @@ describe("CDP relay downloads", () => { ]); }); }); + +describe("CDP relay payload budget", () => { + // 2,048 references to one 40 KiB string: about 80 MiB once serialized, little memory here. + const oversized = Array(2048).fill("x".repeat(40 * 1024)); + + it("fails only an oversized reply and still returns a large screenshot", async () => { + const screenshot = "A".repeat(16 * 1024 * 1024); + const send = vi.fn(async (method: string) => + method === "DOM.getDocument" ? { nodes: oversized } : { data: screenshot }, + ); + const written: Array> = []; + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); + relay.receive( + JSON.stringify({ id: 1, method: "DOM.getDocument", sessionId: "t3-preview-page" }), + ); + relay.receive( + JSON.stringify({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }), + ); + await settle(); + + const replies = written.map(({ id, error, result }) => ({ + id, + error, + size: (result as { data?: string } | undefined)?.data?.length, + })); + expect(replies).toEqual([ + { id: 1, error: { code: -32000, message: "CDP payload too large" }, size: undefined }, + { id: 2, error: undefined, size: screenshot.length }, + ]); + }); + + it.each([ + { args: oversized }, + { args: "\u0001".repeat(12 * 1024 * 1024) }, + { args: Array(2_700_000).fill(-0.0000010000000000000002) }, + ])("closes instead of dropping an oversized event (%#)", async ({ args }) => { + const written: Array> = []; + const close = vi.fn(); + const relay = createCdpRelayConnection( + makeTarget(vi.fn()), + (raw) => written.push(JSON.parse(raw)), + close, + ); + relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); + await settle(); + written.length = 0; + + relay.event("Runtime.consoleAPICalled", { args }, ""); + relay.event("Page.loadEventFired", { timestamp: 1 }, ""); + + relay.receive(JSON.stringify({ id: 2, method: "Target.setAutoAttach" })); + expect(close).toHaveBeenCalledOnce(); + expect(written.map((message) => message["method"])).toEqual([]); + }); +}); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 948bc361f5d92..d52b0a3b2ab55 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -36,6 +36,29 @@ const isCommand = (value: unknown): value is CdpCommand => typeof (value as { id?: unknown }).id === "number" && typeof (value as { method?: unknown }).method === "string"; +// CDP values are plain JSON data, so their serialized size can be bounded before +// JSON.stringify allocates it. A string counts at its length unless it holds a +// character JSON may escape, which can take six code units. +const MAX_CDP_PAYLOAD_SIZE = 64 * 1024 * 1024; +const MAY_ESCAPE = /[^\x20\x21\x23-\x5b\x5d-\ud7ff\ue000-\uffff]/; +const serializedSize = (text: string) => + (MAY_ESCAPE.test(text) ? text.length * 6 : text.length) + 3; + +const fitsPayloadBudget = (message: unknown) => { + let remaining = MAX_CDP_PAYLOAD_SIZE; + const visit = (value: unknown): boolean => { + remaining -= typeof value === "string" ? serializedSize(value) : 26; + if (remaining < 0) return false; + if (typeof value !== "object" || value === null) return true; + if (Array.isArray(value)) return value.every(visit); + return Object.entries(value).every(([key, item]) => { + remaining -= serializedSize(key); + return visit(item); + }); + }; + return visit(message); +}; + const BROWSER_CONTEXT_ID = "t3-preview"; const ANNOUNCE = Symbol("announce"); const PAGE_SESSION_ID = "t3-preview-page"; @@ -50,8 +73,30 @@ const ACKNOWLEDGED = new Set([ export function createCdpRelayConnection( target: CdpRelayTarget, write: (message: string) => void, + close: () => void, ): CdpRelayConnection { - const send = (message: Record) => write(JSON.stringify(message)); + let closed = false; + const send = (message: Record) => { + if (closed) return; + if (fitsPayloadBudget(message)) { + write(JSON.stringify(message)); + return; + } + // An error reply fails only that command. A dropped event would leave + // Playwright out of step with the page, so the relay closes instead. + if (typeof message["id"] === "number") { + write( + JSON.stringify({ + id: message["id"], + error: { code: -32000, message: "CDP payload too large" }, + ...(message["sessionId"] === undefined ? {} : { sessionId: message["sessionId"] }), + }), + ); + return; + } + closed = true; + close(); + }; let attached = false; /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); @@ -151,6 +196,7 @@ export function createCdpRelayConnection( return { receive: (raw) => { + if (closed) return; let command: unknown; try { command = JSON.parse(raw); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 08fefd8d733f5..3168788bebe9b 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron debugger. -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; import { DesktopBrowserEvent } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -968,4 +968,73 @@ describe("DesktopBrowserHost", () => { expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(false); }), ); + + it.effect("detaches only the tab whose CDP event is too large to relay", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const tabs = [makeDebuggee({ deferFocus: true }), makeDebuggee()]; + const send = vi.spyOn(tabs[0]!.tab.debugger, "sendCommand"); + const keys = [key, { threadId: "thread-2", tabId: "tab-2" }]; + const events = yield* Queue.unbounded(); + yield* host.events.pipe( + Stream.runForEach((line) => + Queue.offer(events, decodeEvent(new TextDecoder().decode(line))), + ), + Effect.forkScoped, + ); + for (const [index, tabKey] of keys.entries()) { + host.attach(tabKey, tabs[index]!.tab); + expect(yield* Queue.take(events)).toEqual({ type: "attached", ...tabKey }); + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...tabKey, + message: encodeJson({ id: 1, method: "Target.setAutoAttach" }), + }), + ); + // The command's reply and the page announcement. + yield* Queue.takeN(events, 2); + } + + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...key, + message: encodeJson({ + id: 2, + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, + sessionId: "t3-preview-page", + }), + }), + ); + yield* command(host, 3, "Page.startScreencast"); + tabs[0]!.emit("Runtime.consoleAPICalled", { + args: Array(2048).fill("x".repeat(40 * 1024)), + }); + tabs[1]!.emit("Page.loadEventFired", { timestamp: 1 }); + const [detached, next] = yield* Queue.takeN(events, 2); + + const { type, threadId, tabId } = detached!; + expect({ type, threadId, tabId }).toEqual({ type: "detached", ...key }); + expect(send).toHaveBeenCalledWith("Target.setAutoAttach", { + autoAttach: false, + waitForDebuggerOnStart: false, + flatten: true, + }); + tabs[0]!.settleFocus(); + yield* settle; + expect(send).not.toHaveBeenCalledWith("Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + }); + expect(send).toHaveBeenCalledWith("Page.stopScreencast"); + expect(tabs[0]!.focusEmulation).toEqual([true, false]); + expect(next).toMatchObject({ type: "cdp", ...keys[1] }); + expect(JSON.parse((next as { message: string }).message)).toMatchObject({ + method: "Page.loadEventFired", + }); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 30f252e3a677e..5a2b5463fc674 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -329,7 +329,7 @@ export const make = Effect.gen(function* () { const forward = (current?: () => boolean) => { const change = tab.focusChange; const run = () => - current === undefined || current() + tab.relay === relay && (current === undefined || current()) ? send(method, params, sessionId) : Promise.reject(new Error("The page this screenshot was for has moved on.")); return change === null ? run() : change.then(run); @@ -372,6 +372,24 @@ export const make = Effect.gen(function* () { emit({ type: "cdp", ...tab.key, message }); } }, + // Announcing the tab again would reconnect Playwright, and the page could + // replay the same oversized event, so the tab stays detached. + () => { + if (tab.relay !== relay || tabs.get(keyOf(tab.key)) !== tab) return; + // Disabling auto-attach detaches child sessions and releases their debugger pauses. + void debuggee + .sendCommand("Target.setAutoAttach", { + autoAttach: false, + waitForDebuggerOnStart: false, + flatten: true, + }) + .catch(() => {}); + if (tab.screencasts.size > 0) { + void debuggee.sendCommand("Page.stopScreencast").catch(() => {}); + } + setFocusEmulation(tab, false); + detach(tab.key); + }, ); tab.relay = relay; return relay; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 8ea4f0b6eddb6..22e797b91eabb 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2201,6 +2201,48 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("shows picture-in-picture and keeps capturing after stalled captures", () => + withManager((manager) => + Effect.gen(function* () { + const image = { + toJPEG: () => Buffer.from("preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + }; + const stalledInitial = Promise.withResolvers(); + const stalledLoop = Promise.withResolvers(); + const capturePage = vi + .fn<() => Promise>() + .mockImplementationOnce(() => stalledInitial.promise) + .mockImplementationOnce(() => stalledLoop.promise) + .mockResolvedValue(image); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + yield* manager.createTab("tab_pip_stalled_capture"); + yield* manager.registerWebview("tab_pip_stalled_capture", 42); + + try { + const open = yield* manager + .openPictureInPicture("tab_pip_stalled_capture") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(1_000); + expect(pictureInPictureWindow.showInactive).toHaveBeenCalledOnce(); + yield* Fiber.join(open); + + expect(send).not.toHaveBeenCalled(); + yield* TestClock.adjust(500); + expect(send).toHaveBeenCalled(); + } finally { + // Release stalled native calls after a failing assertion against unchanged base. + stalledInitial.resolve(image); + stalledLoop.resolve(image); + } + }), + ), + ); + effectIt.effect("rejects picture-in-picture when its webview changes during initialization", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index e965e04ad664d..cdbf2d40d91b2 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2485,13 +2485,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!captureSession?.consumers.has("picture-in-picture") || captureSession.scope === null) return; const wc = yield* requireWebContents(tabId); - const image = yield* attemptPromise( - { - operation: "frameCapture.capturePage", - tabId, - webContentsId: wc.id, - }, - () => wc.capturePage(), + const captureContext = { operation: "frameCapture.capturePage", tabId, webContentsId: wc.id }; + const image = yield* attemptPromise(captureContext, () => wc.capturePage()).pipe( + Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail(new PreviewOperationError({ ...captureContext, cause })), + }), ); const currentCaptureSession = yield* Effect.all( [SynchronizedRef.get(frameCaptureSessionsRef), SynchronizedRef.get(tabsRef)], diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 7c63a8b9aadef..fe166f5d099a0 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -1,5 +1,6 @@ import { it as effectIt } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { parseKeybindingShortcut } from "@t3tools/shared/keybindings"; import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; @@ -9,6 +10,7 @@ import { compactAccessibilityTree, findAccessibleWindow, isWaylandSession, + toElectronAccelerator, } from "./snapShot.ts"; describe("accessibleWindowText", () => { @@ -448,6 +450,21 @@ describe("findAccessibleWindow", () => { }); }); +describe("toElectronAccelerator", () => { + // The settings recorder stores shortcuts through parseKeybindingShortcut, which lowercases keys. + it.each([ + ["mod+shift+arrowup", "CommandOrControl+Shift+Up"], + ["mod+shift+arrowdown", "CommandOrControl+Shift+Down"], + ["ctrl+alt+arrowleft", "Control+Alt+Left"], + ["ctrl+alt+arrowright", "Control+Alt+Right"], + ["mod+shift+esc", "CommandOrControl+Shift+Esc"], + ["mod+shift+space", "CommandOrControl+Shift+Space"], + ["mod+shift+2", "CommandOrControl+Shift+2"], + ])("maps a recorded %s to an Electron accelerator", (recorded, accelerator) => { + expect(toElectronAccelerator(parseKeybindingShortcut(recorded)!)).toBe(accelerator); + }); +}); + describe("isWaylandSession", () => { effectIt.effect( "falls back to a live runtime directory socket when session variables are stripped", diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 872f99e1d6a72..8be137cb32a46 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -539,14 +539,15 @@ function findBrowserTitleMatch( return browserMatches.length === 1 ? browserMatches[0] : undefined; } +// Keyed by the lowercase tokens parseKeybindingShortcut stores; Electron has no `ArrowUp` token. const ELECTRON_KEY_NAMES: Readonly> = { " ": "Space", "+": "Plus", - ArrowDown: "Down", - ArrowLeft: "Left", - ArrowRight: "Right", - ArrowUp: "Up", - Escape: "Esc", + arrowdown: "Down", + arrowleft: "Left", + arrowright: "Right", + arrowup: "Up", + escape: "Esc", }; export function sameSnapShotShortcut(left: SnapShotShortcut, right: SnapShotShortcut): boolean { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index d88838439dba7..30db8274c208f 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -232,4 +232,45 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { omittedReleaseCount: 0, }); }); + + it("ignores the Full Changelog paragraph that ends GitHub's generated notes", () => { + const fullChangelog = (from: string, to: string) => + `

Full Changelog: v${from}...v${to}

`; + const result = normalizeDesktopUpdateReleaseNotes( + [ + { + version: "0.1.3-nightly.20261024.3604", + note: fullChangelog("0.1.3-nightly.20261023.3592", "0.1.3-nightly.20261024.3604"), + }, + { + version: "0.1.3-nightly.20261023.3592", + note: + "

What's Changed

\n
    \n" + + '
  • fix(web): A by @a in #1
  • \n' + + '
  • fix(web): B by @b in #2
  • \n' + + "
  • Full Changelog: links open in the browser in #3
  • \n" + + "
\n" + + fullChangelog("0.1.3-nightly.20261023.3579", "0.1.3-nightly.20261023.3592"), + }, + ], + "0.1.3-nightly.20261024.3604", + "nightly", + ); + + expect(result).toEqual({ + releaseNotes: [ + { + version: "0.1.3-nightly.20261023.3592", + items: [ + "Full Changelog: links open in the browser in #3", + "fix(web): B by @b in #2", + "fix(web): A by @a in #1", + ], + releasedAt: "2026-10-23", + totalItems: 3, + }, + ], + omittedReleaseCount: 0, + }); + }); }); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 2704563cfc0b8..1fb81088175c1 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -86,7 +86,9 @@ function isIgnoredReleaseNoteLine(line: string): boolean { normalized === "what's changed" || normalized === "whats changed" || normalized.startsWith("compare: ") || - normalized.includes("/compare/") + normalized.includes("/compare/") || + // The feed's HTML footer loses its compare URL to stripMarkup, leaving only the range. + /^full changelog: \S+\.\.\.\S+$/.test(normalized) ); } diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 7d16b5057fc17..a9880eede305b 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -473,6 +473,45 @@ describe("DesktopWindow", () => { ); }); + it("rejects renderer navigations whose origin only serializes as null", () => { + for (const applicationUrl of ["t3code://app/", "t3code-dev://app/"]) { + assert.isTrue( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl, + navigationUrl: `${applicationUrl}threads?id=1#latest`, + }), + applicationUrl, + ); + } + for (const navigationUrl of [ + "t3code://other/", + "t3code://app.example/", + "t3code://app:8080/", + "t3code://user@other/", + "t3code:app", + "t3code-dev://app/", + "file:///etc/passwd", + "data:text/html,

hi

", + "javascript:alert(1)", + "about:blank", + "blob:t3code://app/0b5e2c4a", + ]) { + assert.isFalse( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "t3code://app/", + navigationUrl, + }), + navigationUrl, + ); + } + assert.isFalse( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "data:text/html,app", + navigationUrl: "data:text/html,other", + }), + ); + }); + it.effect( "opens and reopens the window without backend readiness when local execution is disabled", () => @@ -974,6 +1013,17 @@ describe("DesktopWindow", () => { if (!willNavigate) { return yield* Effect.die("will-navigate listener was not registered"); } + for (const [url, expectedPrevented] of [ + ["t3code-dev://app/threads?id=1#latest", false], + ["t3code-dev://other/", true], + ["t3code://app/", true], + ] as const) { + const preventDefault = vi.fn(); + willNavigate({ preventDefault }, url); + assert.equal(preventDefault.mock.calls.length, expectedPrevented ? 1 : 0, url); + } + assert.deepEqual(openedExternalUrls, []); + let prevented = false; willNavigate( { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index c1c0cf5446cd2..0d1db93290ee4 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -183,12 +183,19 @@ function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } -function isSameOriginRendererNavigation(input: { +export function isSameOriginRendererNavigation(input: { readonly applicationUrl: string; readonly navigationUrl: string; }): boolean { try { - return new URL(input.applicationUrl).origin === new URL(input.navigationUrl).origin; + const applicationUrl = new URL(input.applicationUrl); + const navigationUrl = new URL(input.navigationUrl); + // Node serializes custom-scheme origins as "null", even for different hosts and schemes. + return ( + applicationUrl.host !== "" && + navigationUrl.protocol === applicationUrl.protocol && + navigationUrl.host === applicationUrl.host + ); } catch { return false; } diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index e77d896be2469..ef02cacb8a3a7 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -7,6 +7,7 @@ import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; import * as NetService from "@t3tools/shared/Net"; +import { T3_NPX_COMMAND } from "@t3tools/shared/releasePackage"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; @@ -128,7 +129,7 @@ export const layer = Layer.effect( Ref.set( preflightErrorRef, Option.some( - "Another T3 Code server is using this distro's T3 home, or an older background service is set up for it. Stop that server or pair with it, or try `t3 update` in the distro. If the older launcher cannot update, run `t3 service uninstall` and optionally reinstall with `t3 service install` from a current CLI in the distro. Use `npx t3@latest` if `t3` is missing or outdated, and pass `--base-dir` with the service's T3 home to each command.", + `Another T3 Code server is using this distro's T3 home, or an older background service is set up for it. Stop that server or pair with it, or try \`t3 update\` in the distro. If the older launcher cannot update, run \`t3 service uninstall\` and optionally reinstall with \`t3 service install\` from a current CLI in the distro. Use \`${T3_NPX_COMMAND}@latest\` if \`t3\` is missing or outdated, and pass \`--base-dir\` with the service's T3 home to each command.`, ), ), ), diff --git a/apps/marketing/public/pfps/AtharvaIngle7.webp b/apps/marketing/public/pfps/AtharvaIngle7.webp new file mode 100644 index 0000000000000..3352a53161520 Binary files /dev/null and b/apps/marketing/public/pfps/AtharvaIngle7.webp differ diff --git a/apps/marketing/public/pfps/BennettBuhner.webp b/apps/marketing/public/pfps/BennettBuhner.webp deleted file mode 100644 index 39d5af923c51e..0000000000000 Binary files a/apps/marketing/public/pfps/BennettBuhner.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/Josikinz.webp b/apps/marketing/public/pfps/Josikinz.webp deleted file mode 100644 index 68d5825222ac0..0000000000000 Binary files a/apps/marketing/public/pfps/Josikinz.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/MichaelArnaldi.webp b/apps/marketing/public/pfps/MichaelArnaldi.webp new file mode 100644 index 0000000000000..812ceb1c2ff12 Binary files /dev/null and b/apps/marketing/public/pfps/MichaelArnaldi.webp differ diff --git a/apps/marketing/public/pfps/PavolRusnak.webp b/apps/marketing/public/pfps/PavolRusnak.webp new file mode 100644 index 0000000000000..09ed72e80f4a0 Binary files /dev/null and b/apps/marketing/public/pfps/PavolRusnak.webp differ diff --git a/apps/marketing/public/pfps/Rasmic.webp b/apps/marketing/public/pfps/Rasmic.webp new file mode 100644 index 0000000000000..e6bd6bcae1d26 Binary files /dev/null and b/apps/marketing/public/pfps/Rasmic.webp differ diff --git a/apps/marketing/public/pfps/Shay_Benshabtay.webp b/apps/marketing/public/pfps/Shay_Benshabtay.webp deleted file mode 100644 index c827f7504c44e..0000000000000 Binary files a/apps/marketing/public/pfps/Shay_Benshabtay.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/amritwt.webp b/apps/marketing/public/pfps/amritwt.webp new file mode 100644 index 0000000000000..a89bc38f5a3ab Binary files /dev/null and b/apps/marketing/public/pfps/amritwt.webp differ diff --git a/apps/marketing/public/pfps/appfactory.webp b/apps/marketing/public/pfps/appfactory.webp new file mode 100644 index 0000000000000..92ee890b71cb8 Binary files /dev/null and b/apps/marketing/public/pfps/appfactory.webp differ diff --git a/apps/marketing/public/pfps/aronprins.webp b/apps/marketing/public/pfps/aronprins.webp deleted file mode 100644 index 90db37d28f2d4..0000000000000 Binary files a/apps/marketing/public/pfps/aronprins.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/developedbyed.webp b/apps/marketing/public/pfps/developedbyed.webp deleted file mode 100644 index 8f1dfbab6dbca..0000000000000 Binary files a/apps/marketing/public/pfps/developedbyed.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/dhh.webp b/apps/marketing/public/pfps/dhh.webp new file mode 100644 index 0000000000000..1bdca7e527f0c Binary files /dev/null and b/apps/marketing/public/pfps/dhh.webp differ diff --git a/apps/marketing/public/pfps/enunomaduro.webp b/apps/marketing/public/pfps/enunomaduro.webp deleted file mode 100644 index ce054dbfb6558..0000000000000 Binary files a/apps/marketing/public/pfps/enunomaduro.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/iamkaffe.webp b/apps/marketing/public/pfps/iamkaffe.webp deleted file mode 100644 index ded6ed2cd868f..0000000000000 Binary files a/apps/marketing/public/pfps/iamkaffe.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/kostyniuk00.webp b/apps/marketing/public/pfps/kostyniuk00.webp deleted file mode 100644 index c52f0b305b9b2..0000000000000 Binary files a/apps/marketing/public/pfps/kostyniuk00.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/leodev.webp b/apps/marketing/public/pfps/leodev.webp deleted file mode 100644 index 91a47d5de9429..0000000000000 Binary files a/apps/marketing/public/pfps/leodev.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/linuz90.webp b/apps/marketing/public/pfps/linuz90.webp new file mode 100644 index 0000000000000..23bcb27f88c0a Binary files /dev/null and b/apps/marketing/public/pfps/linuz90.webp differ diff --git a/apps/marketing/public/pfps/mil000.webp b/apps/marketing/public/pfps/mil000.webp deleted file mode 100644 index 37d784b752cfb..0000000000000 Binary files a/apps/marketing/public/pfps/mil000.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/mweinbach.webp b/apps/marketing/public/pfps/mweinbach.webp new file mode 100644 index 0000000000000..f7a30f82b1f66 Binary files /dev/null and b/apps/marketing/public/pfps/mweinbach.webp differ diff --git a/apps/marketing/public/pfps/pcstyle53.webp b/apps/marketing/public/pfps/pcstyle53.webp new file mode 100644 index 0000000000000..6d17caae22d8c Binary files /dev/null and b/apps/marketing/public/pfps/pcstyle53.webp differ diff --git a/apps/marketing/public/pfps/pocarles.webp b/apps/marketing/public/pfps/pocarles.webp deleted file mode 100644 index f259df8312482..0000000000000 Binary files a/apps/marketing/public/pfps/pocarles.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/serhiyhvala.webp b/apps/marketing/public/pfps/serhiyhvala.webp new file mode 100644 index 0000000000000..f299338d9cd58 Binary files /dev/null and b/apps/marketing/public/pfps/serhiyhvala.webp differ diff --git a/apps/marketing/public/pfps/szdziedzic.webp b/apps/marketing/public/pfps/szdziedzic.webp new file mode 100644 index 0000000000000..a4b193b12a0cf Binary files /dev/null and b/apps/marketing/public/pfps/szdziedzic.webp differ diff --git a/apps/marketing/public/pfps/teja2495.webp b/apps/marketing/public/pfps/teja2495.webp deleted file mode 100644 index 457901988d4ce..0000000000000 Binary files a/apps/marketing/public/pfps/teja2495.webp and /dev/null differ diff --git a/apps/marketing/src/lib/tweets.ts b/apps/marketing/src/lib/tweets.ts index 604fbec91484b..54fed029a849b 100644 --- a/apps/marketing/src/lib/tweets.ts +++ b/apps/marketing/src/lib/tweets.ts @@ -15,63 +15,64 @@ export const tweets = [ link: "https://x.com/QuinnyPig/status/2089581739887014376", }, { - handle: "tannerlinsley", - content: "I would just use T3 Code at that point and contribute back.", - link: "https://x.com/tannerlinsley/status/2035067912659341399", + handle: "dhh", + content: + "Omarchy is going to ship with an installer for T3 Code because it's a cool project that a lot of people enjoy. Squashing beefs in the process is just bonus.", + excerpt: + "Omarchy is going to ship with an installer for T3 Code because it's a cool project...", + link: "https://x.com/dhh/status/2090332565966123041", }, { - handle: "ryanrhughes", + handle: "MichaelArnaldi", content: - "I think I've finally found the one AI harness to steal me away from the terminal — @t3dotcodes.", - excerpt: "I think I've finally found the one AI harness to steal me away from the terminal", - link: "https://x.com/ryanrhughes/status/2093051703616573769", + "Since I've started using T3 Code I no longer care which harness is used, I'd actually love to get rid of all of them and just use @tim_smart 's clanka", + excerpt: "Since I've started using T3 Code I no longer care which harness is used", + link: "https://x.com/MichaelArnaldi/status/2110290227021365720", + }, + { + handle: "DavidKPiano", + content: "It's like Claude Code if they didn't vibe-code the entire thing", + link: "https://x.com/DavidKPiano/status/2054682983504719930", }, { - handle: "developedbyed", - content: "might say alpha but T3 code is pretty awesome, really fast too", - link: "https://x.com/developedbyed/status/2030627970532921605", + handle: "tannerlinsley", + content: "I would just use T3 Code at that point and contribute back.", + link: "https://x.com/tannerlinsley/status/2035067912659341399", }, { handle: "RhysSullivan", content: "T3 Code is worth giving a try if you haven’t yet\n\nIt takes what I liked so much about codex’s remote setup and improves upon it, it’s a great UX using it on both desktop and mobile\n\nAlso is just nice to have all the official harnesses in one app", - excerpt: - "It takes what I liked so much about codex’s remote setup and improves upon it, it’s a great UX using it on both desktop and mobile", + excerpt: "It takes what I liked so much about codex’s remote setup and improves upon it", link: "https://x.com/RhysSullivan/status/2089750636686643493", }, { - handle: "DavidKPiano", - content: "It's like Claude Code if they didn't vibe-code the entire thing", - link: "https://x.com/DavidKPiano/status/2054682983504719930", - }, - { - handle: "JoshuaRileyDev", + handle: "Rasmic", content: - "Well I was 2,000 miles away on holiday and able to do iOS development from just my phone, T3 Code’s remote feature is a magical experience", - excerpt: "I was 2,000 miles away on holiday and able to do iOS development from just my phone", - link: "https://x.com/JoshuaRileyDev/status/2094379365408616837", + "I can't believe my favourite gui is built by a YouTuber and a Swedish guy named Julius", + link: "https://x.com/Rasmic/status/2113063390515995066", }, { - handle: "aronprins", + handle: "AtharvaIngle7", content: - "T3 Code works better than Claude and Codex combined… and T3 actually combines then (and more!) 🤯\n\nHuge shoutout to @theo and @jullerino\n\nAmazing product fellers 🔥🙌", - excerpt: - "T3 Code works better than Claude and Codex combined… and T3 actually combines then (and more!)", - link: "https://x.com/aronprins/status/2089761738358915267", + "T3 Code is miles ahead of anything else i've tried (even the Codex app, which i haven't opened in a month fyi).", + excerpt: "T3 Code is miles ahead of anything else i've tried", + link: "https://x.com/AtharvaIngle7/status/2114267747203039682", }, { - handle: "teja2495", + handle: "appfactory", content: - "I’ve completely switched to T3 Code for all my workflows. I just switch between different subscriptions and harnesses depending on what I need.", - excerpt: "I’ve completely switched to T3 Code for all my workflows.", - link: "https://x.com/teja2495/status/2052420254991581623", + "This is a popular opinion now, but T3 Code is a pleasure to use.\n\nIt is the most sane version of an agent orchestrator that I've used: It has well thought out defaults and good performance.\n\nWell done T3 Team!", + excerpt: "It is the most sane version of an agent orchestrator that I've used", + link: "https://x.com/appfactory/status/2108498055074812393", }, { - handle: "ryanrhughes", + handle: "linuz90", content: - "Then there's the mobile app. All of my threads, on all of my environments, in my pocket. Fantastic.\n\nWhile traveling the other day, I had to use T3 Code alongside my old Claude Code + Herdr setup from 36,000ft on my phone.\n\nOn T3 Code, it felt like LAN. On the TUI, every keystroke took about 1sec to show up because it had to roundtrip to the device before I was able to actually see it. Brutal.", - excerpt: "...from 36,000ft on my phone. On T3 Code, it felt like LAN.", - link: "https://x.com/ryanrhughes/status/2093051708939059204", + "I’m at the gym and my own personal gym app misses some exercises.\n\nSo I launch a T3 Code thread and ship updates as I train.\n\nGolden age for makers or peak AI psychosis?", + excerpt: + "...gym app misses some exercises. So I launch a T3 Code thread and ship updates as I train.", + link: "https://x.com/linuz90/status/2110051011578745210", }, { handle: "ex0t1clol", @@ -79,42 +80,30 @@ export const tweets = [ link: "https://x.com/ex0t1clol/status/2054666870008021197", }, { - handle: "enunomaduro", - content: "So I tried @theo's T3 Code...", - link: "https://x.com/enunomaduro/status/2091915930141876495", + handle: "mweinbach", + content: "T3 Code with connect in just a web browser is so killer I love it so much", + link: "https://x.com/mweinbach/status/2077595624707395636", }, { - handle: "pocarles", - content: - "Only using Codex and T3 now.\n\nI thought all AI coding harnesses had roughly the same impact. Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", - excerpt: - "Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", - link: "https://x.com/pocarles/status/2054673964274758046", - }, - { - handle: "BennettBuhner", + handle: "ryanrhughes", content: - "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere! The app is great but the website is even greater, so instead of needing to SSH into a machine, T3 IS my SSH!", - excerpt: - "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere.", - link: "https://x.com/BennettBuhner/status/2054667115697754387", + "I think I've finally found the one AI harness to steal me away from the terminal — @t3dotcodes.", + excerpt: "I think I've finally found the one AI harness to steal me away from the terminal", + link: "https://x.com/ryanrhughes/status/2093051703616573769", }, { - handle: "leodev", + handle: "PavolRusnak", content: - "T3 Code is probably the first coding gui that didn't suck performance wise and had a smart team behind it.", - link: "https://x.com/leodev/status/2054679746353537042", + "Super pleasantly surprised with @t3dotcodes\n\nSpent countless hours trying to get OpenClaw or Hermes Agent working for my use cases to no avail\n\nBut with t3 everything just works as expected and on my first try!", + excerpt: "...with t3 everything just works as expected and on my first try!", + link: "https://x.com/PavolRusnak/status/2114844388136267873", }, { - handle: "Shay_Benshabtay", + handle: "amritwt", content: - "T3 code is not perfect, but it's damn good. Open and fun , I can make my own fork for my uses and use the great tooling with my own needs.", - link: "https://x.com/Shay_Benshabtay/status/2054668503857156326", - }, - { - handle: "iamkaffe", - content: "T3Code was the first one to truly care about Linux users.", - link: "https://x.com/iamkaffe/status/2054675539311411280", + "I finally installed T3 Code and I am in awe about how it connects to a remote VM seamlessly with no Tailscale", + excerpt: "...I am in awe about how it connects to a remote VM seamlessly with no Tailscale", + link: "https://x.com/amritwt/status/2104046763418263999", }, { handle: "_winter_wonders", @@ -122,22 +111,32 @@ export const tweets = [ link: "https://x.com/_winter_wonders/status/2052350198764970434", }, { - handle: "kostyniuk00", + handle: "szdziedzic", content: - "I was not expecting a year ago, that my anti-AI colleagues would thank me a year later, for persuading them to try T3 Code Beta, that really helped them with organizing their workflows. \n\nFantastic product by @jullerino and @theo. Go try it out if you haven’t yet!", - excerpt: - "My anti-AI colleagues thanked me for persuading them to try T3 Code Beta. Fantastic product.", - link: "https://x.com/kostyniuk00/status/2052041388179468521", + "I became a T3 Code user recently - it’s great especially if you have many subs and many machines to manage. I recommend giving it a shot if you haven’t already 👏.", + excerpt: "...it’s great especially if you have many subs and many machines to manage.", + link: "https://x.com/szdziedzic/status/2103225721007005823", + }, + { + handle: "JoshuaRileyDev", + content: + "Well I was 2,000 miles away on holiday and able to do iOS development from just my phone, T3 Code’s remote feature is a magical experience", + excerpt: "I was 2,000 miles away on holiday and able to do iOS development from just my phone", + link: "https://x.com/JoshuaRileyDev/status/2094379365408616837", }, { - handle: "Josikinz", + handle: "pcstyle53", content: - "T3 code is better because it’s like if the codex Mac app didn’t make my computer run like shit 😋", - link: "https://x.com/Josikinz/status/2030367951694745870", + "the fact that @theo's team got the terminal in t3 code so good that you can use advanced TUIs inside it is insane to me", + excerpt: + "...got the terminal in t3 code so good that you can use advanced TUIs inside it is insane to me", + link: "https://x.com/pcstyle53/status/2080949310401757306", }, { - handle: "mil000", - content: "T3 code saved my relationship!", - link: "https://x.com/mil000/status/2030120041451246071", + handle: "serhiyhvala", + content: + "Now it’s the best app I’ve ever used. I can’t imagine my workday without it. I even cancelled my Cursor subscription. I just don’t need it anymore.\n\nIf you tried it once and bounced, give it a second chance.", + excerpt: "If you tried it once and bounced, give it a second chance.", + link: "https://x.com/serhiyhvala/status/2115375016061608018", }, ] satisfies Tweet[]; diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7a0042903a3c3..872ca142c8aaa 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1006,6 +1006,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, + sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, }, }), NotFound: createNativeStackScreen({ diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index c6dc31ca00ad2..ea901f2441331 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -1,6 +1,7 @@ import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useIsFocused } from "@react-navigation/native"; +import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; @@ -8,6 +9,8 @@ import { AppText } from "./ui/AppText"; import { SymbolView } from "./ui/AppSymbol"; import { VideoThumbnailImage } from "./VideoThumbnailImage"; import { useMediaActions } from "../state/mediaActions"; +import { fitVideoFrame } from "../lib/videoFrameFit"; +import { loadVideoThumbnail } from "../lib/videoThumbnails"; import { type MediaActionsSource } from "../lib/mediaActionsSource"; import { useMediaVideoPlayback } from "../lib/useMediaVideoPlayback"; import { MediaActionsMenu } from "./MediaActionsMenu"; @@ -136,23 +139,54 @@ interface MediaVideoPlayerProps { readonly visible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; + /** Fill the parent and size the frame to the video's shape instead of a fixed 16:9 slot. */ + readonly fitToVideo?: boolean; readonly autoPlay?: boolean; readonly paused?: boolean; readonly onPlaybackStart?: () => void; readonly actionsSource?: MediaActionsSource; + readonly borderRadius?: number; } function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { const mediaActions = useMediaActions(props.actionsSource); + const isFocused = useIsFocused(); const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); + const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null); + const sizeSource = props.fitToVideo && isFocused && videoSize === null ? props.uri : null; + // Queue before the poster so early Play cannot cancel the extraction that supplies frame dimensions. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads native thumbnail dimensions independently of the poster's lifetime + useLayoutEffect(() => { + if (sizeSource === null) return; + // Thumbnails are oriented like the displayed frame, unlike VideoTrack.size. + const controller = new AbortController(); + void loadVideoThumbnail( + props.thumbnailKey, + async () => ({ uri: sizeSource, dispose: () => undefined }), + controller.signal, + ).then((thumbnail) => { + if (thumbnail && !controller.signal.aborted) { + setVideoSize({ width: thumbnail.width, height: thumbnail.height }); + } + }); + return () => controller.abort(); + }, [props.thumbnailKey, sizeSource]); + const fittedFrame = + props.fitToVideo && videoSize && bounds ? fitVideoFrame(videoSize, bounds, 480) : null; - return ( + const frame = ( {playbackUri ? ( ); + if (!props.fitToVideo) return frame; + return ( + { + const { width, height } = event.nativeEvent.layout; + setBounds({ width, height }); + }} + > + {frame} + + ); } export function MediaVideoPlayer(props: MediaVideoPlayerProps) { diff --git a/apps/mobile/src/components/RelativeTime.tsx b/apps/mobile/src/components/RelativeTime.tsx new file mode 100644 index 0000000000000..8d2067a4907f9 --- /dev/null +++ b/apps/mobile/src/components/RelativeTime.tsx @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from "react"; +import { msUntilRelativeTimeChange, relativeTime } from "../lib/time"; + +// The React Compiler caches inline relativeTime calls on the timestamp alone. +export function RelativeTime(props: { timestamp: string }) { + const subscribe = (onChange: () => void) => { + let timer: ReturnType | undefined; + const schedule = () => { + const delayMs = msUntilRelativeTimeChange(props.timestamp); + if (delayMs === null) return; + timer = setTimeout(() => { + onChange(); + schedule(); + }, delayMs); + }; + schedule(); + return () => clearTimeout(timer); + }; + const getSnapshot = () => relativeTime(props.timestamp); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/mobile/src/components/ScrollComposeButton.android.tsx b/apps/mobile/src/components/ScrollComposeButton.android.tsx index e9e57373e19c0..d107d9be5d8a0 100644 --- a/apps/mobile/src/components/ScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/ScrollComposeButton.android.tsx @@ -3,13 +3,12 @@ import { Box, ExtendedFloatingActionButton, Icon, Text } from "@expo/ui/jetpack- import { defaultMinSize, fillMaxWidth, - graphicsLayer, height, onSizeChanged, size, } from "@expo/ui/jetpack-compose/modifiers"; import { useState } from "react"; -import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; +import { I18nManager, Pressable, View, type StyleProp, type ViewStyle } from "react-native"; import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { resolveScaledTextRole } from "../lib/appearancePreferences"; @@ -37,7 +36,14 @@ export function ScrollComposeButton(props: { className={props.className} style={[props.style, { left: 20, height: fabSize }]} > - + diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 62e43c04cda61..e958dbfdaaa18 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -24,7 +24,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { relativeTime } from "../../lib/time"; +import { RelativeTime } from "../../components/RelativeTime"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useServerConfigs } from "../../state/entities"; import { useEnvironmentScope } from "../../state/session"; @@ -193,7 +193,6 @@ function ArchivedThreadRow(props: { props.thread.environmentId, AuthOrchestrationOperateScope, ); - const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); @@ -219,7 +218,7 @@ function ArchivedThreadRow(props: { {props.thread.title} - {timestamp} + {subtitle.length > 0 ? ( diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx index 842d58c9aa371..03b6daaa4e715 100644 --- a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -14,6 +14,7 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import * as Dialog from "../../components/ui/Dialog"; import { Menu } from "../../components/ui/Menu"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; import { deregisterManagedRelayEnvironmentCommand, @@ -46,6 +47,9 @@ export function T3ConnectProfilePage() { const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { reportFailure: false, }); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refreshAfterMutation, { + reportFailure: false, + }); const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = useState(null); const mutationPendingRef = useRef(false); @@ -75,6 +79,7 @@ export function T3ConnectProfilePage() { return { accountId, linkedAtById }; }); environmentsState.refresh(); + void refreshRelayEnvironments(); return; } if (isAtomCommandInterrupted(result)) return; diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 1133e17113ee4..c7866cea6ce06 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -283,6 +283,9 @@ export function ConnectionEnvironmentRow(props: { )} props.onReconnect(props.environment.environmentId)} @@ -296,6 +299,8 @@ export function ConnectionEnvironmentRow(props: { props.onRemove(props.environment.environmentId)} > diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 8e7ee048587f0..117dbc44ede58 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -361,6 +361,7 @@ function useThreadFilesWorkspace(params: { readonly threadId?: string | string[]; readonly cwd?: string | string[]; readonly projectName?: string | string[]; + readonly path?: string | string[]; }) { const routeEnvironmentId = firstRouteParam(params.environmentId); const routeThreadId = firstRouteParam(params.threadId); @@ -382,6 +383,7 @@ function useThreadFilesWorkspace(params: { cwd: routeCwd ?? selectedThreadCwd ?? project?.workspaceRoot ?? null, environmentId, projectName: routeProjectName ?? project?.title ?? "Files", + relativePath: normalizeRoutePath(params.path), selectedThread, threadId, }; @@ -592,11 +594,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const params = props.route.params; - const relativePath = normalizeRoutePath(params.path); const targetLine = normalizeRouteLine(firstRouteParam(params.line)); - const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( - props.route.params, - ); + const { cwd, environmentId, projectName, relativePath, selectedThread, threadId } = + useThreadFilesWorkspace(props.route.params); const fileAccessSession = useEnvironmentQuery( environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, ); diff --git a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx index 72b31f061e964..7ee4a818954d5 100644 --- a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx @@ -19,6 +19,7 @@ export function WorkspaceFileVideoPreview(props: { resolvePlaybackUri={props.resolvePlaybackUri} name={props.name} thumbnailKey={props.thumbnailKey} + fitToVideo actionsSource={props.source?.actionsSource} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 1edb92f0d556b..192755ec435df 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -53,7 +53,7 @@ import { useThreadListV2 } from "../threads/useThreadListV2"; import { useThreadListNow } from "../threads/use-thread-list-now"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; -import { threadListEnvironmentsAtom } from "../../state/server"; +import { threadListEnvironmentsAtom } from "../../state/presentation"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index ec0f6a14558dd..9313aef299881 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -13,6 +13,7 @@ import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ + connectedEnvironmentIds: [] as EnvironmentId[], pendingOrder: null as object | null, dropBusy: false, failedAction: null as string | null, @@ -60,6 +61,9 @@ vi.mock("../../state/session", () => ({ readEnvironmentScope: (environmentId: string, scope: string) => state.scopes.get(environmentId)?.has(scope) === true, })); +vi.mock("../../state/presentation", () => ({ + connectedEnvironmentIdsAtom: "connected-environments", +})); vi.mock("../../state/server", () => ({ environmentServerConfigsAtom: "server-configs", })); @@ -69,28 +73,30 @@ vi.mock("../../state/atom-registry", () => ({ state.dropBusy = value; }, get: (atom: string) => - atom === "thread-drop-busy" - ? state.dropBusy - : atom === "thread-shells" - ? state.shells - : atom === "queued-thread-keys" - ? new Set() - : new Map( - [...state.scopes.keys()].map((environmentId) => [ - environmentId, - { - environment: { - capabilities: { - threadSettlement: true, - threadSnooze: true, - threadPinning: true, - threadPinReorder: true, - threadTitleRegeneration: true, + atom === "connected-environments" + ? state.connectedEnvironmentIds + : atom === "thread-drop-busy" + ? state.dropBusy + : atom === "thread-shells" + ? state.shells + : atom === "queued-thread-keys" + ? new Set() + : new Map( + [...state.scopes.keys()].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { + threadSettlement: true, + threadSnooze: true, + threadPinning: true, + threadPinReorder: true, + threadTitleRegeneration: true, + }, }, }, - }, - ]), - ), + ]), + ), }, })); vi.mock("../../state/use-atom-command", () => ({ @@ -130,6 +136,7 @@ vi.mock("../../state/threads", () => ({ "pin", "unpin", "reorderPin", + "reorderActive", "updateMetadata", ].map((action) => [ action, @@ -179,6 +186,7 @@ const mutationCases = [ ] as const; beforeEach(() => { + state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; state.failedAction = null; state.archivedRefreshes = []; state.pendingOrder = null; @@ -286,6 +294,76 @@ describe("pinned thread operation permissions", () => { }); }); +describe("thread reorder connection availability", () => { + it.each(["active", "pinned"] as const)( + "rejects %s materialization involving a cached capable offline neighbor", + async (section) => { + const moved = makeThread({ + pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null, + }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("offline-neighbor"), + environmentId: otherEnvironmentId, + createdAt: "2026-09-02T00:00:00.000Z", + pinnedAt: moved.pinnedAt, + }), + ]; + state.scopes.get(otherEnvironmentId)?.add(AuthOrchestrationOperateScope); + state.connectedEnvironmentIds = [primaryEnvironmentId]; + + const { moveThread } = useThreadListActions(); + expect(await moveThread(moved, "up")).toBe(false); + expect(state.requests).toEqual([]); + + state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; + + expect(await moveThread(moved, "up")).toBe(true); + expect(state.requests).toHaveLength(2); + expect(new Set(state.requests.map(({ environmentId }) => environmentId))).toEqual( + new Set([primaryEnvironmentId, otherEnvironmentId]), + ); + }, + ); + + it.each(["active", "pinned"] as const)( + "uses the latest connection state in a retained %s move callback", + async (section) => { + const moved = makeThread({ + pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null, + pinOrderKey: section === "pinned" ? "m" : null, + activeOrderKey: section === "active" ? "m" : null, + }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("offline-neighbor"), + environmentId: otherEnvironmentId, + pinnedAt: moved.pinnedAt, + pinOrderKey: section === "pinned" ? "g" : null, + activeOrderKey: section === "active" ? "g" : null, + }), + ]; + const { moveThread } = useThreadListActions(); + state.connectedEnvironmentIds = []; + + expect(await moveThread(moved, "up")).toBe(false); + expect(state.requests).toEqual([]); + + state.connectedEnvironmentIds = [primaryEnvironmentId]; + + expect(await moveThread(moved, "up")).toBe(true); + expect(state.requests).toEqual([ + expect.objectContaining({ + action: section === "pinned" ? "reorderPin" : "reorderActive", + environmentId: primaryEnvironmentId, + }), + ]); + }, + ); +}); + describe("thread action undo", () => { it.each([ ["settleThread", "settle", "Settled", "unsettle"], diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index cac37f0552802..248c436905311 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -15,6 +15,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; +import { connectedEnvironmentIdsAtom } from "../../state/presentation"; import { readEnvironmentScope } from "../../state/session"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; @@ -591,6 +592,10 @@ export function useThreadListActions(): { return settled; } const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const connectedEnvironmentIds = new Set(appAtomRegistry.get(connectedEnvironmentIdsAtom)); + + if (!connectedEnvironmentIds.has(thread.environmentId)) return false; + if (!configs.has(thread.environmentId)) { Dialog.alert( "Could not move thread", @@ -610,7 +615,9 @@ export function useThreadListActions(): { allThreads: shells, ordered, section, - reorderableEnvironmentIds: new Set(configs.keys()), + reorderableEnvironmentIds: new Set( + [...configs.keys()].filter((environmentId) => connectedEnvironmentIds.has(environmentId)), + ), })(scopedThreadKey(thread.environmentId, thread.id), direction); if (assignments === null) return false; const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 88987819ddf00..cc9946dded8ab 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -8,7 +8,7 @@ import { type EnvironmentThreadSearchMatch, type ShownOrder, } from "@t3tools/client-runtime/state/thread-search"; -import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import { isListedThreadLineage, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { foldSearchQuery, PALETTE_MESSAGE_LIMIT, @@ -422,10 +422,7 @@ export function CommandPalette(props: { }), })); const threadItems: CommandPaletteItem[] = threads - .filter( - (thread) => - thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent", - ) + .filter((thread) => thread.archivedAt === null && isListedThreadLineage(thread.lineage)) .map((thread) => { const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); const environment = diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c3b46cf8e24c2..517d3ddf2981b 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -15,7 +15,16 @@ import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3too import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; -import { use, useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + use, + useDeferredValue, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { @@ -129,12 +138,48 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi ); })(); - // useFocusEffect re-runs when the renderer changes while focused, so the - // pane always shows this render's content, and its cleanup deactivates on - // blur and unmount. - useFocusEffect(() => - wrappedRender === undefined ? undefined : registerWorkspaceInspector(wrappedRender), - ); + const focusedRef = useRef(false); + const deactivateRef = useRef<(() => void) | null>(null); + + const syncRegistration = useEffectEvent(() => { + if (!focusedRef.current || wrappedRender === undefined) { + deactivateRef.current?.(); + return; + } + deactivateRef.current = registerWorkspaceInspector(wrappedRender); + }); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Navigation focus events own the inspector registration lifetime. + useEffect(() => { + if (navigation === undefined) return; + const focus = () => { + if (focusedRef.current) return; + focusedRef.current = true; + syncRegistration(); + }; + const blur = () => { + focusedRef.current = false; + syncRegistration(); + }; + if (navigation.isFocused()) focus(); + const unsubscribeFocus = navigation.addListener("focus", focus); + const unsubscribeBlur = navigation.addListener("blur", blur); + return () => { + unsubscribeFocus(); + unsubscribeBlur(); + focusedRef.current = false; + deactivateRef.current?.(); + deactivateRef.current = null; + }; + }, [navigation]); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Changed pane content replaces the focused inspector registration. + useEffect(() => { + if (focusedRef.current) { + syncRegistration(); + } + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed pane content replaces its existing native workspace registration. + }, [wrappedRender]); } export function AdaptiveWorkspaceLayout(props: { diff --git a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx index f8f7a5f9c5b15..6bdb399d0ad30 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx @@ -9,7 +9,7 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { Menu } from "../../components/ui/Menu"; import type { MenuProps } from "../../components/ui/Menu.types"; import { cn } from "../../lib/cn"; -import { relativeTime } from "../../lib/time"; +import { RelativeTime } from "../../components/RelativeTime"; import { selectedThreadRowColors, THREAD_LIST_V2_SIDEBAR_ROW_STYLE, @@ -132,7 +132,7 @@ export const PullRequestListRow = memo( ) : null} - {relativeTime(item.updatedAt)} + ReadonlyArray) | null>( null, ); +const LineBreaksContext = createContext(false); export function PullRequestMarkdownRepository(props: { /** The change request the markdown belongs to, whose host decides what its text links. */ @@ -44,6 +48,7 @@ export function PullRequestMarkdownRepository(props: { provider === undefined || url === undefined ? null : changeRequestReferenceAutolinker({ provider, url }); + const lineBreaks = sourceControlClients.find(provider ?? "")?.hardLineBreaks ?? false; const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, @@ -53,7 +58,9 @@ export function PullRequestMarkdownRepository(props: { const media = cwd ? { environmentId: target.environmentId, cwd } : null; return ( - {props.children} + + {props.children} + ); } @@ -88,6 +95,7 @@ function MarkdownImage(props: { export function PullRequestMarkdown(props: { readonly markdown: string; readonly imageGroupMarkdown?: string; + readonly lineBreaks?: boolean; }) { const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]); return ( @@ -98,6 +106,7 @@ export function PullRequestMarkdown(props: { key={segment.id} markdown={segment.text} imageGroupMarkdown={props.imageGroupMarkdown ?? props.markdown} + lineBreaks={props.lineBreaks} /> ) : ( @@ -110,7 +119,9 @@ export function PullRequestMarkdown(props: { function PullRequestMarkdownContent(props: { readonly markdown: string; readonly imageGroupMarkdown?: string; + readonly lineBreaks: boolean | undefined; }) { + const hostLineBreaks = useContext(LineBreaksContext); const autolinks = useContext(AutolinksContext); const mediaContext = useContext(PullRequestMediaContext); const markdown = linkReferenceAutolinks(props.markdown, autolinks); @@ -136,6 +147,7 @@ function PullRequestMarkdownContent(props: { - Updated {relativeTime(pr.updatedAt)} ago + Updated ago diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index 6c675219dc943..02e8b516deccb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -11,6 +11,7 @@ import { useAtomValue } from "@effect/atom-react"; import { Keyboard, Platform, + Pressable, RefreshControl, ScrollView, View, @@ -20,6 +21,7 @@ import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeS import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; +import { SymbolView } from "../../components/ui/AppSymbol"; import { EmptyState } from "../../components/ui/EmptyState"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; @@ -32,7 +34,6 @@ import { usePullRequestListFilters, type PullRequestFilterMenu, } from "./PullRequestListFilters"; -import { PullRequestCardRow } from "./PullRequestControls"; import { usePullRequestList } from "./usePullRequestList"; import { usePullRequestListControls } from "./usePullRequestListControls"; import { usePullRequestListActions } from "./usePullRequestListActions"; @@ -58,26 +59,38 @@ function PullRequestScrollView(props: ScrollViewProps) { ); } -function PullRequestListErrors(props: { readonly messages: readonly string[] }) { +function PullRequestListError(props: { readonly message: string }) { const [expanded, setExpanded] = useState(false); - const title = `${props.messages.length} ${props.messages.length === 1 ? "error" : "errors"} loading pull requests`; return ( - - setExpanded((value) => !value)} + setExpanded((value) => !value)} + // A long press selects the text; collapsing under that selection crashes Android's + // smart-select animation, so it must not also count as a press. + onLongPress={() => undefined} + className="min-h-11 flex-row items-center gap-3 px-4 py-2.5 active:opacity-70" + > + - {expanded ? ( - - {props.messages.join("\n")} - - ) : null} - + + {props.message} + + + ); } @@ -334,13 +347,14 @@ export function PullRequestsScreen( <> {result.data?.errors.length ? ( - - `${error.projectTitle || environments.find((environment) => environment.environmentId === error.environmentId)?.environmentLabel || "Environment"}: ${error.message}`, - )} - /> + + {result.data.errors.map((error) => ( + environment.environmentId === error.environmentId)?.environmentLabel || "Environment"}: ${error.message}`} + /> + ))} + ) : null} } diff --git a/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx b/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx index 1308d4caee799..9fd845eff921e 100644 --- a/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx +++ b/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx @@ -59,7 +59,7 @@ export function GuideOutline({ {guide.stops[0]?.summary ? ( - + ) : null} {guide.stops.map((stop, position) => { diff --git a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx index caa1e72938c87..3cae97487f9fb 100644 --- a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx +++ b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx @@ -188,7 +188,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps {stop.title} - + {stop.attention ? ( @@ -197,7 +197,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps - + ) : null} {stale ? ( @@ -237,7 +237,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps {note.path.split("/").at(-1)}:{note.line} - + ))} @@ -266,7 +266,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps Questions to consider {stop.questions.map((question) => ( - + ))} ) : null} diff --git a/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx b/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx index aa5f9e8d87f34..c280c6ea46525 100644 --- a/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx +++ b/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx @@ -55,7 +55,9 @@ export function ReviewGuideExcerpt({ const [NativeDiff] = useState(() => resolveNativeReviewDiffView()); return ( - {excerpt.caption.trim() ? : null} + {excerpt.caption.trim() ? ( + + ) : null} ({ failPage: false })); + +vi.mock("../../connection/catalog", async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentCatalog: { + sessionGenerationAtom: Atom.family((_environmentId: string) => Atom.make(0)), + }, + }; +}); + +vi.mock("../../state/pull-requests", async () => { + const { AsyncResult, Atom } = await import("effect/reactivity"); + const Effect = await import("effect/Effect"); + const page = (nextCursors: PullRequestListResult["nextCursors"]): PullRequestListResult => ({ + entries: [], + nextCursors, + truncated: false, + viewers: {}, + providers: [], + errors: [], + }); + const list = Atom.family((key: string) => { + const target: { environmentId: string; input: PullRequestListInput } = JSON.parse(key); + const response = page(target.input.cursors ? {} : { "github.com org/repo": "next" }); + return Atom.make( + Effect.suspend(() => { + if (target.environmentId === "offline") + return Effect.fail(new Error("Environment is not connected.")); + if (target.input.cursors && mocks.failPage) return Effect.fail(new Error("Page failed.")); + return Effect.succeed(response); + }), + ); + }); + const { pullRequestListHead } = await import("./pullRequestList"); + const pages = Atom.family((_key: string) => + Atom.make | null>(null), + ); + return { + pullRequestListPagesKey: (target: unknown) => JSON.stringify(target), + pullRequestListPagesFamily: pages, + pullRequestListStatsFamily: Atom.family((_key: string) => Atom.make(new Map())), + composerPullRequests: { + list: (target: unknown) => list(JSON.stringify(target)), + listStats: Atom.family(() => Atom.make(AsyncResult.success({ stats: [] }))), + invalidate: { + run: async () => AsyncResult.success({}), + }, + }, + }; +}); + +import { usePullRequestList } from "./usePullRequestList"; + +function setup(ids: readonly string[]) { + mocks.failPage = false; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const registry = AtomRegistry.make(); + const targets = ids.map((id) => ({ + environmentId: EnvironmentId.make(id), + input: { state: "open" as const }, + })); + let latest: ReturnType; + function Probe() { + const result = usePullRequestList(targets); + useLayoutEffect(() => { + latest = result; + }); + return null; + } + const root = createRoot(document.createElement("div")); + act(() => { + root.render( + + + , + ); + }); + return { + get latest() { + return latest; + }, + [Symbol.dispose]() { + act(() => root.unmount()); + registry.dispose(); + }, + }; +} + +it("keeps a refresh failure in the source errors and allows healthy pagination", async () => { + using probe = setup(["healthy", "offline"]); + await act(async () => probe.latest.onRefresh()); + expect(probe.latest.data.errors.map((error) => error.message)).toEqual([ + "Environment is not connected.", + ]); + expect(probe.latest.refreshing).toBe(false); + expect(probe.latest.loadMoreError).toBeNull(); + + await act(async () => probe.latest.loadMore()); + expect(probe.latest.continued).toBe(true); + expect(probe.latest.hasMore).toBe(false); + expect(probe.latest.loadMoreError).toBeNull(); +}); + +it("keeps load-more failures retryable", async () => { + using probe = setup(["healthy"]); + await act(async () => probe.latest.onRefresh()); + mocks.failPage = true; + await act(async () => probe.latest.loadMore()); + expect(probe.latest.loadMoreError).toBe("Page failed."); + expect(probe.latest.hasMore).toBe(true); + expect(probe.latest.continued).toBe(false); + + mocks.failPage = false; + await act(async () => probe.latest.loadMore(true)); + expect(probe.latest.loadMoreError).toBeNull(); + expect(probe.latest.hasMore).toBe(false); + expect(probe.latest.continued).toBe(true); +}); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index 5bc3cf730b520..8b94f5d1993c7 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -162,8 +162,7 @@ export function usePullRequestList(targets: readonly ListTarget[]) { const refreshAtom = refreshLoadFamily(scopeKey); const page = useAtomValue(pageAtom); const refresh = useAtomValue(refreshAtom); - // Starting one kind of load clears the other, so at most one holds an error. - const loadError = settledError(refresh) ?? settledError(page); + const loadError = settledError(page); // A page or refresh asked for while an environment was unreachable fails at once, and its // error stops loading on scroll. Once it is back, ask for the page again; a refresh's error is @@ -184,7 +183,7 @@ export function usePullRequestList(targets: readonly ListTarget[]) { const pageResult = registry.get(pageAtom); const refreshResult = registry.get(refreshAtom); if (pageResult.waiting || refreshResult.waiting) return; - if (!retry && (settledError(refreshResult) ?? settledError(pageResult))) return; + if (!retry && settledError(pageResult)) return; if (!continuations(registry, targets).length) return; registry.set(refreshAtom, Atom.Reset); registry.set(pageAtom, retry); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 18908302d577e..359ea229cb8cc 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -435,7 +435,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const environment = useEnvironmentPresentation(environmentId); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; - const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); + const { draftMessage, draftContext } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const { selectedThreadCwd } = useSelectedThreadWorktree(); const columnMetrics = useNativeColumnLayoutMetrics(); @@ -475,6 +475,7 @@ export function ReviewSheet(props: ReviewSheetProps) { cwd: selectedThreadCwd, selectedSection, draftMessage, + draftContext, }); // Expo may return null before registration or in older binaries; use the fallback. const NativeReviewDiffView = resolveNativeReviewDiffView(); diff --git a/apps/mobile/src/features/review/reviewCommentSelection.test.ts b/apps/mobile/src/features/review/reviewCommentSelection.test.ts index 59a0bb6bcb683..81eb0bba4083a 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.test.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; +import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; + +import { pullRequestComposerContext } from "../../lib/composerContext"; import { clearReviewCommentTarget, @@ -50,6 +54,74 @@ function makeTarget(): ReviewCommentTarget { } describe("review comment serialization", () => { + it("excludes a linked pull request chip from draft review comments", () => { + const record = pullRequestComposerContext( + { + number: 42, + title: "Fix checkout", + url: "https://github.com/example/repo/pull/42", + headBranch: "fix-checkout", + baseBranch: "main", + state: "open", + isDraft: false, + }, + "pr-42", + ); + + expect( + parseReviewInlineComments(formatComposerContextReference(record), { + version: 1, + records: [record], + }), + ).toEqual([]); + }); + + it("reads an added review comment after the composer upgrades it to a context chip", () => { + const upgraded = upgradeLegacyContextMessage( + formatReviewCommentContext(makeTarget(), "Please keep this configurable."), + ); + const comments = parseReviewInlineComments(upgraded.text, { + version: 1, + records: upgraded.records, + }); + + expect(comments).toEqual([ + { + id: upgraded.records[0]!.contextId, + sectionId: "section-1", + sectionTitle: "Working tree", + filePath: "apps/demo/src/main.ts", + startIndex: 0, + endIndex: 1, + rangeLabel: "7", + text: "Please keep this configurable.", + diff: "@@ -7,1 +7,1 @@\n-const retryLimit = 2;\n+const retryLimit = 4;", + fenceLanguage: "diff", + }, + ]); + }); + + it("keeps legacy comments and counts a typed comment only while its chip is present", () => { + const upgraded = upgradeLegacyContextMessage( + formatReviewCommentContext(makeTarget(), "Typed comment"), + ); + const context = { version: 1, records: upgraded.records } as const; + const legacy = formatReviewCommentContext(makeTarget(), "Legacy comment"); + const comments = parseReviewInlineComments( + `${legacy}\n${upgraded.text}\n${upgraded.text}`, + context, + ); + + expect(comments.map((comment) => comment.text)).toEqual(["Legacy comment", "Typed comment"]); + expect(parseReviewInlineComments(legacy, context).map((comment) => comment.text)).toEqual([ + "Legacy comment", + ]); + expect(parseReviewInlineComments("", context)).toEqual([]); + expect( + parseReviewInlineComments(upgraded.text.replace("/review-comment/", "/mention/"), context), + ).toEqual([]); + }); + it("keeps closing-tag text inside a chip label within a real review body", () => { const body = "Before [](t3-context://v1/mention/context-1) after"; const serialized = `${body}`; diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 4e3b22407f5a0..70f226d604e04 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -1,5 +1,9 @@ import { useSyncExternalStore } from "react"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import type { OrchestrationMessageContext } from "@t3tools/contracts"; +import { + collectComposerContextReferences, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; import type { ReviewRenderableLineRow } from "./reviewModel"; @@ -357,11 +361,10 @@ export function formatReviewCommentContext(target: ReviewCommentTarget, comment: ].join("\n"); } -export function countReviewCommentContexts(value: string): number { - return Array.from(value.matchAll(/ { +export function parseReviewInlineComments( + value: string, + context?: OrchestrationMessageContext, +): ReadonlyArray { const comments: ReviewInlineComment[] = []; // Mask chip delimiters: labels containing would otherwise truncate the block. const masked = replaceComposerContextReferences(value, (reference) => @@ -385,6 +388,33 @@ export function parseReviewInlineComments(value: string): ReadonlyArray reference.kind === "review-comment") + .map((reference) => reference.contextId), + ); + for (const record of context?.records ?? []) { + if ( + record.kind !== "review-comment" || + "payload" in record || + record.sectionId.startsWith("pull-request:") || + !referencedIds.has(record.contextId) + ) { + continue; + } + comments.push({ + id: record.contextId, + sectionId: record.sectionId, + sectionTitle: record.sectionTitle, + filePath: record.filePath, + startIndex: record.startIndex, + endIndex: record.endIndex, + rangeLabel: record.rangeLabel, + text: record.text, + diff: record.diff, + ...(record.fenceLanguage !== undefined ? { fenceLanguage: record.fenceLanguage } : {}), + }); + } return comments; } diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index 6ccc5ae3f495c..d9ec656cbc773 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -1,7 +1,7 @@ import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; +import { parseReviewInlineComments } from "./reviewCommentSelection"; import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; import { getCachedReviewParsedDiff } from "./reviewState"; import { @@ -14,6 +14,7 @@ import { import type { EnvironmentId, + OrchestrationMessageContext, ReviewDiffFileStat, ReviewDiffPreviewSource, } from "@t3tools/contracts"; @@ -126,8 +127,9 @@ export function useReviewDiffData(input: { readonly cwd: string | null; readonly selectedSection: ReviewSectionItem | null; readonly draftMessage: string; + readonly draftContext: OrchestrationMessageContext | undefined; }) { - const { draftMessage, selectedSection, threadKey } = input; + const { draftMessage, draftContext, selectedSection, threadKey } = input; const selectedSectionId = selectedSection?.id ?? null; const source = selectedSection?.source; const lazySource = source?.truncated && source.files ? source : null; @@ -230,7 +232,7 @@ export function useReviewDiffData(input: { () => formatHeaderDiffSummary(parsedDiff, selectedSection?.files), [parsedDiff, selectedSection?.files], ); - const inlineReviewComments = parseReviewInlineComments(draftMessage); + const inlineReviewComments = parseReviewInlineComments(draftMessage, draftContext); const selectedSectionInlineComments = useMemo(() => { if (!selectedSectionId || inlineReviewComments.length === 0) { return EMPTY_INLINE_REVIEW_COMMENTS; @@ -241,7 +243,7 @@ export function useReviewDiffData(input: { parsedDiff, comments: selectedSectionInlineComments, }); - const pendingReviewCommentCount = countReviewCommentContexts(draftMessage); + const pendingReviewCommentCount = inlineReviewComments.length; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- records review perf marks and diagnostic logs once a parsed diff has committed useEffect(() => { diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 842965dbf1575..4b02247c61376 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -10,6 +10,7 @@ import { connectionRouteLabel, connectionRoutes, isLearned, + pendingRouteOrder, } from "@t3tools/client-runtime/connection"; import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; import { @@ -67,12 +68,10 @@ export function EnvironmentRoutesSection({ const saved = entry === undefined ? [] : connectionRoutes(entry); const savedIds = saved.map((route) => connectionRouteId(route.target)); const [pending, setPending] = useState | null>(null); - const order = - pending !== null && - pending.length === savedIds.length && - pending.some((id, index) => id !== savedIds[index]) - ? pending - : savedIds; + const shownPending = pendingRouteOrder(pending, savedIds); + // Dropped during render so a stale order never outlives the save it was for. + if (pending !== null && shownPending === null) setPending(null); + const order = shownPending ?? savedIds; const [drag, setDrag] = useState<{ readonly id: string; readonly translation: number } | null>( null, ); diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 583ab61cf7d2f..bb1faf28f0d5c 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -72,6 +72,7 @@ import { SettingsSection } from "./components/SettingsSection"; import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; import { editDraft, + parseIntervalMinutes, scheduledTaskDefaultModel, scheduleFromDraft, type ScheduledTaskDraft as Draft, @@ -1127,7 +1128,7 @@ function TaskForm({ setDraft({ ...draft, schedule: { ...draft.schedule, intervalMinutes } }) } /> - {Number(draft.schedule.intervalMinutes) < 1 ? ( + {!(parseIntervalMinutes(draft.schedule.intervalMinutes) >= 1) ? ( Intervals must be at least 1 minute. Update this interval before saving. diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index 05b267242d41c..b61a57c8b7536 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -2,6 +2,7 @@ import { useNavigation, useRoute } from "@react-navigation/native"; import type { ReactNode } from "react"; import { Platform, View } from "react-native"; +import { AndroidSheetHeader } from "../../../components/ui/AndroidScreenHeader"; import type { AppBarProps } from "../../../components/ui/AppBar.types"; import { AppBar } from "../../../components/ui/AppBar"; import { ScreenContent as SettingsScreenContent } from "../../../components/ui/ScreenContent"; @@ -43,18 +44,28 @@ export function SettingsScreen( return ( - navigation.goBack()} - hideBottomBorder={!props.formSheet} - /> + {Platform.OS === "android" && props.formSheet ? ( + navigation.goBack()} + hideBottomBorder + /> + ) : ( + navigation.goBack()} + hideBottomBorder={!props.formSheet} + /> + )} - {props.formSheet ? ( + {props.formSheet && Platform.OS !== "android" ? ( props.children ) : ( { it.each([ ["1.5", 90_000], + ["2,5", 150_000], + [" 2,5 ", 150_000], [String(60_001 / 60_000), 60_001], ])("preserves a valid %s minute interval when saving", (intervalMinutes, everyMs) => { expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "interval", intervalMinutes })).toEqual({ @@ -96,6 +98,15 @@ describe("scheduleFromDraft", () => { everyMs, }); }); + + it.each(["0,5", "2,5,0", "Infinity", "150119987580", ""])( + "rejects invalid interval minutes: %s", + (intervalMinutes) => { + expect( + scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "interval", intervalMinutes }), + ).toBeNull(); + }, + ); }); describe("scheduled task model defaults", () => { diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 3ef1a2f3faf6e..4ff15c4d9e45b 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -116,6 +116,10 @@ export function scheduleDraftForTask(task: Pick): Sch } } +export function parseIntervalMinutes(value: string): number { + return Number(value.trim().replace(",", ".")); +} + export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null { if (draft.mode === "webhook") { const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); @@ -163,7 +167,7 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche }; } if (draft.mode === "interval") { - const minutes = Number(draft.intervalMinutes); + const minutes = parseIntervalMinutes(draft.intervalMinutes); const everyMs = Math.round(minutes * 60_000); return minutes >= 1 && Number.isSafeInteger(everyMs) ? { type: "interval", everyMs } : null; } diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.test.ts b/apps/mobile/src/features/sharing/incoming-share-storage.test.ts index 82913d48fd2e9..ceaaff3216a26 100644 --- a/apps/mobile/src/features/sharing/incoming-share-storage.test.ts +++ b/apps/mobile/src/features/sharing/incoming-share-storage.test.ts @@ -2,18 +2,84 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; const fileSystemMocks = vi.hoisted(() => { - let entries: File[] = []; + const files = new Map(); + let failNextWrite = false; + let failRelocationTo = ""; + let snapshots: Map[] | undefined; + const record = () => snapshots?.push(new Map(files)); class File { - readonly exists = true; + name: string; + readonly parentDirectory: Directory; + readonly contents: string; - constructor( - readonly name: string, - private readonly contents: string, - ) {} + constructor(parent: string | Directory, nameOrContents: string) { + this.name = typeof parent === "string" ? parent : nameOrContents; + this.parentDirectory = typeof parent === "string" ? new Directory() : parent; + this.contents = typeof parent === "string" ? nameOrContents : ""; + } + + get exists(): boolean { + return files.has(this.name); + } + + create(): void { + files.set(this.name, ""); + record(); + } + + async write(contents: string): Promise { + files.set(this.name, contents.slice(0, 1)); + record(); + if (failNextWrite) { + failNextWrite = false; + throw new Error("interrupted write"); + } + files.set(this.name, contents); + record(); + } + + copySync(destination: File): void { + files.delete(destination.name); + record(); + files.set(destination.name, files.get(this.name)!.slice(0, 1)); + record(); + if (destination.name.endsWith(failRelocationTo) && failRelocationTo) { + failRelocationTo = ""; + throw new Error("copy failed after partial destination write"); + } + files.set(destination.name, files.get(this.name)!); + record(); + } + + moveSync(destination: File): void { + // Expo removes the destination before attempting the native move. + files.delete(destination.name); + record(); + if (destination.name.endsWith(failRelocationTo) && failRelocationTo) { + failRelocationTo = ""; + throw new Error("move failed after destination removal"); + } + // Android can fall back to copy-and-delete after native moves fail. + files.set(destination.name, files.get(this.name)!.slice(0, 1)); + record(); + if (failRelocationTo === "partial") { + failRelocationTo = ""; + throw new Error("move failed during fallback copy"); + } + files.set(destination.name, files.get(this.name)!); + files.delete(this.name); + this.name = destination.name; + record(); + } + + delete(): void { + files.delete(this.name); + record(); + } async text(): Promise { - return this.contents; + return files.get(this.name)!; } } @@ -21,15 +87,31 @@ const fileSystemMocks = vi.hoisted(() => { create(): void {} list(): ReadonlyArray { - return entries; + return [...files].map(([name, contents]) => new File(name, contents)); } } return { Directory, File, + failNextWrite() { + failNextWrite = true; + }, + failRelocationTo(suffix: string) { + failRelocationTo = suffix; + }, + trackOperations() { + snapshots = []; + return snapshots; + }, setEntries(next: File[]) { - entries = next; + files.clear(); + for (const file of next) { + files.set(file.name, file.contents); + } + failNextWrite = false; + failRelocationTo = ""; + snapshots = undefined; }, }; }); @@ -40,7 +122,12 @@ vi.mock("expo-file-system", () => ({ Paths: { document: "/documents" }, })); -import { loadIncomingShareDrafts } from "./incoming-share-storage"; +import { + IncomingShareStorageError, + loadIncomingShareDrafts, + removeIncomingShareDraft, + writeIncomingShareDraft, +} from "./incoming-share-storage"; const VALID_DRAFT = { schemaVersion: 1, @@ -67,4 +154,109 @@ describe("incoming share storage", () => { await expect(loadIncomingShareDrafts()).resolves.toEqual([VALID_DRAFT]); expect(warning).toHaveBeenCalledOnce(); }); + + it.each(["write", "partial"])("retries an interrupted initial %s", async (failure) => { + if (failure === "write") fileSystemMocks.failNextWrite(); + else fileSystemMocks.failRelocationTo(failure); + + await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toMatchObject({ + operation: "write", + shareId: VALID_DRAFT.id, + }); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + await expect(loadIncomingShareDrafts()).resolves.toEqual([]); + + await writeIncomingShareDraft(VALID_DRAFT); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + }); + + it("preserves a saved share when its reservation write fails and allows retrying", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const reserved = { + ...VALID_DRAFT, + destination: { environmentId: "env-1", projectId: "project-1" }, + }; + fileSystemMocks.failNextWrite(); + + await expect(writeIncomingShareDraft(reserved)).rejects.toBeInstanceOf( + IncomingShareStorageError, + ); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + + await writeIncomingShareDraft(reserved); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([reserved]); + }); + + it("removes staging and backup files after a successful replacement", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const updated = { ...VALID_DRAFT, text: "Updated" }; + + await writeIncomingShareDraft(updated); + + expect(new fileSystemMocks.Directory().list().map((file) => file.name)).toEqual([ + `${VALID_DRAFT.id}.json`, + ]); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([updated]); + }); + + it("keeps a successful replacement when backup cleanup fails", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const updated = { ...VALID_DRAFT, text: "Updated" }; + vi.spyOn(fileSystemMocks.File.prototype, "delete").mockImplementationOnce(() => { + throw new Error("backup cleanup failed"); + }); + + await expect(writeIncomingShareDraft(updated)).resolves.toBeUndefined(); + + expect(new fileSystemMocks.File(`${VALID_DRAFT.id}.json.bak`, "").exists).toBe(true); + expect(new fileSystemMocks.File(`${VALID_DRAFT.id}.json.tmp`, "").exists).toBe(false); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([updated]); + }); + + it.each([".bak", ".json", "partial"])( + "recovers after a failed replacement of %s", + async (suffix) => { + await writeIncomingShareDraft(VALID_DRAFT); + fileSystemMocks.failRelocationTo(suffix); + + await expect( + writeIncomingShareDraft({ ...VALID_DRAFT, text: "Updated" }), + ).rejects.toBeInstanceOf(IncomingShareStorageError); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + + // A second failed write must not damage a backup left by the failed move. + fileSystemMocks.failNextWrite(); + await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toBeInstanceOf( + IncomingShareStorageError, + ); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + fileSystemMocks.failRelocationTo(".json"); + await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toBeInstanceOf( + IncomingShareStorageError, + ); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + await writeIncomingShareDraft(VALID_DRAFT); + await removeIncomingShareDraft(VALID_DRAFT.id); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([]); + }, + ); + + it("loads one complete share after interruption at each replacement operation", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const updated = { ...VALID_DRAFT, text: "Updated" }; + const snapshots = fileSystemMocks.trackOperations(); + await writeIncomingShareDraft(updated); + + for (const snapshot of snapshots) { + fileSystemMocks.setEntries( + [...snapshot].map(([name, contents]) => new fileSystemMocks.File(name, contents)), + ); + const loaded = await loadIncomingShareDrafts({ strict: true }); + expect(loaded).toHaveLength(1); + expect([VALID_DRAFT, updated]).toContainEqual(loaded[0]); + await removeIncomingShareDraft(VALID_DRAFT.id); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([]); + } + expect(snapshots.length).toBeGreaterThan(0); + }); }); diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.ts b/apps/mobile/src/features/sharing/incoming-share-storage.ts index 763da41cba8ca..06d64e4bc349b 100644 --- a/apps/mobile/src/features/sharing/incoming-share-storage.ts +++ b/apps/mobile/src/features/sharing/incoming-share-storage.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import type { File } from "expo-file-system"; import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model"; @@ -28,9 +29,23 @@ async function getDirectory() { return directory; } -async function getFile(shareId: string) { +async function getFile(shareId: string, suffix = "") { const { File } = await import("expo-file-system"); - return new File(await getDirectory(), fileName(shareId)); + return new File(await getDirectory(), `${fileName(shareId)}${suffix}`); +} + +async function readSavedDraft(file: File) { + try { + return { file, draft: decodeIncomingShareDraft(JSON.parse(await file.text()) as unknown) }; + } catch (cause) { + const { File } = await import("expo-file-system"); + const backup = new File(file.parentDirectory, `${file.name}.bak`); + if (!backup.exists) throw cause; + return { + file: backup, + draft: decodeIncomingShareDraft(JSON.parse(await backup.text()) as unknown), + }; + } } export async function loadIncomingShareDrafts(options?: { @@ -40,11 +55,14 @@ export async function loadIncomingShareDrafts(options?: { const { File } = await import("expo-file-system"); const drafts: IncomingShareDraft[] = []; for (const entry of (await getDirectory()).list()) { - if (!(entry instanceof File) || !entry.name.endsWith(".json")) { + if (!(entry instanceof File)) continue; + if (entry.name.endsWith(".json.bak")) { + if (new File(entry.parentDirectory, entry.name.slice(0, -4)).exists) continue; + } else if (!entry.name.endsWith(".json")) { continue; } try { - drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown)); + drafts.push((await readSavedDraft(entry)).draft); } catch (cause) { const error = new IncomingShareStorageError({ operation: "load", shareId: null, cause }); if (options?.strict) { @@ -65,10 +83,22 @@ export async function loadIncomingShareDrafts(options?: { export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promise { try { const file = await getFile(draft.id); - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); + const backup = await getFile(draft.id, ".bak"); + const temp = await getFile(draft.id, ".tmp"); + // Inbox mutations are serialized, so each share can reuse its staging path. + temp.create({ intermediates: true, overwrite: true }); + await temp.write(JSON.stringify(draft)); + // Expo can remove or partially copy the destination before a move fails. + if (file.exists) { + const saved = await readSavedDraft(file).catch(() => undefined); + if (!saved || saved.file === file) file.copySync(backup, { overwrite: true }); + } + temp.moveSync(file, { overwrite: true }); + try { + if (backup.exists) backup.delete(); + } catch { + // The installed JSON is authoritative even if backup cleanup fails. } - await file.write(JSON.stringify(draft)); } catch (cause) { throw new IncomingShareStorageError({ operation: "write", shareId: draft.id, cause }); } @@ -76,9 +106,10 @@ export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promis export async function removeIncomingShareDraft(shareId: string): Promise { try { - const file = await getFile(shareId); - if (file.exists) { - file.delete(); + // Delete the backup first so a failed removal cannot resurrect a consumed share. + for (const suffix of [".tmp", ".bak", ""]) { + const file = await getFile(shareId, suffix); + if (file.exists) file.delete(); } } catch (cause) { throw new IncomingShareStorageError({ operation: "remove", shareId, cause }); diff --git a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx index ef2ba6abd1ace..fe4fddd3e2c37 100644 --- a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx +++ b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx @@ -17,7 +17,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import * as Dialog from "../../components/ui/Dialog"; import { Menu } from "../../components/ui/Menu"; import { cn } from "../../lib/cn"; -import { relativeTime } from "../../lib/time"; +import { RelativeTime } from "../../components/RelativeTime"; import type { EnvironmentRuntimeState } from "../../state/remote-runtime-types"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; @@ -150,7 +150,7 @@ export const LinkedPullRequestRow = memo(function LinkedPullRequestRow(props: { ) : null} {snapshot?.updatedAt ? ( - {relativeTime(snapshot.updatedAt)} + ) : null} diff --git a/apps/mobile/src/features/threads/SecretRequestCard.tsx b/apps/mobile/src/features/threads/SecretRequestCard.tsx index 4ab7a18beffec..19bf52ccd48c7 100644 --- a/apps/mobile/src/features/threads/SecretRequestCard.tsx +++ b/apps/mobile/src/features/threads/SecretRequestCard.tsx @@ -101,7 +101,9 @@ function PendingSecretRequestForm(props: { {item.label} {item.reason.trim() ? ( - {item.reason} + + {item.reason} + ) : null} scopedThreadKey(thread.environmentId, thread.id); type Section = "pinned" | "active" | "snoozed" | "settled"; type Destination = Exclude; @@ -162,8 +163,15 @@ function DragHandle(props: { } function ThreadArrangementSheet(props: { onClose: () => void }) { + const { appearance } = useAppearancePreferences(); + const { fontScale } = useWindowDimensions(); + const { rowHeight, headerHeight } = deriveThreadArrangementSizing({ + baseFontSize: appearance.baseFontSize, + fontScale, + }); const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom); const configs = useAtomValue(environmentServerConfigsAtom); + const listEnvironments = useAtomValue(threadListEnvironmentsAtom); const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom); const pendingOrder = useAtomValue(pendingThreadOrderAtom); const dropBusy = useAtomValue(threadDropBusyAtom); @@ -214,31 +222,34 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { ordered: sections[section], allThreads: threads, section, - reorderableEnvironmentIds: new Set( - section === "pinned" || !workingShelfEnabled ? configs.keys() : [], - ), + reorderableEnvironmentIds: + section === "pinned" + ? listEnvironments.pinReorderEnvironmentIds + : workingShelfEnabled + ? new Set() + : listEnvironments.activeReorderEnvironmentIds, }); return { pinned: planner("pinned"), active: planner("active") }; - }, [sections, threads, configs, workingShelfEnabled]); + }, [sections, threads, listEnvironments, workingShelfEnabled]); const rows = useMemo(() => { const result: Row[] = []; let offset = 0; for (const section of ["pinned", "active", "snoozed", "settled"] as const) { if (section === "snoozed" && sections[section].length === 0) continue; - result.push({ key: section, section, offset, height: HEADER_HEIGHT }); - offset += HEADER_HEIGHT; + result.push({ key: section, section, offset, height: headerHeight }); + offset += headerHeight; if ((section === "snoozed" || section === "settled") && !expanded[section]) continue; for (const thread of section === "settled" ? visibleSettled : sections[section]) { - result.push({ key: keyOf(thread), section, thread, offset, height: ROW_HEIGHT }); - offset += ROW_HEIGHT; + result.push({ key: keyOf(thread), section, thread, offset, height: rowHeight }); + offset += rowHeight; } if (section === "settled" && settledCount > visibleSettled.length) { - result.push({ key: "settled-more", section, more: true, offset, height: ROW_HEIGHT }); - offset += ROW_HEIGHT; + result.push({ key: "settled-more", section, more: true, offset, height: rowHeight }); + offset += rowHeight; } } return result; - }, [sections, expanded, visibleSettled, settledCount]); + }, [sections, expanded, visibleSettled, settledCount, rowHeight, headerHeight]); const list = useRef>(null); const geometry = useRef({ height: 0, offset: 0 }); const drag = useRef(null); @@ -256,7 +267,9 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { setPreview(null); } const orderVersion = rows - .map((row) => `${row.key}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`) + .map( + (row) => `${row.key}:${row.height}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`, + ) .join("|"); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the in-progress drag gesture when the thread order changes underneath it useEffect(() => { @@ -277,7 +290,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { current.translation = translation; const { height, offset } = geometry.current; const y = current.startY + translation; - translateY.setValue(Math.max(0, Math.min(height - ROW_HEIGHT, y - ROW_HEIGHT / 2))); + translateY.setValue(Math.max(0, Math.min(height - rowHeight, y - rowHeight / 2))); const contentY = Math.max(0, y + offset); const target = latest.current.rows.find((row) => contentY < row.offset + row.height) ?? @@ -316,7 +329,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { orderVersion, sourceSection: row.section, thread: row.thread, - startY: row.offset + ROW_HEIGHT / 2 - geometry.current.offset, + startY: row.offset + row.height / 2 - geometry.current.offset, translation: 0, destination: null, }; @@ -468,7 +481,12 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { disabled={ dropBusy || pendingOrder !== null || - !configs.has(thread.environmentId) + (!listEnvironments.pinReorderEnvironmentIds.has( + thread.environmentId, + ) && + !listEnvironments.activeReorderEnvironmentIds.has( + thread.environmentId, + )) } sectionActions={sectionActions} onSectionMove={(section) => { @@ -544,7 +562,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { text.lineHeight * 1.5 || (compactWidth > 0 && contentSize.width > compactWidth)); const compactRowHeight = Math.max(44, minimum); + const isRTL = I18nManager.isRTL; const editorStyle = useAnimatedStyle(() => ({ position: "absolute", overflow: "hidden", height, - left: isExpanded ? 14 : editorX.value + 4, - right: isExpanded ? 14 : rowWidth.value - editorX.value - editorWidth.value + 4, + ...(isExpanded + ? { left: 14, right: 14 } + : compactEditorInsets({ + slotX: editorX.value, + slotWidth: editorWidth.value, + rowWidth: rowWidth.value, + isRTL, + })), // The full-width editor must move above the controls in the same frame it widens. bottom: isExpanded ? 52 : (compactRowHeight - minimum) / 2, })); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 6695e7963eff0..870ab31f00003 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -195,6 +195,7 @@ import { useThreadTurnSubagents } from "./ThreadAgentsSheet"; import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; import { useThreadQueuedCount } from "./ThreadQueueControl"; import { ThreadGuestShareBar } from "./ThreadGuestShareBar"; +import { ThreadSideChatsRow } from "./ThreadSideChatsRow"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; import { useOpenLinkedThread } from "./useOpenLinkedThread"; @@ -346,6 +347,9 @@ const NO_RUN_STOP_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-run-stop:empty"), ); +// Connection failures survive retries and app suspension. +const RECONNECT_ERROR_DELAY_MS = 5_000; + // Anchors, the submitted message, follow state and pending scrolls belong to // one thread, so the body remounts when the thread changes. export function ThreadDetailScreen(props: ThreadDetailScreenProps) { @@ -527,6 +531,14 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { } })(); const threadSyncLabel = useDelayedStatus(selectedThreadKey, realThreadSyncLabel); + const connectionRetrying = + props.connectionStateLabel === "connecting" || props.connectionStateLabel === "reconnecting"; + const retryError = connectionRetrying ? props.connectionError : null; + const retryConnectionError = useDelayedStatus( + `${props.environmentId}:${retryError !== null}`, + retryError, + RECONNECT_ERROR_DELAY_MS, + ); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -661,7 +673,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { ); const floatingStatus = ((): FloatingWorkingStatus | null => { const connectionStatus = connectionFloatingStatus({ - connectionError: props.connectionError, + connectionError: connectionRetrying ? retryConnectionError : props.connectionError, connectionState: props.connectionStateLabel, environmentLabel: props.environmentLabel, onReconnect: props.onReconnectEnvironment, @@ -1520,6 +1532,12 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { threadRef={props.selectedThreadDetailRef} canOperateThread={props.canOperateThread} /> + {props.draftMode ? null : ( + + )} {props.feedbackSubmissions.map((submission) => ( setBusy(false)); }} className="h-7 w-7 items-center justify-center disabled:opacity-40" + style={ + Platform.OS === "android" + ? { width: props.buttonSize, height: props.buttonSize } + : undefined + } > {busy ? ( ) : ( @@ -485,12 +499,57 @@ function AssistantForkButton(props: { ); } +function MessageImage(props: { + readonly source: ComponentPropsWithRef["source"]; + readonly className: string; + readonly maxWidth?: number; + readonly height?: number; + readonly onLoad?: () => void; + readonly onError?: () => void; +}) { + const imageStyle = useResolveClassNames(props.className); + const [size, setSize] = useState<{ width: number; height: number } | null>(null); + const displaySize = + props.maxWidth !== undefined && size !== null + ? resolveMarkdownImageDisplaySize({ + sourceWidth: size.width, + sourceHeight: size.height, + availableWidth: props.maxWidth, + }) + : null; + return ( + { + if (props.maxWidth !== undefined) setSize(event.source); + props.onLoad?.(); + }} + onError={props.onError} + /> + ); +} + function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly name: string; readonly mimeType: string; readonly className: string; + readonly maxWidth?: number; + readonly height?: number; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const withGallery = useFilePreviewGallery(); @@ -504,11 +563,19 @@ function MessageAttachmentImage(props: { const uri = useAssetUrl(props.environmentId, resource); const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource); const retriedImage = useRef(false); - const imageStyle = useResolveClassNames(props.className); if (uri === null) { return ( - + ); @@ -537,11 +604,11 @@ function MessageAttachmentImage(props: { ) } > - { retriedImage.current = false; }} @@ -564,11 +631,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt return attachment.type === "file"; } +function isMediaAttachment(attachment: ChatAttachment): boolean { + return ( + isImageAttachment(attachment) || + (isFileAttachment(attachment) && videoMimeType(attachment) !== null) + ); +} + function MessageAttachmentFile(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPlaybackStart: () => void; + readonly standaloneMedia?: boolean; + readonly standaloneFile?: boolean; + readonly available?: boolean; }) { const sourceIdentifier = useId(); const navigation = useNavigation(); @@ -603,7 +680,7 @@ function MessageAttachmentFile(props: { ); const shareFile = (sourceIdentifier?: string) => { - if (httpBaseUrl === null || openingRef.current) return; + if (props.available === false || httpBaseUrl === null || openingRef.current) return; const controller = new AbortController(); openingRef.current = controller; setOpening(true); @@ -703,29 +780,34 @@ function MessageAttachmentFile(props: { if (videoType !== null) { return ( - + ); } + const disabled = props.available === false || opening || httpBaseUrl === null; return ( - <> - + + shareFile(sourceIdentifier)} onPress={openAttachment} > @@ -751,8 +833,205 @@ function MessageAttachmentFile(props: { type="monochrome" /> - - + {props.standaloneFile ? ( + shareFile(sourceIdentifier)} + > + + + ) : null} + + + ); +} + +function UserMessageMedia(props: { + readonly attachments: ReadonlyArray; + readonly pendingAttachments: QueuedThreadMessage["attachments"]; + readonly environmentId: EnvironmentId; + readonly maxWidth: number; + readonly onPressPreview: (source: FilePreviewSource) => void; + readonly onPlaybackStart: () => void; +}) { + const attachments = [...props.pendingAttachments, ...props.attachments]; + const collage = attachments.length > 1 && attachments.every(isImageAttachment); + const imageProps = (index: number) => ({ + className: "w-full bg-user-bubble-foreground/15", + maxWidth: collage ? undefined : props.maxWidth, + height: !collage + ? undefined + : attachments.length === 2 || (attachments.length === 3 && index === 0) + ? 192 + : attachments.length === 3 + ? (192 - StyleSheet.hairlineWidth) / 2 + : 96, + }); + const media = [ + ...props.pendingAttachments.map((attachment, index) => { + const image = isImageAttachment(attachment); + return { + id: attachment.id, + image, + content: + image && attachment.uploadedAttachmentId ? ( + + ) : !image && attachment.uploadedAttachmentId ? ( + + ) : ( + + ), + }; + }), + ...props.attachments.map((attachment, index) => ({ + id: attachment.id, + image: isImageAttachment(attachment), + content: isImageAttachment(attachment) ? ( + + ) : isFileAttachment(attachment) ? ( + + ) : null, + })), + ]; + return collage ? ( + + {media.length === 3 ? ( + + + {media[0]!.content} + + + {media[1]!.content} + {media[2]!.content} + + + ) : ( + media + .filter((_, index) => index % 2 === 0) + .map((item, index) => ( + + {item.content} + {media[index * 2 + 1] ? ( + {media[index * 2 + 1]!.content} + ) : null} + + )) + )} + + + ) : ( + + {media.map((item) => ( + + {item.content} + + + ))} + + ); +} + +function PendingMessageMedia(props: { + readonly attachment: QueuedThreadMessage["attachments"][number]; + readonly className: string; + readonly maxWidth?: number; + readonly height?: number; + readonly onPressPreview: (source: FilePreviewSource) => void; + readonly onPlaybackStart: () => void; +}) { + const withGallery = useFilePreviewGallery(); + const { attachment } = props; + const fileBacked = isFileBackedComposerAttachment(attachment); + const preview = useLocalAttachmentPreview(fileBacked ? attachment : null); + if (isImageAttachment(attachment)) { + const uri = fileBacked ? preview.uri : (attachment.dataUrl ?? attachment.previewUri); + return ( + { + const source = composerImageGallery([attachment])[0]; + if (source) props.onPressPreview(withGallery(source)); + }} + > + + + ); + } + if (preview.uri === null) { + return ( + + {preview.error ? ( + {preview.error.message} + ) : ( + + )} + + ); + } + return ( + ); } @@ -1326,6 +1605,7 @@ function renderFeedEntry( readonly renderViewedImage: MarkdownImageRenderer; readonly renderReasoning: (text: string) => ReactNode; readonly iconSubtleColor: string | import("react-native").ColorValue; + readonly replyActionSize: { readonly button: number; readonly icon: number }; readonly screenColor: string; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -1338,6 +1618,7 @@ function renderFeedEntry( readonly contentWidth: number; readonly threadTitle: string; readonly workflowOpening: WorkflowOpening | null; + readonly userMessageGrouping: GroupedThreadFeedRow["userMessageGrouping"]; }, ) { const entry = info.item; @@ -1582,7 +1863,9 @@ function renderFeedEntry( const foldRowId = presentation.attribution === "agent" ? `agent:${message.id}` : null; const foldExpanded = foldRowId !== null && (props.expandedWorkRows[foldRowId] ?? false); const bubbleBodyVisible = foldRowId === null || foldExpanded; - const intentBadge = resolveUserMessageIntentBadge(message.inputIntent); + const intentBadge = props.userMessageGrouping.continuesSteer + ? null + : resolveUserMessageIntentBadge(message.inputIntent); const referenceIds = new Set( collectComposerContextReferences(message.text).map((reference) => reference.contextId), ); @@ -1593,22 +1876,36 @@ function renderFeedEntry( : [], ), ); + const standaloneMedia = foldRowId === null && presentation.attribution !== "guest"; + const mediaAttachments = standaloneMedia ? attachments.filter(isMediaAttachment) : []; const visibleAttachments = attachments.filter( - (attachment) => isImageAttachment(attachment) || !inlineAttachmentIds.has(attachment.id), + (attachment) => + !(standaloneMedia && isMediaAttachment(attachment)) && + (isImageAttachment(attachment) || !inlineAttachmentIds.has(attachment.id)), ); - // Images sent without words show as images, not as a bubble around them. - const mediaOnly = - foldRowId === null && - presentation.attribution !== "guest" && - presentation.text.trim() === "" && - visibleAttachments.length + (entry.pendingMessage?.attachments.length ?? 0) > 0 && - visibleAttachments.every(isImageAttachment) && - (entry.pendingMessage?.attachments ?? []).every( - (attachment) => attachment.type === "image", - ); + const pendingAttachments = entry.pendingMessage?.attachments ?? []; + const pendingMedia = standaloneMedia ? pendingAttachments.filter(isMediaAttachment) : []; + const pendingBubbleAttachments = standaloneMedia + ? pendingAttachments.filter( + (attachment) => + !isMediaAttachment(attachment) && !inlineAttachmentIds.has(attachment.id), + ) + : pendingAttachments; + const fileCards = standaloneMedia + ? resolveUserMessageFileCards({ + text: presentation.text, + attachments: attachments.length > 0 ? attachments : pendingAttachments, + context: message.context, + }) + : null; + const hasBubble = + fileCards === null && + (foldRowId !== null || + presentation.text.trim().length > 0 || + visibleAttachments.length + pendingBubbleAttachments.length > 0); return ( {presentation.attribution === "t3code" ? ( @@ -1619,136 +1916,174 @@ function renderFeedEntry( {presentation.guestName !== undefined ? ( ) : null} - 0) - ? { width: props.duoColumns ? "85%" : props.userBubbleMaxWidth } - : null), - }} - > - {presentation.attribution === "guest" ? : null} - {foldRowId !== null ? ( - 0 ? ( + + ref.label) - .split(/\r?\n/) - .find((line) => line.trim().length > 0) - ?.trim() ?? "" - } - expanded={foldExpanded} - onToggle={() => props.onToggleWorkRow(foldRowId, entry.id)} - iconSubtleColor={iconSubtleColor} + maxWidth={props.userBubbleMaxWidth} + onPressPreview={props.onPressPreview} + onPlaybackStart={props.onPlaybackStart} /> - ) : null} - {bubbleBodyVisible ? ( - - {entry.pendingMessage?.attachments.map((attachment) => - attachment.type === "image" && attachment.uploadedAttachmentId ? ( - - ) : attachment.type === "image" ? ( - - props.onPressPreview({ - kind: "image", - name: attachment.name, - uri: attachment.dataUrl ?? attachment.previewUri, - gallery: composerImageGallery(entry.pendingMessage?.attachments ?? []), - }) - } - > - + ) : null} + {fileCards ? ( + + {fileCards.map((file) => { + const pendingFile = pendingAttachments.find( + (attachment) => attachment.id === file.id, + ); + return ( + + ); + })} + + ) : null} + {hasBubble ? ( + + {presentation.attribution === "guest" ? : null} + {foldRowId !== null ? ( + ref.label) + .split(/\r?\n/) + .find((line) => line.trim().length > 0) + ?.trim() ?? "" + } + expanded={foldExpanded} + onToggle={() => props.onToggleWorkRow(foldRowId, entry.id)} + iconSubtleColor={iconSubtleColor} + /> + ) : null} + {bubbleBodyVisible ? ( + + {pendingBubbleAttachments.map((attachment) => + attachment.type === "image" && attachment.uploadedAttachmentId ? ( + - - ) : ( - - ), - )} - {/* An empty container still takes a gap, which pads every attachment-free bubble. */} - {visibleAttachments.length > 0 ? ( - - {visibleAttachments.map((attachment) => { - return isImageAttachment(attachment) ? ( - - ) : isFileAttachment(attachment) ? ( - { + const source = composerImageGallery([attachment])[0]; + if (source) + props.onPressPreview({ + ...source, + gallery: composerImageGallery( + entry.pendingMessage?.attachments ?? [], + props.environmentId, + ), + }); + }} + > + - ) : ( - - ); - })} - - ) : null} - {presentation.text.trim().length > 0 ? ( - - - - ) : null} - - ) : null} - + + ) : ( + + ), + )} + {/* An empty container still takes a gap, which pads every attachment-free bubble. */} + {visibleAttachments.length > 0 ? ( + + {visibleAttachments.map((attachment) => { + return isImageAttachment(attachment) ? ( + + ) : isFileAttachment(attachment) ? ( + + ) : ( + + ); + })} + + ) : null} + {presentation.text.trim().length > 0 ? ( + + + + ) : null} + + ) : null} + + ) : null} {bubbleBodyVisible ? ( {foldRowId !== null && message.senderThreadId ? ( @@ -1869,13 +2204,15 @@ function renderFeedEntry( accessibilityLabel="Copy message" text={renderedText} tintColor={iconSubtleColor} - buttonSize={28} - iconSize={13} + buttonSize={props.replyActionSize.button} + iconSize={props.replyActionSize.icon} /> {message.projectedItem ? ( @@ -2339,6 +2676,15 @@ export const ThreadFeed = memo(function ThreadFeed({ const theme = useUniwindTheme(); const iconSubtleColor = theme["--color-icon-subtle"]; + const { buttonSize: androidButtonSize, smallIconSize: androidIconSize } = + useAndroidControlSizing(); + const replyActionSize = useMemo( + () => + Platform.OS === "android" + ? { button: androidButtonSize, icon: androidIconSize } + : { button: 28, icon: 13 }, + [androidButtonSize, androidIconSize], + ); const screenColor = theme["--color-screen"]; const userBubbleColor = theme["--color-user-bubble"]; const openSkillInstructions = (path: string) => { @@ -2830,6 +3176,12 @@ export const ThreadFeed = memo(function ThreadFeed({ const setupAnchorIndex = presentedFeed.findIndex( (entry) => entry.type === "message" && entry.message.role === "user", ); + const [groupFeed] = useState(createThreadFeedGrouping); + const groupedFeed = groupFeed(presentedFeed, { + workflowOpeningMessageId: workflowOpening?.messageId, + setupAnchorIndex: + props.worktreeSetup || props.setupWorkingStartedAt ? setupAnchorIndex : undefined, + }); // The empty↔filled key below remounts the list and resets its imperative // content-inset override. Seed each fresh instance as it attaches with the // current overlay height before the scroll integration's next reaction; @@ -3149,7 +3501,7 @@ export const ThreadFeed = memo(function ThreadFeed({ } }, [textSizeKey, contentWidth, hasHtmlRenders, beginReflow, listRef]); - const renderItem = (info: { item: ThreadFeedRow; index: number }) => ( + const renderItem = (info: { item: GroupedThreadFeedRow; index: number }) => ( {renderFeedEntry(info, { + userMessageGrouping: info.item.userMessageGrouping, environmentId: props.environmentId, dispatchingMessageId: props.dispatchingMessageId, onEditPendingMessage: props.onEditPendingMessage, @@ -3187,6 +3540,7 @@ export const ThreadFeed = memo(function ThreadFeed({ renderViewedImage, renderReasoning, iconSubtleColor, + replyActionSize, screenColor, userBubbleColor, markdownStyles, @@ -3284,7 +3638,7 @@ export const ThreadFeed = memo(function ThreadFeed({ maintainVisibleContentPosition={ followsEnd && !disclosureToggleSettling ? false : maintainVisibleContentPosition } - data={presentedFeed} + data={groupedFeed} extraData={listAppearanceData} renderItem={renderItem} viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} @@ -3422,7 +3776,7 @@ function MessageImageGalleryScope(props: { entry.type === "message" ? [ ...messageImageGallery(entry.message, { environmentId, threadId, workspaceRoot }), - ...composerImageGallery(entry.pendingMessage?.attachments ?? []), + ...composerImageGallery(entry.pendingMessage?.attachments ?? [], environmentId), ] : []; return {props.children}; diff --git a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx index 5f4b5ec263d48..457b7878ea5e7 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx @@ -13,6 +13,7 @@ export function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource; readonly thumbnailVisible?: boolean; readonly onPlaybackStart?: () => void; + readonly borderRadius?: number; }) { const { source } = props; const feedVisible = useContext(ThreadMediaVisibleContext); @@ -43,6 +44,7 @@ export function ThreadMarkdownVideo(props: { onPlaybackStart={props.onPlaybackStart} unavailable={"resource" in source && asset._tag === "Failure"} actionsSource={source.actionsSource} + borderRadius={props.borderRadius} /> ); } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 6ba0583a7a541..d57fb7cee9ab0 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -37,7 +37,7 @@ import { useThreadSearch } from "../../state/queries"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { useThreadListNow } from "./use-thread-list-now"; import { usePendingThreadOrder } from "../../state/thread-order"; -import { threadListEnvironmentsAtom } from "../../state/server"; +import { threadListEnvironmentsAtom } from "../../state/presentation"; import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; @@ -74,6 +74,7 @@ import { } from "./thread-list-v2-items"; import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; import type { ThreadPrPresentation } from "../../state/thread-pr-presentation"; +import { ThreadPrDemandProvider } from "../../state/thread-pr-demand"; import { isThreadListV2ListItem, threadListV2ListItemsAreEqual, @@ -115,10 +116,15 @@ interface ThreadNavigationSidebarProps { } export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) { - if (Platform.OS !== "ios") { - return ; - } - return ; + return ( + + {Platform.OS !== "ios" ? ( + + ) : ( + + )} + + ); } function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 11d8710470d66..1f17fd3bb842e 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -49,7 +49,12 @@ import { type ReactNode, } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, TextInput, View } from "react-native"; -import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; +import Animated, { + FadeIn, + FadeOut, + LinearTransition, + useReducedMotion, +} from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/ui/AppSymbol"; @@ -938,7 +943,10 @@ function ThreadSettingsMainContent(props: { if (error) Dialog.alert("Could not refresh models", error); }); }; - const [animationsReady, setAnimationsReady] = useState(false); + const [listLoaded, setListLoaded] = useState(false); + // Android can drop the first frame of a reduced-motion fade, leaving rows invisible. + const reducedMotion = useReducedMotion(); + const animationsReady = listLoaded && !reducedMotion; const catalogItems = buildThreadSettingsCatalogItems(session); const hasActiveCatalogFilter = session.providerFilter !== null || session.searchQuery.trim().length > 0; @@ -1064,7 +1072,7 @@ function ThreadSettingsMainContent(props: { ) : undefined } - onLoad={() => setAnimationsReady(true)} + onLoad={() => setListLoaded(true)} renderItem={renderCatalogItem} showsVerticalScrollIndicator={false} /> diff --git a/apps/mobile/src/features/threads/ThreadSideChatsRow.tsx b/apps/mobile/src/features/threads/ThreadSideChatsRow.tsx new file mode 100644 index 0000000000000..b6c04c21ae4a8 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadSideChatsRow.tsx @@ -0,0 +1,66 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text } from "../../components/ui/AppText"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { cn } from "../../lib/cn"; +import { useSideChats } from "../../state/entities"; +import { useOpenLinkedThread } from "./useOpenLinkedThread"; + +/** + * The thread's side chats, started on web or desktop. Each opens as a normal thread, so its + * approvals and questions can be answered from the phone. + */ +export function ThreadSideChatsRow(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const sideChats = useSideChats({ environmentId: props.environmentId, threadId: props.threadId }); + const openLinkedThread = useOpenLinkedThread(); + if (sideChats.length === 0) return null; + return ( + + + Side chats + + + {sideChats.map((sideChat) => { + const needs = + sideChat.hasPendingApprovals || sideChat.pendingSubagentRequest === "approval" + ? "approval" + : sideChat.hasPendingUserInput || sideChat.pendingSubagentRequest + ? "input" + : null; + return ( + + openLinkedThread({ environmentId: props.environmentId, threadId: sideChat.id }) + } + > + {needs === null ? null : ( + + )} + + {sideChat.title} + + + ); + })} + + + + ); +} diff --git a/apps/mobile/src/features/threads/compactEditorInsets.test.ts b/apps/mobile/src/features/threads/compactEditorInsets.test.ts new file mode 100644 index 0000000000000..ebb7a39f85a2d --- /dev/null +++ b/apps/mobile/src/features/threads/compactEditorInsets.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { compactEditorInsets } from "./compactEditorInsets"; + +describe("compact editor insets", () => { + it("keeps the measured slot position in left-to-right layouts", () => { + expect(compactEditorInsets({ slotX: 48, slotWidth: 200, rowWidth: 360, isRTL: false })).toEqual( + { left: 52, right: 116 }, + ); + }); + + it("swaps the insets in right-to-left layouts so the editor stays over its slot", () => { + expect(compactEditorInsets({ slotX: 112, slotWidth: 200, rowWidth: 360, isRTL: true })).toEqual( + { left: 52, right: 116 }, + ); + }); +}); diff --git a/apps/mobile/src/features/threads/compactEditorInsets.ts b/apps/mobile/src/features/threads/compactEditorInsets.ts new file mode 100644 index 0000000000000..46cb521b9b308 --- /dev/null +++ b/apps/mobile/src/features/threads/compactEditorInsets.ts @@ -0,0 +1,17 @@ +const COMPACT_EDITOR_SLOT_INSET = 4; + +/** + * Places the floating editor over its slot. Layout x is measured from the left, + * but React Native swaps left and right styles in RTL, so the insets trade places. + */ +export function compactEditorInsets(input: { + readonly slotX: number; + readonly slotWidth: number; + readonly rowWidth: number; + readonly isRTL: boolean; +}): { left: number; right: number } { + "worklet"; + const fromLeft = input.slotX + COMPACT_EDITOR_SLOT_INSET; + const fromRight = input.rowWidth - input.slotX - input.slotWidth + COMPACT_EDITOR_SLOT_INSET; + return input.isRTL ? { left: fromRight, right: fromLeft } : { left: fromLeft, right: fromRight }; +} diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index 4f26496160e34..ad76eb41540e0 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -94,6 +94,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { gitState.selectedThreadBranches.length > 0 ? gitState.selectedThreadBranches : await gitActions.refreshSelectedThreadBranches(); + if (branches === null) return; const newBranchName = resolveAutoFeatureBranchName( Arr.filterMap(branches, (branch) => branch.isRemote ? Result.failVoid : Result.succeed(branch.name), diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14a9720cb4e96..4aa44e73c5586 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -327,72 +327,13 @@ export function NewTaskFlowProvider( null); const linkedEnvironments = useLinkedEnvironments(selectedProject?.environmentId ?? null); + const storedStickyModelSelection = useStickyComposerModelSelection(); + const modelSelectionNow = useNowMinuteMs(); const projectDefaults = useNewTaskProjectDefaults(selectedProject); const selectedEnvironmentServerConfig = projectDefaults.serverConfig; const isScratchDraft = projectDefaults.isScratch; const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); - const scratchEnvironments = connectedEnvironments.filter( - (environment) => - availableScratchWorkspaceRoot( - environment.connectionState, - serverConfigs.get(environment.environmentId), - ) !== null, - ); - - const selectedRepositoryKey = selectedProject?.repositoryIdentity - ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) - : null; - // Use || null: placeholder workspaceRoot can be empty, and an empty basename rejects real hosts. - const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - const selectedProjectTitle = selectedProject?.title ?? null; - const environments = (() => { - if (isScratchDraft) { - return scratchEnvironments.map((environment) => ({ - environmentId: environment.environmentId, - environmentLabel: environment.environmentLabel, - })); - } - const seen = new Set(); - const result: Array<{ - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; - }> = []; - const hostsSelectedRepository = (project: EnvironmentProject) => { - if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { - return true; - } - const projectKey = project.repositoryIdentity - ? repositoryGroupingKeyOf(project.repositoryIdentity) - : null; - if (selectedRepositoryKey !== null && projectKey !== null) { - return projectKey === selectedRepositoryKey; - } - return ( - project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || - (selectedProjectTitle !== null && project.title === selectedProjectTitle) - ); - }; - for (const project of projects) { - if (!hostsSelectedRepository(project)) { - continue; - } - if (seen.has(project.environmentId)) { - continue; - } - const environment = savedConnectionsById[project.environmentId]; - if (!environment) { - continue; - } - seen.add(project.environmentId); - result.push({ - environmentId: project.environmentId, - environmentLabel: environment.environmentLabel, - }); - } - return result; - })(); - const selectedProjectDraftKey = selectedProject ? activeDraftKey : null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates the persistent draft in the new-task flow store once a project is selected, before the composer mounts useEffect(() => { @@ -485,13 +426,11 @@ export function NewTaskFlowProvider( selectedProjectDraft.modelSelection ?? null, selectedProject?.id ?? null, ); - const modelSelectionNow = useNowMinuteMs(); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, selectedProject?.id ?? null, ); - const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, storedStickyModelSelection, @@ -842,6 +781,67 @@ export function NewTaskFlowProvider( }; }; + const scratchEnvironments = connectedEnvironments.filter( + (environment) => + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, + ); + + const selectedRepositoryKey = selectedProject?.repositoryIdentity + ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) + : null; + // Use || null: placeholder workspaceRoot can be empty, and an empty basename rejects real hosts. + const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; + const selectedProjectTitle = selectedProject?.title ?? null; + const environments = (() => { + if (isScratchDraft) { + return scratchEnvironments.map((environment) => ({ + environmentId: environment.environmentId, + environmentLabel: environment.environmentLabel, + })); + } + const seen = new Set(); + const result: Array<{ + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + }> = []; + const hostsSelectedRepository = (project: EnvironmentProject) => { + if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { + return true; + } + const projectKey = project.repositoryIdentity + ? repositoryGroupingKeyOf(project.repositoryIdentity) + : null; + if (selectedRepositoryKey !== null && projectKey !== null) { + return projectKey === selectedRepositoryKey; + } + return ( + project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || + (selectedProjectTitle !== null && project.title === selectedProjectTitle) + ); + }; + for (const project of projects) { + if (!hostsSelectedRepository(project)) { + continue; + } + if (seen.has(project.environmentId)) { + continue; + } + const environment = savedConnectionsById[project.environmentId]; + if (!environment) { + continue; + } + seen.add(project.environmentId); + result.push({ + environmentId: project.environmentId, + environmentLabel: environment.environmentLabel, + }); + } + return result; + })(); + const value = { activeNewTask, projectScopes, diff --git a/apps/mobile/src/features/threads/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts index 3d9463c79bd06..b16fab3a20a90 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.test.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.test.ts @@ -10,6 +10,7 @@ import { import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { appendPendingThreadMessages, + createThreadFeedGrouping, isCompactionCommandFeedEntry, selectPendingAcknowledgedMessages, } from "./pending-thread-feed"; @@ -25,6 +26,71 @@ const pending = (id: string): QueuedThreadMessage => ({ }); describe("pending timeline messages", () => { + it("keeps unchanged row identities while text streams and updates adjacent grouping", () => { + const group = createThreadFeedGrouping(); + const [first, second, third] = appendPendingThreadMessages( + [], + [], + [pending("first"), pending("second"), pending("third")], + ); + if (first?.type !== "message" || second?.type !== "message" || third?.type !== "message") { + throw new Error("Expected message rows"); + } + const reply = { + ...third, + id: "reply", + message: { ...third.message, role: "assistant" as const, text: "start" }, + }; + const initial = group([first, second, reply], {}); + const streamed = group( + [first, second, { ...reply, message: { ...reply.message, text: "streamed" } }], + {}, + ); + expect(streamed[0]).toBe(initial[0]); + expect(streamed[1]).toBe(initial[1]); + expect(streamed[2]).not.toBe(initial[2]); + const appended = group([first, second, third, reply], {}); + expect(appended[0]).toBe(initial[0]); + expect(appended[1]).not.toBe(initial[1]); + expect(appended[1]?.userMessageGrouping.groupedWithNext).toBe(true); + expect(appended[2]?.userMessageGrouping.groupedWithPrevious).toBe(true); + const removed = group([first, second, reply], {}); + expect(removed[1]?.userMessageGrouping.groupedWithNext).toBe(false); + const steer = { + ...second, + message: { ...second.message, inputIntent: "promoted_queued_to_steer" as const }, + }; + const singleSteer = group([first, steer], {}); + const steers = group( + [{ ...first, message: { ...first.message, inputIntent: "steer" } }, steer], + {}, + ); + expect(steers[1]?.userMessageGrouping.continuesSteer).toBe(true); + expect(singleSteer[1]?.userMessageGrouping.continuesSteer).toBe(false); + expect(steers[1]).not.toBe(singleSteer[1]); + }); + + it("preserves setup, workflow, and compaction grouping boundaries", () => { + const group = createThreadFeedGrouping(); + const feed = appendPendingThreadMessages([], [], [pending("first"), pending("second")]); + expect(group(feed, {})[0]?.userMessageGrouping.groupedWithNext).toBe(true); + for (const options of [{ setupAnchorIndex: 0 }, { workflowOpeningMessageId: "first" }]) { + const rows = group(feed, options); + expect(rows[0]?.userMessageGrouping.groupedWithNext).toBe(false); + expect(rows[1]?.userMessageGrouping.groupedWithPrevious).toBe(false); + } + const compact = appendPendingThreadMessages( + [], + [], + [{ ...pending("compact"), text: "/compact" }], + )[0]!; + expect(group([feed[0]!, compact], {})[0]?.userMessageGrouping.groupedWithNext).toBe(true); + expect( + group([feed[0]!, { ...compact, pendingMessage: undefined }], {})[0]?.userMessageGrouping + .groupedWithNext, + ).toBe(false); + }); + it.each([false, true])( "keeps a queued compact command in the pending message row (attachment: %s)", (withAttachment) => { diff --git a/apps/mobile/src/features/threads/pending-thread-feed.ts b/apps/mobile/src/features/threads/pending-thread-feed.ts index d38cc026fdd62..586f8ff3b8820 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.ts @@ -6,6 +6,7 @@ import { import type { ThreadFeedEntry } from "../../lib/threadActivity"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { threadHasCaughtUpTo } from "@t3tools/client-runtime/state/threads"; +import { resolveUserMessageGrouping } from "@t3tools/client-runtime/user-message"; import type { AcknowledgedThreadMessage, QueuedThreadMessage, @@ -53,6 +54,50 @@ export function selectPendingAcknowledgedMessages( /** A row of the thread feed's list. */ export type ThreadFeedRow = PendingThreadFeedEntry | GuestPendingFeedEntry; +export type GroupedThreadFeedRow = ThreadFeedRow & { + readonly userMessageGrouping: ReturnType; +}; + +export function createThreadFeedGrouping() { + const rows = new WeakMap(); + const ungrouped = resolveUserMessageGrouping(undefined, undefined, undefined); + return ( + feed: ReadonlyArray, + options: { readonly workflowOpeningMessageId?: string; readonly setupAnchorIndex?: number }, + ): ReadonlyArray => { + const groupingMessage = (index: number) => { + const entry = feed[index]; + return entry?.type === "message" && + entry.message.role === "user" && + !isCompactionCommandFeedEntry(entry) && + entry.message.id !== options.workflowOpeningMessageId + ? entry.message + : undefined; + }; + return feed.map((entry, index) => { + const message = groupingMessage(index); + const grouping = + message === undefined + ? ungrouped + : resolveUserMessageGrouping( + message, + index - 1 === options.setupAnchorIndex ? undefined : groupingMessage(index - 1), + index === options.setupAnchorIndex ? undefined : groupingMessage(index + 1), + ); + const previous = rows.get(entry); + if ( + previous?.userMessageGrouping.groupedWithPrevious === grouping.groupedWithPrevious && + previous.userMessageGrouping.groupedWithNext === grouping.groupedWithNext && + previous.userMessageGrouping.continuesSteer === grouping.continuesSteer + ) + return previous; + const row = { ...entry, userMessageGrouping: grouping }; + rows.set(entry, row); + return row; + }); + }; +} + /** Waiting guest messages go after the transcript, oldest first. */ export function appendGuestPendingMessages( presentedFeed: ReadonlyArray, diff --git a/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx b/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx index a8cf66463de5b..cd148bff2d969 100644 --- a/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx @@ -1,4 +1,6 @@ +import * as NodePath from "node:path"; import * as NodeModule from "node:module"; +import * as React from "react"; import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; @@ -167,3 +169,120 @@ it("keeps the parent when the maximized child's Agents sheet opens another child expect(screen.open({ type: "thread", ...child, threadId: "grandchild" }, child)).toBe(false); expect(screen.state).toEqual(originalState); }); + +it("keeps a focused inspector registered across renders and updates its content", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const requireMobile = NodeModule.createRequire(import.meta.url); + const listeners = { focus: new Set<() => void>(), blur: new Set<() => void>() }; + let focused = true; + let activeRender: (() => React.ReactNode) | undefined; + const deactivate = vi.fn(() => { + activeRender = undefined; + }); + const registerWorkspaceInspector = vi.fn((render: () => React.ReactNode) => { + activeRender = render; + return deactivate; + }); + const navigation = { + isFocused: () => focused, + addListener(event: "focus" | "blur", listener: () => void) { + listeners[event].add(listener); + return () => { + listeners[event].delete(listener); + }; + }, + }; + const navigationContext = React.createContext(navigation); + const routeContext = React.createContext({ key: "file" }); + const babel = requireMobile("@babel/core") as { + transformFileSync(file: string, options: Record): { code: string }; + }; + const compiled = babel.transformFileSync( + NodePath.resolve(__dirname, "../layout/AdaptiveWorkspaceLayout.tsx"), + { + babelrc: false, + configFile: false, + presets: [ + [ + requireMobile.resolve("babel-preset-expo"), + { unstable_transformImportMeta: true, enableBabelRuntime: false }, + ], + ], + caller: { + name: "metro", + bundler: "metro", + platform: "ios", + isDev: false, + isServer: false, + isNodeModule: false, + supportsStaticESM: false, + supportsReactCompiler: true, + }, + }, + ); + const exports: { + useRegisterWorkspaceInspector?: (render: (() => React.ReactNode) | undefined) => void; + } = {}; + const load = (name: string) => { + if (name === "react" || name.startsWith("react/")) return requireMobile(name); + if (name === "./adaptive-workspace-context") + return { + useAdaptiveWorkspaceLayout: () => ({ registerWorkspaceInspector }), + }; + if (name === "@react-navigation/native") + return { + NavigationContext: navigationContext, + NavigationRouteContext: routeContext, + useFocusEffect: (callback: () => (() => void) | undefined) => + React.useEffect(callback, [callback]), + }; + return {}; + }; + new Function("require", "exports", compiled.code)(load, exports); + const useRegisterWorkspaceInspector = exports.useRegisterWorkspaceInspector!; + function Screen({ render }: { render: (() => React.ReactNode) | undefined }) { + useRegisterWorkspaceInspector(render); + return null; + } + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + const render = (content: (() => React.ReactNode) | undefined) => + act(() => root.render()); + const first = () => "first"; + render(first); + registerWorkspaceInspector.mockClear(); + deactivate.mockClear(); + render(first); + render(first); + expect(registerWorkspaceInspector).not.toHaveBeenCalled(); + expect(deactivate).not.toHaveBeenCalled(); + const second = () => "second"; + render(second); + expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1); + const paneContainer = document.createElement("div"); + const paneRoot = createRoot(paneContainer); + using _pane = { [Symbol.dispose]: () => act(() => paneRoot.unmount()) }; + act(() => paneRoot.render(activeRender?.())); + expect(paneContainer.textContent).toBe("second"); + const emit = (event: "focus" | "blur") => + act(() => { + focused = event === "focus"; + for (const listener of listeners[event]) listener(); + }); + emit("blur"); + expect(deactivate).toHaveBeenCalledTimes(1); + registerWorkspaceInspector.mockClear(); + render(first); + expect(registerWorkspaceInspector).not.toHaveBeenCalled(); + emit("focus"); + expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1); + emit("focus"); + expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1); + render(undefined); + expect(activeRender).toBeUndefined(); + render(second); + expect(activeRender).toBeDefined(); + act(() => root.unmount()); + expect(activeRender).toBeUndefined(); + expect(listeners.focus.size + listeners.blur.size).toBe(0); +}); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 5b0c1fd2f5284..3951ffef8cab2 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -695,6 +695,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? { ...workingLabel, label: "Goal" } : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); const recede = shouldRecedeThreadRow({ status, selected }); + const isRegeneratingTitle = thread.titleRegeneration != null; + const rowAccessibilityLabel = `${thread.title}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`; const timeLabel = props.timeLabel; const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); @@ -845,7 +847,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { { id: "rename", title: "Rename", image: "square.and.pencil" }, ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, - isRegenerating: thread.titleRegeneration != null, + isRegenerating: isRegeneratingTitle, }), ...(canTurnIntoProject ? [ @@ -858,12 +860,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ] : []), ], - [ - canTurnIntoProject, - props.titleRegenerationSupported, - thread.runtime, - thread.titleRegeneration, - ], + [canTurnIntoProject, props.titleRegenerationSupported, thread.runtime, isRegeneratingTitle], ); const snoozableCardMenuActions = useMemo( () => [ @@ -1079,6 +1076,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { selected ? selectedThreadRowColors.foregroundClassName : rowAppearance.foregroundClassName, + isRegeneratingTitle && "opacity-55", )} numberOfLines={2} > @@ -1299,9 +1297,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { interactionOpacity={rowAppearance.interactionOpacity} className={rowAppearance.className} accessibilityHint={swipeAccessibilityHint} - accessibilityLabel={ - props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title - } + accessibilityLabel={rowAccessibilityLabel} accessibilityRole="button" accessibilityState={{ selected }} accessibilityActions={rowAccessibilityActions} @@ -1332,9 +1328,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { interactionClassName={rowAppearance.interactionClassName} interactionOpacity={rowAppearance.interactionOpacity} accessibilityHint={swipeAccessibilityHint} - accessibilityLabel={ - props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title - } + accessibilityLabel={rowAccessibilityLabel} accessibilityRole="button" accessibilityState={{ selected }} className={rowAppearance.className} @@ -1370,6 +1364,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { selected ? selectedThreadRowColors.foregroundClassName : rowAppearance.mutedForegroundClassName, + isRegeneratingTitle && "opacity-55", )} numberOfLines={1} > diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts index be63be589b975..56ddec3ab1542 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.test.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts @@ -9,6 +9,8 @@ import { import { describe, expect, it } from "vite-plus/test"; import { Atom, AtomRegistry } from "effect/reactivity"; +import { computeThreadMoveAvailability, createThreadMovePlanner } from "./threadOrder"; +import { makeThreadShellFixture } from "../../test-fixtures"; import { createThreadListEnvironmentsAtom } from "../../state/thread-list-environments"; import { @@ -127,7 +129,7 @@ describe("createThreadRowProviderInstanceResolver", () => { const configs = Atom.make>( new Map([[environmentId, config]]), ); - const environments = createThreadListEnvironmentsAtom(configs); + const environments = createThreadListEnvironmentsAtom(configs, Atom.make([environmentId])); const registry = AtomRegistry.make(); registry.mount(environments); try { @@ -168,3 +170,133 @@ describe("createThreadRowProviderInstanceResolver", () => { } }); }); + +describe("thread list reorder availability", () => { + it.each(["active", "pinned"] as const)( + "updates %s moves on disconnect and reconnect without changing cached config", + (section) => { + const onlineId = EnvironmentId.make("online"); + const offlineId = EnvironmentId.make("offline"); + const config = makeConfig([{ instanceId: "codex", driver: "codex" }]); + const configs = Atom.make>( + new Map([ + [onlineId, config], + [offlineId, config], + ]), + ); + const connected = Atom.make>([onlineId, offlineId]); + const environments = createThreadListEnvironmentsAtom(configs, connected); + const registry = AtomRegistry.make(); + registry.mount(environments); + try { + const ordered = [onlineId, offlineId].map((environmentId, index) => + makeThreadShellFixture({ + environmentId, + id: ThreadId.make(`thread-${index}`), + pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null, + }), + ); + const movedId = `${onlineId}:thread-0`; + const plannerInput = () => { + const current = registry.get(environments); + return { + ordered, + section, + reorderableEnvironmentIds: + section === "pinned" + ? current.pinReorderEnvironmentIds + : current.activeReorderEnvironmentIds, + }; + }; + expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toHaveLength(2); + const before = registry.get(environments); + + registry.set(connected, [onlineId]); + + expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toBeNull(); + expect(computeThreadMoveAvailability(plannerInput()).get(movedId)?.canMoveDown).toBe(false); + expect(registry.get(environments).pinningEnvironmentIds.has(offlineId)).toBe(true); + expect(registry.get(environments).settlementEnvironmentIds.has(offlineId)).toBe(true); + expect(registry.get(environments).providersByEnvironmentId.get(offlineId)).toBe( + before.providersByEnvironmentId.get(offlineId), + ); + + const keyedInput = { + ...plannerInput(), + ordered: ordered.map((row, index) => ({ + ...row, + pinOrderKey: section === "pinned" ? (index === 0 ? "bb" : "dd") : null, + activeOrderKey: section === "active" ? (index === 0 ? "bb" : "dd") : null, + })), + }; + expect(createThreadMovePlanner(keyedInput)(movedId, "down")).toEqual([ + expect.objectContaining({ id: movedId }), + ]); + expect(createThreadMovePlanner(keyedInput)(`${offlineId}:thread-1`, "up")).toBeNull(); + expect(computeThreadMoveAvailability(keyedInput).get(movedId)?.canMoveDown).toBe(true); + expect(computeThreadMoveAvailability(keyedInput).get(`${offlineId}:thread-1`)).toEqual({ + canMoveUp: false, + canMoveDown: false, + }); + + const updatedConfig = { + ...config, + settings: { + ...config.settings, + snoozePresets: [{ kind: "after" as const, minutes: 90 }], + }, + }; + registry.set( + configs, + new Map([ + [onlineId, config], + [offlineId, updatedConfig], + ]), + ); + + const updated = registry.get(environments); + expect(updated.snoozePresetsByEnvironmentId.get(offlineId)).toEqual( + updatedConfig.settings.snoozePresets, + ); + expect(updated.pinReorderEnvironmentIds.has(offlineId)).toBe(false); + expect(updated.activeReorderEnvironmentIds.has(offlineId)).toBe(false); + expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toBeNull(); + + registry.set( + configs, + new Map([ + [onlineId, config], + [ + offlineId, + { + ...updatedConfig, + settings: { + ...updatedConfig.settings, + snoozePresets: updatedConfig.settings.snoozePresets.map((preset) => ({ + ...preset, + })), + }, + }, + ], + ]), + ); + expect(registry.get(environments)).toBe(updated); + + registry.set(connected, [onlineId, offlineId]); + + expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toHaveLength(2); + expect(computeThreadMoveAvailability(plannerInput()).get(movedId)?.canMoveDown).toBe(true); + expect(registry.get(environments).snoozePresetsByEnvironmentId.get(offlineId)).toBe( + updated.snoozePresetsByEnvironmentId.get(offlineId), + ); + + registry.set(configs, new Map([[onlineId, config]])); + + expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toBeNull(); + expect(computeThreadMoveAvailability(plannerInput()).get(movedId)?.canMoveDown).toBe(false); + } finally { + registry.dispose(); + } + }, + ); +}); diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 71944a6fa054f..f83382d7531e3 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -96,6 +96,7 @@ import Animated, { FadeOut, LinearTransition, ReduceMotion, + useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming, @@ -530,11 +531,13 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { props.activities.every((row) => row.projectedItem.item.type === "reasoning") ) { return ( - - {props.activities.map((row) => ( - {props.renderReasoning(row.workEntry.detail ?? "")} - ))} - + + + ); } @@ -557,6 +560,55 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { ); } +function ThreadReasoningGroup(props: { + readonly activities: ReadonlyArray; + readonly edgeFadeColor: string; + readonly renderReasoning: (text: string) => ReactNode; +}) { + const scrollOffset = useSharedValue(0); + const contentHeight = useSharedValue(0); + const viewportHeight = useSharedValue(0); + const onScroll = useAnimatedScrollHandler((event) => { + scrollOffset.set(event.contentOffset.y); + }); + const bottomFadeStyle = useAnimatedStyle(() => ({ + opacity: Math.min( + 1, + Math.max(0, contentHeight.value - viewportHeight.value - scrollOffset.value) / + WORK_GROUP_EDGE_FADE_HEIGHT, + ), + })); + + return ( + + { + viewportHeight.set(event.nativeEvent.layout.height); + }} + onContentSizeChange={(_width, height) => { + contentHeight.set(height); + }} + > + {props.activities.map((row) => ( + {props.renderReasoning(row.workEntry.detail ?? "")} + ))} + + + + + + ); +} + function ThreadWorkGroupList(props: { readonly activities: ReadonlyArray; readonly edgeFadeColor: string; diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ad02dd1192d63..61280bad84efb 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -28,7 +28,7 @@ import { sortPinnedThreadsByOrderKey, sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { isListedThreadLineage, type EnvironmentId, type ProjectId } from "@t3tools/contracts"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; import type { ThreadListProvider } from "../../state/thread-list-environments"; @@ -265,8 +265,7 @@ export function getThreadListV2OrderedSection(input: { }): EnvironmentThreadShell[] { const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined; const threads = input.threads.filter((thread) => { - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") - return false; + if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) return false; if ( (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && thread.settledOverride === "settled" && @@ -647,7 +646,7 @@ export function buildThreadListV2Items(input: { const listedThreads = input.threads.filter( (thread) => thread.archivedAt === null && - thread.lineage.relationshipToParent !== "subagent" && + isListedThreadLineage(thread.lineage) && (input.environmentId === null || thread.environmentId === input.environmentId) && (projectKeys === null || projectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); diff --git a/apps/mobile/src/features/triage/useStartTriage.ts b/apps/mobile/src/features/triage/useStartTriage.ts index eefb775bcd177..bf160bbce5787 100644 --- a/apps/mobile/src/features/triage/useStartTriage.ts +++ b/apps/mobile/src/features/triage/useStartTriage.ts @@ -1,4 +1,5 @@ import { pipe } from "effect/Function"; +import { T3_NPX_COMMAND } from "@t3tools/shared/releasePackage"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -20,7 +21,7 @@ import { } from "../../state/use-composer-drafts"; import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; -const CLI_COMMAND = "npx t3 triage"; +const CLI_COMMAND = `${T3_NPX_COMMAND} triage`; /** * In-app `t3 triage`, as on web: the server writes the session files and diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx index ae7e6aa0842c0..1fa6a87bf596a 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx @@ -33,64 +33,45 @@ export function WorkspaceRootScreen() { const floating = floatingTabsMetrics(enabled.length + 1, scale, fabSize, window.width); const context = { ...workspace, - bottomClearance: floatingTabsEnabled - ? insets.bottom + - FLOATING_TABS.bottom + - FLOATING_TABS.edge + - Math.max( - floating.height, - workspace.state.selectedTab === "threads" ? floating.fabOffset + fabSize : 0, - ) - : workspace.state.selectedTab === "threads" - ? fabClearance + 16 - : 16, + bottomClearance: layout.usesSplitView + ? workspace.bottomClearance + : floatingTabsEnabled + ? insets.bottom + + FLOATING_TABS.bottom + + FLOATING_TABS.edge + + Math.max( + floating.height, + workspace.state.selectedTab === "threads" ? floating.fabOffset + fabSize : 0, + ) + : workspace.state.selectedTab === "threads" + ? fabClearance + 16 + : 16, }; const tab = workspace.state.selectedTab; - if (layout.usesSplitView) { - if (enabled.length === 0) return ; - return ( - - tab === "threads" ? ( - - ) : tab !== "pullRequests" ? ( - - ) : ( - <> - - - - - - - ) - } - /> - ); - } - return ( - - + + {!layout.usesSplitView ? ( + + ) : null} {enabled.length === 0 ? ( - + layout.usesSplitView ? ( + + ) : ( + + ) ) : ( ) : tab !== "pullRequests" ? ( + ) : layout.usesSplitView ? ( + <> + + + + + + ) : ( ( @@ -114,7 +107,7 @@ export function WorkspaceRootScreen() { /> )} - {floatingTabsEnabled ? ( + {layout.usesSplitView ? null : floatingTabsEnabled ? ( ) : ( diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts index d0f72faa07727..8b1fccef6ebc1 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, createElement, createContext, useState, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { WorkspaceRootScreen } from "./WorkspaceRootScreen.android"; import { initialWorkspaceTabsState, workspaceTabsReducer, @@ -161,3 +164,91 @@ describe("workspace root navigation", () => { expect(workspaceTabForNativeSlot(order, "removed")).toBeNull(); }); }); + +const mocks = vi.hoisted(() => ({ split: false, floating: false })); + +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAndroidFloatingTabs: () => mocks.floating, +})); +vi.mock("react-native", () => ({ + useWindowDimensions: () => ({ width: 400, height: 800 }), + View: ({ children }: { children: ReactNode }) => createElement("div", null, children), +})); +vi.mock("react-native-safe-area-context", () => ({ + SafeAreaInsetsContext: createContext({ top: 0, right: 0, bottom: 24, left: 0 }), + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 24, left: 0 }), +})); +vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({ + useAdaptiveWorkspaceLayout: () => ({ + layout: { usesSplitView: mocks.split }, + startNewTask: () => undefined, + }), +})); +vi.mock("./navigation-tabs", () => ({ + useEnabledNavigationTabs: () => ["threads", "pullRequests", "usage"], +})); +vi.mock("./WorkspaceTabsContext", () => ({ + WorkspaceTabsContext: createContext(null), + useWorkspaceTabs: () => ({ state: { selectedTab: "usage" } }), +})); +vi.mock("./WorkspaceExtraTab", () => ({ + WorkspaceExtraTab: function UsageProbe() { + const [tab, setTab] = useState("Limits"); + return createElement("button", { onClick: () => setTab("Usage") }, tab); + }, +})); +vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../components/ui/AndroidScreenHeader", () => ({ AndroidScreenHeader: () => null })); +vi.mock("../../components/ui/EmptyState", () => ({ EmptyState: () => null })); +vi.mock("../../components/ui/ScreenContent", () => ({ + ScreenContent: ({ children }: { children: ReactNode }) => createElement("div", null, children), +})); +vi.mock("../../components/ui/useAndroidControlSizing", () => ({ + useAndroidControlSizing: () => ({ fabClearance: 64, scale: 1, fabSize: 56 }), +})); +vi.mock("../home/HomeRouteScreen", () => ({ HomeRouteScreen: () => null })); +vi.mock("../pull-requests/PullRequestFilterMenuContext", () => ({ + PullRequestFilterMenuProvider: ({ children }: { children: ReactNode }) => + createElement("div", null, children), +})); +vi.mock("../pull-requests/PullRequestsScreen", () => ({ PullRequestsScreen: () => null })); +vi.mock("../pull-requests/PullRequestToolbar.android", () => ({ PullRequestToolbar: () => null })); +vi.mock("./AndroidWorkspaceNavigation", () => ({ + AndroidWorkspaceNavigation: () => null, + FloatingWorkspaceTabs: () => null, +})); +vi.mock("./WorkspaceNoTabs", () => ({ WorkspaceNoTabs: () => null })); + +function setup(split: boolean, floating: boolean) { + mocks.split = split; + mocks.floating = floating; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + return { [Symbol.dispose]: () => vi.unstubAllGlobals() }; +} + +it.each([ + [false, false], + [true, false], + [false, true], + [true, true], +])( + "keeps the Usage page mounted through layout transitions (split: %s, floating: %s)", + async (split, floating) => { + using _actEnvironment = setup(split, floating); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + await act(async () => root.render(createElement(WorkspaceRootScreen))); + + const button = container.querySelector("button")!; + await act(async () => button.click()); + expect(button.textContent).toBe("Usage"); + + for (const nextSplit of [!split, split, !split, split]) { + mocks.split = nextSplit; + await act(async () => root.render(createElement(WorkspaceRootScreen))); + expect(container.querySelector("button")?.textContent).toBe("Usage"); + expect(container.querySelector("button")).toBe(button); + } + }, +); diff --git a/apps/mobile/src/lib/composerImageGallery.ts b/apps/mobile/src/lib/composerImageGallery.ts index 3df558ae5d4db..f308dac2ff19b 100644 --- a/apps/mobile/src/lib/composerImageGallery.ts +++ b/apps/mobile/src/lib/composerImageGallery.ts @@ -1,12 +1,29 @@ import { imageMimeType } from "@t3tools/shared/image"; +import type { EnvironmentId } from "@t3tools/contracts"; import type { FilePreviewItem } from "../components/FilePreviewModal.types"; import { isFileBackedComposerAttachment, type DraftComposerAttachment } from "./composerImages"; export function composerImageGallery( attachments: readonly DraftComposerAttachment[], + environmentId?: EnvironmentId, ): FilePreviewItem[] { return attachments.flatMap((attachment): FilePreviewItem[] => { if (attachment.type !== "image" && imageMimeType(attachment) === null) return []; + if (environmentId !== undefined && attachment.uploadedAttachmentId) { + return [ + { + kind: "image", + name: attachment.name, + environmentId, + resource: { + _tag: "attachment", + attachmentId: attachment.uploadedAttachmentId, + fileName: attachment.name, + mimeType: attachment.mimeType, + }, + }, + ]; + } if (isFileBackedComposerAttachment(attachment)) { return [{ kind: "image", name: attachment.name, attachment }]; } diff --git a/apps/mobile/src/lib/filePreviewGallery.test.ts b/apps/mobile/src/lib/filePreviewGallery.test.ts index 95d52e5a7fe6e..60576317b95c9 100644 --- a/apps/mobile/src/lib/filePreviewGallery.test.ts +++ b/apps/mobile/src/lib/filePreviewGallery.test.ts @@ -1,9 +1,72 @@ -import { describe, expect, it } from "vite-plus/test"; -import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { FilePreviewItem } from "../components/FilePreviewModal.types"; +import type { DraftComposerAttachment } from "./composerImages"; +import { composerImageGallery } from "./composerImageGallery"; +import { messageImageGallery } from "../features/threads/messageImageGallery"; import { filePreviewGallery, filePreviewKey } from "./filePreviewGallery"; +vi.mock("./uuid", () => ({})); + describe("image preview groups", () => { + it.each([false, true])( + "keeps one entry per image through upload and delivery (file: %s)", + (fileBacked) => { + const environmentId = EnvironmentId.make("test"); + const context = { environmentId, threadId: ThreadId.make("thread"), workspaceRoot: null }; + const attachments: DraftComposerAttachment[] = ["a", "b"].map((id) => ({ + id, + type: "image", + name: `${id}.png`, + mimeType: "image/png", + sizeBytes: 3, + previewUri: `data:image/png;base64,${id}`, + ...(fileBacked ? { fileUri: `file:///documents/${id}.png` } : {}), + })); + const delivered = (id: string) => + messageImageGallery( + { + text: "", + attachments: [ + { + type: "image", + id: `uploaded-${id}`, + name: `${id}.png`, + mimeType: "image/png", + sizeBytes: 3, + }, + ], + }, + context, + )[0]!; + const local = composerImageGallery(attachments, environmentId); + const uploading = composerImageGallery( + [{ ...attachments[0]!, uploadedAttachmentId: "uploaded-a" }, attachments[1]!], + environmentId, + ); + const uploaded = composerImageGallery( + attachments.map((attachment) => ({ + ...attachment, + uploadedAttachmentId: `uploaded-${attachment.id}`, + })), + environmentId, + ); + for (const [selected, gallery] of [ + [local[0]!, local], + [delivered("a"), uploading], + [uploading[1]!, uploading], + [delivered("a"), uploaded], + [delivered("a"), [...uploaded, delivered("a"), delivered("b")]], + [delivered("a"), [delivered("a"), delivered("b")]], + ] as const) { + const result = filePreviewGallery(selected, gallery); + expect(result).toHaveLength(2); + expect(result.map(filePreviewKey)).toEqual([...new Set(gallery.map(filePreviewKey))]); + } + expect(composerImageGallery(attachments)).toEqual(local); + }, + ); + it("matches remote originals despite metadata and property order differences", () => { const environmentId = EnvironmentId.make("test"); const a: FilePreviewItem = { diff --git a/apps/mobile/src/lib/highlightLanguage.test.ts b/apps/mobile/src/lib/highlightLanguage.test.ts new file mode 100644 index 0000000000000..3e2b029387eb3 --- /dev/null +++ b/apps/mobile/src/lib/highlightLanguage.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveHighlightLanguage } from "./highlightLanguage"; + +describe("resolveHighlightLanguage", () => { + it.each([ + ["src/init.luau", "lua"], + ["styles/page.xsl", "xml"], + ["styles/page.xslt", "xml"], + ])("%s highlights as %s", (path, language) => { + expect(resolveHighlightLanguage({ path })).toBe(language); + }); +}); diff --git a/apps/mobile/src/lib/highlightLanguage.ts b/apps/mobile/src/lib/highlightLanguage.ts index 6178f2470b937..c782907ab9621 100644 --- a/apps/mobile/src/lib/highlightLanguage.ts +++ b/apps/mobile/src/lib/highlightLanguage.ts @@ -1,7 +1,16 @@ -import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName"; +import "@t3tools/client-runtime/assembly-highlighting"; +import { + getFiletypeFromFileName, + setCustomExtension, +} from "@pierre/diffs/utils/getFiletypeFromFileName"; import { HIGHLIGHTS_LANGUAGE_IDS } from "./highlights.generated"; +// Highlights has no lexer for these dialects, so they use their base language's. +setCustomExtension("luau", "lua"); +setCustomExtension("xsl", "xml"); +setCustomExtension("xslt", "xml"); + /** The native lexer ID for a @pierre/highlights language name, or 0 (plain text). */ export function highlightLanguageId(language: string): number { return Object.hasOwn(HIGHLIGHTS_LANGUAGE_IDS, language) ? HIGHLIGHTS_LANGUAGE_IDS[language]! : 0; diff --git a/apps/mobile/src/lib/highlights.test.ts b/apps/mobile/src/lib/highlights.test.ts index 83469ef745e65..0867b29384691 100644 --- a/apps/mobile/src/lib/highlights.test.ts +++ b/apps/mobile/src/lib/highlights.test.ts @@ -2,6 +2,7 @@ import { codeToTokens } from "@pierre/highlights"; import { pierreDark, pierreLight } from "@pierre/highlights/themes"; import { describe, expect, it, vi } from "vite-plus/test"; +import { resolveHighlightLanguage } from "./highlightLanguage"; import { highlightCode, tokenizeCode } from "./highlights"; vi.mock("expo", async () => { @@ -57,3 +58,20 @@ it("normalizes only requested lines from full-source records, including CRLF and expect(source.highlightLine(2, "dark")?.[0]?.color).toBe("#737373"); expect(source.highlightLine(100, "dark")).toBeUndefined(); }); + +it.each([ + "asm", + "assembly", + "s", + ...["asm", "ASM", "s", "S", "nasm", "NASM"].map((ext) => `src/start.${ext}`), +])("highlights assembly in %s", (hint) => { + const language = resolveHighlightLanguage( + hint.includes("/") ? { path: hint } : { language: hint }, + ); + const code = "mov eax, 42 ; answer\nret"; + for (const scheme of ["light", "dark"] as const) { + const tokens = highlightCode(code, language, scheme); + expect(tokens.map((line) => line.map((token) => token.content).join("")).join("\n")).toBe(code); + expect(new Set(tokens.flat().map((token) => token.color)).size).toBeGreaterThan(1); + } +}); diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 09802951e99dd..0eaed72c1979e 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -4,11 +4,24 @@ import { alignListPaneToHinge, deriveLayout, deriveThreadFeedInitialContentInset, + deriveThreadArrangementSizing, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, isPrimarySidebarPinned, } from "./layout"; +describe("thread arrangement text sizing", () => { + it("fits two title lines and section labels with combined app and system text scaling", () => { + const sizing = deriveThreadArrangementSizing({ baseFontSize: 22, fontScale: 1.3 }); + expect(sizing.rowHeight).toBeGreaterThanOrEqual(2 * 32 * 1.3 + 10); + expect(sizing.headerHeight).toBeGreaterThanOrEqual(26 * 1.3 + 24); + expect(deriveThreadArrangementSizing({ baseFontSize: 16, fontScale: 1 })).toEqual({ + rowHeight: 56, + headerHeight: 48, + }); + }); +}); + describe("thread work-log text sizing", () => { it.each([ { baseFontSize: 16, fontScale: 1.25, estimatedRowHeight: 28 }, diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index ccff7fdf6c890..4155d834ba24d 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -19,6 +19,21 @@ export const ANDROID_READING_MAX_WIDTH = 640; // min-h-8 uses the 14px rem configured in metro.config.js. export const THREAD_WORK_ROW_MIN_HEIGHT = 28; +export function deriveThreadArrangementSizing(input: { + readonly baseFontSize: number; + readonly fontScale: number; +}) { + const bodyLineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.body, input.baseFontSize); + const headerLineHeight = scaledTypographyLineHeight( + MOBILE_TYPOGRAPHY.footnote, + input.baseFontSize, + ); + return { + rowHeight: Math.max(56, Math.ceil(2 * bodyLineHeight * input.fontScale) + 10), + headerHeight: Math.max(48, Math.ceil(headerLineHeight * input.fontScale) + 24), + }; +} + export function deriveThreadWorkLogSizing(input: { readonly baseFontSize: number; readonly fontScale: number; diff --git a/apps/mobile/src/lib/time.test.ts b/apps/mobile/src/lib/time.test.ts new file mode 100644 index 0000000000000..8881b6bbab8b7 --- /dev/null +++ b/apps/mobile/src/lib/time.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; +import { msUntilRelativeTimeChange, relativeTime } from "./time"; + +const at = "2026-10-19T12:00:00.000Z"; +const timestamp = Date.parse(at); + +describe("relative time", () => { + it.each([ + [59_999, "<1m", "1m"], + [119_999, "1m", "2m"], + [3_599_999, "59m", "1h"], + [7_199_999, "1h", "2h"], + [86_399_999, "23h", "1d"], + [172_799_999, "1d", "2d"], + ] as const)("updates at the next boundary after %i ms", (elapsed, before, after) => { + const nowMs = timestamp + elapsed; + expect(relativeTime(at, nowMs)).toBe(before); + expect(msUntilRelativeTimeChange(at, nowMs)).toBe(1); + expect(relativeTime(at, nowMs + 1)).toBe(after); + }); + + it.each([ + [0, 60_000], + [60_000, 60_000], + [3_600_000, 3_600_000], + [86_400_000, 86_400_000], + ])("waits a full label interval at %i ms", (elapsed, delay) => { + expect(msUntilRelativeTimeChange(at, timestamp + elapsed)).toBe(delay); + }); + + it("keeps invalid timestamps static", () => { + expect(relativeTime("invalid", timestamp)).toBe("<1m"); + expect(msUntilRelativeTimeChange("invalid", timestamp)).toBeNull(); + }); + + it("waits until a future timestamp is a minute old, with bounded timers", () => { + expect(relativeTime(at, timestamp - 30_000)).toBe("<1m"); + expect(msUntilRelativeTimeChange(at, timestamp - 30_000)).toBe(90_000); + expect(msUntilRelativeTimeChange(at, timestamp - 30 * 86_400_000)).toBe(86_400_000); + }); +}); diff --git a/apps/mobile/src/lib/time.ts b/apps/mobile/src/lib/time.ts index 6cdd56ae6dcf9..8635ccb37138b 100644 --- a/apps/mobile/src/lib/time.ts +++ b/apps/mobile/src/lib/time.ts @@ -1,10 +1,10 @@ -export function relativeTime(input: string): string { +export function relativeTime(input: string, nowMs: number = Date.now()): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return "<1m"; } - const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + const deltaSeconds = Math.max(0, Math.floor((nowMs - timestamp) / 1000)); if (deltaSeconds < 60) return "<1m"; const deltaMinutes = Math.floor(deltaSeconds / 60); @@ -16,3 +16,17 @@ export function relativeTime(input: string): string { const deltaDays = Math.floor(deltaHours / 24); return `${deltaDays}d`; } + +/** Milliseconds until the relative-time label next changes. */ +export function msUntilRelativeTimeChange( + input: string, + nowMs: number = Date.now(), +): number | null { + const timestamp = Date.parse(input); + if (Number.isNaN(timestamp)) return null; + const elapsedMs = Math.max(0, nowMs - timestamp); + const unitMs = elapsedMs < 3_600_000 ? 60_000 : elapsedMs < 86_400_000 ? 3_600_000 : 86_400_000; + const nextChangeMs = timestamp + (Math.floor(elapsedMs / unitMs) + 1) * unitMs; + // Far-future timestamps must not overflow setTimeout's signed 32-bit delay. + return Math.min(nextChangeMs - nowMs, 86_400_000); +} diff --git a/apps/mobile/src/lib/useDelayedStatus.ts b/apps/mobile/src/lib/useDelayedStatus.ts index 2bf3aaa5538b8..305a375a1da12 100644 --- a/apps/mobile/src/lib/useDelayedStatus.ts +++ b/apps/mobile/src/lib/useDelayedStatus.ts @@ -7,12 +7,12 @@ import { useState } from "react"; import { useDeadlinePassed } from "./clock"; -export function useDelayedStatus(key: string, value: A | null): A | null { +export function useDelayedStatus(key: string, value: A | null, showDelayMs?: number): A | null { const [state, setState] = useState(() => - stepDelayedStatus(null, { key, value, deadlinePassed: false }, Date.now), + stepDelayedStatus(null, { key, value, deadlinePassed: false, showDelayMs }, Date.now), ); const deadlinePassed = useDeadlinePassed(delayedStatusDeadline(state)); - const next = stepDelayedStatus(state, { key, value, deadlinePassed }, Date.now); + const next = stepDelayedStatus(state, { key, value, deadlinePassed, showDelayMs }, Date.now); if (next !== state) setState(next); return shownDelayedStatus(next, key); } diff --git a/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx b/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx index c52563b4b4e23..261017c49b06e 100644 --- a/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx +++ b/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx @@ -119,6 +119,7 @@ import { PullRequestMediaContext, PullRequestMediaVisibleContext, } from "../features/pull-requests/PullRequestMedia"; +import { MediaVideoPlayer } from "../components/MediaVideoPlayer"; function createPlayer() { let playing = false; @@ -149,7 +150,7 @@ function createPlayer() { }; } -function setup(kind: "thread" | "PR", compact = false) { +function setup(kind: "thread" | "PR" | "files", compact = false) { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; mocks.focused = true; mocks.appState = "active"; @@ -166,10 +167,20 @@ function setup(kind: "thread" | "PR", compact = false) { const environmentId = EnvironmentId.make("test"); return { render(visible = true) { + if (kind === "files") mocks.focused = visible; act(() => root.render( - {kind === "thread" ? ( + {kind === "files" ? ( + + ) : kind === "thread" ? ( { - using screen = setup("PR", compact); +it.each([ + ["PR", false], + ["PR", true], + ["files", false], +] as const)( + "stops inactive %s thumbnail requests (compact: %s) and restarts on return", + async (kind, compact) => { + using screen = setup(kind, compact); mocks.thumbnail.mockReturnValue(new Promise(() => {})); screen.render(false); expect(mocks.thumbnail).not.toHaveBeenCalled(); await act(async () => screen.render(true)); - expect(mocks.thumbnail).toHaveBeenCalledTimes(1); - const signal = mocks.thumbnail.mock.calls[0]![2]; - expect(signal.aborted).toBe(false); - const aborted = new Promise((resolve) => signal.addEventListener("abort", resolve)); + const requests = kind === "files" ? 2 : 1; + expect(mocks.thumbnail).toHaveBeenCalledTimes(requests); + const aborted = Promise.all( + mocks.thumbnail.mock.calls.map(([, , signal]) => { + expect(signal.aborted).toBe(false); + return new Promise((resolve) => signal.addEventListener("abort", resolve)); + }), + ); screen.render(false); await act(() => aborted); - expect(mocks.thumbnail).toHaveBeenCalledTimes(1); + expect(mocks.thumbnail).toHaveBeenCalledTimes(requests); await act(async () => screen.render(true)); - expect(mocks.thumbnail).toHaveBeenCalledTimes(2); + expect(mocks.thumbnail).toHaveBeenCalledTimes(requests * 2); }, ); diff --git a/apps/mobile/src/lib/videoFrameFit.test.ts b/apps/mobile/src/lib/videoFrameFit.test.ts new file mode 100644 index 0000000000000..065434a8c2624 --- /dev/null +++ b/apps/mobile/src/lib/videoFrameFit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { fitVideoFrame } from "./videoFrameFit"; + +describe("fitVideoFrame", () => { + const bounds = { width: 360, height: 600 }; + + it("keeps landscape videos at the full capped width", () => { + expect(fitVideoFrame({ width: 1920, height: 1080 }, bounds, 480)).toEqual({ + width: 360, + height: 202.5, + }); + }); + + it("limits portrait videos by the available height", () => { + expect(fitVideoFrame({ width: 1080, height: 1920 }, bounds, 480)).toEqual({ + width: 337.5, + height: 600, + }); + }); + + it("caps wide bounds at the inline width", () => { + expect(fitVideoFrame({ width: 1080, height: 1080 }, { width: 800, height: 900 }, 480)).toEqual({ + width: 480, + height: 480, + }); + }); + + it("returns null before the video or bounds have a size", () => { + expect(fitVideoFrame({ width: 0, height: 0 }, bounds, 480)).toBeNull(); + expect(fitVideoFrame({ width: 1080, height: 1920 }, { width: 0, height: 0 }, 480)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/videoFrameFit.ts b/apps/mobile/src/lib/videoFrameFit.ts new file mode 100644 index 0000000000000..667132df98955 --- /dev/null +++ b/apps/mobile/src/lib/videoFrameFit.ts @@ -0,0 +1,14 @@ +interface Size { + readonly width: number; + readonly height: number; +} + +/** Largest frame with the video's aspect ratio that fits the bounds and the inline width cap. */ +export function fitVideoFrame(video: Size, bounds: Size, maxWidth: number): Size | null { + if (video.width <= 0 || video.height <= 0 || bounds.width <= 0 || bounds.height <= 0) { + return null; + } + const ratio = video.width / video.height; + const width = Math.min(bounds.width, maxWidth, bounds.height * ratio); + return { width, height: width / ratio }; +} diff --git a/apps/mobile/src/native/V5StackHeader.ios.test.tsx b/apps/mobile/src/native/V5StackHeader.ios.test.tsx new file mode 100644 index 0000000000000..7f794e3f22025 --- /dev/null +++ b/apps/mobile/src/native/V5StackHeader.ios.test.tsx @@ -0,0 +1,76 @@ +import type { NativeStackHeaderItem } from "@react-navigation/native-stack"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { StackHeaderConfigProps } from "react-native-screens"; +import { expect, it, vi } from "vite-plus/test"; + +const bridge = vi.hoisted(() => ({ ios: undefined as StackHeaderConfigProps["ios"] })); + +vi.mock("react-native", () => ({ StyleSheet: { flatten: (style: unknown) => style } })); +vi.mock("react-native-keyboard-controller", () => ({ + KeyboardController: { dismiss: vi.fn() }, + useKeyboardState: () => false, +})); +vi.mock("../lib/useMobileNavigationTheme", () => ({ + useMobileNavigationTheme: () => ({ colors: { card: "white" } }), +})); +vi.mock("./native-layout-metrics", () => ({ + useDeviceType: () => "phone", + useNativeLayoutMetrics: () => null, +})); +vi.mock("react-native-screens", () => ({ + Stack: { + HeaderConfig: ({ ios }: StackHeaderConfigProps) => { + bridge.ios = ios; + return null; + }, + }, +})); + +import { V5StackHeader } from "./V5StackHeader.ios"; + +it.each(["leadingItems", "trailingItems", "toolbarItems"] as const)( + "updates native enabled state after permissions change in %s", + (placement) => { + bridge.ios = undefined; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const root = createRoot(document.createElement("div")); + const onPress = () => {}; + try { + for (const disabled of [true, false, undefined, true]) { + const items: NativeStackHeaderItem[] = [ + { type: "button", label: "Files", disabled, onPress }, + { + type: "menu", + label: "Terminal", + disabled, + menu: { items: [{ type: "action", label: "Open terminal", onPress }] }, + }, + ]; + act(() => + root.render( + (placement === "leadingItems" ? items : []), + unstable_headerRightItems: () => (placement === "trailingItems" ? items : []), + unstable_headerToolbarItems: () => (placement === "toolbarItems" ? items : []), + }} + />, + ), + ); + const nativeItems = (bridge.ios as StackHeaderConfigProps["ios"])?.[placement]; + expect(nativeItems).toHaveLength(2); + for (const item of nativeItems ?? []) { + if (item.type !== "item" || "render" in item) + throw new Error("Expected a native bar item"); + // RNSHeaderContentFactory sets UIBarButtonItem.enabled to !item.disabled. + expect(!item.disabled).toBe(!disabled); + } + } + } finally { + act(() => root.unmount()); + vi.unstubAllGlobals(); + } + }, +); diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx index 9caf92d583457..f6bbefce28594 100644 --- a/apps/mobile/src/native/V5StackHeader.ios.tsx +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -124,6 +124,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte axisBehavior: item.axisBehavior, icon: convertIcon(item.icon), menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`), + disabled: item.disabled, variant: item.variant, tintColor: item.tintColor, hidesSharedBackground: item.hidesSharedBackground, @@ -137,6 +138,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte axisBehavior: item.axisBehavior, icon: convertIcon(item.icon), onPress: item.disabled ? undefined : item.onPress, + disabled: item.disabled, visibilityPriority: item.visibilityPriority === "default" ? "standard" : item.visibilityPriority, variant: item.variant, diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index 654ab883d5550..192b70edba47e 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -182,6 +182,14 @@ describe("React Compiler coverage", () => { ).toEqual([true]); }); + // The compiler once silently dropped these caches, so the new-task context + // value changed on every provider render and re-rendered every consumer. + it("caches the new-task model options and host environments", () => { + const file = "src/features/threads/new-task-flow-provider.tsx"; + expect(compilerCachedCalls(file, "buildModelOptions")).toEqual([true]); + expect(compilerCachedCalls(file, "repositoryGroupingKeyOf")).toEqual([true, true]); + }); + // An uncached registration would re-register the workspace inspector on every render. it("registers the workspace inspector only when its renderer changes", () => { expect( diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index a376938ffb41f..c5c33a5432e2b 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -198,6 +198,10 @@ export function useChildThreadInputs(ref: ScopedThreadRef) { return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref)); } +export function useSideChats(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadShells.sideChatsAtom(ref)); +} + export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { const titles = useAtomValue( hasThreadLinks(markdown) diff --git a/apps/mobile/src/state/presentation.ts b/apps/mobile/src/state/presentation.ts index 437a4b7ccb6f4..68b2d278cd95b 100644 --- a/apps/mobile/src/state/presentation.ts +++ b/apps/mobile/src/state/presentation.ts @@ -1,11 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; -import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; +import { + createEnvironmentPresentationAtoms, + createEnvironmentSummaryAtoms, +} from "@t3tools/client-runtime/state/presentation"; import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; -import { serverEnvironment } from "./server"; +import { environmentServerConfigsAtom, serverEnvironment } from "./server"; +import { createThreadListEnvironmentsAtom } from "./thread-list-environments"; export const environmentPresentations = createEnvironmentPresentationAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, @@ -13,6 +17,16 @@ export const environmentPresentations = createEnvironmentPresentationAtoms({ serverConfigValueAtom: serverEnvironment.configValueAtom, }); +export const { connectedEnvironmentIdsAtom } = createEnvironmentSummaryAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + presentationAtom: environmentPresentations.presentationAtom, +}); + +export const threadListEnvironmentsAtom = createThreadListEnvironmentsAtom( + environmentServerConfigsAtom, + connectedEnvironmentIdsAtom, +); + const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-environment-presentation:empty"), ); diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx index 0a4c8ff828f3a..dcef7887012df 100644 --- a/apps/mobile/src/state/queries.debounce.test.tsx +++ b/apps/mobile/src/state/queries.debounce.test.tsx @@ -1,11 +1,16 @@ import { AuthFilesystemReadScope, EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; +import { RegistryContext } from "@effect/atom-react"; +import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ sessionAtom: {}, searchEntries: vi.fn(() => null), + listRefs: vi.fn(), + refresh: vi.fn(), })); vi.mock("./session", () => ({ @@ -21,9 +26,9 @@ vi.mock("./pull-requests", () => ({ composerPullRequests: {} })); vi.mock("./projects", () => ({ projectEnvironment: { searchEntries: mocks.searchEntries }, })); -vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: { refresh: mocks.refresh } })); vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); -vi.mock("./vcs", () => ({ vcsEnvironment: {} })); +vi.mock("./vcs", () => ({ vcsEnvironment: { listRefs: mocks.listRefs } })); vi.mock("./threads", () => ({ olderSettledThreads: {}, threadFamilies: {} })); vi.mock("./query", () => ({ useEnvironmentQuery: (atom: unknown) => ({ @@ -37,7 +42,11 @@ vi.mock("./query", () => ({ }), })); -import { useComposerPathSearch, type ComposerPathSearchTarget } from "./queries"; +import { + useComposerPathSearch, + usePaginatedBranches, + type ComposerPathSearchTarget, +} from "./queries"; function setup() { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -104,3 +113,91 @@ it.each([ input: { cwd: changed.cwd, query: changed.query, limit: 20 }, }); }); + +it("keeps compiled branch results stable and resets pagination for a new target or refresh", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const registry = AtomRegistry.make(); + const pages = Atom.family((key: string) => { + const [, cwd, cursor] = JSON.parse(key) as [string, string, number | null]; + return Atom.make( + AsyncResult.success({ + refs: [ + { name: `${cwd}-${cursor ?? 0}`, current: false, isDefault: false, worktreePath: null }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: cursor === null ? 100 : null, + totalCount: 2, + }), + ); + }); + mocks.listRefs.mockImplementation( + ({ + environmentId, + input, + }: { + environmentId: EnvironmentId; + input: { cwd: string; cursor?: number }; + }) => pages(JSON.stringify([environmentId, input.cwd, input.cursor ?? null])), + ); + mocks.refresh.mockClear(); + const root = createRoot(document.createElement("div")); + using _cleanup = { + [Symbol.dispose]() { + act(() => root.unmount()); + registry.dispose(); + }, + }; + let current: ReturnType; + function Probe({ target }: { target: VcsRefTarget }) { + const result = usePaginatedBranches(target); + useLayoutEffect(() => { + current = result; + }); + return null; + } + const render = (cwd: string | null, options: Partial = {}) => + act(() => + root.render( + + + , + ), + ); + render("/repo"); + const first = current!; + render("/repo"); + expect(current!.data).toBe(first.data); + expect(current!.refs).toBe(first.refs); + expect(current!.refresh).toBe(first.refresh); + expect(current!.loadNext).toBe(first.loadNext); + act(() => + registry.set( + pages(JSON.stringify([target.environmentId, "/repo", null])), + AsyncResult.success({ + ...first.data!, + refs: [{ name: "/repo-0", current: true, isDefault: false, worktreePath: null }], + }), + ), + ); + expect(current!.refs[0]?.current).toBe(true); + act(() => current!.loadNext()); + expect(current!.refs.map((ref) => ref.name)).toEqual(["/repo-0", "/repo-100"]); + act(() => current!.refresh()); + expect(current!.refs.map((ref) => ref.name)).toEqual(["/repo-0"]); + expect(mocks.refresh).toHaveBeenCalledExactlyOnceWith( + pages(JSON.stringify([target.environmentId, "/repo", null])), + ); + act(() => current!.loadNext()); + render("/other"); + expect(current!.refs.map((ref) => ref.name)).toEqual(["/other-0"]); + act(() => current!.loadNext()); + render("/other", { query: "feature" }); + expect(current!.refs).toHaveLength(1); + act(() => current!.loadNext()); + render("/other", { environmentId: EnvironmentId.make("other-environment"), query: "feature" }); + expect(current!.refs).toHaveLength(1); + render(null); + expect(current!.data).toBeNull(); + expect(current!.refs).toEqual([]); +}); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 75e60887546d3..8721b9ecfda88 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -31,7 +31,7 @@ import * as Cause from "effect/Cause"; import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "./atom-registry"; import { orchestrationEnvironment } from "./orchestration"; @@ -299,22 +299,29 @@ export function usePaginatedBranches(target: VcsRefTarget) { cursors: INITIAL_BRANCH_CURSORS, }); const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; - const pageAtoms = - target.environmentId !== null && target.cwd !== null - ? cursors.map((cursor) => - vcsEnvironment.listRefs({ - environmentId: target.environmentId!, - input: { - cwd: target.cwd!, - ...(query.length > 0 ? { query } : {}), - ...(cursor === undefined ? {} : { cursor }), - limit: VCS_REF_LIST_LIMIT, - }, - }), - ) - : []; - const pagesAtom = Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( - Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), + const pageAtoms = useMemo( + () => + target.environmentId !== null && target.cwd !== null + ? cursors.map((cursor) => + vcsEnvironment.listRefs({ + environmentId: target.environmentId!, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit: VCS_REF_LIST_LIMIT, + }, + }), + ) + : [], + [target.environmentId, target.cwd, query, cursors], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], ); const results = useAtomValue(pagesAtom); const values = results.flatMap((result) => { diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 6670505ad3988..8a6176c1140c0 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { Atom } from "effect/reactivity"; +import { + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + restoreAttachmentOnlyBootstrapPrompt, +} from "@t3tools/shared/composerContextReferences"; import { ChatAttachmentId, ComposerContextId, @@ -112,6 +116,30 @@ describe("resolveQueuedEditPayload", () => { }); describe("beginQueuedRunEdit", () => { + it.each(["", "Ultrathink:\n"])( + "opens attachment-only edits without the placeholder and treats them as untouched (%j)", + (prefix) => { + const threadKey = `env-exit:attachment-only-${prefix.length}`; + const original = { + ...edit([image("kept")]), + originalText: prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + }; + const runs = Atom.make | null>( + [{ id: original.runId, status: "queued" }], + ); + beginQueuedRunEdit(threadKey, original, runs); + const draft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, original.runId)); + expect(draft.text).toBe(prefix.trimEnd()); + expect( + restoreAttachmentOnlyBootstrapPrompt(draft.text, original.existingAttachments.length > 0), + ).toBe(original.originalText); + expect(getQueuedRunEdit(threadKey)).toEqual({ ...original, originalText: prefix.trimEnd() }); + appAtomRegistry.set(runs, [{ id: original.runId, status: "running" }]); + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBeUndefined(); + }, + ); + it("silently drops an untouched edit when its run starts", () => { const threadKey = "env-exit:thread-untouched"; const runId = RunId.make("run-1"); diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index 08f893d4770a2..45dab37d0528a 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -6,6 +6,7 @@ import type { UploadChatAttachment, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; +import { stripAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { makeQueuedRunEditStore, resolveQueuedEditExit, @@ -118,10 +119,15 @@ export function beginQueuedRunEdit( // The composer moves off the thread draft, so a paste or pick still running for it is dropped. abortComposerDraftOperations(threadKey); const draftKey = queuedEditDraftKey(threadKey, edit.runId); + const originalText = stripAttachmentOnlyBootstrapPrompt(edit.originalText); clearComposerDraft(draftKey); - setComposerDraftText(draftKey, edit.originalText); + setComposerDraftText(draftKey, originalText); setComposerDraftContext(draftKey, edit.context); - queuedRunEdits.begin(threadKey, edit, { runs, onRunLeftQueue: exitQueuedRunEdit }); + queuedRunEdits.begin( + threadKey, + { ...edit, originalText }, + { runs, onRunLeftQueue: exitQueuedRunEdit }, + ); } export function removeQueuedRunEditAttachment(threadKey: string, attachmentId: string): void { diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 991fd72fdbad3..8de9354d38542 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -8,7 +8,6 @@ import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/stat import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSession } from "./session"; -import { createThreadListEnvironmentsAtom } from "./thread-list-environments"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, @@ -21,7 +20,3 @@ export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, serverConfigValueAtom: serverEnvironment.configValueAtom, }); - -export const threadListEnvironmentsAtom = createThreadListEnvironmentsAtom( - environmentServerConfigsAtom, -); diff --git a/apps/mobile/src/state/thread-list-environments.ts b/apps/mobile/src/state/thread-list-environments.ts index 0bf3fff4e3507..9a2a6f42990ff 100644 --- a/apps/mobile/src/state/thread-list-environments.ts +++ b/apps/mobile/src/state/thread-list-environments.ts @@ -14,8 +14,9 @@ export type ThreadListProvider = Pick< const capabilityKeys = ["threadAutoSettleOptOut"] as const; -function selectEnvironment(config: ServerConfig) { +function selectEnvironment(config: ServerConfig, isConnected: boolean) { return { + isConnected, providers: config.providers.map( ({ instanceId, driver, enabled, displayName, accentColor, iconUrl }) => ({ instanceId, @@ -60,13 +61,18 @@ function collectEnvironments(environments: ReadonlyMap(); const autoSettleOptOutEnvironmentIds = new Set(); + const reorderableEnvironmentIds = new Set(); const snoozePresetsByEnvironmentId = new Map(); - for (const [id, { providers, machineKind, capabilities, snoozePresets }] of environments) { + for (const [ + id, + { providers, machineKind, capabilities, isConnected, snoozePresets }, + ] of environments) { providersByEnvironmentId.set(id, providers); snoozePresetsByEnvironmentId.set(id, snoozePresets); machineByEnvironmentId.set(id, machineKind); loadedEnvironmentIds.add(id); if (capabilities.threadAutoSettleOptOut === true) autoSettleOptOutEnvironmentIds.add(id); + if (isConnected) reorderableEnvironmentIds.add(id); } return { providersByEnvironmentId, @@ -75,8 +81,8 @@ function collectEnvironments(environments: ReadonlyMap>, + connectedEnvironmentIdsAtom: Atom.Atom>, ) { let previous = new Map(); let result = collectEnvironments(previous); return Atom.make((get) => { const configs = get(configsAtom); + const connectedEnvironmentIds = new Set(get(connectedEnvironmentIdsAtom)); const next = new Map(); let changed = configs.size !== previous.size; for (const [id, config] of configs) { - const selected = selectEnvironment(config); + const selected = selectEnvironment(config, connectedEnvironmentIds.has(id)); const prior = previous.get(id); if (prior && sameProviders(prior.providers, selected.providers)) { selected.providers = prior.providers; @@ -104,6 +112,7 @@ export function createThreadListEnvironmentsAtom( prior && prior.providers === selected.providers && prior.machineKind === selected.machineKind && + prior.isConnected === selected.isConnected && prior.snoozePresets === selected.snoozePresets && capabilityKeys.every( (key) => (prior.capabilities[key] === true) === (selected.capabilities[key] === true), diff --git a/apps/mobile/src/state/thread-pr-demand.tsx b/apps/mobile/src/state/thread-pr-demand.tsx new file mode 100644 index 0000000000000..d6e507bd33727 --- /dev/null +++ b/apps/mobile/src/state/thread-pr-demand.tsx @@ -0,0 +1,16 @@ +import { createContext, type ReactNode } from "react"; + +import { useAppActive } from "../lib/appState"; + +export const ThreadPrDemandContext = createContext(true); + +export function ThreadPrDemandProvider(props: { + readonly visible: boolean; + readonly children: ReactNode; +}) { + const active = useAppActive(); + // A sidebar can stay visible beside a focused detail route, so route focus cannot gate its reads. + return ( + {props.children} + ); +} diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 8a8003a6e27d4..c75456461bf64 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,6 +1,7 @@ import * as Dialog from "../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; +import { Discovery } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -17,8 +18,17 @@ import { environmentSession } from "./session"; import { environmentCatalog } from "../connection/catalog"; import { createRemoteEnvironmentProjectionAtoms } from "./remote-environment-projections"; import { serverEnvironment } from "./server"; +import { relayEnvironmentDiscovery } from "./relay"; import { connectPairingUrl as connectPairingUrlCommand } from "../connection/onboarding"; +const RELAY_REMOVAL_ACCOUNT_NOTE: Record = { + registered: + "It stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.", + unregistered: "It's no longer on your T3 Connect account.", + unknown: + "This doesn't change your T3 Connect account. To deregister it there, use T3 Account → T3 Connect.", +}; + const connectionPairingUrlAtom = Atom.make("").pipe( Atom.keepAlive, Atom.withLabel("mobile:connection-pairing-url"), @@ -180,22 +190,20 @@ export function useRemoteConnections() { }, } as const; if (environment.isRelayManaged) { + const registration = Discovery.relayEnvironmentRegistration( + appAtomRegistry.get(relayEnvironmentDiscovery.stateValueAtom), + environmentId, + ); + const openAccount = { + text: "Open T3 Account", + onPress: () => navigation.navigate("SettingsAuth"), + }; Dialog.alert( "Remove from this device?", - `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`, - [ - { text: "Cancel", style: "cancel" }, - // SettingsAuth renders nothing in builds without cloud config. - ...(hasCloudPublicConfig() - ? [ - { - text: "Open T3 Account", - onPress: () => navigation.navigate("SettingsAuth"), - }, - ] - : []), - remove, - ], + `Forget ${environment.environmentLabel} and its cached threads on this device.\n\n${RELAY_REMOVAL_ACCOUNT_NOTE[registration]}`, + registration === "unregistered" || !hasCloudPublicConfig() + ? [{ text: "Cancel", style: "cancel" }, remove] + : [{ text: "Cancel", style: "cancel" }, openAccount, remove], ); return; } diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts index ce3f8a83f2c13..548ae2d3ce6b0 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -1,4 +1,6 @@ import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; +import type { VcsListRefsResult } from "@t3tools/contracts"; +import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -20,6 +22,8 @@ const state = vi.hoisted(() => ({ results: [] as { type: string; description?: string }[], afterGitAction: undefined as (() => void) | undefined, statusRefreshes: 0, + readRefs: vi.fn(), + cachedRefs: [] as VcsListRefsResult["refs"], })); vi.mock("react", () => ({ @@ -46,7 +50,7 @@ vi.mock("./use-selected-thread-worktree", () => ({ }), })); vi.mock("./queries", () => ({ - useBranches: () => ({ data: { refs: [] }, refresh: () => {} }), + useBranches: () => ({ data: { refs: state.cachedRefs }, refresh: () => {} }), })); vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); @@ -79,6 +83,7 @@ vi.mock("./threads", () => ({ })); vi.mock("./vcs", () => ({ vcsEnvironment: { + readRefs: state.readRefs, refreshStatus: async () => { state.statusRefreshes += 1; return AsyncResult.success({ refName: state.branch }); @@ -231,3 +236,71 @@ describe("thread Git mutation permissions", () => { }, ); }); + +describe("automatic feature branch refs", () => { + const empty: VcsListRefsResult = { + refs: [], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 0, + }; + + it.each([false, true])("awaits fresh refs before naming, cached: %s", async (cached) => { + const request = Promise.withResolvers(); + state.cachedRefs = cached + ? [ + { + name: "old-branch", + isRemote: false, + current: false, + isDefault: false, + worktreePath: null, + }, + ] + : []; + state.readRefs.mockReturnValue(request.promise.then(AsyncResult.success)); + let settled = false; + const refresh = useSelectedThreadGitActions() + .refreshSelectedThreadBranches() + .then((refs) => { + settled = true; + return refs; + }); + await Promise.resolve(); + expect(settled).toBe(false); + const local = { + name: "feature/update", + isRemote: false, + current: false, + isDefault: false, + worktreePath: null, + }; + request.resolve({ + ...empty, + refs: [ + local, + { ...local, name: "origin/feature/update", isRemote: true, remoteName: "origin" }, + ], + totalCount: 2, + }); + const refs = await refresh; + expect(refs).toEqual([local]); + expect(resolveAutoFeatureBranchName(refs!.map((ref) => ref.name))).toBe("feature/update-2"); + expect(state.readRefs).toHaveBeenCalledWith({ + environmentId: "environment", + input: { cwd: "/repo", limit: 100, refresh: true }, + }); + }); + + it("reports lookup failure instead of returning empty refs", async () => { + state.results = []; + state.readRefs.mockResolvedValue( + AsyncResult.failure(Cause.fail(new Error("Cannot list refs"))), + ); + expect(await useSelectedThreadGitActions().refreshSelectedThreadBranches()).toBeNull(); + expect(state.results).toEqual([ + { type: "error", title: "Git action failed", description: "Cannot list refs" }, + ]); + }); +}); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index 0d1e5b9388e24..a5351fb3141d1 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -56,6 +56,7 @@ export function useSelectedThreadGitActions() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const readRefs = useAtomCommand(vcsEnvironment.readRefs, { reportFailure: false }); const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false }); @@ -206,9 +207,21 @@ export function useSelectedThreadGitActions() { return result.value; }; - const refreshSelectedThreadBranches = async (): Promise> => { - branchState.refresh(); - return dedupeRemoteBranchesWithLocalMatches(branchState.data?.refs ?? []).filter( + const refreshSelectedThreadBranches = async (): Promise | null> => { + if (branchTarget.environmentId === null || branchTarget.cwd === null) return null; + const result = await readRefs({ + environmentId: branchTarget.environmentId, + input: { cwd: branchTarget.cwd, limit: 100, refresh: true }, + }); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + const message = error instanceof Error ? error.message : "Failed to refresh branches."; + setPendingConnectionError(message); + showGitActionResult({ type: "error", title: "Git action failed", description: message }); + return null; + } + setPendingConnectionError(null); + return dedupeRemoteBranchesWithLocalMatches(result.value.refs).filter( (branch) => !branch.isRemote, ); }; diff --git a/apps/mobile/src/state/use-selected-thread-git-state.test.tsx b/apps/mobile/src/state/use-selected-thread-git-state.test.tsx new file mode 100644 index 0000000000000..73c3b22d02abe --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-git-state.test.tsx @@ -0,0 +1,106 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + root: "", + cwd: "", +})); + +vi.mock("./use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: { environmentId: "environment-1" }, + selectedThreadProject: { workspaceRoot: harness.root }, + }), +})); +vi.mock("./use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ selectedThreadCwd: harness.cwd }), +})); +vi.mock("./use-vcs-action-state", () => ({ + useVcsActionState: () => ({ currentLabel: null }), +})); +vi.mock("./sourceControl", () => ({ + sourceControlEnvironment: { discovery: () => null }, +})); +vi.mock("./query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); +vi.mock("./queries", () => ({ + useBranches: ({ cwd }: { cwd: string }) => ({ + data: { + refs: NodeChildProcess.execFileSync( + "git", + ["for-each-ref", "--format=%(refname:short) %(HEAD)", "refs/heads"], + { + cwd, + encoding: "utf8", + }, + ) + .trim() + .split("\n") + .map((line) => ({ + name: line.split(" ")[0], + current: line.endsWith("*"), + isRemote: false, + })), + }, + isPending: false, + }), +})); + +import { useSelectedThreadGitState } from "./use-selected-thread-git-state"; + +it("marks the selected checkout's branch current when switching threads in one project", () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "mobile-branches-")); + const git = (...args: string[]) => + NodeChildProcess.execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd: directory, + encoding: "utf8", + stdio: "pipe", + }); + const root = createRoot(document.createElement("div")); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + let currentBranches: string[] = []; + function Consumer() { + const { selectedThreadBranches } = useSelectedThreadGitState(); + useLayoutEffect(() => { + currentBranches = selectedThreadBranches + .filter((branch) => branch.current) + .map((branch) => branch.name); + }, [selectedThreadBranches]); + return null; + } + + try { + git("init", "--initial-branch=main"); + git( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "initial", + ); + const worktree = NodePath.join(directory, "linked"); + git("worktree", "add", "-b", "feature/mobile", worktree); + harness.root = directory; + harness.cwd = directory; + act(() => root.render()); + expect(currentBranches).toEqual(["main"]); + + harness.cwd = worktree; + act(() => root.render()); + expect(currentBranches).toEqual(["feature/mobile"]); + + harness.cwd = directory; + act(() => root.render()); + expect(currentBranches).toEqual(["main"]); + } finally { + act(() => root.unmount()); + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/mobile/src/state/use-selected-thread-git-state.ts b/apps/mobile/src/state/use-selected-thread-git-state.ts index e8b3339c79493..e9a961b152fed 100644 --- a/apps/mobile/src/state/use-selected-thread-git-state.ts +++ b/apps/mobile/src/state/use-selected-thread-git-state.ts @@ -10,7 +10,7 @@ import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; export function useSelectedThreadGitState() { - const { selectedThread, selectedThreadProject } = useThreadSelection(); + const { selectedThread } = useThreadSelection(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const selectedThreadGitTarget = { @@ -29,7 +29,7 @@ export function useSelectedThreadGitState() { const selectedThreadBranchTarget = { environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadProject?.workspaceRoot ?? null, + cwd: selectedThreadCwd, query: null, }; const selectedThreadBranchState = useBranches(selectedThreadBranchTarget); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 4c14fb56254cf..3926b2f781f5d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -34,6 +34,7 @@ import { } from "@t3tools/client-runtime/state/threads"; import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; +import { restoreAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { composerContextSendBlockReason, reidentifyComposerContext } from "../lib/composerContext"; import { uuidv4 } from "../lib/uuid"; @@ -163,6 +164,7 @@ export function useThreadDraftForThread(input: { return { draftMessage: draft.text, + draftContext: draft.context, draftAttachments: draft.attachments, }; } @@ -403,7 +405,11 @@ export function useThreadComposerState() { if (edit === null) return; const draft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, edit.runId)); const text = draft.text.trim(); - if (text.length === 0) { + if ( + text.length === 0 && + edit.existingAttachments.length === 0 && + draft.attachments.length === 0 + ) { Dialog.alert("Add a message", "A queued message cannot be left empty."); return; } @@ -457,7 +463,7 @@ export function useThreadComposerState() { input: { threadId: thread.id, runId: edit.runId, - text, + text: restoreAttachmentOnlyBootstrapPrompt(text, payload.attachments.length > 0), edit: { messageId: edit.messageId, attachments: payload.attachments, diff --git a/apps/mobile/src/state/use-thread-pr.demand.test.tsx b/apps/mobile/src/state/use-thread-pr.demand.test.tsx new file mode 100644 index 0000000000000..b3e6965531f63 --- /dev/null +++ b/apps/mobile/src/state/use-thread-pr.demand.test.tsx @@ -0,0 +1,345 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + EnvironmentId, + ProjectId, + ThreadId, + WS_METHODS, + type PullRequestRef, +} from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentRegistry, + EnvironmentSupervisor, + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type SupervisorConnectionState, + type PreparedConnection, + type ConnectionCatalogEntry, +} from "@t3tools/client-runtime/connection"; +import type { RpcSession } from "@t3tools/client-runtime/rpc"; +import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AtomRegistry } from "effect/reactivity"; +import { Activity, act, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +const fixture = vi.hoisted(() => ({ + environment: undefined as EnvironmentRegistry.EnvironmentRegistry["Service"] | undefined, + active: "active", + listeners: new Set<() => void>(), +})); + +vi.mock("react-native", () => ({ + AppState: { + get currentState() { + return fixture.active; + }, + addEventListener: (_event: string, listener: () => void) => { + fixture.listeners.add(listener); + return { remove: () => fixture.listeners.delete(listener) }; + }, + }, +})); +vi.mock("../connection/runtime", async () => { + const { Atom } = await import("effect/reactivity"); + const Layer = await import("effect/Layer"); + const { EnvironmentRegistry } = await import("@t3tools/client-runtime/connection"); + return { + connectionAtomRuntime: Atom.runtime( + Layer.suspend(() => + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, fixture.environment!), + ), + ), + }; +}); +vi.mock("./server", async () => { + const { Atom } = await import("effect/reactivity"); + const config = Atom.make({ environment: { capabilities: { threadPullRequests: true } } }); + return { serverEnvironment: { configValueAtom: () => config } }; +}); + +import { ThreadPrDemandProvider } from "./thread-pr-demand"; +import { useThreadPr } from "./use-thread-pr"; + +const environmentId = EnvironmentId.make("sidebar-environment"); +const reference = { + projectId: ProjectId.make("project"), + repository: "acme/app", + number: 12, + url: "https://github.com/acme/app/pull/12", +}; +function thread(overrides: Partial = {}): EnvironmentThreadShell { + return { + environmentId, + id: ThreadId.make("thread"), + pullRequests: [], + linkedPullRequest: null, + branchPullRequest: reference, + ...overrides, + } as EnvironmentThreadShell; +} + +const setup = Effect.fn("TestThreadPr.setup")(function* () { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers({ + toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], + }); + vi.setSystemTime(0); + fixture.active = "active"; + fixture.listeners.clear(); + const requests: Array<{ environmentId: EnvironmentId; input: PullRequestRef }> = []; + let state = "open"; + let replyGate: Latch.Latch | null = null; + const target = new PrimaryConnectionTarget({ + environmentId, + label: "Sidebar environment", + httpBaseUrl: "https://sidebar.test", + wsBaseUrl: "wss://sidebar.test", + }); + const supervisor = yield* Effect.gen(function* () { + return EnvironmentSupervisor.EnvironmentSupervisor.of({ + target, + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected" as const, + desired: true, + network: "online" as const, + attempt: 1, + generation: 1, + }), + session: yield* SubscriptionRef.make( + Option.some({ + client: { + [WS_METHODS.pullRequestsSummary]: (input: PullRequestRef) => + Effect.gen(function* () { + requests.push({ environmentId: currentEnvironment, input }); + if (replyGate !== null) yield* replyGate.await; + return { + ...input, + provider: "github", + state, + title: `Change ${input.number}`, + url: `https://github.com/${input.repository}/pull/${input.number}`, + baseBranch: "main", + headBranch: "feature", + isDraft: false, + updatedAt: null, + }; + }), + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.never, + }, + ready: Effect.void, + } as unknown as RpcSession), + ), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + }); + let currentEnvironment = environmentId; + const environment = { + entries: yield* SubscriptionRef.make>( + new Map(), + ), + run: (id, effect) => { + currentEnvironment = id; + return Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + }, + followStream: (_id, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + stateChanges: () => SubscriptionRef.changes(supervisor.state), + } satisfies Pick< + EnvironmentRegistry.EnvironmentRegistry["Service"], + "entries" | "run" | "followStream" | "stateChanges" + >; + fixture.environment = + environment as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]; + const registry = AtomRegistry.make(); + const container = document.createElement("div"); + const root = createRoot(container); + let mounts = 0; + function Row({ value, name }: { value: EnvironmentThreadShell; name: string }) { + const [passive] = useState(() => ++mounts); + const pr = useThreadPr(value); + return ( + + {pr?.accessibilityLabel ?? "none"} + + ); + } + return { + requests, + get mounts() { + return mounts; + }, + setState(next: string) { + state = next; + }, + holdReplies() { + replyGate = Latch.makeUnsafe(); + }, + async releaseReplies() { + await act(async () => { + replyGate?.openUnsafe(); + replyGate = null; + }); + }, + badge(name = "sidebar") { + return container.querySelector(`output[data-name="${name}"]`)?.textContent; + }, + passive() { + return container.querySelector("output")?.getAttribute("data-passive"); + }, + async render({ + visible = true, + value = thread(), + shared = false, + activity = "visible", + }: { + visible?: boolean; + value?: EnvironmentThreadShell; + shared?: boolean; + activity?: "visible" | "hidden"; + } = {}) { + await act(async () => + root.render( + + + + + {shared ? : null} + , + ), + ); + }, + async advance(ms: number) { + await act(async () => vi.advanceTimersByTimeAsync(ms)); + }, + async appState(next: string) { + await act(async () => { + fixture.active = next; + for (const listener of fixture.listeners) listener(); + }); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + registry.dispose(); + expect(fixture.listeners.size).toBe(0); + vi.useRealTimers(); + }, + }; +}); + +it.effect( + "releases retained hidden sidebar demand and keeps the badge while reveal revalidates", + () => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render(); + expect(h.badge()).toBe("#12 pull request open"); + const passive = h.passive(); + expect(h.requests).toHaveLength(1); + await h.render({ visible: false }); + await h.advance(10_000); + const afterGrace = h.requests.length; + await h.advance(180_000); + expect(h.requests.length - afterGrace).toBe(0); + expect(h.passive()).toBe(passive); + h.holdReplies(); + h.setState("closed"); + await h.render(); + expect(h.badge()).toBe("#12 pull request open"); + expect(h.requests.length).toBe(afterGrace + 1); + expect(h.mounts).toBe(1); + await h.releaseReplies(); + expect(h.badge()).toBe("#12 pull request closed"); + }); + }), +); + +it.effect("does not start a fallback query for a sidebar first mounted hidden", () => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render({ visible: false }); + await h.advance(180_000); + expect(h.requests).toHaveLength(0); + expect(h.badge()).toBe("none"); + await h.render(); + expect(h.requests).toHaveLength(1); + expect(h.badge()).toBe("#12 pull request open"); + expect(h.mounts).toBe(1); + }); + }), +); + +it.effect("keeps a shared visible reader polling while its sidebar reader is hidden", () => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render({ shared: true }); + expect(h.requests).toHaveLength(1); + await h.render({ visible: false, shared: true }); + await h.advance(180_000); + expect(h.requests).toHaveLength(4); + expect(h.badge("shared")).toBe("#12 pull request open"); + h.setState("closed"); + await h.advance(60_000); + expect(h.requests).toHaveLength(5); + expect(h.badge("shared")).toBe("#12 pull request closed"); + await h.render({ shared: true }); + expect(h.badge()).toBe("#12 pull request closed"); + }); + }), +); + +it.effect.each(["inactive", "background"])("releases sidebar demand while the OS is %s", (state) => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render(); + await h.appState(state); + await h.advance(10_000); + const before = h.requests.length; + await h.advance(180_000); + expect(h.requests.length - before).toBe(0); + h.holdReplies(); + await h.appState("active"); + expect(h.badge()).toBe("#12 pull request open"); + expect(h.requests.length - before).toBe(1); + expect(h.mounts).toBe(1); + }); + }), +); + +it.effect("keeps mounted passive state through an Android-style Activity tab switch", () => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render(); + const passive = h.passive(); + await h.render({ activity: "hidden" }); + await h.advance(10_000); + const before = h.requests.length; + await h.advance(180_000); + expect(h.requests.length - before).toBe(0); + h.holdReplies(); + await h.render(); + expect(h.badge()).toBe("#12 pull request open"); + expect(h.passive()).toBe(passive); + expect(h.mounts).toBe(1); + expect(h.requests.length - before).toBe(1); + }); + }), +); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 549bdb5f1a91c..af14ce3827b4e 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -7,11 +7,12 @@ import { import type { EnvironmentId, PullRequestSummary } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { Atom } from "effect/reactivity"; -import { useMemo } from "react"; +import { use, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { serverEnvironment } from "./server"; import { useEnvironmentQuery } from "./query"; +import { ThreadPrDemandContext } from "./thread-pr-demand"; import { rememberBounded, resolveThreadPrSource, @@ -59,6 +60,7 @@ const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily( export { type ThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { + const demand = use(ThreadPrDemandContext); const supportsLinks = useAtomValue( serverEnvironment.configValueAtom(thread.environmentId), (config) => config !== null, @@ -81,7 +83,7 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio key === null ? undefined : current.get(key), ); const pullRequestSummary = useEnvironmentQuery( - pullRequestRef === null + pullRequestRef === null || !demand ? null : pullRequestSummaryAtom({ environmentId: thread.environmentId, diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 9ddb84efbc832..6e24636671608 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -4,6 +4,8 @@ import { defineConfig, normalizePath } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/workspace/workspace-tabs-state.test.ts", + "src/state/use-thread-pr.demand.test.tsx", "src/features/threads/git/GitBranchesSheet.occupancy.test.tsx", "src/features/files/useFileTreeEntries.test.tsx", "src/features/projects/AddProjectScreen.test.ts", @@ -15,6 +17,7 @@ const reactTests = [ "src/features/pull-requests/usePullRequestDiff.test.tsx", "src/components/ProjectFavicon.test.tsx", "src/components/ui/Dialog.android.test.tsx", + "src/features/pull-requests/usePullRequestList.test.tsx", "src/features/files/ZipArchiveView.test.tsx", "src/features/pull-requests/guide/useReviewGuide.test.tsx", "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", @@ -24,10 +27,12 @@ const reactTests = [ "src/features/threads/UsageLimitRecoveryCard.test.tsx", "src/features/threads/NewTaskDraftRouteScreen.test.tsx", "src/native/NativeHeaderToolbar.test.tsx", + "src/native/V5StackHeader.ios.test.tsx", "src/native/useRestingTopOffset.test.tsx", "src/native/useRetryingImageSource.test.tsx", "src/state/queries.debounce.test.tsx", "src/state/use-selected-thread-git-actions.test.tsx", + "src/state/use-selected-thread-git-state.test.tsx", "src/features/terminal/useTerminalExitNavigation.test.tsx", "src/features/threads/use-thread-list-now.test.tsx", "src/features/home/home-list-options.test.tsx", diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index c9a8f801755eb..2b56111a98bdd 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -95,6 +95,10 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio VALUES ('delivery-1', 'task-1', 'key-1', 1900000000000, '{}', '{}', '[]')`; yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at) VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`; + yield* sql`INSERT INTO peer_environment_links + (environment_id, secret_name, label, urls_json, access, linked_at, expires_at) + VALUES ('environment-box', 'peer-link-box', 'Box', '["https://box.example"]', 'auto', + '2026-08-01', '2027-08-01')`; }), ); return databasePath; @@ -130,13 +134,15 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { tasks: number; webhooks: number; transfers: number; + links: number; }>` SELECT (SELECT COUNT(*) FROM auth_sessions) AS auth, (SELECT COUNT(*) FROM scheduled_tasks) AS tasks, (SELECT COUNT(*) FROM scheduled_task_webhook_replay_keys) + (SELECT COUNT(*) FROM scheduled_task_webhook_pending) AS webhooks, - (SELECT COUNT(*) FROM orchestration_v2_projection_context_transfers) AS transfers`; + (SELECT COUNT(*) FROM orchestration_v2_projection_context_transfers) AS transfers, + (SELECT COUNT(*) FROM peer_environment_links) AS links`; return { threads, events, sessions, leftovers }; }), ); @@ -152,7 +158,13 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { kept.sessions.map((row) => row.provider_session_id), ["session-shared"], ); - assert.deepStrictEqual(kept.leftovers, { auth: 0, tasks: 0, webhooks: 0, transfers: 0 }); + assert.deepStrictEqual(kept.leftovers, { + auth: 0, + tasks: 0, + webhooks: 0, + transfers: 0, + links: 0, + }); }), ); diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 2a22a6040a841..5d1446a8c0712 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -211,6 +211,7 @@ const CLEARED_TABLES: ReadonlyArray = [ "provider_session_runtime", "auth_sessions", "auth_pairing_links", + "peer_environment_links", ]; const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions"; diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index bd5d3b569958f..6d080abbaf880 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -703,40 +703,44 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ); const rpc = yield* serverAuth.issueWebSocketTicket(session, { use: "rpc" }); - const bare = yield* Effect.flip(upgrade(rpc.ticket, "rpc")); + const bare = yield* Effect.flip(upgrade(Redacted.value(rpc.ticket), "rpc")); expect(bare._tag).toBe("ServerAuthInvalidCredentialError"); const signed = yield* upgrade( - rpc.ticket, + Redacted.value(rpc.ticket), "rpc", - key.sign({ method: "GET", url: socketUrl, token: rpc.ticket, iat }), + key.sign({ method: "GET", url: socketUrl, token: Redacted.value(rpc.ticket), iat }), ); expect(signed.sessionId).toBe(session.sessionId); const wrongKey = yield* Effect.flip( upgrade( - rpc.ticket, + Redacted.value(rpc.ticket), "rpc", - other.sign({ method: "GET", url: socketUrl, token: rpc.ticket, iat }), + other.sign({ method: "GET", url: socketUrl, token: Redacted.value(rpc.ticket), iat }), ), ); expect(wrongKey._tag).toBe("ServerAuthInvalidCredentialError"); const onMedia = yield* Effect.flip( upgrade( - rpc.ticket, + Redacted.value(rpc.ticket), "media", - key.sign({ method: "GET", url: socketUrl, token: rpc.ticket, iat }), + key.sign({ method: "GET", url: socketUrl, token: Redacted.value(rpc.ticket), iat }), ), ); expect(onMedia._tag).toBe("ServerAuthInvalidCredentialError"); const media = yield* serverAuth.issueWebSocketTicket(session, { use: "media" }); - expect((yield* upgrade(media.ticket, "media")).sessionId).toBe(session.sessionId); - const mediaOnRpc = yield* Effect.flip(upgrade(media.ticket, "rpc")); + expect((yield* upgrade(Redacted.value(media.ticket), "media")).sessionId).toBe( + session.sessionId, + ); + const mediaOnRpc = yield* Effect.flip(upgrade(Redacted.value(media.ticket), "rpc")); expect(mediaOnRpc._tag).toBe("ServerAuthInvalidCredentialError"); // Clients that predate ticket binding still connect with the unbound ticket they mint. const legacy = yield* serverAuth.issueWebSocketTicket(session); - expect((yield* upgrade(legacy.ticket, "rpc")).sessionId).toBe(session.sessionId); + expect((yield* upgrade(Redacted.value(legacy.ticket), "rpc")).sessionId).toBe( + session.sessionId, + ); }).pipe( Effect.provide(layerEnvironmentAuth({ desktopBootstrapToken: "desktop-bootstrap-token" })), ), @@ -757,10 +761,10 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { const rpc = yield* serverAuth.issueWebSocketTicket(session, { use: "rpc" }); const request = { method: "GET", - url: `/ws?wsTicket=${rpc.ticket}&dpop=${key.sign({ + url: `/ws?wsTicket=${Redacted.value(rpc.ticket)}&dpop=${key.sign({ method: "GET", url: "http://192.168.1.50:3773/ws", - token: rpc.ticket, + token: Redacted.value(rpc.ticket), iat: Math.floor((yield* DateTime.now).epochMilliseconds / 1_000), })}`, cookies: {}, diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 7d73e65925228..6f77975734245 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -31,6 +31,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; @@ -568,8 +569,11 @@ type BootstrapExchangeResult = { const AUTHORIZATION_PREFIX = "Bearer "; const DPOP_AUTHORIZATION_PREFIX = "DPoP "; -const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; -const WEBSOCKET_PROOF_QUERY_PARAM = "dpop"; +const WebSocketUpgradeQuery = Schema.Struct({ + wsTicket: Schema.optionalKey(Schema.RedactedFromValue(Schema.String)), + dpop: Schema.optionalKey(Schema.RedactedFromValue(Schema.String)), +}); +const decodeWebSocketUpgradeQuery = Schema.decodeEffect(WebSocketUpgradeQuery); const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { const leftCanManage = left.scopes.includes(AuthAccessWriteScope); @@ -1215,7 +1219,7 @@ export const make = Effect.gen(function* () { Effect.map( (issued) => ({ - ticket: issued.token, + ticket: Redacted.make(issued.token), expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies AuthWebSocketTicketResult, ), @@ -1231,7 +1235,10 @@ export const make = Effect.gen(function* () { Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request, use) { const requestUrl = HttpServerRequest.toURL(request); if (Option.isSome(requestUrl)) { - const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM); + const query = yield* decodeWebSocketUpgradeQuery( + Object.fromEntries(requestUrl.value.searchParams), + ).pipe(Effect.orDie); + const websocketTicket = query.wsTicket && Redacted.value(query.wsTicket); if (websocketTicket && websocketTicket.trim().length > 0) { const session = yield* sessions .verifyWebSocketToken(websocketTicket) @@ -1245,10 +1252,7 @@ export const make = Effect.gen(function* () { // WebSocket clients cannot set headers, so the proof travels in the query. // The proof hashes the ticket; consuming its jti prevents replay after first use. // Only encrypted transport prevents an active interceptor from racing that use. - const proof = - request.headers.dpop ?? - requestUrl.value.searchParams.get(WEBSOCKET_PROOF_QUERY_PARAM) ?? - undefined; + const proof = request.headers.dpop ?? (query.dpop && Redacted.value(query.dpop)); if (!proof) { return yield* new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound WebSocket ticket requires a DPoP proof.", diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 10aa0e430d742..7416f391ed66d 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -23,6 +23,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpServerRequest } from "effect/http"; @@ -162,7 +163,7 @@ export class McpOAuthRedirectError extends Schema.TaggedError | undefined; readonly resource: string; readonly issuer: string; } @@ -220,10 +221,15 @@ export class McpOAuth extends Context.Service< } >()("t3/auth/McpOAuth") {} -const redirectWith = (redirectUri: string, params: Record) => { +const redirectWith = ( + redirectUri: string, + params: Record | undefined>, +) => { const url = new URL(redirectUri); for (const [name, value] of Object.entries(params)) { - if (value !== undefined) url.searchParams.set(name, value); + if (value !== undefined) { + url.searchParams.set(name, Redacted.isRedacted(value) ? Redacted.value(value) : value); + } } return url.toString(); }; @@ -484,8 +490,9 @@ const make = Effect.gen(function* () { if (token.grant_type !== "authorization_code") { return yield* fail("unsupported_grant_type", "Only authorization_code is supported."); } - const { code, redirect_uri: redirectUri, client_id: clientId } = token; - const verifier = token.code_verifier; + const { redirect_uri: redirectUri, client_id: clientId } = token; + const code = token.code && Redacted.value(token.code); + const verifier = token.code_verifier && Redacted.value(token.code_verifier); if (!code || !redirectUri || !clientId || !verifier) { return yield* fail( "invalid_request", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 635da06b6c015..7e96632019558 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -61,6 +61,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetNpmRelease]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope, [WS_METHODS.providerAuthStart]: AuthProvidersManageScope, @@ -146,6 +147,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlAppAccess]: AuthOrchestrationReadScope, + [WS_METHODS.sourceControlStarred]: AuthOrchestrationReadScope, [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, [WS_METHODS.projectsListIgnoredFiles]: AuthFilesystemReadScope, diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index c94adf8761d06..2f6b757abb12f 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -98,10 +98,10 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { Effect.forkScoped({ startImmediately: true }), ); const first = yield* sessions - .awaitInvalidation(client.sessionId) + .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint) .pipe(Effect.forkScoped({ startImmediately: true })); const second = yield* sessions - .awaitInvalidation(client.sessionId) + .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint) .pipe(Effect.forkScoped({ startImmediately: true })); if (operation === "revoke") yield* sessions.revoke(client.sessionId); else if (operation === "revokeAllExcept") yield* sessions.revokeAllExcept(owner.sessionId); @@ -120,7 +120,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { yield* sessions.markConnected(client.sessionId); const invalidated = yield* Deferred.make(); const watcher = yield* sessions - .awaitInvalidation(client.sessionId) + .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint) .pipe( Effect.andThen(Deferred.succeed(invalidated, undefined)), Effect.forkScoped({ startImmediately: true }), @@ -133,6 +133,102 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), ); + it.effect.each([ + { name: "a renewal for the same key", renewal: {}, closesAtMinute: 119 }, + { + name: "a renewal for another key", + renewal: { proofKeyThumbprint: "other-proof-key" }, + closesAtMinute: 60, + }, + { + name: "a renewal with fewer scopes", + renewal: { scopes: ["orchestration:read"] as const }, + closesAtMinute: 60, + }, + { + name: "a renewal, until the original is revoked", + renewal: {}, + closesAtMinute: 90, + revoke: "original", + }, + { + name: "a renewal, until the renewal is revoked", + renewal: {}, + closesAtMinute: 90, + revoke: "renewal", + }, + ] as const)("closes a DPoP socket at minute $closesAtMinute after $name", (testCase) => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const client = yield* sessions.issue(relaySessionInput); + const invalidated = yield* Deferred.make(); + const watcher = yield* sessions + .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint) + .pipe( + Effect.andThen(Deferred.succeed(invalidated, undefined)), + Effect.forkScoped({ startImmediately: true }), + ); + yield* TestClock.adjust(Duration.minutes(59)); + const renewal = yield* sessions.issue({ ...relaySessionInput, ...testCase.renewal }); + yield* TestClock.adjust(Duration.millis((testCase.closesAtMinute - 59) * 60_000 - 1)); + expect(yield* Deferred.isDone(invalidated)).toBe(false); + yield* TestClock.adjust(Duration.millis(1)); + if ("revoke" in testCase) { + yield* sessions.revoke( + testCase.revoke === "original" ? client.sessionId : renewal.sessionId, + ); + } + yield* Fiber.join(watcher); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), + ); + + it.effect("closes a DPoP socket when an earlier renewal in its chain is revoked", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const client = yield* sessions.issue(relaySessionInput); + const invalidated = yield* Deferred.make(); + const watcher = yield* sessions + .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint) + .pipe( + Effect.andThen(Deferred.succeed(invalidated, undefined)), + Effect.forkScoped({ startImmediately: true }), + ); + yield* TestClock.adjust(Duration.minutes(59)); + const first = yield* sessions.issue(relaySessionInput); + yield* TestClock.adjust(Duration.minutes(59)); + yield* sessions.issue(relaySessionInput); + // Past the first renewal's expiry, so the socket now follows the second. + yield* TestClock.adjust(Duration.minutes(2)); + expect(yield* Deferred.isDone(invalidated)).toBe(false); + yield* sessions.revoke(first.sessionId); + yield* Fiber.join(watcher); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), + ); + + it.effect("keeps a renewed DPoP socket when another socket on its expired session closes", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const client = yield* sessions.issue(relaySessionInput); + yield* sessions.markConnected(client.sessionId); + const invalidated = yield* Deferred.make(); + const watcher = yield* sessions + .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint) + .pipe( + Effect.andThen(Deferred.succeed(invalidated, undefined)), + Effect.forkScoped({ startImmediately: true }), + ); + yield* TestClock.adjust(Duration.minutes(59)); + yield* sessions.issue(relaySessionInput); + yield* TestClock.adjust(Duration.minutes(2)); + // An expired session that loses its last socket is reported as removed, not revoked. + yield* sessions.markDisconnected(client.sessionId); + yield* TestClock.adjust(Duration.millis(58 * 60_000 - 1)); + expect(yield* Deferred.isDone(invalidated)).toBe(false); + yield* TestClock.adjust(Duration.millis(1)); + yield* Fiber.join(watcher); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), + ); + it.effect("keys remote cookies by environment identity instead of state directory", () => Effect.gen(function* () { const cookieName = (stateDir: string, environmentId: EnvironmentId) => diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 0ef7a692fd6db..51e0153c0ef2e 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -409,8 +409,10 @@ export class SessionStore extends Context.Service< SessionCredentialInternalError >; readonly streamChanges: Stream.Stream; + /** Completes when a live socket's session is revoked or expires without a renewal for its DPoP key. */ readonly awaitInvalidation: ( sessionId: AuthSessionId, + proofKeyThumbprint?: string, ) => Effect.Effect; readonly revoke: ( sessionId: AuthSessionId, @@ -497,6 +499,9 @@ const make = Effect.gen(function* () { const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); + // Newest session per DPoP key. A live socket moves to it at expiry, so a + // client that renews its short-lived token keeps its socket. + const latestSessionByProofKey = new Map(); const cookieInput = { mode: serverConfig.mode, port: serverConfig.port, @@ -703,6 +708,7 @@ const make = Effect.gen(function* () { }) : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray)) ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); + if (claims.jkt) latestSessionByProofKey.set(claims.jkt, sessionId); if (replacedSessionIds.length > 0) { yield* Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); @@ -1034,31 +1040,61 @@ const make = Effect.gen(function* () { return revokedSessionIds.length; }); - const awaitInvalidation = Effect.fn(function* (sessionId: AuthSessionId) { + const awaitInvalidation = Effect.fn(function* ( + sessionId: AuthSessionId, + proofKeyThumbprint?: string, + ) { return yield* Effect.scoped( Effect.gen(function* () { // Subscribe before reading: revocation can race with the WebSocket upgrade. const subscription = yield* PubSub.subscribe(changesPubSub); - const row = yield* authSessions - .getById({ sessionId }) - .pipe( - Effect.mapError( - (cause) => new SessionCredentialVerificationError({ sessionId, cause }), - ), - ); - if (Option.isNone(row) || row.value.revokedAt !== null) return; - const now = yield* DateTime.now; - const remaining = row.value.expiresAt.epochMilliseconds - now.epochMilliseconds; - if (remaining <= 0) return; - yield* Effect.raceFirst( - Stream.fromSubscription(subscription).pipe( - Stream.filter( - (change) => change.type === "clientRemoved" && change.sessionId === sessionId, - ), - Stream.runHead, - ), - Effect.sleep(Duration.millis(remaining)), - ); + const readRow = (id: AuthSessionId) => + authSessions + .getById({ sessionId: id }) + .pipe( + Effect.mapError( + (cause) => new SessionCredentialVerificationError({ sessionId: id, cause }), + ), + ); + const original = yield* readRow(sessionId); + if (Option.isNone(original)) return; + // Revoking any session the socket has used closes it. Events only wake this loop + // and the rows decide: revocations are written before they are published, and + // clientRemoved also reports an expired session losing its last socket. + const chain = [sessionId]; + const nextRemoval = Effect.gen(function* () { + while (true) { + const change = yield* PubSub.take(subscription); + if (change.type === "clientRemoved" && chain.includes(change.sessionId)) return; + } + }); + let current = original.value; + while (true) { + const rows = yield* Effect.forEach(chain, readRow); + if (rows.some((row) => Option.isNone(row) || row.value.revokedAt !== null)) return; + const now = yield* DateTime.now; + const remaining = current.expiresAt.epochMilliseconds - now.epochMilliseconds; + if (remaining > 0) { + yield* Effect.raceFirst(nextRemoval, Effect.sleep(Duration.millis(remaining))); + continue; + } + const successorId = + proofKeyThumbprint === undefined + ? undefined + : latestSessionByProofKey.get(proofKeyThumbprint); + if (successorId === undefined || successorId === current.sessionId) return; + const successor = yield* readRow(successorId); + // The socket keeps its original scopes, so the renewal must still grant all of them. + if ( + Option.isNone(successor) || + successor.value.subject !== original.value.subject || + !original.value.scopes.every((scope) => successor.value.scopes.includes(scope)) + ) { + return; + } + chain.push(successorId); + current = successor.value; + } }), ); }); diff --git a/apps/server/src/auth/mcpOAuthHttp.ts b/apps/server/src/auth/mcpOAuthHttp.ts index dfb272605bcef..8a2c439d9bbb2 100644 --- a/apps/server/src/auth/mcpOAuthHttp.ts +++ b/apps/server/src/auth/mcpOAuthHttp.ts @@ -1,12 +1,13 @@ import { AuthMcpApprovalError, - type AuthMcpAuthorizationRequest, + AuthMcpAuthorizationRequest, AuthMcpClientAccess, EnvironmentHttpApi, mcpClientScopes, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { HttpServerRequest, HttpServerResponse } from "effect/http"; import * as HttpEffect from "effect/http/HttpEffect"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; @@ -33,6 +34,7 @@ const noStore = HttpEffect.appendPreResponseHandler((_request, response) => ); const requestUrls = Effect.map(HttpServerRequest.HttpServerRequest, McpOAuth.requestUrls); +const decodeAuthorizationRequest = Schema.decodeEffect(AuthMcpAuthorizationRequest); /** * An unverified client or redirect is reported, never redirected; any other problem goes back to @@ -86,8 +88,11 @@ export const layer = HttpApiBuilder.group( Effect.gen(function* () { const urls = yield* requestUrls; const url = new URL(request.url, urls.issuer); + const authorizationRequest = yield* decodeAuthorizationRequest( + Object.fromEntries(url.searchParams), + ).pipe(Effect.orDie); const authorization = yield* oauth - .validateAuthorization({ urls, request: Object.fromEntries(url.searchParams) }) + .validateAuthorization({ urls, request: authorizationRequest }) .pipe(Effect.result); const headers = { "cache-control": "no-store", "referrer-policy": "no-referrer" }; if (Result.isSuccess(authorization)) { diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index b26b4fd9f0dbc..7c136ae53268c 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -13,7 +13,6 @@ import { authCommand } from "./cli/auth.ts"; import { environmentCommand } from "./cli/environment.ts"; import { appCommand } from "./cli/app.ts"; import { browserCommand } from "./cli/browser.ts"; -import { connectCommand } from "./cli/connect.ts"; import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { extendConnectionAttemptTimeout, keepOutboundFetchOnHttp1 } from "./outboundHttp.ts"; @@ -31,6 +30,7 @@ import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { themeCommand } from "./cli/theme.ts"; import { traceCommand } from "./cli/trace.ts"; import { triageCommand } from "./cli/triage.ts"; +import { connectCommand, statusCommand, upCommand } from "./cli/up.ts"; const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -60,7 +60,9 @@ const connectUnavailableCommand = Command.make("connect", { export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => Command.make("t3", { ...sharedServerCommandFlags }).pipe( - Command.withDescription("Run the T3 Code server."), + Command.withDescription( + "Run T3 Code in this terminal and open it in your browser. To use it from your other devices, run `t3 up`.", + ), Command.withHandler(runDefaultServerCommand), Command.withSubcommands([ Command.make("help").pipe( @@ -80,6 +82,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => browserCommand, projectCommand, threadCommand, + upCommand, + statusCommand, serviceCommand, updateCommand, uninstallCommand, diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index b4184663625eb..bef134147930e 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -108,6 +108,7 @@ async function startFakeDesktop(input: { }); return { + directory: target.directory, received, close: async () => { await new Promise((resolve) => server.close(() => resolve())); @@ -134,9 +135,10 @@ const fakeDesktop = Effect.fn(function* ( const withTempDirectory = ( prefix: string, use: (root: string) => Effect.Effect, + tempDir = NodeOS.tmpdir(), ) => Effect.acquireUseRelease( - Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), prefix))), + Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(tempDir, prefix))), use, (root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })), ); @@ -215,6 +217,60 @@ describe("t3 server command safety", () => { }); describe("t3 app", () => { + it.live.each(["permissions", "owner", "symlink", "missing owner"] as const)( + "rejects unsafe %s on the socket directory before sending", + (unsafe) => + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + if (platform === "win32") return; + yield* withTempDirectory( + "t3-app-unsafe-", + (root) => + Effect.gen(function* () { + const previousTmpdir = process.env.TMPDIR; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.env.TMPDIR = root; + }), + () => + Effect.sync(() => { + if (previousTmpdir === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = previousTmpdir; + }), + ); + const baseDir = NodePath.join(root, ".t3"); + const userId = yield* HostProcess.UserId; + assert(userId !== undefined); + const expectedUserId = + unsafe === "missing owner" ? undefined : unsafe === "owner" ? userId + 1 : userId; + const desktop = yield* fakeDesktop({ baseDir }).pipe( + Effect.provideService(HostProcess.UserId, expectedUserId), + ); + const directory = desktop.directory; + assert(directory !== null); + if (unsafe === "permissions") { + yield* Effect.promise(() => NodeFSP.chmod(directory, 0o755)); + } else if (unsafe === "symlink") { + const moved = NodePath.join(root, "moved"); + yield* Effect.promise(() => NodeFSP.rename(directory, moved)); + yield* Effect.promise(() => NodeFSP.symlink(moved, directory)); + } + const result = yield* runCli(["app"]).pipe( + Effect.provideService(HostProcess.HomeDirectory, root), + Effect.provideService(HostProcess.UserId, expectedUserId), + Effect.result, + ); + expect(desktop.received).toEqual([]); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "DesktopAppUnreachableError" }, + }); + }).pipe(Effect.scoped), + "/tmp", + ); + }), + ); + it.effect("uses T3CODE_HOME or --base-dir and sends the default or explicit path", () => withTempDirectory("t3-app-command-test-", (root) => Effect.gen(function* () { diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts index 003a95af95973..d92ad2ff68f43 100644 --- a/apps/server/src/cli/app.ts +++ b/apps/server/src/cli/app.ts @@ -1,4 +1,6 @@ // @effect-diagnostics globalTimers:off -- The Node socket client owns its response deadline and clears it on every completion path. +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect stat follows symlinks; IPC ownership validation requires lstat. +import * as NodeFSP from "node:fs/promises"; import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; @@ -9,7 +11,10 @@ import { type DesktopAppActivationPlatform, type DesktopAppActivationRequest, } from "@t3tools/contracts"; -import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { + resolveDesktopAppControlAddress, + type DesktopAppControlAddress, +} from "@t3tools/shared/desktopAppControl"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Config from "effect/Config"; import * as Console from "effect/Console"; @@ -77,14 +82,26 @@ function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppAct return platform === "darwin" || platform === "linux" || platform === "win32"; } -function sendDesktopAppActivationRequest(input: { - readonly address: string; - readonly fallbackAddress?: string; +async function sendDesktopAppActivationRequest(input: { + readonly address: DesktopAppControlAddress; + readonly fallbackAddress?: DesktopAppControlAddress; + readonly userId: number | undefined; readonly request: DesktopAppActivationRequest; readonly timeoutMs?: number; }): Promise { + if (input.address.directory !== null) { + const stat = await NodeFSP.lstat(input.address.directory); + if ( + !stat.isDirectory() || + input.userId === undefined || + stat.uid !== input.userId || + (stat.mode & 0o077) !== 0 + ) { + throw new Error("The desktop app socket directory is not private or owned by this user."); + } + } return new Promise((resolve, reject) => { - const socket = NodeNet.createConnection(input.address); + const socket = NodeNet.createConnection(input.address.address); socket.setEncoding("utf8"); let buffer = ""; let settled = false; @@ -159,6 +176,7 @@ function sendDesktopAppActivationRequest(input: { resolve( sendDesktopAppActivationRequest({ address: input.fallbackAddress, + userId: input.userId, request: input.request, ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), }), @@ -207,7 +225,7 @@ const runAppCommand = Effect.fn("cli.app")(function* (flags: { tempDir: NodeOS.tmpdir(), userId, joinPath: path.join, - }).address; + }); const crypto = yield* Crypto.Crypto; const requestId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); const request: DesktopAppActivationRequest = { @@ -224,12 +242,16 @@ const runAppCommand = Effect.fn("cli.app")(function* (flags: { try: () => sendDesktopAppActivationRequest({ address, + userId, ...(fallbackAddress === undefined ? {} : { fallbackAddress }), request, }), catch: (cause) => new DesktopAppUnreachableError({ - candidateAddresses: fallbackAddress === undefined ? [address] : [address, fallbackAddress], + candidateAddresses: + fallbackAddress === undefined + ? [address.address] + : [address.address, fallbackAddress.address], requestId: request.requestId, workspaceRoot, cause, diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 06b949e3013ab..b75fba88bddc3 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -32,7 +32,7 @@ const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function }, ) { const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveCliAuthConfig(flags, logLevel); + const config = yield* resolveCliAuthConfig(flags, logLevel, options); const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; return yield* Effect.gen(function* () { const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/cli/browser.ts b/apps/server/src/cli/browser.ts index 34a2efc55d5ca..ea3dbd347e592 100644 --- a/apps/server/src/cli/browser.ts +++ b/apps/server/src/cli/browser.ts @@ -114,16 +114,21 @@ const browserSetupCommand = Command.make("setup", { baseDir: baseDirFlag }).pipe } const fs = yield* FileSystem.FileSystem; const isRoot = (yield* HostProcess.UserId) === 0; - const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); - // Root, unless sudo came from another user, is likely the server's own account. const env = yield* HostProcess.Environment; + const baseDirPath = yield* setupBaseDir(baseDir); + // sudo resets the environment, so a chosen home has to be on the command line. + const setupCommand = yield* resolveRootCliCommand( + PreviewBrowserHost.SETUP_SUBCOMMAND, + Option.isSome(baseDir) || env.T3CODE_HOME !== undefined ? ["--base-dir", baseDirPath] : [], + ); + // Root, unless sudo came from another user, is likely the server's own account. const rootWarning = isRoot && (env.SUDO_USER ?? "root") === "root" && !PreviewBrowserHost.sandboxDisabled(env) ? new PreviewBrowserHost.PreviewBrowserRootError().message : undefined; const needsProfile = yield* PreviewBrowserHost.sandboxBlocked; - const browser = yield* installedBrowser(yield* setupBaseDir(baseDir)); + const browser = yield* installedBrowser(baseDirPath); const missing = Option.isSome(browser) ? yield* PreviewBrowserHost.missingLibraries(browser.value) : []; diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index c7f47c655dc22..297b225b6adce 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -49,6 +49,7 @@ const minimalDesktopFlags = (baseDir: string) => ({ logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }); const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); @@ -231,6 +232,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }; const layerConfig = ConfigProvider.layer( ConfigProvider.fromEnv({ @@ -275,6 +277,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }; const layerConfig = ConfigProvider.layer( ConfigProvider.fromEnv({ env: { T3CODE_DEV_AUTH_TOKEN: secret } }), @@ -330,6 +333,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.some(false), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }, Option.none(), ).pipe( @@ -406,6 +410,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }, Option.some("Debug"), ).pipe( @@ -553,6 +558,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }, Option.none(), ).pipe( @@ -601,6 +607,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }, Option.none(), ).pipe( @@ -641,6 +648,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }, Option.none(), { @@ -703,6 +711,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }, Option.none(), ).pipe( @@ -742,6 +751,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }); it.effect( @@ -863,7 +873,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("listens on private networks only under the service launcher with no host set", () => + it.effect("listens on private networks per T3CODE_LAN, else under the service launcher", () => Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-service-host"); @@ -893,6 +903,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + // A service installed before T3CODE_LAN existed. expect(yield* resolveHost({ connected: true })).toEqual({ host: "0.0.0.0", privateNetworkOnly: true, @@ -902,6 +913,14 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { host: "127.0.0.1", privateNetworkOnly: undefined, }); + expect(yield* resolveHost({ connected: true, env: { T3CODE_LAN: "false" } })).toEqual({ + host: undefined, + privateNetworkOnly: undefined, + }); + expect(yield* resolveHost({ connected: false, env: { T3CODE_LAN: "true" } })).toEqual({ + host: "0.0.0.0", + privateNetworkOnly: true, + }); // Without the launcher's IPC channel the context was inherited, e.g. by // `t3 serve` started from an agent inside the service. expect(yield* resolveHost({ connected: false })).toEqual({ diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 00621e332deb9..f86fde0f59750 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -15,6 +15,7 @@ import * as LogLevel from "effect/LogLevel"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Redacted from "effect/Redacted"; +import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; @@ -84,6 +85,12 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +const lanFlag = Flag.Boolean("lan").pipe( + Flag.withDescription( + "Accept connections from your local network and tailnet, not only this machine (equivalent to T3CODE_LAN).", + ), + Flag.optional, +); export const traceFileConfig = Config.String("T3CODE_TRACE_FILE").pipe( Config.option, @@ -164,6 +171,7 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + lan: Config.Boolean("T3CODE_LAN").pipe(Config.option, Config.map(Option.getOrUndefined)), }); const DevAuthTokenConfig = Config.Redacted("T3CODE_DEV_AUTH_TOKEN").pipe( @@ -199,6 +207,7 @@ export interface CliServerFlags { readonly logWebSocketEvents: Option.Option; readonly tailscaleServeEnabled: Option.Option; readonly tailscaleServePort: Option.Option; + readonly lan: Option.Option; } export interface CliAuthLocationFlags { @@ -233,6 +242,7 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + lan: lanFlag, } as const; const resolveOptionPrecedence = ( @@ -276,6 +286,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + lan: flags.lan ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = @@ -409,16 +420,19 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct Option.fromUndefinedOr(env.host), Option.fromUndefinedOr(bootstrap?.host), ); - // A background service exists to be reached from other devices, so it - // listens on private networks unless `service.env` sets `T3CODE_HOST`. - // Clients then learn its LAN address and prefer it over T3 Connect. + // Private networks only, so clients learn a LAN address and prefer it over + // T3 Connect. `t3 service install` writes T3CODE_LAN=false for a new + // service; one installed before T3CODE_LAN existed keeps listening. + const lan = resolveOptionPrecedence(normalizedFlags.lan, Option.fromUndefinedOr(env.lan)); const privateNetworkOnly = Option.isNone(explicitHost) && mode === "web" && - (yield* resolveServiceLauncherMode().pipe( - Effect.map((launcher) => launcher.managed), - Effect.orElseSucceed(() => false), - )); + (Option.isSome(lan) + ? lan.value + : yield* resolveServiceLauncherMode().pipe( + Effect.map((launcher) => launcher.managed), + Effect.orElseSucceed(() => false), + )); const host = Option.getOrElse(explicitHost, () => privateNetworkOnly ? "0.0.0.0" : mode === "desktop" ? "127.0.0.1" : undefined, ); @@ -513,8 +527,9 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct export const resolveCliAuthConfig = ( flags: CliAuthLocationFlags, cliLogLevel: Option.Option, -) => - resolveServerConfig( + options?: { readonly quietLogs?: boolean }, +) => { + const resolved = resolveServerConfig( { mode: Option.none(), port: Option.none(), @@ -528,9 +543,16 @@ export const resolveCliAuthConfig = ( logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + lan: Option.none(), }, cliLogLevel, ); + // Logs go to stdout, where --json and --token-only print, and resolving can + // log, for example when it sweeps abandoned attachment uploads. + return options?.quietLogs === true + ? resolved.pipe(Effect.provideService(References.MinimumLogLevel, "Error")) + : resolved; +}; const DurationShorthandPattern = /^(?\d+)(?ms|s|m|h|d|w)$/i; diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 134fa17f026eb..e0aea76ef04c2 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -9,7 +9,6 @@ import { type RelayClientInstallProgressStage, } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; @@ -23,6 +22,7 @@ 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 References from "effect/References"; import * as Schema from "effect/Schema"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; @@ -60,8 +60,10 @@ const jsonFlag = Flag.Boolean("json").pipe( const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError); -const headlessFlag = Flag.Boolean("headless").pipe( - Flag.withDescription("Authorize without a local browser using the OAuth device flow."), +export const headlessFlag = Flag.Boolean("headless").pipe( + Flag.withDescription( + "Sign in with a URL and code on any device. Automatic without a terminal or over SSH.", + ), Flag.withDefault(false), ); @@ -85,13 +87,10 @@ function formatDeviceAuthorizationPrompt( ): string { const minutes = Math.max(1, Math.round(Duration.toMinutes(prompt.expiresIn))); return [ - "Headless authorization", - "Open this URL on a device with a browser:", + "Sign in to T3 Connect. Open on any device:", ` ${prompt.verificationUriComplete ?? prompt.verificationUri}`, - "", - `Confirm this code when asked: ${prompt.userCode}`, - "", - `Waiting for approval (expires in ${minutes} min). Press Ctrl+C to cancel.`, + `Code: ${prompt.userCode}`, + `Waiting for approval. Expires in ${minutes} min. Ctrl+C cancels.`, ].join("\n"); } @@ -99,20 +98,21 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { readonly headless: boolean; }) { const tokens = yield* CliTokenManager.CloudCliTokenManager; - const useOutOfBandOAuth = options.headless || (yield* headlessSessionConfig); + // Without a terminal, no key press can open a browser, so sign in with a code. + const useOutOfBandOAuth = + options.headless || + (yield* headlessSessionConfig) || + !(process.stdin.isTTY && process.stdout.isTTY); if (!useOutOfBandOAuth) { const authorization = yield* tokens.get; if (authorization._tag === "Authorized") { return authorization.token.identity ?? null; } - yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below."); } const existing = yield* tokens.getExisting.pipe( Effect.catchTags({ CloudCliCredentialRefreshError: () => - Console.log( - "The stored T3 Connect credential could not be refreshed; signing in again.", - ).pipe(Effect.as(Option.none())), + Console.log("T3 Connect sign-in expired. Sign in again.").pipe(Effect.as(Option.none())), }), ); if (Option.isSome(existing)) { @@ -363,17 +363,15 @@ function formatAlertBox(text: string): string { * Waits for the server that `t3 connect` just started to prove other devices * can reach it, so a broken tunnel fails setup instead of looking green. */ -const waitForReachability = Effect.fn("cloud.cli.wait_for_reachability")(function* () { - yield* Console.log("\nChecking that other devices can reach this machine..."); +export const waitForReachability = Effect.fn("cloud.cli.wait_for_reachability")(function* () { + yield* Console.log("Checking T3 Connect..."); const deadline = (yield* Clock.currentTimeMillis) + Duration.toMillis(CLOUD_CLI_REACHABILITY_WAIT); let last: EnvironmentConnectReachability | null = null; while (true) { last = (yield* checkLiveReachability()) ?? last; if (last?.status === "reachable") { - yield* Console.log( - `✓ Other devices can reach this machine · ${formatConnectReachable(last)}`, - ); + yield* Console.log("✓ T3 Connect is reachable."); return; } if ((yield* Clock.currentTimeMillis) >= deadline) break; @@ -481,7 +479,7 @@ const reportCloudDisconnectResults = Effect.fn("cloud.cli.report_disconnect_resu }, ); -const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { +export const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { readonly clearAuthorization: boolean; }) { yield* CliState.setCliDesiredCloudLink(false); @@ -505,9 +503,10 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.", ); } + return liveResult; }); -const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* ( +export const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* ( flags: { readonly baseDir: Option.Option }, run: Effect.Effect< A, @@ -519,6 +518,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* Effect.succeed(true) : confirmRelayClientInstall, + options.installRelayClient === true + ? (event) => + event.type === "progress" && event.stage === "downloading" + ? Console.log("Installing the T3 Connect relay client...") + : Effect.void + : reportRelayClientInstallProgress, ); if (Option.isNone(installed)) { yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); return null; } - yield* Console.log(formatRelayClientReady(installed.value.version)); + if (options.installRelayClient !== true) { + yield* Console.log(formatRelayClientReady(installed.value.version)); + } } const identity = yield* authorizeCli(options); yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed"); if (yield* CliState.dropRevokedCloudLink) { - yield* Console.log( - "Removed the link T3 Connect no longer accepts. The next start links this environment again.", - ); + yield* Console.log("Replacing the link T3 Connect revoked."); } if (publishOnly) { const secrets = yield* ServerSecretStore.ServerSecretStore; @@ -587,6 +594,7 @@ const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(functi } return { identity } as const; }); +export { linkEnvironmentForConnect }; const connectLoginCommand = Command.make("login", { ...projectLocationFlags, @@ -776,62 +784,12 @@ const connectLogoutCommand = Command.make("logout", { ), ); -const reportServiceOnboarding = Effect.fn("cli.connect.report_service_onboarding")(function* ( - outcome: CliService.ServiceOnboardingOutcome, -) { - if (outcome === "pending") { - yield* Console.log( - "\nBackground service restarting\n\nThe server establishes the T3 Connect link once it is back. Check the log above if it does not come back.", - ); - return; - } - if (outcome === "ready") { - const platform = yield* HostProcess.Platform; - yield* Console.log( - platform === "darwin" - ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup." - : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.", - ); - return; - } - const serveCommand = yield* resolveCliCommand("serve"); - yield* Console.log( - `\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.\n It checks that other devices can reach this machine when it starts.`, - ); -}); - -export const connectCommand = Command.make("connect", { - ...projectLocationFlags, - headless: headlessFlag, -}).pipe( - Command.withDescription("Set up T3 Connect for this machine."), - Command.withHandler((flags) => - runCloudCommand( - flags, - Effect.gen(function* () { - yield* Console.log("T3 Connect\n"); - const linked = yield* linkEnvironmentForConnect(flags); - if (!linked) { - return; - } - yield* Console.log(`✓ Authorized${connectedAs(linked.identity)}`); - - const outcome = yield* CliService.recoverServiceOnboardingOffer( - CliService.offerServiceDuringOnboarding, - ); - yield* reportServiceOnboarding(outcome); - if (outcome !== "skipped") { - yield* waitForReachability(); - } - }), - ), - ), - Command.withSubcommands([ - connectLoginCommand, - connectLinkCommand, - connectPublishCommand, - connectStatusCommand, - connectUnlinkCommand, - connectLogoutCommand, - ]), -); +// `t3 connect` itself is `t3 up --connect`, in up.ts. +export const connectSubcommands = [ + connectLoginCommand, + connectLinkCommand, + connectPublishCommand, + connectStatusCommand, + connectUnlinkCommand, + connectLogoutCommand, +] as const; diff --git a/apps/server/src/cli/environment.ts b/apps/server/src/cli/environment.ts index c8d6a2e0112ee..2b023f6562eb3 100644 --- a/apps/server/src/cli/environment.ts +++ b/apps/server/src/cli/environment.ts @@ -29,7 +29,7 @@ const runWithPeerLinks = Effect.fn("environment.runWithPeerLinks")(function* { readonly path?: string; readonly env?: Record; readonly executable?: boolean; + readonly args?: ReadonlyArray; }) => - resolveRootCliCommand("browser setup").pipe( + resolveRootCliCommand("browser setup", input.args).pipe( Effect.provideService(HostProcess.ExecutablePath, input.node), Effect.provideService(HostProcess.Arguments, [input.node, input.entry]), Effect.provideService(HostProcess.IsExecutable, input.executable ?? false), @@ -57,6 +58,23 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { }).pipe(Effect.scoped), ); + it.effect("keeps the arguments, so root runs setup on the same T3 home", () => + Effect.gen(function* () { + const args = ["--base-dir", "/srv/t3 home"]; + expect( + yield* rootCommand({ + node: "/home/theo/.local/node/bin/node", + entry: "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs", + path: yield* pathWithT3, + args, + }), + ).toBe(`sudo env "PATH=$PATH" t3 browser setup --base-dir '/srv/t3 home'`); + expect( + yield* rootCommand({ node: "/opt/t3/t3", entry: "/opt/t3/t3", executable: true, args }), + ).toBe("sudo /opt/t3/t3 browser setup --base-dir '/srv/t3 home'"); + }).pipe(Effect.scoped), + ); + it.effect("names this install's launcher when t3 is not on PATH", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -73,7 +91,15 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { expect(yield* rootCommand(desktop)).toBe(`sudo '${shim}' browser setup`); + // An unrelated `t3` on PATH, such as an older npm global, is another install's CLI. expect(yield* rootCommand({ ...desktop, path: yield* pathWithT3 })).toBe( + `sudo '${shim}' browser setup`, + ); + + // The link the app installs onto PATH leads to its own launcher, so plain `t3` is right. + const linked = yield* fs.makeTempDirectoryScoped(); + yield* fs.symlink(shim, path.join(linked, "t3")); + expect(yield* rootCommand({ ...desktop, path: linked })).toBe( 'sudo env "PATH=$PATH" t3 browser setup', ); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index c1f97f5c86889..714203264f628 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import type { ServerInstallation } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { isCommandAvailable } from "@t3tools/shared/shell"; +import { resolveCommandPath } from "@t3tools/shared/shell"; import packageJson from "../../package.json" with { type: "json" }; @@ -148,7 +148,7 @@ const resolveInstallLauncher = Effect.gen(function* () { }); /** - * Uses `t3` on PATH, the originating package runner, or this install's absolute launcher path. + * Uses this install's `t3` on PATH, the originating runner, or its absolute launcher path. * Arguments are quoted for POSIX shells or PowerShell on Windows. */ export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand")(function* ( @@ -160,7 +160,16 @@ export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand [prefix, ...args.map((arg) => hostShellWord(platform, arg))].join(" "); const command = yield* resolveCliCommand(subcommand); if (command !== `t3 ${subcommand}`) return { command: withArgs(command), launcher: false }; - if (yield* isCommandAvailable("t3")) return { command: withArgs(command), launcher: false }; + const onPath = yield* resolveCommandPath("t3"); + if (onPath !== null) { + const shim = (yield* HostProcess.Environment).T3CODE_CLI_PATH?.trim(); + const fs = yield* FileSystem.FileSystem; + const real = (file: string) => fs.realPath(file).pipe(Effect.orElseSucceed(() => null)); + const shimReal = shim ? yield* real(shim) : null; + if (shimReal === null || shimReal === (yield* real(onPath))) { + return { command: withArgs(command), launcher: false }; + } + } const launcher = yield* resolveInstallLauncher; if (Option.isNone(launcher)) return { command: withArgs(command), launcher: false }; // PowerShell runs a quoted path only through the call operator. @@ -177,8 +186,9 @@ export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand */ export const resolveRootCliCommand = Effect.fn("invocation.resolveRootCliCommand")(function* ( subcommand: string, + args: ReadonlyArray = [], ) { - const { command, launcher } = yield* resolveHostCliCommand(subcommand); + const { command, launcher } = yield* resolveHostCliCommand(subcommand, args); if (launcher) return `sudo ${command}`; const executablePath = yield* HostProcess.ExecutablePath; const systemNode = ROOT_PATH_DIRECTORIES.some((directory) => diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 88fc9cec74c13..88717f9a7f549 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -18,6 +18,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { FetchHttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as TestClock from "effect/testing/TestClock"; import * as TestConsole from "effect/testing/TestConsole"; import { Command, CliError } from "effect/cli"; @@ -548,3 +549,42 @@ describe("auth pairing create --base-url", () => { }), ); }); + +describe("scripted auth output", () => { + it.effect.each( + [ + ["auth", "session", "issue", "--token-only"], + ["auth", "session", "list", "--json"], + ["auth", "pairing", "list", "--json"], + ].map((command) => ({ command, label: command.join(" ") })), + )("prints only the result for $label after removing an abandoned upload", ({ command }) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-quiet-test-")); + const attachmentsDir = NodePath.join(baseDir, "userdata", "attachments"); + NodeFS.mkdirSync(attachmentsDir, { recursive: true }); + // An abandoned upload makes config resolution log that it removed it. + const abandoned = NodePath.join(attachmentsDir, "abandoned.part"); + NodeFS.writeFileSync(abandoned, ""); + const jan2020Seconds = 1_577_836_800; + NodeFS.utimesSync(abandoned, jan2020Seconds, jan2020Seconds); + // The sweep reads Clock, so a week past the upload's mtime makes it expired. + yield* TestClock.setTime((jan2020Seconds + 7 * 24 * 60 * 60) * 1000); + + const lines = yield* provideCliTestLayers( + Effect.gen(function* () { + yield* runCli([...command, "--base-dir", baseDir]); + return yield* TestConsole.logLines; + }), + ); + + assert.isFalse(NodeFS.existsSync(abandoned)); + assert.lengthOf(lines, 1); + const output = String(lines[0]); + if (command.includes("--json")) { + assert.deepEqual(JSON.parse(output), []); + } else { + assert.match(output.trim(), /^[\w-]+\.[\w-]+/); + } + }), + ); +}); diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index dfff812711513..78ca1a4e93b23 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -84,7 +84,7 @@ export class IncompatibleServerProtocolError extends Schema.TaggedError runServerCommand(flags, { rejectRunningServer: true })), ); export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( + Command.withShortDescription("Run T3 Code in this terminal, with no browser."), Command.withDescription( - "Run the T3 Code server without opening a browser and print headless pairing details.", + "Run T3 Code in this terminal without opening a browser, and print pairing details. The background service runs this. To set up access from your other devices, run `t3 up`.", ), Command.withHandler((flags) => runServerCommand(flags, { diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 2ea799d9f09e4..4358165958a1b 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -6,18 +6,13 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Terminal from "effect/Terminal"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/cli"; import { afterEach, vi } from "vite-plus/test"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; -import { - offerServiceDuringOnboarding, - recoverServiceOnboardingOffer, - serviceCommand, -} from "./service.ts"; +import { serviceCommand } from "./service.ts"; afterEach(() => vi.restoreAllMocks()); @@ -90,6 +85,47 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands }), ); + it.effect.each([ + { label: "a new service", installed: false, env: "", expected: "T3CODE_LAN=false\n" }, + // Services installed before T3CODE_LAN existed keep listening on the local network. + { label: "an older service", installed: true, env: "", expected: "" }, + { + label: "a set host", + installed: false, + env: "T3CODE_HOST=::\n", + expected: "T3CODE_HOST=::\n", + }, + ])("install keeps $label off the local network only when it is new", (input) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + yield* fs.writeFileString(`${baseDir}/service.env`, input.env); + const { service } = makeTestService({ + ...status, + installed: input.installed, + current: false, + }); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + + yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + "install", + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcess.Environment, {}), + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + Layer.fresh(TestConsole.layer), + ), + ), + ); + expect(yield* fs.readFileString(`${baseDir}/service.env`)).toBe(input.expected); + }), + ); + it.effect("protect refuses root before creating any T3 directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -159,35 +195,3 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands }), ); }); - -it.effect("leaves a newer service unchanged during onboarding without prompting", () => - Effect.gen(function* () { - const { service, installOptions } = makeTestService(newerServiceStatus); - const terminal = Terminal.make({ - columns: Effect.succeed(80), - rows: Effect.succeed(24), - readInput: Effect.die("Onboarding must not prompt to replace a newer service."), - readLine: Effect.die("Onboarding must not prompt to replace a newer service."), - display: () => Effect.die("Onboarding must not prompt to replace a newer service."), - }); - - const ready = yield* offerServiceDuringOnboarding.pipe( - Effect.provideService(BootService.BootService, service), - Effect.provideService(Terminal.Terminal, terminal), - Effect.provide(NodeServices.layer), - ); - - expect(ready).toBe("skipped"); - expect(installOptions).toEqual([]); - }), -); - -// regression: #9602 diagnose incomplete headless server setup -it.effect("keeps the manual-server fallback when background prerequisites fail", () => - Effect.gen(function* () { - const ready = yield* recoverServiceOnboardingOffer( - Effect.fail(new BootService.BootServicePrerequisiteError({ problem: "linger-disabled" })), - ); - expect(ready).toBe("skipped"); - }), -); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index fe05ac4d86c0c..4cbac0dbdc0c2 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -1,17 +1,19 @@ +import * as NodeUtil from "node:util"; + import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Console from "effect/Console"; 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 Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import { FetchHttpClient } from "effect/http"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import * as ServiceProtection from "../cloud/serviceProtection.ts"; -import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; -import type * as ServerConfig from "../config.ts"; +import { compareExactServiceVersions, SERVICE_ENV_FILE } from "../cloud/serviceProtocol.ts"; +import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { updateRunningService } from "./serviceUpdate.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -35,15 +37,72 @@ export type ServiceReconcileResult = readonly plan: BootService.BootServicePlan; }; -const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { +const SERVICE_ENV_KEY = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/u; + +/** + * Sets or removes (`undefined`) entries in `service.env` text and keeps every + * other line, including comments, as the user wrote it. + */ +export const upsertServiceEnv = ( + contents: string, + entries: Readonly>, +): string => { + const written = new Set(); + const lines = contents.split("\n").flatMap((line) => { + const key = SERVICE_ENV_KEY.exec(line)?.[1]; + if (key === undefined || !Object.hasOwn(entries, key)) return [line]; + const value = entries[key]; + if (value === undefined || written.has(key)) return []; + written.add(key); + return [`${key}=${value}`]; + }); + while (lines.at(-1) === "") lines.pop(); + for (const [key, value] of Object.entries(entries)) { + if (value !== undefined && !written.has(key)) lines.push(`${key}=${value}`); + } + return lines.length === 0 ? "" : `${lines.join("\n")}\n`; +}; + +/** `service.env` under `baseDir`, parsed as the service launcher parses it. */ +export const readServiceEnv = Effect.fn("cli.service.read_env")(function* (baseDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = (yield* Path.Path).join(baseDir, SERVICE_ENV_FILE); + const contents = (yield* fs.exists(path)) ? yield* fs.readFileString(path) : ""; + const entries = Object.entries(NodeUtil.parseEnv(contents)).flatMap(([key, value]) => + value === undefined ? [] : [[key, value] as const], + ); + return { path, contents, entries: Object.fromEntries(entries) }; +}); + +export const writeServiceEnv = Effect.fn("cli.service.write_env")(function* ( + baseDir: string, + entries: Readonly>, +) { + const fs = yield* FileSystem.FileSystem; + const current = yield* readServiceEnv(baseDir); + const next = upsertServiceEnv(current.contents, entries); + if (next === current.contents) return; + yield* fs.makeDirectory(baseDir, { recursive: true }); + yield* fs.writeFileString(current.path, next); +}); + +export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { readonly allowDowngrade?: boolean; readonly start?: boolean; }) { + const { baseDir } = yield* ServerConfig.ServerConfig; const service = yield* BootService.BootService; const status = yield* service.status; if (status.installed && status.current) { return { changed: false, status } satisfies ServiceReconcileResult; } + if (status.supported && !status.installed) { + // A new service listens on this machine only, until `t3 up` opens the local network. + const { entries } = yield* readServiceEnv(baseDir); + if (entries.T3CODE_LAN === undefined && entries.T3CODE_HOST === undefined) { + yield* writeServiceEnv(baseDir, { T3CODE_LAN: "false" }); + } + } if ( status.installedVersion !== undefined && options?.allowDowngrade !== true && @@ -62,13 +121,13 @@ const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: } satisfies ServiceReconcileResult; }); -function formatServiceChange( +export function formatServiceChange( result: Extract, ): string { if (result.plan.handedOff === true) { - return `Handed the T3 Code service switch to t3@${packageJson.version} to a separate systemd unit, since this session runs inside the service and disconnects when it restarts.\nResult: ${result.plan.logPath}`; + return `Service update to t3@${packageJson.version} queued. This session will disconnect.\nLog: ${result.plan.logPath}`; } - return `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`; + return `Service ${result.previouslyInstalled ? "updated" : "installed"} · t3@${packageJson.version}\nLog: ${result.plan.logPath}`; } function formatProtection( @@ -130,7 +189,7 @@ const runServiceCommand = Effect.fn("cli.service.run")(function* ( ) { const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveCliAuthConfig(flags, logLevel); - return yield* run.pipe(Effect.provide(layer(config))); + return yield* run.pipe(Effect.provide(Layer.merge(layer(config), ServerConfig.layer(config)))); }); const serviceReconcileFlags = { @@ -142,7 +201,9 @@ const serviceReconcileFlags = { }; const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pipe( - Command.withDescription("Install T3 Code as a background service for this user."), + Command.withDescription( + "Install T3 Code as a background service for this user. `t3 up` also chooses how your other devices reach it.", + ), Command.withHandler((flags) => runServiceCommand( flags, @@ -155,6 +216,9 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip return; } yield* Console.log(formatServiceChange(result)); + if (!result.previouslyInstalled) { + yield* Console.log("Run `t3 up` to choose how your other devices reach it."); + } }), ), ), @@ -189,7 +253,7 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( return; } yield* Console.log(formatServiceChange(result)); - }).pipe(Effect.provide(layer(config))); + }).pipe(Effect.provide(Layer.merge(layer(config), ServerConfig.layer(config)))); }), ), ); @@ -326,6 +390,14 @@ const serviceProtectCommand = Command.make("protect", { ); } + yield* Console.log( + [ + "Keeps T3 Code running when agents fill this machine's memory:", + " - reserves memory for the T3 Code server, so the kernel takes memory from agents first", + " - makes the out-of-memory killer pick agents before the server", + "It adds systemd drop-in files and changes nothing else. Undo with `t3 service protect --undo`.", + ].join("\n"), + ); const names = remote.map((unit) => unit.replace(/\.service$/, "")).join(" and "); const protectRemote = remote.length > 0 && @@ -340,7 +412,7 @@ const serviceProtectCommand = Command.make("protect", { }); yield* Console.log( [ - "As root, through sudo:", + "Drop-ins it writes, as root through sudo:", ...formatDropIns(plan.system), "As you:", ...formatDropIns([plan.managerDefaults, ...plan.user]), @@ -357,88 +429,6 @@ const serviceProtectCommand = Command.make("protect", { ), ); -export type ServiceOnboardingOutcome = "ready" | "pending" | "skipped"; - -export const offerServiceDuringOnboarding = Effect.gen(function* () { - const service = yield* BootService.BootService; - const status = yield* service.status; - const { supported, installed, current } = status; - if (!supported) { - return "skipped" as const; - } - if (installed && current) { - yield* Console.log("T3 Code is already set up to run in the background on this machine."); - return "ready" as const; - } - for (const problem of status.problems ?? []) { - yield* Console.warn(`[${problem}] ${BootService.formatBootServiceProblem(problem)}`); - } - if ( - installed && - status.installedVersion !== undefined && - compareExactServiceVersions(status.installedVersion, packageJson.version) > 0 - ) { - yield* Console.log( - `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`, - ); - return "skipped" as const; - } - const platform = yield* HostProcess.Platform; - const wanted = yield* Prompt.run( - Prompt.Confirm({ - message: installed - ? "The installed T3 Code service needs an update or repair. Update it now?" - : platform === "darwin" - ? "Run T3 Code in the background whenever you log in to this Mac? " + - "It stays reachable through T3 Connect while you are logged in." - : "Run T3 Code in the background whenever this machine boots? " + - "It stays reachable through T3 Connect even after you log out.", - initial: true, - }), - ); - if (!wanted) { - return "skipped" as const; - } - const result = yield* reconcileService(); - if (result.changed) { - yield* Console.log( - result.plan.handedOff === true - ? formatServiceChange(result) - : `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, - ); - } - return result.changed && result.plan.handedOff === true - ? ("pending" as const) - : ("ready" as const); -}); - -const reportServiceSetupFailure = (error: BootService.BootServiceError) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe( - Effect.as("skipped" as const), - ); - -export const recoverServiceOnboardingOffer = ( - offer: Effect.Effect< - ServiceOnboardingOutcome, - BootService.BootServiceError | Terminal.QuitError, - R - >, -) => - offer.pipe( - Effect.catchTags({ - QuitError: () => Effect.succeed("skipped" as const), - BootServiceUnsupportedError: (error) => - Console.log(`Skipping background setup: ${error.message}`).pipe( - Effect.as("skipped" as const), - ), - BootServiceCommandError: reportServiceSetupFailure, - BootServiceInstallError: reportServiceSetupFailure, - BootServicePrerequisiteError: reportServiceSetupFailure, - BootServiceUpdatePendingError: reportServiceSetupFailure, - BootServiceDowngradeRefusedError: reportServiceSetupFailure, - }), - ); - export const serviceCommand = Command.make("service").pipe( Command.withDescription("Manage the T3 Code background service."), Command.withSubcommands([ diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index c2e4bc5c8168c..e09f3be00312b 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest"; import { RunId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { type ThreadTools, waitForRun } from "./thread.ts"; +import { readLatestMessages, type ThreadTools, waitForRun } from "./thread.ts"; const threadId = ThreadId.make("thread-1"); const runId = RunId.make("run-1"); @@ -73,3 +73,90 @@ it.effect("--wait reports its run's result when a later run is the one waiting", }); }), ); + +/** Serves t3_thread_read pages the way the server does: positions count every item. */ +const timelineTools = (types: ReadonlyArray) => { + const items = types.map((type, position) => ({ position, itemId: `item-${position}`, type })); + const served: Array = []; + const call = ((_tool: string, input: unknown) => { + const args = input as { readonly afterPosition?: number; readonly limit: number }; + // Scanning one match past the page keeps reads of huge fixtures linear. + const matching: typeof items = []; + for (let position = (args.afterPosition ?? -1) + 1; position < items.length; position++) { + const item = items[position]!; + if (["user_message", "assistant_message", "proposed_plan"].includes(item.type)) { + matching.push(item); + if (matching.length > args.limit) break; + } + } + const page = matching.slice(0, args.limit); + served.push(page.length); + return Effect.succeed({ + thread: { itemCount: items.length }, + items: page, + nextPosition: page.at(-1)?.position ?? null, + hasMore: matching.length > page.length, + }); + }) as ThreadTools["call"]; + const latest = (count: number) => + items + .filter((item) => item.type === "user_message" || item.type === "assistant_message") + .slice(-count) + .map((item) => item.itemId); + return { tools: { call } satisfies ThreadTools, served, latest }; +}; + +const tail = (tools: number) => [...Array(tools).fill("tool_call"), "assistant_message"]; + +it.effect("show reads the end of a long thread, not its whole history", () => + Effect.gen(function* () { + const { tools, served, latest } = timelineTools([ + ...Array.from({ length: 1000 }, (_, index) => + index % 2 === 0 ? "user_message" : "assistant_message", + ), + ...Array.from({ length: 10 }, () => tail(30)).flat(), + "proposed_plan", + ]); + + const { messages } = yield* readLatestMessages(tools, threadId, 10); + + assert.deepStrictEqual( + messages.map((item) => item.itemId), + latest(10), + ); + assert.isBelow( + served.reduce((sum, count) => sum + count, 0), + 100, + ); + }), +); + +it.effect("show widens back to the first item when the thread has fewer messages", () => + Effect.gen(function* () { + const { tools, latest } = timelineTools([ + "user_message", + ...Array.from({ length: 4 }, () => tail(150)).flat(), + ]); + + const { messages } = yield* readLatestMessages(tools, threadId, 10); + + assert.deepStrictEqual( + messages.map((item) => item.itemId), + latest(10), + ); + assert.lengthOf(messages, 5); + }), +); + +it.effect("show returns a --messages count larger than one call can spread", () => + Effect.gen(function* () { + const { tools, latest } = timelineTools(Array(200_000).fill("assistant_message")); + + const { messages } = yield* readLatestMessages(tools, threadId, 200_000); + + assert.deepStrictEqual( + messages.map((item) => item.itemId), + latest(200_000), + ); + }), +); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 777213bc9ff5f..5d944e2723ee4 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -333,6 +333,63 @@ const readTimeline = Effect.fn("cli.thread.readTimeline")(function* ( return { thread: page.thread, items }; }); +/** + * The latest `count` user and assistant messages, oldest first. Positions + * count every timeline item, so the read starts near the end and widens + * backward only while tool and plan items leave too few messages. + */ +export const readLatestMessages = Effect.fn("cli.thread.readLatestMessages")(function* ( + tools: ThreadTools, + threadId: ThreadId, + count: number, +) { + let { thread } = yield* tools.call( + "t3_thread_read", + { threadId, limit: 1, runLimit: 1 }, + OrchestratorMcpThreadReadResult, + ); + let messages: Array = []; + // The newest window has no upper bound, so items added since the first + // read are still shown. + let before: number | undefined; + let start = thread.itemCount; + let span = Math.max(count, READ_PAGE_LIMIT); + do { + const end = before; + start = Math.max(0, start - span); + const window: Array = []; + let afterPosition = start - 1; + let page: OrchestratorMcpThreadReadResult; + do { + page = yield* tools.call( + "t3_thread_read", + { + threadId, + view: "messages", + limit: READ_PAGE_LIMIT, + runLimit: 1, + ...(afterPosition < 0 ? {} : { afterPosition }), + }, + OrchestratorMcpThreadReadResult, + ); + window.push( + ...page.items.filter( + (item) => + (item.type === "user_message" || item.type === "assistant_message") && + (end === undefined || item.position < end), + ), + ); + afterPosition = page.nextPosition ?? Number.POSITIVE_INFINITY; + } while (page.hasMore && afterPosition < (end ?? Number.POSITIVE_INFINITY) - 1); + thread = page.thread; + // A window can hold the whole --messages count, too many to spread as arguments. + messages = window.concat(messages); + before = start; + span *= 2; + } while (messages.length < count && start > 0); + return { thread, messages: messages.slice(Math.max(0, messages.length - count)) }; +}); + /** Item text past the read's per-item cap. */ const readFullText = Effect.fn("cli.thread.readFullText")(function* ( tools: ThreadTools, @@ -662,11 +719,11 @@ const threadShowCommand = Command.make("show", { yield* withThreadTools( flags, Effect.fn("cli.thread.show")(function* (tools) { - const { thread, items } = yield* readTimeline(tools, threadId, "messages"); - const conversation = items.filter( - (item) => item.type === "user_message" || item.type === "assistant_message", + const { thread, messages: latest } = yield* readLatestMessages( + tools, + threadId, + flags.messages, ); - const latest = conversation.slice(Math.max(0, conversation.length - flags.messages)); const messages = yield* Effect.forEach(latest, (item) => readFullText(tools, threadId, item).pipe( Effect.map((text) => ({ diff --git a/apps/server/src/cli/up.connect.test.ts b/apps/server/src/cli/up.connect.test.ts new file mode 100644 index 0000000000000..a0d707e505191 --- /dev/null +++ b/apps/server/src/cli/up.connect.test.ts @@ -0,0 +1,126 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/cli"; +import { vi } from "vite-plus/test"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import { upCommand } from "./up.ts"; + +// T3 Connect's flows run only in builds with its public config, and they call +// the relay; this file swaps in the link and unlink steps. +const events = vi.hoisted((): Array => []); +// The CLI link marker, kept in memory instead of the secret store. +const link = vi.hoisted(() => ({ + desired: false, + mode: "managed" as "managed" | "publish_only", +})); +vi.mock("../cloud/CliState.ts", async (importOriginal) => ({ + ...(await importOriginal()), + readCliDesiredCloudLink: Effect.sync(() => link.desired), + readCliDesiredLinkMode: Effect.sync(() => link.mode), + setCliDesiredCloudLink: (desired: boolean, mode: "managed" | "publish_only" = "managed") => + Effect.sync(() => { + link.desired = desired; + link.mode = mode; + }), +})); +vi.mock("../cloud/publicConfig.ts", async (importOriginal) => ({ + ...(await importOriginal()), + hasCloudPublicConfig: true, +})); +vi.mock("./connect.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + linkEnvironmentForConnect: () => + Effect.sync(() => { + events.push("link"); + link.desired = true; + link.mode = "managed"; + return { identity: "me@example.com" } as const; + }), + disconnectCloud: () => + Effect.sync(() => { + events.push("unlink"); + link.desired = false; + return { status: "succeeded" } as const; + }), + }; +}); + +it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up with T3 Connect", (it) => { + it.effect.each([ + { label: "no link", prior: null, events: ["link", "restart", "unlink", "restart"] }, + // The publish-only link comes back, with its activity publishing. + { + label: "a publish-only link", + prior: "publish_only" as const, + events: ["link", "restart", "restart"], + }, + ])("a failed restart after --connect undoes it, then restarts: $label before", (input) => + Effect.gen(function* () { + events.length = 0; + link.desired = input.prior !== null; + link.mode = input.prior ?? "managed"; + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-up-connect-test-" }); + yield* fs.writeFileString(`${baseDir}/service.env`, "T3CODE_LAN=false\n"); + const status = { + supported: true, + installed: true, + current: true, + unitPath: "/home/me/.config/systemd/user/t3code.service", + logPath: "/home/me/.t3/userdata/logs/boot-service.log", + }; + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed( + BootService.BootService, + BootService.BootService.of({ + status: Effect.succeed(status), + withStopped: (operation) => operation, + restart: Effect.sync(() => events.push("restart")).pipe( + Effect.andThen( + Effect.fail(new BootService.BootServiceCommandError({ step: "restart" })), + ), + ), + install: () => Effect.die("an installed, current service is not reinstalled"), + uninstall: Effect.succeed(false), + }), + ), + ); + + const exit = yield* Command.runWith(upCommand, { version: packageJson.version })([ + "--base-dir", + baseDir, + "--connect", + ]).pipe( + Effect.provideService(HostProcess.Environment, {}), + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + Layer.fresh(TestConsole.layer), + ), + ), + Effect.exit, + ); + + expect(exit._tag).toBe("Failure"); + // Any unlink comes before the restart on the old settings. + expect(events).toEqual(input.events); + expect(link).toEqual( + input.prior === null + ? { desired: false, mode: "managed" } + : { desired: true, mode: input.prior }, + ); + expect(yield* fs.readFileString(`${baseDir}/service.env`)).toBe("T3CODE_LAN=false\n"); + }), + ); +}); diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts new file mode 100644 index 0000000000000..9fbe515944a83 --- /dev/null +++ b/apps/server/src/cli/up.test.ts @@ -0,0 +1,356 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as Cause from "effect/Cause"; +import * as ConfigProvider from "effect/ConfigProvider"; +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 Queue from "effect/Queue"; +import * as Terminal from "effect/Terminal"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/cli"; +import { afterEach, vi } from "vite-plus/test"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import { upsertServiceEnv } from "./service.ts"; +import { connectEnabled, planRoutes, upCommand } from "./up.ts"; + +const none = Option.none(); + +it.each([ + { + label: "a new machine starts from T3 Connect and asks", + setup: { installed: false, connect: false, lan: false }, + flags: { connect: none, lan: none }, + expected: { routes: { connect: true, lan: false }, ask: ["connect", "lan"] }, + }, + { + label: "a build without T3 Connect never offers it", + setup: { installed: false, connect: null, lan: false }, + flags: { connect: none, lan: none }, + expected: { routes: { connect: false, lan: false }, ask: ["lan"] }, + }, + { + label: "a rerun starts from the current setup", + setup: { installed: true, connect: false, lan: true }, + flags: { connect: none, lan: none }, + expected: { routes: { connect: false, lan: true }, ask: ["connect", "lan"] }, + }, + { + label: "a route flag on a new machine turns on only that route", + setup: { installed: false, connect: false, lan: false }, + flags: { connect: none, lan: Option.some(true) }, + expected: { routes: { connect: false, lan: true }, ask: [] }, + }, + { + label: "a route flag asks nothing and keeps the other route", + setup: { installed: true, connect: true, lan: true }, + flags: { connect: none, lan: Option.some(false) }, + expected: { routes: { connect: true, lan: false }, ask: [] }, + }, +])("$label", ({ setup, flags, expected }) => { + expect(planRoutes(setup, flags)).toEqual(expected); +}); + +const SERVICE = { + supported: true, + installed: true, + current: true, + unitPath: "/home/me/.config/systemd/user/t3code.service", + logPath: "/home/me/.t3/userdata/logs/boot-service.log", +} as const; + +it.each([ + { + label: "a CLI managed link", + cliDesired: true, + cliMode: "managed", + managedTunnel: false, + revoked: false, + on: true, + }, + { + label: "a link the app made", + cliDesired: false, + cliMode: "managed", + managedTunnel: true, + revoked: false, + on: true, + }, + { + label: "a publish-only link", + cliDesired: true, + cliMode: "publish_only", + managedTunnel: false, + revoked: false, + on: false, + }, + { + label: "a publish-only link with an old tunnel config", + cliDesired: true, + cliMode: "publish_only", + managedTunnel: true, + revoked: false, + on: false, + }, + { + label: "a revoked link", + cliDesired: true, + cliMode: "managed", + managedTunnel: true, + revoked: true, + on: false, + }, + { + label: "no link", + cliDesired: false, + cliMode: "managed", + managedTunnel: false, + revoked: false, + on: false, + }, +] as const)("T3 Connect is on for $label: $on", ({ label: _, on, ...link }) => { + expect(connectEnabled(link)).toBe(on); +}); + +// Prompts run only when both streams are terminals. +const asTerminal = Effect.acquireRelease( + Effect.sync(() => + [process.stdin, process.stdout].map((stream) => { + const descriptor = Object.getOwnPropertyDescriptor(stream, "isTTY"); + Object.defineProperty(stream, "isTTY", { configurable: true, value: true }); + return { stream, descriptor }; + }), + ), + (saved) => + Effect.sync(() => { + for (const { stream, descriptor } of saved) { + if (descriptor === undefined) Reflect.deleteProperty(stream, "isTTY"); + else Object.defineProperty(stream, "isTTY", descriptor); + } + }), +); + +const answering = (text: string) => + Terminal.make({ + columns: Effect.succeed(80), + rows: Effect.succeed(24), + readInput: Effect.gen(function* () { + const input = yield* Queue.unbounded(); + yield* Queue.offer(input, { + input: Option.some(text), + key: { name: text, ctrl: false, meta: false, shift: false }, + }); + return input; + }), + readLine: Effect.succeed(text), + display: () => Effect.void, + }); + +it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => { + afterEach(() => vi.restoreAllMocks()); + + it.effect.each<{ + readonly label: string; + readonly installed: boolean; + readonly status?: Partial; + readonly installFails?: boolean; + readonly restartFails?: boolean; + /** Runs as if in a terminal, answering each question with this text. */ + readonly answer?: string; + readonly prints?: string; + readonly env: string; + readonly args: ReadonlyArray; + readonly installs: number; + readonly restarts: number; + readonly envAfter: string; + }>([ + { + label: "nothing changed", + installed: true, + env: "T3CODE_LAN=false\n", + args: ["--no-lan"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_LAN=false\n", + }, + { + label: "the local network opens", + installed: true, + env: "T3CODE_LAN=false\n", + args: ["--lan"], + installs: 0, + restarts: 1, + envAfter: "T3CODE_LAN=true\n", + }, + { + label: "a new service starts with its settings", + installed: false, + env: "", + args: ["--lan"], + installs: 1, + restarts: 0, + envAfter: "T3CODE_LAN=true\n", + }, + { + label: "a route turns off with no service to run", + installed: false, + env: "T3CODE_LAN=true\n", + args: ["--no-lan"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_LAN=false\n", + }, + { + label: "a stopped newer service starts as it is", + installed: true, + status: { current: false, installedVersion: "999.0.0", problems: ["service-stopped"] }, + env: "T3CODE_LAN=false\n", + args: ["--yes"], + installs: 0, + restarts: 1, + envAfter: "T3CODE_LAN=false\n", + }, + { + label: "a failed update keeps service.env matching the old service", + // The old service still runs, so no foreground server is offered. + prints: "Fix that, then run `t3 up` again.", + installed: true, + status: { current: false }, + installFails: true, + env: "", + args: ["--no-lan"], + installs: 0, + restarts: 0, + envAfter: "", + }, + { + label: "a failed restart puts service.env back and restarts on it", + installed: true, + restartFails: true, + env: "T3CODE_LAN=false\n", + args: ["--lan"], + installs: 0, + restarts: 2, + envAfter: "T3CODE_LAN=false\n", + }, + { + label: "declining an update changes nothing", + installed: true, + status: { current: false }, + answer: "n", + env: "T3CODE_LAN=false\n", + args: ["--lan"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_LAN=false\n", + }, + { + label: "no service manager still records the choice", + installed: false, + status: { supported: false }, + env: "", + args: ["--lan"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_LAN=true\n", + }, + { + label: "the old default is kept", + installed: true, + env: "", + args: ["--yes"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_LAN=true\n", + }, + ])("restarts the service only when it must: $label", (input) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-up-test-" }); + yield* fs.writeFileString(`${baseDir}/service.env`, input.env); + let installs = 0; + let restarts = 0; + const status = { + ...SERVICE, + installed: input.installed, + current: input.installed, + ...input.status, + }; + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed( + BootService.BootService, + BootService.BootService.of({ + status: Effect.succeed(status), + withStopped: (operation) => operation, + restart: Effect.sync(() => restarts++).pipe( + Effect.andThen( + input.restartFails === true + ? Effect.fail(new BootService.BootServiceCommandError({ step: "restart" })) + : Effect.succeed("restarted" as const), + ), + ), + install: () => + input.installFails === true + ? Effect.fail( + new BootService.BootServicePrerequisiteError({ problem: "linger-disabled" }), + ) + : Effect.sync(() => { + installs++; + return { + program: ["t3"], + baseDir, + unitPath: status.unitPath, + logPath: status.logPath, + }; + }), + uninstall: Effect.succeed(false), + }), + ), + ); + + if (input.answer !== undefined) yield* asTerminal; + const { exit, lines } = yield* Command.runWith(upCommand, { version: packageJson.version })([ + "--base-dir", + baseDir, + ...input.args, + ]).pipe( + Effect.provideService(HostProcess.Environment, {}), + input.answer === undefined + ? (effect) => effect + : Effect.provideService(Terminal.Terminal, answering(input.answer)), + Effect.exit, + Effect.flatMap((exit) => + Effect.map(TestConsole.logLines, (lines) => ({ exit, lines: lines.map(String) })), + ), + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + Layer.fresh(TestConsole.layer), + ), + ), + ); + if (input.prints !== undefined) { + expect(lines.join("\n")).toContain(input.prints); + } + expect(exit._tag).toBe(input.restartFails === true ? "Failure" : "Success"); + expect({ installs, restarts }).toEqual({ + installs: input.installs, + restarts: input.restarts, + }); + expect(yield* fs.readFileString(`${baseDir}/service.env`)).toBe(input.envAfter); + }), + ); +}); + +it("rewrites only the named service.env keys and keeps the user's lines", () => { + const before = ["# my settings", "T3CODE_PORT=4000", "export T3CODE_LAN=0", "T3CODE_LAN=1", ""]; + expect(upsertServiceEnv(before.join("\n"), { T3CODE_LAN: "true", T3CODE_HOST: undefined })).toBe( + ["# my settings", "T3CODE_PORT=4000", "T3CODE_LAN=true", ""].join("\n"), + ); + expect(upsertServiceEnv("", { T3CODE_LAN: "false" })).toBe("T3CODE_LAN=false\n"); +}); diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts new file mode 100644 index 0000000000000..7f307aec4b3a3 --- /dev/null +++ b/apps/server/src/cli/up.ts @@ -0,0 +1,510 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; +import * as CliError from "effect/cli/CliError"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as BootService from "../cloud/bootService.ts"; +import * as CliState from "../cloud/CliState.ts"; +import { CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINK_REVOKED } from "../cloud/config.ts"; +import { hasCloudPublicConfig } from "../cloud/publicConfig.ts"; +import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; +import * as ServerConfig from "../config.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { + connectSubcommands, + disconnectCloud, + headlessFlag, + linkEnvironmentForConnect, + runCloudCommand, + waitForReachability, +} from "./connect.ts"; +import { resolveHostCliCommand } from "./invocation.ts"; +import * as CliService from "./service.ts"; + +type Route = "connect" | "lan"; +type Routes = Readonly>; + +/** How other devices reach this machine now. */ +interface Setup { + readonly service: BootService.BootServiceStatus; + /** Null in builds without T3 Connect. Off when T3 Connect revoked the link. */ + readonly connect: boolean | null; + /** T3 Connect revoked the link; `t3 connect` links again. */ + readonly connectRevoked: boolean; + readonly lan: boolean; + /** An installed service with no T3CODE_LAN keeps the old default: on. */ + readonly lanFromOldDefault: boolean; + readonly host: string | undefined; +} + +/** + * T3 Connect is on when this CLI asked for a managed link, or when the desktop or + * web app made one: that saves the tunnel config but no CLI marker. Revoked is off. + */ +export const connectEnabled = (link: { + readonly cliDesired: boolean; + readonly cliMode: CliState.CliDesiredLinkMode; + readonly managedTunnel: boolean; + readonly revoked: boolean; +}) => + // The server starts no tunnel for a CLI publish-only link, even with an old tunnel config. + !link.revoked && (link.cliDesired ? link.cliMode === "managed" : link.managedTunnel); + +const readSetup = Effect.fn("cli.up.read_setup")(function* () { + const { baseDir } = yield* ServerConfig.ServerConfig; + const service = yield* (yield* BootService.BootService).status; + const { entries } = yield* CliService.readServiceEnv(baseDir); + // Parsed as the server parses it. + const lan = yield* Config.Boolean("T3CODE_LAN") + .pipe(Config.option) + .parse(ConfigProvider.fromEnv({ env: entries })); + const secrets = yield* ServerSecretStore.ServerSecretStore; + const connectRevoked = + hasCloudPublicConfig && Option.isSome(yield* secrets.get(CLOUD_LINK_REVOKED)); + const connect = hasCloudPublicConfig + ? connectEnabled({ + cliDesired: yield* CliState.readCliDesiredCloudLink, + cliMode: yield* CliState.readCliDesiredLinkMode, + managedTunnel: Option.isSome(yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG)), + revoked: connectRevoked, + }) + : null; + return { + service, + connect, + connectRevoked, + lan: Option.getOrElse(lan, () => service.installed), + lanFromOldDefault: Option.isNone(lan) && service.installed, + host: entries.T3CODE_HOST, + } satisfies Setup; +}); + +/** + * The routes `t3 up` starts from. With no route flag it asks about every + * route, starting a machine with no service from T3 Connect. Any route flag + * means it asks nothing and keeps the routes not named as they are. + */ +export const planRoutes = ( + setup: { readonly installed: boolean; readonly connect: boolean | null; readonly lan: boolean }, + flags: { readonly connect: Option.Option; readonly lan: Option.Option }, +) => { + const ask = Option.isNone(flags.connect) && Option.isNone(flags.lan); + const start = { + connect: setup.connect === true || (ask && !setup.installed && setup.connect !== null), + lan: setup.lan, + }; + return { + routes: { + connect: Option.getOrElse(flags.connect, () => start.connect), + lan: Option.getOrElse(flags.lan, () => start.lan), + } satisfies Routes, + ask: ask + ? (["connect", "lan"] as const).filter((r) => r !== "connect" || setup.connect !== null) + : [], + }; +}; + +/** A running service reads T3CODE_LAN and its T3 Connect link only when it starts. */ +const needsRestart = (before: Routes, after: Routes) => + after.lan !== before.lan || (after.connect && !before.connect); + +const CHOICES = { + connect: { title: "T3 Connect", description: "Creates a tunnel to control your PC." }, + lan: { title: "Local network", description: "LAN and tailnet. Pairing code required." }, +} satisfies Record; + +const formatSetup = (setup: Setup) => { + const { service } = setup; + const rows: ReadonlyArray = [ + [ + "Service", + !service.supported + ? "unavailable" + : service.installed + ? `installed · t3@${service.installedVersion ?? packageJson.version}` + : "not installed", + ], + ...(setup.connect === null + ? [] + : [ + [ + "T3 Connect", + setup.connect + ? "on · your T3 account" + : setup.connectRevoked + ? "off · link revoked. Run `t3 connect`." + : "off", + ] as const, + ]), + [ + "Local network", + setup.host !== undefined + ? `overridden · T3CODE_HOST=${setup.host}` + : setup.lanFromOldDefault + ? "on · kept from an older install" + : setup.lan + ? "on · pairing code only" + : "off", + ], + ]; + const notes = [ + ...(setup.host === undefined && setup.lan + ? ["Anyone on your LAN or tailnet can reach the port. A pairing code is the only lock."] + : []), + ...(setup.lanFromOldDefault + ? ["Older installs keep LAN access on. Turn it off: `t3 up --no-lan`."] + : []), + ]; + return [ + ...rows.map(([label, value]) => ` ${label.padEnd(15)}${value}`), + ...(notes.length === 0 ? [] : ["", ...notes]), + ].join("\n"); +}; + +const formatNextSteps = (routes: Routes, serviceInstalled: boolean) => + [ + ...(routes.connect + ? ["On your other device, sign in to T3 Connect with the same account."] + : []), + ...(routes.lan ? ["Run `t3 pair`. Use the code on your other device."] : []), + ...(routes.connect || routes.lan + ? [] + : [ + serviceInstalled + ? "Remote access is off. T3 Code still runs here in the background." + : "Remote access is off. Run `t3` to use T3 Code here.", + ]), + ].join("\n"); + +const interactiveTerminal = () => Boolean(process.stdin.isTTY && process.stdout.isTTY); + +const setUp = Effect.fn("cli.up")(function* (flags: { + readonly baseDir: Option.Option; + readonly connect: Option.Option; + readonly lan: Option.Option; + readonly yes: boolean; + readonly headless: boolean; +}) { + const { baseDir } = yield* ServerConfig.ServerConfig; + const service = yield* BootService.BootService; + const before = yield* readSetup(); + if (before.connect === null && Option.getOrElse(flags.connect, () => false)) { + return yield* new CliError.UserError({ + cause: "connect", + userMessage: "T3 Connect is unavailable in this build.", + }); + } + + const current = { connect: before.connect === true, lan: before.lan }; + // A publish-only link is not "on", but a rollback must put it back. + const priorPublishOnly = + hasCloudPublicConfig && + (yield* CliState.readCliDesiredCloudLink) && + (yield* CliState.readCliDesiredLinkMode) === "publish_only"; + const plan = planRoutes({ ...before, installed: before.service.installed }, flags); + const interactive = !flags.yes && interactiveTerminal(); + let routes = plan.routes; + if (plan.ask.length > 0 && interactive) { + if (before.service.installed) yield* Console.log(`Current setup\n${formatSetup(before)}\n`); + const picked = yield* Prompt.run( + Prompt.MultiSelect({ + message: "Allow connections through:", + selectAll: "Select all", + selectNone: "Clear all", + inverseSelection: "Invert selection", + choices: plan.ask.map((route) => ({ + ...CHOICES[route], + value: route, + selected: plan.routes[route], + })), + }), + ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(null) })); + if (picked === null) return yield* Console.log("Nothing changed."); + routes = { connect: picked.includes("connect"), lan: picked.includes("lan") }; + } else if (plan.ask.length > 0 && !flags.yes && !before.service.installed) { + return yield* new CliError.UserError({ + cause: "routes", + userMessage: `No terminal. Choose access with ${ + before.connect === null ? "--lan" : "--connect or --lan" + }, or use --yes for the default. See \`t3 up --help\`.`, + }); + } + // Nothing to run in the background, but a route left on still gets turned off. + if (!before.service.installed && !routes.connect && !routes.lan) { + if (current.connect || before.connectRevoked) { + yield* disconnectCloud({ clearAuthorization: false }); + } + if (current.lan) yield* CliService.writeServiceEnv(baseDir, { T3CODE_LAN: "false" }); + return yield* Console.log(formatNextSteps(routes, false)); + } + const { installedVersion } = before.service; + const newer = + installedVersion !== undefined && + compareExactServiceVersions(installedVersion, packageJson.version) > 0; + // Asked before anything changes, so a declined restart leaves service.env + // matching what the running service read when it started. + // A stopped service of this version is not current, so reconcile starts it; + // a newer one is left alone and needs this restart to start. + const stopped = before.service.problems?.includes("service-stopped") === true; + const restart = + before.service.installed && + (before.service.current || newer) && + (needsRestart(current, routes) || stopped); + // An older or broken service is reinstalled, which restarts it too. + const update = before.service.installed && !before.service.current && !newer && !stopped; + if ((restart || update) && interactive && !stopped) { + const confirmed = yield* Prompt.run( + Prompt.Confirm({ + message: update + ? "Update and restart T3 Code? Running agent turns will stop." + : "Apply changes and restart? Running agent turns will stop.", + initial: true, + }), + ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) })); + if (!confirmed) return yield* Console.log("Nothing changed."); + } + + if (routes.connect && !current.connect) { + const linked = yield* linkEnvironmentForConnect({ + headless: flags.headless, + installRelayClient: true, + }); + if (linked === null) return; + yield* Console.log( + `✓ Signed in to T3 Connect${linked.identity ? ` as ${linked.identity}` : ""}`, + ); + } + // A running server unlinks live; when that fails, only a restart stops its tunnel. + const unlinkFailed = + !routes.connect && (current.connect || before.connectRevoked) + ? (yield* disconnectCloud({ clearAuthorization: false })).status === "failed" + : false; + const previousLan = (yield* CliService.readServiceEnv(baseDir)).entries.T3CODE_LAN; + // Also with no service manager: the file records the choice that `t3 status` shows. + yield* CliService.writeServiceEnv(baseDir, { T3CODE_LAN: String(routes.lan) }); + // When the service step fails, an installed service still runs with the old + // value, so the file goes back to match it. + // A new T3 Connect link is undone too, with any tunnel the failed start saved, so a + // rerun sees it as a change and restarts. The sign-in stays. + const restoreServiceEnv = before.service.installed + ? CliService.writeServiceEnv(baseDir, { T3CODE_LAN: previousLan }).pipe( + Effect.andThen( + !routes.connect || current.connect + ? Effect.void + : priorPublishOnly + ? CliState.setCliDesiredCloudLink(true, "publish_only") + : disconnectCloud({ clearAuthorization: false }).pipe(Effect.asVoid), + ), + ) + : Effect.void; + // A failed update or restart may still have started the service with the new + // settings, so restart it once more on the old ones. + const rollBack = before.service.installed + ? restoreServiceEnv.pipe(Effect.andThen(service.restart.pipe(Effect.ignore))) + : Effect.void; + if (before.host !== undefined) { + yield* Console.warn( + `T3CODE_HOST=${before.host} in service.env overrides the network choice. Remove it to apply it.`, + ); + } + + const printServeCommand = Effect.fn(function* (reason: string) { + const { command } = yield* resolveHostCliCommand("serve", [ + ...Option.match(flags.baseDir, { onNone: () => [], onSome: (dir) => ["--base-dir", dir] }), + // Explicit either way, so a T3CODE_LAN in the shell cannot override the choice. + routes.lan ? "--lan" : "--no-lan", + ]); + yield* Console.log(`${reason} Keep this command running:\n ${command}`); + // A host wins over --lan and --no-lan, as it does in service.env. + const shellHost = (yield* HostProcess.Environment).T3CODE_HOST; + if (shellHost !== undefined) { + yield* Console.warn( + `T3CODE_HOST=${shellHost} in this shell overrides that choice. Unset it before you run the command.`, + ); + } + }); + let started = false; + if (!before.service.supported) { + yield* printServeCommand("Background service unavailable."); + } else { + if (newer) { + yield* Console.log(`Keeping installed service version t3@${installedVersion}.`); + } + if (update && !interactive) { + yield* Console.log("Updating T3 Code. Running agent turns will stop."); + } + const result = newer + ? ({ changed: false } as const) + : yield* CliService.reconcileService().pipe( + Effect.catchTags({ + BootServicePrerequisiteError: (error) => Effect.succeed({ failed: error.message }), + BootServiceUnsupportedError: (error) => Effect.succeed({ failed: error.message }), + }), + Effect.tapError(() => rollBack), + ); + if ("failed" in result && before.service.installed) { + // The old service still runs, so a foreground server would clash with it. + yield* restoreServiceEnv; + yield* Console.log( + `Service setup failed: ${result.failed} Fix that, then run \`t3 up\` again.`, + ); + } else if ("failed" in result) { + yield* printServeCommand(`Service setup failed: ${result.failed}`); + } else if (result.changed) { + yield* Console.log(CliService.formatServiceChange(result)); + started = true; + } else if (restart || (unlinkFailed && before.service.installed)) { + const confirmed = + restart || + !interactive || + (yield* Prompt.run( + Prompt.Confirm({ + message: + "The running service still has T3 Connect on. Restart it? Running agent turns will stop.", + initial: true, + }), + ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) }))); + if (!confirmed) { + yield* Console.log("Run `t3 service restart` to turn T3 Connect off."); + } else { + if (!interactive && !stopped) { + yield* Console.log("Restarting T3 Code. Running agent turns will stop."); + } + const outcome = yield* service.restart.pipe(Effect.tapError(() => rollBack)); + if (outcome === "queued") { + yield* Console.log("Restart queued. This session will disconnect."); + } + if (outcome === "skipped") { + yield* Console.log( + "The installed service runs another T3 home, so it was not restarted. Pass its --base-dir.", + ); + } + started = outcome === "restarted"; + } + } + } + if (started && routes.connect && !current.connect) yield* waitForReachability(); + + const after = yield* readSetup(); + yield* Console.log( + // From what took effect: a failed service step can undo a choice. + `\nThis machine\n${formatSetup(after)}\n\n${formatNextSteps( + { connect: after.connect === true, lan: after.lan }, + after.service.installed, + )}`, + ); +}); + +export const upCommand = Command.make("up", { + ...projectLocationFlags, + connect: Flag.Boolean("connect").pipe( + Flag.withDescription( + "Allow access through T3 Connect with your T3 account. Turn off with --no-connect.", + ), + Flag.optional, + ), + lan: Flag.Boolean("lan").pipe( + Flag.withDescription( + "Allow LAN and tailnet connections. Anyone there can reach the port; a pairing code is the only lock. Turn off with --no-lan.", + ), + Flag.optional, + ), + yes: Flag.Boolean("yes").pipe( + Flag.withDescription( + "Ask nothing. Keep the access settings; a new machine gets T3 Connect. A restart stops running agent turns.", + ), + Flag.withDefault(false), + ), + headless: headlessFlag, +}).pipe( + Command.withShortDescription("Set up remote access and the T3 Code background service."), + Command.withDescription( + "Set up remote access and the T3 Code background service. Access flags skip the question, and routes not named keep their settings. Without a terminal, a needed restart runs without asking and stops running agent turns.", + ), + Command.withExamples([ + { command: "t3 up", description: "Choose remote access." }, + { command: "t3 up --connect", description: "Turn on T3 Connect. Keep other settings." }, + { command: "t3 up --lan", description: "Allow LAN access with pairing codes." }, + { command: "t3 up --no-lan", description: "Stop LAN access." }, + { command: "t3 up --yes", description: "Take the defaults with no questions." }, + { command: "t3 status --json", description: "Read the settings from a script." }, + ]), + Command.withHandler((flags) => runCloudCommand(flags, setUp(flags), { quietLogs: true })), +); + +export const connectCommand = Command.make("connect", { + ...projectLocationFlags, + headless: headlessFlag, +}).pipe( + Command.withDescription("Set up T3 Connect for this machine. Same as `t3 up --connect`."), + Command.withHandler((flags) => + runCloudCommand( + flags, + setUp({ ...flags, connect: Option.some(true), lan: Option.none(), yes: false }), + { quietLogs: true }, + ), + ), + Command.withSubcommands(connectSubcommands), +); + +/** `t3 status --json`, for scripts and agents. */ +const setupJson = (setup: Setup) => + JSON.stringify( + { + service: { + supported: setup.service.supported, + installed: setup.service.installed, + installedVersion: setup.service.installedVersion ?? null, + }, + connect: + setup.connect === null ? null : { enabled: setup.connect, revoked: setup.connectRevoked }, + lan: { + enabled: setup.lan, + inheritedDefault: setup.lanFromOldDefault, + hostOverride: setup.host ?? null, + }, + }, + null, + 2, + ); + +export const statusCommand = Command.make("status", { + ...projectLocationFlags, + json: Flag.Boolean("json").pipe( + Flag.withDescription("Print the settings as JSON. Does not test connections."), + Flag.withDefault(false), + ), +}).pipe( + Command.withDescription("Show service setup and remote access settings."), + Command.withExamples([ + { command: "t3 status", description: "Show the settings." }, + { command: "t3 status --json", description: "Print them for scripts and agents." }, + ]), + Command.withHandler( + Effect.fn("cli.status")(function* (flags) { + const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel, { + quietLogs: flags.json, + }); + // Only what readSetup reads: no database, unlike the T3 Connect commands. + const setup = yield* readSetup().pipe( + Effect.provide( + Layer.merge(CliService.layer(config), ServerSecretStore.layer).pipe( + Layer.provideMerge(ServerConfig.layer(config)), + ), + ), + ); + yield* Console.log( + flags.json + ? setupJson(setup) + : `This machine\n${formatSetup(setup)}\n\nChange access: \`t3 up\`.`, + ); + }), + ), +); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 3b9d55ce3f6cd..409ed3ce2fb61 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -5,9 +5,11 @@ 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 Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; +import * as References from "effect/References"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -148,6 +150,28 @@ describe("CloudManagedEndpointRuntime", () => { ).toBe("warning"); }); + it("classifies connection unregistration apart from registration and routine output", () => { + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-10-06T00:00:00Z INF Unregistered tunnel connection connIndex=0 event=0 ip=198.41.200.23", + ), + ).toBe("unregistered"); + for (const level of ["ERR", "WRN", "FTL", "PNC"]) { + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + `2026-10-06T00:00:00Z ${level} Unregistered tunnel connection connIndex=0`, + ), + ).toBe("warning"); + } + for (const line of [ + "2026-10-06T00:00:00Z INF Initiating graceful shutdown due to signal terminated ...", + "2026-10-06T00:00:00Z INF Starting metrics server on 127.0.0.1:20241/metrics", + "2026-10-06T00:00:00Z DBG Unregistered tunnel connection connIndex=0", + ]) { + expect(ManagedEndpointRuntime.classifyRelayClientOutput(line)).toBe("debug"); + } + }); + it("recognizes tunnel authorization failures without matching ordinary transport errors", () => { expect( ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( @@ -203,6 +227,83 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + it.effect("logs a connection unregistration at info with the connector token redacted", () => { + const logs: Array<{ readonly level: string; readonly message: unknown }> = []; + const unregistrationLogged = Deferred.makeUnsafe(); + const logger = Logger.make(({ logLevel, message }) => { + logs.push({ level: logLevel, message }); + if (Array.isArray(message) && message[0] === "Relay client tunnel connection unregistered") { + Deferred.doneUnsafe(unregistrationLogged, Effect.void); + } + }); + + return Effect.gen(function* () { + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "secret-connector-token", + tunnelId: "tunnel-1", + }; + const encoder = new TextEncoder(); + const output = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ + pid: 701, + onKill: () => {}, + output: Stream.fromQueue(output), + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + yield* runtime.applyConfig(config); + yield* Queue.offer( + output, + encoder.encode( + "2026-10-06T00:00:00Z INF Unregistered tunnel connection connIndex=0 token=secret-connector-token\n", + ), + ); + yield* Deferred.await(unregistrationLogged); + + // An unregistration is not a registration, so the registration timeout still recovers. + yield* TestClock.adjust(Duration.minutes(3)); + expect(Option.getOrNull(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config); + + yield* Queue.offer( + output, + encoder.encode( + "2026-10-06T00:00:01Z INF Starting metrics server on 127.0.0.1:20241/metrics\n" + + "2026-10-06T00:00:01Z INF Registered tunnel connection connIndex=0\n", + ), + ); + yield* Stream.runHead(runtime.tunnelConnected); + + const relayLogs = logs.filter( + ({ message }) => + Array.isArray(message) && String(message[0]).startsWith("Relay client tunnel"), + ); + expect(relayLogs.map(({ level, message }) => [level, (message as unknown[])[0]])).toEqual([ + ["Info", "Relay client tunnel connection unregistered"], + ["Info", "Relay client tunnel connection registered"], + ]); + expect((relayLogs[0]!.message as unknown[])[1]).toMatchObject({ + output: + "2026-10-06T00:00:00Z INF Unregistered tunnel connection connIndex=0 token=", + }); + const logged = JSON.stringify(logs.map(({ message }) => message)); + expect(logged).not.toContain("secret-connector-token"); + expect(logged).not.toContain("Starting metrics server"); + }).pipe( + Effect.provide( + Layer.merge( + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), + ), + ), + ); + }); + const tunnelConfig = { providerKind: "cloudflare_tunnel" as const, connectorToken: "token", diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 5ffd31de9acab..2528a08630a1a 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -124,12 +124,17 @@ function needsPinnedRelayClient(executable: RelayClient.RelayClientStatus): bool ); } -export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { +export function classifyRelayClientOutput( + line: string, +): "connected" | "unregistered" | "warning" | "debug" { if (/\bRegistered tunnel connection\b/iu.test(line)) { return "connected"; } // cloudflared uses zerolog FTL/PNC for fatal/panic; classify them above debug too. - return /\b(?:ERR|WRN|FTL|PNC)\b/u.test(line) ? "warning" : "debug"; + if (/\b(?:ERR|WRN|FTL|PNC)\b/u.test(line)) { + return "warning"; + } + return /\bINF\b.*\bUnregistered tunnel connection\b/u.test(line) ? "unregistered" : "debug"; } /** Edge rejection wording varies; any Unauthorized registration error counts. */ @@ -513,6 +518,8 @@ export const make = Effect.gen(function* () { ), Effect.asVoid, ); + case "unregistered": + return Effect.logInfo("Relay client tunnel connection unregistered", attributes); case "warning": if (/\btimeout: no recent network activity\b/u.test(line)) { return noteQuicStall(connector).pipe( diff --git a/apps/server/src/cloud/npmRelease.test.ts b/apps/server/src/cloud/npmRelease.test.ts new file mode 100644 index 0000000000000..644e24150dd67 --- /dev/null +++ b/apps/server/src/cloud/npmRelease.test.ts @@ -0,0 +1,206 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ServerNpmReleaseError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Fiber from "effect/Fiber"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as Result from "effect/Result"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as NpmRelease from "./npmRelease.ts"; + +const version = "0.1.5-nightly.20261030.1"; +const output = (stdout: string, code = 0): ProcessRunner.ProcessRunOutput => ({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(code), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); + +const noPublicRegistry = HttpClient.make(() => + Effect.die("a configured npm lookup must not fall back to public npm"), +); +// A fake npm on PATH decides whether npm counts as installed. +const makeService = Effect.fnUntraced(function* ( + run: ProcessRunner.ProcessRunner["Service"]["run"], + publicRegistry: HttpClient.HttpClient = noPublicRegistry, + npmInstalled = true, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const npmBin = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-release-" }); + for (const name of ["npm", "npm.cmd"]) { + yield* fs.writeFileString(path.join(npmBin, name), "", { mode: 0o755 }); + } + return yield* NpmRelease.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { run }), + Effect.provideService(HttpClient.HttpClient, publicRegistry), + Effect.provideService(HostProcess.Environment, { + PATH: npmInstalled ? npmBin : "", + PATHEXT: ".CMD", + }), + ); +}, Effect.provide(NodeServices.layer)); + +it.effect.each([ + { + spec: version, + stdout: JSON.stringify({ version, t3code: { databaseMigration: 42 } }), + migration: 42, + }, + { + spec: "nightly", + stdout: JSON.stringify([{ version, t3code: { databaseMigration: 42 } }]), + migration: 42, + }, + { spec: "latest", stdout: JSON.stringify(version), migration: undefined }, +])("reads $spec through npm and retains migration compatibility metadata", (fixture) => + Effect.gen(function* () { + const calls: ProcessRunner.ProcessRunInput[] = []; + const service = yield* makeService((input) => { + calls.push(input); + return Effect.succeed(output(fixture.stdout)); + }); + const expected = { + version, + ...(fixture.migration === undefined ? {} : { databaseMigration: fixture.migration }), + }; + expect(yield* service.get({ spec: fixture.spec })).toEqual(expected); + expect(yield* service.get({ spec: fixture.spec })).toEqual(expected); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + command: expect.stringMatching(/[/\\]npm(?:\.cmd)?$/i), + args: [ + "view", + "--global", + "--json", + "--registry=https://npm-registry.rtvision.com/", + `@rtvision/t3@${fixture.spec}`, + "version", + "t3code", + ], + maxOutputBytes: 256 * 1024, + }); + yield* TestClock.adjust("1 minute"); + expect(yield* service.get({ spec: fixture.spec })).toEqual(expected); + expect(calls).toHaveLength(2); + }), +); + +it.effect("treats a registry 404 as unpublished without trying another registry", () => + Effect.gen(function* () { + const service = yield* makeService(() => + Effect.succeed(output('{"error":{"code":"E404"}}', 1)), + ); + expect(yield* service.get({ spec: version })).toBeNull(); + }), +); + +const spawnFailure = () => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "npm", + argumentCount: 6, + cause: new Error("EACCES"), + }), + ); + +it.effect.each([ + output("not json"), + output('{"error":{"code":"E401","detail":"sensitive"}}', 1), + output('{"version":"9.9.9"}'), + output("[]"), + output("", 9009), + { ...output(JSON.stringify({ version })), stdoutTruncated: true }, + { ...output(JSON.stringify({ version })), timedOut: true }, + "spawn failure", +] as const)("reports an unavailable lookup rather than an unpublished release: %j", (result) => + Effect.gen(function* () { + const service = yield* makeService(() => + result === "spawn failure" ? spawnFailure() : Effect.succeed(result), + ); + const found = yield* service.get({ spec: version }).pipe(Effect.result); + expect(Result.isFailure(found)).toBe(true); + if (Result.isFailure(found)) { + expect(found.failure).toBeInstanceOf(ServerNpmReleaseError); + expect(found.failure.message).not.toContain("sensitive"); + } + }), +); + +it.effect("uses RTVision's registry when npm is not installed", () => + Effect.gen(function* () { + const urls: string[] = []; + const service = yield* makeService( + () => Effect.die("npm is not installed"), + HttpClient.make((request) => { + urls.push(request.url); + const response = request.url.endsWith("/nightly") + ? new Response(JSON.stringify({ version, t3code: { databaseMigration: 42 } })) + : request.url.endsWith("/latest") + ? new Response(new ReadableStream()) + : new Response("{}", { status: 404 }); + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + }), + false, + ); + expect(yield* service.get({ spec: "nightly" })).toEqual({ version, databaseMigration: 42 }); + expect(yield* service.get({ spec: "9.9.9" })).toBeNull(); + const stalled = yield* service.get({ spec: "latest" }).pipe(Effect.result, Effect.forkChild); + yield* TestClock.adjust("15 seconds"); + expect(Result.isFailure(yield* Fiber.join(stalled))).toBe(true); + expect(urls).toEqual([ + "https://npm-registry.rtvision.com/@rtvision%2Ft3/nightly", + "https://npm-registry.rtvision.com/@rtvision%2Ft3/9.9.9", + "https://npm-registry.rtvision.com/@rtvision%2Ft3/latest", + ]); + }), +); + +it.effect("runs at most two npm lookups at once", () => + Effect.gen(function* () { + const started = yield* Queue.unbounded(); + const release = yield* Deferred.make(); + let running = 0; + let peak = 0; + const service = yield* makeService(() => + Effect.gen(function* () { + peak = Math.max(peak, ++running); + yield* Queue.offer(started, undefined); + yield* Deferred.await(release); + running--; + return output('{"error":{"code":"E404"}}', 1); + }), + ); + const lookups = yield* Effect.forEach( + ["1.0.0", "1.0.1", "1.0.2"], + (spec) => service.get({ spec }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Queue.take(started); + yield* Queue.take(started); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(lookups)).toEqual([null, null, null]); + expect(peak).toBe(2); + }), +); + +it.effect.each(["--registry=https://example.com", "../other", "other-package@latest", "*", ""])( + "rejects invalid release specs before spawning npm: %s", + (spec) => + Effect.gen(function* () { + const service = yield* makeService(() => Effect.die("invalid specs must not spawn npm")); + expect(Result.isFailure(yield* service.get({ spec }).pipe(Effect.result))).toBe(true); + }), +); diff --git a/apps/server/src/cloud/npmRelease.ts b/apps/server/src/cloud/npmRelease.ts new file mode 100644 index 0000000000000..db1d94ce8ce7f --- /dev/null +++ b/apps/server/src/cloud/npmRelease.ts @@ -0,0 +1,143 @@ +import { + ServerNpmReleaseError, + ServerNpmReleaseInput, + type ServerNpmRelease, +} from "@t3tools/contracts"; +import { npmReleaseDatabaseMigration } from "@t3tools/shared/cliRelease"; +import { T3_NPM_PACKAGE, T3_NPM_REGISTRY } from "@t3tools/shared/releasePackage"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { FetchHttpClient, HttpClient } from "effect/http"; + +import * as ProcessRunner from "../processRunner.ts"; + +const Version = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/)); +const Manifest = Schema.Struct({ version: Version, t3code: Schema.optionalKey(Schema.Unknown) }); +// npm prints a bare version string when the release has no `t3code` field. +const decodeManifest = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Union([Manifest, Schema.Tuple([Manifest]), Version])), +); +const decodeError = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.String }) })), +); +const decodeInput = Schema.decodeUnknownEffect(ServerNpmReleaseInput); + +export class NpmRelease extends Context.Service< + NpmRelease, + { + readonly get: ( + input: ServerNpmReleaseInput, + ) => Effect.Effect; + } +>()("t3/cloud/npmRelease") {} + +function toRelease(spec: string, json: string) { + const decoded = Option.getOrUndefined(decodeManifest(json)); + const manifest = + typeof decoded === "string" + ? { version: decoded } + : decoded && ("version" in decoded ? decoded : decoded[0]); + if (!manifest || (spec !== "latest" && spec !== "nightly" && manifest.version !== spec)) { + return Effect.fail( + new ServerNpmReleaseError({ reason: "npm returned invalid release metadata." }), + ); + } + const databaseMigration = npmReleaseDatabaseMigration(manifest); + return Effect.succeed({ + version: manifest.version, + ...(databaseMigration === undefined ? {} : { databaseMigration }), + }); +} + +export const make = Effect.fn("NpmRelease.make")(function* () { + const runner = yield* ProcessRunner.ProcessRunner; + const http = yield* HttpClient.HttpClient; + const lookupRegistry = Effect.fn("NpmRelease.lookupRegistry")(function* (spec: string) { + const unreachable = new ServerNpmReleaseError({ + reason: "npm is not installed and RTVision's npm registry could not be reached.", + }); + const json = yield* Effect.gen(function* () { + const response = yield* http.get( + `${T3_NPM_REGISTRY}${T3_NPM_PACKAGE.replace("/", "%2F")}/${encodeURIComponent(spec)}`, + ); + if (response.status === 404) return Option.none(); + if (response.status < 200 || response.status >= 300) return yield* unreachable; + return Option.some(yield* response.text); + }).pipe( + Effect.timeout(Duration.seconds(15)), + Effect.mapError(() => unreachable), + ); + return Option.isNone(json) ? null : yield* toRelease(spec, json.value); + }); + // npm loads the whole package history per lookup, so a burst of specs must not spawn one each. + const npmPermits = yield* Semaphore.make(2); + const cache = yield* Cache.make({ + capacity: 32, + timeToLive: Duration.minutes(1), + lookup: Effect.fn("NpmRelease.lookup")(function* (spec: string) { + // Without npm, query the same private registry used by the packaged CLI. + const npmPath = yield* resolveCommandPath("npm"); + if (npmPath === null) return yield* lookupRegistry(spec); + const output = yield* npmPermits + .withPermits(1)( + runner.run({ + command: npmPath, + // Named fields keep the output small; the full package document grows with every release. + args: [ + "view", + "--global", + "--json", + `--registry=${T3_NPM_REGISTRY}`, + `${T3_NPM_PACKAGE}@${spec}`, + "version", + "t3code", + ], + timeout: Duration.seconds(15), + maxOutputBytes: 256 * 1024, + }), + ) + .pipe( + Effect.tapError((error) => + Effect.logInfo("npm release lookup failed", { tag: error._tag }), + ), + Effect.mapError( + () => new ServerNpmReleaseError({ reason: "npm could not complete the lookup." }), + ), + ); + if (output.timedOut || output.stdoutTruncated || output.stdoutInvalidUtf8) { + return yield* new ServerNpmReleaseError({ reason: "npm returned an incomplete response." }); + } + if (output.code !== 0) { + const error = Option.getOrUndefined(decodeError(output.stdout)); + if (error?.error.code === "E404") return null; + yield* Effect.logInfo("npm release lookup failed", { + exitCode: output.code, + npmErrorCode: error?.error.code, + }); + return yield* new ServerNpmReleaseError({ reason: "npm could not read release metadata." }); + } + return yield* toRelease(spec, output.stdout); + }), + }); + return NpmRelease.of({ + get: Effect.fn("NpmRelease.get")(function* (input) { + const { spec } = yield* decodeInput(input).pipe( + Effect.mapError( + () => new ServerNpmReleaseError({ reason: "Invalid release version or track." }), + ), + ); + return yield* Cache.get(cache, spec); + }), + }); +}); + +export const layer = Layer.effect(NpmRelease, make()).pipe( + Layer.provide([ProcessRunner.layer, FetchHttpClient.layer]), +); diff --git a/apps/server/src/cloud/serviceProtection.test.ts b/apps/server/src/cloud/serviceProtection.test.ts index b2e4a1506cb31..eef78cd3f82e7 100644 --- a/apps/server/src/cloud/serviceProtection.test.ts +++ b/apps/server/src/cloud/serviceProtection.test.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import type * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -122,13 +123,13 @@ it.each([ expect(ServiceProtection.loadedRemoteAccessUnits(shown)).toEqual(expected); }); -const fakeSudo = (exitCode: number, onRun: () => void) => +const fakeSudo = (exitCode: number, onRun: (command: ChildProcess.StandardCommand) => void) => ChildProcessSpawner.make((command) => Effect.gen(function* () { if (command._tag !== "StandardCommand" || command.command !== "sudo") { return yield* Effect.die("Unexpected command"); } - onRun(); + onRun(command); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(123), stdout: Stream.empty, @@ -180,11 +181,13 @@ it.effect("lowers no score before the manager default is in place, and undoes in writesManagerDropIn = false, ) => { let atSudo: ReturnType | undefined; + let sudoOptions: ChildProcess.CommandOptions | undefined; return effect.pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, - fakeSudo(exitCode, () => { + fakeSudo(exitCode, (command) => { atSudo = present(); + sudoOptions = command.options; if (writesManagerDropIn) { NodeFS.mkdirSync(NodePath.dirname(managerDropIn), { recursive: true }); NodeFS.writeFileSync(managerDropIn, "[Service]\nOOMScoreAdjust=-900\n"); @@ -192,7 +195,7 @@ it.effect("lowers no score before the manager default is in place, and undoes in }), ), Effect.exit, - Effect.map((exit) => ({ exit, atSudo })), + Effect.map((exit) => ({ exit, atSudo, sudoOptions })), ); }; const refused = yield* withSudo(ServiceProtection.applyProtection(plan), 1); @@ -209,6 +212,8 @@ it.effect("lowers no score before the manager default is in place, and undoes in const applied = yield* withSudo(ServiceProtection.applyProtection(plan), 0); expect(applied.atSudo).toEqual({ managerDefaults: true, user: 0 }); + // sudo needs the controlling terminal to ask for a password. + expect(applied.sudoOptions).toMatchObject({ detached: false, stdin: "inherit" }); expect(present()).toEqual({ managerDefaults: true, user: plan.user.length }); const removed = yield* withSudo(ServiceProtection.removeProtection(plan), 0); diff --git a/apps/server/src/cloud/serviceProtection.ts b/apps/server/src/cloud/serviceProtection.ts index b23cd36e3a545..780aee83b4c48 100644 --- a/apps/server/src/cloud/serviceProtection.ts +++ b/apps/server/src/cloud/serviceProtection.ts @@ -240,7 +240,10 @@ const runPrivileged = Effect.fn("ServiceProtection.runPrivileged")(function* (sc const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const exitCode = yield* spawner .exitCode( + // Effect detaches children on POSIX by default; sudo then has no controlling + // terminal and cannot ask for a password. ChildProcess.make("sudo", ["/bin/sh", "-c", script], { + detached: false, stdin: "inherit", stdout: "inherit", stderr: "inherit", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 91d443308776c..468cc89ad01e6 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -192,6 +192,14 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server ], { concurrency: "unbounded" }, ); + // Logs, traces and the database are written with the process umask; keep them owner-only. + yield* fs + .chmod(derivedPaths.stateDir, 0o700) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Could not restrict state directory permissions.", { cause }), + ), + ); const swept = sweepStalePendingAttachments({ attachmentsDir: derivedPaths.attachmentsDir, diff --git a/apps/server/src/device/AgentDeviceTarget.ts b/apps/server/src/device/AgentDeviceTarget.ts index ac66a52f6d2aa..54affde4eca93 100644 --- a/apps/server/src/device/AgentDeviceTarget.ts +++ b/apps/server/src/device/AgentDeviceTarget.ts @@ -1,9 +1,12 @@ +import { DeviceOperationError } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as Schema from "effect/Schema"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import type { AgentDaemonEndpoint } from "./DeviceHost.ts"; @@ -62,3 +65,36 @@ export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig") yield* fs.rename(temporary, file); }).pipe(Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.ignore))); }); + +const AgentCloseResult = Schema.Union([ + Schema.Struct({ result: Schema.Struct({ ok: Schema.Literal(true) }) }), + Schema.Struct({ + error: Schema.Struct({ + data: Schema.Struct({ code: Schema.Literal("SESSION_NOT_FOUND") }), + }), + }), +]); + +/** Ends an agent-device session on a running daemon; an already-released session succeeds. */ +export const releaseAgentDeviceSession = + (http: HttpClient.HttpClient, endpoint: AgentDaemonEndpoint) => (session: string) => + HttpClientRequest.post(`${endpoint.baseUrl}/rpc`).pipe( + HttpClientRequest.bearerToken(endpoint.token), + HttpClientRequest.bodyJson({ + jsonrpc: "2.0", + id: session, + method: "agent_device.command", + params: { command: "close", session, positionals: [], flags: {} }, + }), + Effect.flatMap((request) => http.pipe(HttpClient.withScope).execute(request)), + // agent-device reports an already-released session as HTTP 404. + Effect.filterOrElse((response) => response.status === 404, HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(AgentCloseResult)), + Effect.scoped, + Effect.timeout(Duration.seconds(15)), + Effect.asVoid, + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "close", reason: "request_failed", cause }), + ), + ); diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 90f46a4b66d33..a0bc0499d6f71 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -155,6 +155,18 @@ describe("runDeviceAction", () => { }); describe("readDeviceDetail", () => { + it.effect("reads the smallest iOS text size as small", () => + Effect.gen(function* () { + const { ready } = makeReady((call) => + call.args.join(" ").endsWith("ui SIM-1 content_size") + ? { stdout: "extra-small\n" } + : { code: 1 }, + ); + const detail = yield* readDeviceDetail(ready, "ios", udid); + expect(detail.settings.textSize).toBe("small"); + }), + ); + it.effect("reads iOS settings from simctl and the accessibility helper", () => Effect.gen(function* () { const { ready } = makeReady((call) => { diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 6b734b6b9b484..64630488f3daa 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -102,10 +102,8 @@ const textSizeFromIos = (category: string): DeviceTextSize | undefined => { ); if (entry) return entry[0]; if (category.startsWith("accessibility")) return "extra-large"; - if (category.includes("extra")) return "large"; - return category === "extra-small" || category === "small" || category === "medium" - ? "small" - : "default"; + if (category === "extra-small" || category === "small" || category === "medium") return "small"; + return category.includes("extra") ? "large" : "default"; }; const textSizeFromAndroid = (scale: number): DeviceTextSize => { diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index c168ba657afe6..7a0962e06f18b 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -145,6 +145,16 @@ export interface DeviceOperations { export interface DeviceHostReady { readonly nodePath: string; + /** + * Ends an agent-device session on the daemon that serves `deviceId`. Hosts + * with a shared daemon provide it only while that daemon runs; hosts with a + * daemon per device succeed when the device's daemon is gone. Cleanup never + * starts a daemon or a device. + */ + readonly releaseAgentSession?: ( + session: string, + deviceId: string, + ) => Effect.Effect; readonly hub: DeviceHubEndpoint; readonly devices: DeviceOperations; readonly run: ( diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 9371484f4c93f..131e3b95241ae 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -7,7 +7,7 @@ import { import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import type { DeviceHostReady, DeviceOperations, HostDevice } from "./DeviceHost.ts"; @@ -239,7 +239,10 @@ export const makeDeviceHubOperations = (options: { (cause) => new DeviceOperationError({ operation: "screenshot", - reason: "request_failed", + reason: + HttpClientError.isHttpClientError(cause) && cause.reason._tag === "StatusCodeError" + ? "hub_rejected" + : "request_failed", cause, }), ), diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index 7bbd8418de774..44115303e7b8a 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -6,6 +6,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Effect from "effect/Effect"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerSettings from "../serverSettings.ts"; +import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import * as DeviceService from "./DeviceService.ts"; @@ -16,19 +17,23 @@ it.effect("keeps hosts independent when serials collide and another host fails", Effect.succeed( HttpClientResponse.fromWeb( request, - Response.json({ - simulators: [], - emulators: [ - { - id: "emulator-5554", - name: "Pixel", - version: "36", - platform: "android", - booted: true, - physical: false, - }, - ], - }), + Response.json( + request.url.endsWith("/rpc") + ? { result: { ok: true } } + : { + simulators: [], + emulators: [ + { + id: "emulator-5554", + name: "Pixel", + version: "36", + platform: "android", + booted: true, + physical: false, + }, + ], + }, + ), ), ), ); @@ -50,6 +55,10 @@ it.effect("keeps hosts independent when serials collide and another host fails", androidAvailable: Effect.succeed(true), }), agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, + releaseAgentSession: releaseAgentDeviceSession(http, { + baseUrl: `http://${id}`, + token: "test", + }), run, helpers: { serveSimAxSettings: null, serveSimCli: null }, }; diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index a9ff957b91997..9b680280d3298 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -36,12 +36,43 @@ import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "./DeviceService.ts"; -import { agentDeviceSession } from "./AgentDeviceTarget.ts"; +import { agentDeviceSession, releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBootError)); const encodeBootError = Schema.encodeSync(Schema.fromJsonString(DeviceBootError)); +it.effect.each([400, 500])( + "distinguishes screenshot HTTP rejection (%s) from transport failure", + (status) => + Effect.gen(function* () { + const operations = makeDeviceHubOperations({ + hostId: LOCAL_DEVICE_HOST_ID, + origin: "http://device.test", + http: HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("private hub output", { status })), + ), + ), + run: () => Effect.die("Screenshot does not run a command"), + androidAvailable: Effect.succeed(false), + }); + const result = yield* operations + .screenshot({ + id: "phone", + name: "Phone", + version: "iOS 26.3", + platform: "ios", + booted: true, + physical: false, + }) + .pipe(Effect.result); + expect(result).toMatchObject({ + failure: { _tag: "DeviceOperationError", reason: "hub_rejected" }, + }); + }), +); + const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, bootError?: string | { error?: string; errors?: Array<{ message: string; error?: string }> }, @@ -52,26 +83,65 @@ const fixture = Effect.fn("fixture")(function* ( | DeviceHost.AgentDeviceStartError, inspectError = false, installTool?: Parameters[3], - options: { readonly endsDevices?: boolean; readonly routesStreams?: boolean } = {}, + options: { + readonly endsDevices?: boolean; + readonly routesStreams?: boolean; + readonly onClose?: Effect.Effect<"ok" | "missing" | "reject" | "http_error" | "malformed">; + readonly onPlatformAvailability?: Effect.Effect; + } = {}, ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; const agentStarts: string[] = []; const agentStops: string[] = []; const requests: string[] = []; + const closedSessions: unknown[] = []; const devices: Array> = []; const discoveryErrors = yield* Ref.make>([]); const androidAvailable = yield* Ref.make(true); const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" }); let booted = false; let shutDown = false; + const connection = { online: true, kind: "local" as "local" | "ssh", failShutdown: false }; const http = HttpClient.make((request) => Effect.gen(function* () { requests.push(request.url); + if (request.url === "http://agent.test/rpc") { + if (request.body._tag !== "Uint8Array") throw new Error("Missing close body"); + const body = decodeJson(new TextDecoder().decode(request.body.body)); + closedSessions.push(body); + expect(request.headers.authorization).toBe("Bearer test"); + const outcome = yield* options.onClose ?? Effect.succeed("ok"); + return HttpClientResponse.fromWeb( + request, + outcome === "missing" + ? Response.json( + { + error: { + code: -32000, + message: "No active session", + data: { code: "SESSION_NOT_FOUND" }, + }, + }, + { status: 404 }, + ) + : outcome === "http_error" + ? new Response("Offline", { status: 503 }) + : outcome === "malformed" + ? new Response("not JSON") + : Response.json( + outcome === "ok" + ? { result: { ok: true } } + : { error: { code: -32000, message: "offline" } }, + ), + ); + } if (request.url.includes("/api/screenshot")) { return HttpClientResponse.fromWeb(request, new Response(new Uint8Array([137, 80, 78, 71]))); } if (request.url.endsWith("/shutdown")) { + if (connection.failShutdown) + return HttpClientResponse.fromWeb(request, Response.json({ ok: false })); shutDown = true; booted = false; return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); @@ -153,9 +223,9 @@ const fixture = Effect.fn("fixture")(function* ( : {}), id: LOCAL_DEVICE_HOST_ID, label: "This machine", - summary: Effect.succeed({ + summary: Effect.sync(() => ({ id: LOCAL_DEVICE_HOST_ID, - kind: "local", + kind: connection.kind, label: "Test server", platforms: [ { platform: "android", available: true }, @@ -163,8 +233,11 @@ const fixture = Effect.fn("fixture")(function* ( ], hubInstalled: true, agentDeviceInstalled: false, - }), - platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + })), + platformAvailability: (platform) => + (options.onPlatformAvailability ?? Effect.void).pipe( + Effect.as({ platform, available: true }), + ), ensureReady: (onPhase) => Effect.gen(function* () { if (runtimeFailure && runtimeFailure._tag !== "AgentDeviceStartError") @@ -183,7 +256,21 @@ const fixture = Effect.fn("fixture")(function* ( agentDevice: { baseUrl: "http://agent.test", token: "test", entryPath: "/agent" }, }; }), - current: Effect.succeed(null), + current: Effect.sync(() => + connection.online + ? { + ...ready, + ...(agentStarts.length > agentStops.length + ? { + releaseAgentSession: releaseAgentDeviceSession(http, { + baseUrl: "http://agent.test", + token: "test", + }), + } + : {}), + } + : null, + ), stopAgent: Effect.sync(() => { agentStops.push("stop"); }), @@ -194,7 +281,7 @@ const fixture = Effect.fn("fixture")(function* ( const service = yield* DeviceService.makeWithHosts( new Map([[host.id, host]]), undefined, - undefined, + () => Effect.succeed("/host.json"), installTool, ).pipe( Effect.provide(NodeCrypto.layer), @@ -232,6 +319,8 @@ const fixture = Effect.fn("fixture")(function* ( agentStops, requests, settings, + closedSessions, + connection, devices, encoderResult, discoveryErrors, @@ -801,8 +890,8 @@ it.effect.each(["shutdown", "close"] as const)( operation === "shutdown" ? service.shutdown(input) : service.close({ threadId }), ); expect((yield* PubSub.take(updates)).sessions).toEqual([]); - // list writes through the same state lock, so the retirement has committed. - expect((yield* service.list).sessions).toEqual([]); + yield* service.refreshHosts; + expect((yield* service.state).sessions).toEqual([]); yield* Effect.sync(() => retiring.interruptUnsafe()); yield* Deferred.succeed(pauseBind.proceed, undefined); yield* Fiber.join(binding); @@ -1363,6 +1452,229 @@ it.effect("a failed tool update reports npm's exit code but not its output", () ), ); +describe("agent session release on close", () => { + const openClaim = ( + service: DeviceService.DeviceService["Service"], + threadId: ThreadId, + deviceId = "Pixel_API_35", + ) => service.open({ threadId, deviceId, platform: "android" }); + + it.effect("releases only the caller's session, keeps the device booted, and is idempotent", () => + Effect.gen(function* () { + const { service, closedSessions, requests } = yield* fixture(); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("closing"); + const other = ThreadId.make("other"); + const session = yield* openClaim(service, threadId); + yield* openClaim(service, other, session.deviceId); + yield* service.close({ threadId, deviceId: session.deviceId }); + yield* service.close({ threadId, deviceId: session.deviceId }); + const name = yield* agentDeviceSession(threadId, session.hostId, session.deviceId).pipe( + Effect.provide(NodeCrypto.layer), + ); + expect(closedSessions).toEqual([ + { + jsonrpc: "2.0", + id: name, + method: "agent_device.command", + params: { command: "close", session: name, positionals: [], flags: {} }, + }, + ]); + const state = yield* service.state; + expect(state.sessions.map((entry) => entry.threadId)).toEqual([other]); + expect(state.devices.find((entry) => entry.id === session.deviceId)?.booted).toBe(true); + expect(requests.some((url) => url.endsWith("/shutdown"))).toBe(false); + }), + ); + + it.effect.each(["reject", "http_error", "malformed"] as const)( + "retains retryable state on %s", + (failure) => + Effect.gen(function* () { + let fail = true; + const { service, requests, closedSessions } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { onClose: Effect.sync(() => (fail ? failure : "ok")) }, + ); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("retry"); + yield* openClaim(service, threadId); + const exit = yield* Effect.exit(service.close({ threadId, shutdown: true })); + expect(Exit.isFailure(exit)).toBe(true); + expect((yield* service.state).sessions).toHaveLength(1); + expect(requests.some((url) => url.endsWith("/shutdown"))).toBe(false); + fail = false; + yield* service.close({ threadId }); + expect((yield* service.state).sessions).toHaveLength(0); + expect(closedSessions).toHaveLength(2); + }), + ); + + it.effect.each([false, true])("accepts a missing agent session, shutdown=%s", (shutdown) => + Effect.gen(function* () { + const { service, requests } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { onClose: Effect.succeed("missing") }, + ); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("no-claim"); + yield* openClaim(service, threadId); + yield* service.close({ threadId, shutdown }); + expect((yield* service.state).sessions).toHaveLength(0); + expect(requests.some((url) => url.endsWith("/shutdown"))).toBe(shutdown); + }), + ); + + it.effect("retries shutdown after the first attempt already released the agent session", () => + Effect.gen(function* () { + let released = false; + const { service, connection } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { + onClose: Effect.sync(() => { + const result = released ? "missing" : "ok"; + released = true; + return result; + }), + }, + ); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("retry-shutdown"); + yield* openClaim(service, threadId); + connection.failShutdown = true; + expect(Exit.isFailure(yield* Effect.exit(service.close({ threadId, shutdown: true })))).toBe( + true, + ); + expect((yield* service.state).sessions).toHaveLength(1); + connection.failShutdown = false; + yield* service.close({ threadId, shutdown: true }); + expect((yield* service.state).sessions).toHaveLength(0); + }), + ); + + it.effect("releases the post-boot agentTarget session for a cold Android AVD", () => + Effect.gen(function* () { + const { service, closedSessions } = yield* fixture(); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("cold-avd"); + const before = yield* service.agentTarget({ + threadId, + hostId: "local", + deviceId: "Pixel_API_35", + }); + const session = yield* openClaim(service, threadId); + const after = yield* service.agentTarget({ + threadId, + hostId: session.hostId, + deviceId: session.deviceId, + }); + expect(session.deviceId).toBe("emulator-5554"); + expect(after.args[3]).not.toBe(before.args[3]); + yield* service.close({ threadId }); + expect(closedSessions).toEqual([ + expect.objectContaining({ params: expect.objectContaining({ session: after.args[3] }) }), + ]); + }), + ); + + it.effect("retains an SSH record during reconnect and releases it on retry", () => + Effect.gen(function* () { + const { service, connection, closedSessions, agentStarts } = yield* fixture(); + connection.kind = "ssh"; + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("ssh-reconnect"); + yield* openClaim(service, threadId); + const starts = agentStarts.length; + connection.online = false; + expect(Exit.isFailure(yield* Effect.exit(service.close({ threadId })))).toBe(true); + expect((yield* service.state).sessions).toHaveLength(1); + expect(closedSessions).toHaveLength(0); + expect(agentStarts).toHaveLength(starts); + connection.online = true; + yield* service.close({ threadId }); + expect((yield* service.state).sessions).toHaveLength(0); + expect(closedSessions).toHaveLength(1); + }), + ); + it.effect("releases peer claims before a close shuts down", () => + Effect.gen(function* () { + const { service, closedSessions } = yield* fixture(); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("shutting-down"); + const other = ThreadId.make("peer"); + const session = yield* openClaim(service, threadId); + yield* openClaim(service, other, session.deviceId); + yield* service.close({ threadId, shutdown: true }); + expect((yield* service.state).sessions).toHaveLength(0); + yield* service.close({ threadId: other }); + expect((yield* service.state).sessions).toHaveLength(0); + const names = yield* Effect.forEach([threadId, other], (thread) => + agentDeviceSession(thread, session.hostId, session.deviceId), + ).pipe(Effect.provide(NodeCrypto.layer)); + expect( + closedSessions.map((body) => (body as { params: { session: string } }).params.session), + ).toEqual(names); + }), + ); + + it.effect("publishes a reopened session only after the old claim is released and removed", () => + Effect.gen(function* () { + const entered = yield* Queue.unbounded(); + const releasing = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const { service } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { + onClose: Effect.gen(function* () { + yield* Deferred.succeed(releasing, undefined); + yield* Deferred.await(finish); + return "ok" as const; + }), + onPlatformAvailability: Queue.offer(entered, undefined), + }, + ); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("reopen"); + const session = yield* openClaim(service, threadId); + yield* Queue.take(entered); + const publications = yield* service.subscribe; + const closing = yield* service.close({ threadId }).pipe(Effect.forkChild); + yield* Deferred.await(releasing); + const opening = yield* openClaim(service, threadId, session.deviceId).pipe(Effect.forkChild); + yield* Queue.take(entered); + yield* Deferred.succeed(finish, undefined); + yield* Fiber.join(closing); + const reopened = yield* Fiber.join(opening); + const published = (yield* PubSub.takeAll(publications)) + .map(({ sessions }) => + sessions.includes(session) ? "old" : sessions.includes(reopened) ? "reopened" : "none", + ) + .filter((label, index, labels) => label !== labels[index - 1]); + expect(published).toEqual(["none", "reopened"]); + }).pipe(Effect.scoped), + ); +}); + describe("locating a device id across hosts", () => { const threadId = ThreadId.make("thread-locate"); const thisMachine = { id: LOCAL_DEVICE_HOST_ID, label: "This machine" }; diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index b2ef41f148a14..d91c9d8d0b53b 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -338,6 +338,26 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }), ); + const releaseLaunchers = (sessions: ReadonlyArray) => + launcherLock.withPermit( + Effect.forEach( + sessions, + (session) => + Effect.gen(function* () { + const name = yield* agentDeviceSession( + session.threadId, + session.hostId, + session.deviceId, + ).pipe(Effect.provideService(Crypto.Crypto, crypto)); + const launcher = boundLaunchers.get(name); + if (!launcher || hostSessionLaunchers.has(name) || !launchers?.release) return; + yield* launchers.release(launcher); + boundLaunchers.delete(name); + }), + { discard: true }, + ), + ); + // Uninterruptible so a committed retirement always prunes, even if its caller is cancelled. const publish = (update: (state: DeviceServiceState) => DeviceServiceState) => SynchronizedRef.modifyEffect(stateRef, ({ state }) => { @@ -817,12 +837,18 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }, ); + const refreshAfterShutdown = (ready: DeviceReadiness) => + refresh(ready).pipe( + Effect.catch((cause) => + Effect.logWarning("Device discovery unavailable after shutdown", { cause }), + ), + ); + const shutdownDevice = Effect.fn("DeviceService.shutdownDevice")(function* ( - hostId: DeviceHostId, + ready: DeviceReadiness, deviceId: DeviceId, platform: DevicePlatform, ) { - const ready = yield* readiness(hostId); yield* ready.devices.shutdown({ id: deviceId, platform }); yield* publish((state) => ({ ...state, @@ -831,46 +857,76 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ? { ...device, booted: false } : device, ), - sessions: state.sessions.filter( - (session) => !(session.hostId === ready.hostId && session.deviceId === deviceId), - ), })); - // Snapshot saves can stall discovery; refresh failure must not fail an accepted shutdown. - yield* refresh(ready).pipe( - Effect.catch((cause) => - Effect.logWarning("Device discovery unavailable after shutdown", { cause }), - ), - ); }); const close: DeviceService["Service"]["close"] = Effect.fn("DeviceService.close")( function* (input) { - const { state } = yield* SynchronizedRef.get(stateRef); - const closing = state.sessions.filter( - (session) => - session.threadId === input.threadId && - (input.hostId === undefined || session.hostId === input.hostId) && - (input.deviceId === undefined || session.deviceId === input.deviceId), + // Open publishes under the same lock; an old close must not release a reopened claim. + const closing = yield* lifecycleLock.withPermit( + Effect.gen(function* () { + const { state } = yield* SynchronizedRef.get(stateRef); + const closing = state.sessions.filter( + (session) => + session.threadId === input.threadId && + (input.hostId === undefined || session.hostId === input.hostId) && + (input.deviceId === undefined || session.deviceId === input.deviceId), + ); + const ending = input.shutdown + ? state.sessions.filter((session) => + closing.some( + (closed) => + closed.hostId === session.hostId && closed.deviceId === session.deviceId, + ), + ) + : closing; + for (const session of ending) { + const host = yield* resolveHost(session.hostId); + const ready = yield* host.current; + // A lost SSH tunnel does not establish that the remote daemon stopped. + if (!ready && (yield* host.summary).kind === "ssh") + return yield* new DeviceHostUnavailableError({ + hostId: session.hostId, + hostLabel: host.label, + reason: "Device host is reconnecting. Retry close after it reconnects.", + }); + if (ready?.releaseAgentSession) { + const name = yield* agentDeviceSession( + session.threadId, + session.hostId, + session.deviceId, + ).pipe(Effect.provideService(Crypto.Crypto, crypto)); + yield* ready.releaseAgentSession(name, session.deviceId); + } + } + if (input.shutdown) yield* releaseLaunchers(ending); + if (input.shutdown) { + for (const session of closing) { + const ready = yield* currentReadiness(session.hostId); + if (!ready) + return yield* new DeviceHostUnavailableError({ + hostId: session.hostId, + reason: "Device host is not running. Reconnect before retrying close.", + }); + yield* shutdownDevice(ready, session.deviceId, session.platform); + } + } + yield* publish((current) => ({ + ...current, + sessions: current.sessions.filter((session) => !ending.includes(session)), + })); + return closing; + }), ); - if (closing.length === 0) return; - // Powering off ends every session on the device, so all of them retire, and release, while it is on. - const ending = (session: DeviceSession) => - closing.includes(session) || - (input.shutdown === true && - closing.some( - (closed) => closed.hostId === session.hostId && closed.deviceId === session.deviceId, - )); - yield* publish((current) => ({ - ...current, - sessions: current.sessions.filter((session) => !ending(session)), - })); - if (input.shutdown) { + if (input.shutdown) yield* Effect.forEach( closing, - (session) => shutdownDevice(session.hostId, session.deviceId, session.platform), + (session) => + currentReadiness(session.hostId).pipe( + Effect.flatMap((ready) => (ready ? refreshAfterShutdown(ready) : Effect.void)), + ), { discard: true }, ); - } }, ); @@ -885,7 +941,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (session) => !(session.hostId === host.id && session.deviceId === input.deviceId), ), })); - yield* shutdownDevice(host.id, input.deviceId, input.platform); + const ready = yield* readiness(host.id); + yield* shutdownDevice(ready, input.deviceId, input.platform); + yield* refreshAfterShutdown(ready); }, ); diff --git a/apps/server/src/device/EasDeviceHost.test.ts b/apps/server/src/device/EasDeviceHost.test.ts index 62a6a0c247a45..b5825d3f973e7 100644 --- a/apps/server/src/device/EasDeviceHost.test.ts +++ b/apps/server/src/device/EasDeviceHost.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { EasDeviceHostConfig } from "@t3tools/contracts"; +import { type EasDeviceHostConfig, ThreadId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -15,6 +16,8 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as DeviceService from "./DeviceService.ts"; import * as EasDeviceHost from "./EasDeviceHost.ts"; const APP_ID = "d763fcb8-d37c-41ea-a773-b54a0ab4a454"; @@ -57,9 +60,12 @@ const fakeExpo = ( readonly activeIncludesCreated?: boolean; readonly failPreview?: boolean; readonly omitCreatedFromActive?: boolean; + /** The session's agent-device daemon answers with a server error. */ + readonly failAgent?: () => boolean; } = {}, ) => { const calls: Array<{ operation: string; variables: Record }> = []; + const releases: Array = []; let created = 0; let createdName = ""; const previews: string[] = []; @@ -91,6 +97,18 @@ const fakeExpo = ( ), ); } + if (url.origin === "https://agent.test") { + expect(request.headers.authorization).toBe("Bearer agent-token"); + if (request.body._tag === "Uint8Array") { + const rpc = JSON.parse(new TextDecoder().decode(request.body.body)) as { + params: { command: string; session: string }; + }; + if (rpc.params.command === "close") releases.push(rpc.params.session); + } + if (options.failAgent?.()) + return HttpClientResponse.fromWeb(request, new Response("bad gateway", { status: 502 })); + return HttpClientResponse.fromWeb(request, Response.json({ result: { ok: true } })); + } const body = request.body._tag === "Uint8Array" ? (JSON.parse(new TextDecoder().decode(request.body.body)) as { @@ -207,7 +225,7 @@ const fakeExpo = ( const allowCreate = () => { createFails = false; }; - return { http, named, stopped, allowPreview, allowCreate, previews }; + return { http, named, stopped, allowPreview, allowCreate, releases, previews }; }; const layer = Layer.mergeAll( @@ -566,4 +584,63 @@ describe("EAS Simulator host", () => { ]); }).pipe(Effect.scoped, Effect.provide(layer)), ); + + it.effect("releases each thread's agent session on close and keeps the session running", () => + Effect.gen(function* () { + let agentFails = false; + const expo = fakeExpo({ failAgent: () => agentFails }); + const host = yield* makeHost(expo, config(), yield* Scope.make()); + const service = yield* DeviceService.makeWithHosts(new Map([["eas", host]]), undefined, () => + Effect.succeed("/eas-agent.json"), + ); + const first = ThreadId.make("first"); + const second = ThreadId.make("second"); + const opened = yield* service.open({ + threadId: first, + hostId: "eas", + deviceId: "eas-ios", + platform: "ios", + }); + expect(opened.deviceId).toBe("session-1"); + yield* service.open({ + threadId: second, + hostId: "eas", + deviceId: opened.deviceId, + platform: "ios", + }); + const sessionOf = (threadId: ThreadId) => + service + .agentTarget({ threadId, hostId: "eas", deviceId: opened.deviceId }) + .pipe(Effect.map((target) => target.args[3])); + const firstSession = yield* sessionOf(first); + const secondSession = yield* sessionOf(second); + + yield* service.close({ threadId: first }); + expect(expo.releases).toEqual([firstSession]); + expect((yield* service.state).sessions.map((entry) => entry.threadId)).toEqual([second]); + + agentFails = true; + const failed = yield* Effect.exit(service.close({ threadId: second, shutdown: false })); + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* service.state).sessions.map((entry) => entry.threadId)).toEqual([second]); + + agentFails = false; + yield* service.close({ threadId: second, shutdown: false }); + expect(expo.releases).toEqual([firstSession, secondSession, secondSession]); + expect((yield* service.state).sessions).toEqual([]); + expect(expo.stopped()).not.toContain("session-1"); + expect( + (yield* service.state).devices.find((device) => device.id === "session-1")?.booted, + ).toBe(true); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + layer, + NodeCrypto.layer, + ServerSettings.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }), + ), + ), + ), + ); }); diff --git a/apps/server/src/device/EasDeviceHost.ts b/apps/server/src/device/EasDeviceHost.ts index a1137fe5156a0..e82638dbd76fa 100644 --- a/apps/server/src/device/EasDeviceHost.ts +++ b/apps/server/src/device/EasDeviceHost.ts @@ -32,6 +32,7 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; +import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { findEasProjectId } from "./easProjectId.ts"; import { type ExpoApi, makeExpoApi, resolveExpoCredential } from "./ExpoApi.ts"; @@ -827,6 +828,12 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice hub: { origin: "https://api.expo.dev" }, devices, upstream, + // Each session runs its own daemon, so one that has ended holds no agent sessions. + releaseAgentSession: (session, deviceId) => + Option.match(sessionFor(deviceId), { + onNone: () => Effect.void, + onSome: (running) => releaseAgentDeviceSession(http, running.agent)(session), + }), // Device settings run simctl on the simulator's own machine, which EAS does not expose. run: () => Effect.succeed({ diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index bffd912a3987f..6b86a4d032610 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -5,6 +5,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as FileSystem from "effect/FileSystem"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -42,7 +44,9 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-agent-start-" }); + const hub = { running: true, spawnFails: false }; const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); + const daemonHealthy = yield* Ref.make(true); for (const [name, version, entry] of [ ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"], ["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"], @@ -61,21 +65,30 @@ const agentFixture = Effect.fn("agentFixture")(function* ( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(123), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.never, - isRunning: Effect.succeed(true), - kill: () => Effect.void, - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }), - ), + hub.spawnFails + ? Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "Hub entry missing", + }), + ) + : Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.never, + isRunning: Effect.sync(() => hub.running), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ), ), ), Effect.provideService(ProcessRunner.ProcessRunner, { @@ -87,15 +100,56 @@ const agentFixture = Effect.fn("agentFixture")(function* ( Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))), + Ref.get(daemonHealthy).pipe( + Effect.map((healthy) => + HttpClientResponse.fromWeb( + request, + new Response("ok", { + status: request.url.endsWith("/health") && !healthy ? 503 : 200, + }), + ), + ), + ), ), ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, stateDir }; + return { host, fs, stateDir, hub, daemonHealthy }; }); describe("AgentDevice startup", () => { + it.effect("restarts a cached daemon that no longer answers its health check", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + let attempts = 0; + const { host, daemonHealthy } = yield* agentFixture((stateDir) => + Effect.gen(function* () { + attempts++; + yield* fs + .writeFileString( + `${stateDir}/daemon.json`, + JSON.stringify({ httpPort: 12345 + attempts, token: `token-${attempts}` }), + ) + .pipe(Effect.orDie); + return processOutput(0); + }), + ); + const first = yield* host.ensureAgentReady(() => Effect.void); + expect((yield* host.ensureAgentReady(() => Effect.void)).agentDevice).toEqual( + first.agentDevice, + ); + expect(attempts).toBe(1); + + yield* Ref.set(daemonHealthy, false); + const restarted = yield* host.ensureAgentReady(() => Effect.void); + expect(attempts).toBe(2); + expect(restarted.agentDevice).toMatchObject({ + baseUrl: "http://127.0.0.1:12347", + token: "token-2", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("reports an incompatible lock immediately and retains it for manual repair", () => Effect.gen(function* () { const result = processOutput( @@ -196,9 +250,32 @@ describe("AgentDevice startup", () => { expect((yield* host.ensureAgentReady(() => Effect.void)).agentDevice).toEqual( ready.agentDevice, ); + expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function"); expect(attempts).toBe(1); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("keeps releasing agent sessions while a dead hub fails to restart and after", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { host, hub } = yield* agentFixture((stateDir) => + fs + .writeFileString( + `${stateDir}/daemon.json`, + JSON.stringify({ httpPort: 12345, token: "test-token" }), + ) + .pipe(Effect.orDie, Effect.as(processOutput(0))), + ); + yield* host.ensureAgentReady(() => Effect.void); + hub.running = false; + hub.spawnFails = true; + expect((yield* host.ensureReady(() => Effect.void).pipe(Effect.result))._tag).toBe("Failure"); + expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function"); + hub.spawnFails = false; + yield* host.ensureReady(() => Effect.void); + expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 051803bd49616..13ad97fcec660 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -36,6 +36,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; +import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { @@ -521,7 +522,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { }); const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* ( - agentTool: DeviceToolPaths, + agentTool: Pick, nodePath: string, ): Effect.fn.Return< DeviceHost.AgentDeviceEndpoint, @@ -650,10 +651,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ): Effect.fn.Return { const running = yield* Ref.get(runningRef); + // A dead hub's record stays until a replacement is published: the agent + // daemon outlives the hub, and close needs its endpoint if the restart fails. if (running) { const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)); if (alive) return running; - yield* Ref.set(runningRef, null); } const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -696,7 +698,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ]); const next: RunningHost = { hub, - agentDevice: null, + agentDevice: running?.agentDevice ?? null, helpers: { serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null, serveSimCli: cliExists ? candidate.serveSimCli : null, @@ -726,7 +728,14 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { DeviceHost.DeviceHostAgentReady > { const running = yield* ensureHubReady(onPhase); - if (running.agentDevice) return { ...toReady(running), agentDevice: running.agentDevice }; + if (running.agentDevice) { + const agentDevice = yield* startAgentDeviceDaemon( + running.agentDevice, + running.hub.nodePath, + ); + yield* Ref.set(runningRef, { ...running, agentDevice }); + return { ...toReady(running), agentDevice }; + } const installed = yield* isAgentDeviceInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -821,6 +830,9 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { nodePath: running.hub.nodePath, run, helpers: running.helpers, + ...(running.agentDevice + ? { releaseAgentSession: releaseAgentDeviceSession(httpClient, running.agentDevice) } + : {}), }); const current: DeviceHost.DeviceHost["Service"]["current"] = Ref.get(runningRef).pipe( diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index cde68d1b50c99..193df164c3086 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -26,6 +26,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ let forwards = 0; let failForward = true; let rejectConfig = true; + const releases: Array = []; const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); @@ -118,7 +119,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))), + Effect.sync(() => { + if (!request.url.endsWith("/rpc")) + return HttpClientResponse.fromWeb(request, new Response("ok")); + releases.push(request.headers.authorization); + return HttpClientResponse.fromWeb(request, Response.json({ result: { ok: true } })); + }), ), ), ); @@ -156,6 +162,10 @@ it.effect("preserves installed status after probes and cleans failed agent activ yield* host.ensureAgentReady(() => Effect.void); yield* host.platformAvailability("ios"); expect((yield* host.summary).agentDeviceInstalled).toBe(true); + // Close releases agent sessions through the forwarded daemon. + const agentReady = yield* host.current; + yield* agentReady!.releaseAgentSession!("t3-session", "device"); + expect(releases).toEqual(["Bearer fixture"]); yield* host.stopAgent; expect(forwards).toBe(1); const multiplexingOff = ["ControlMaster=no", "ControlPath=none", "ControlPersist=no"]; diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index d7324d515b006..1eb0b713983a8 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -22,6 +22,7 @@ import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; +import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; @@ -351,7 +352,9 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( }).pipe(Effect.provideService(HttpClient.HttpClient, http)); } if (next.agentDevice) yield* onReady({ ...next, agentDevice: next.agentDevice }); - ready = next; + ready = next.agentDevice + ? { ...next, releaseAgentSession: releaseAgentDeviceSession(http, next.agentDevice) } + : next; yield* onStatus("ready"); // Reconnect also repairs helpers that died while SSH itself stayed connected. const unhealthy = Effect.gen(function* () { diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index e3af344e63709..d855320d3103c 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -7,7 +7,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeUtil from "node:util"; -import { quoteRemoteArg, remoteDeviceEnvironment } from "./sshDeviceScript.ts"; +import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; const exec = NodeUtil.promisify(NodeChildProcess.execFile); @@ -45,3 +45,90 @@ it.effect("preserves shell metacharacters and newlines in remote arguments", () }); }), ); + +it.effect("stops a daemon left for its owner when starting without agent tools", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + yield* Effect.promise(async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-start-")); + try { + const state = NodePath.join(home, ".t3/device/hosts/owner"); + const daemonFile = NodePath.join(state, "daemon.json"); + const calls = NodePath.join(home, "calls"); + const failMarker = NodePath.join(home, "fail-stop"); + const entry = NodePath.join(home, "agent-device.mjs"); + await NodeFSP.mkdir(NodePath.join(home, "bin")); + await NodeFSP.mkdir(state, { recursive: true }); + await NodeFSP.writeFile( + entry, + `import * as fs from "node:fs"; +fs.appendFileSync(${JSON.stringify(calls)}, process.argv.slice(2).join(" ") + "\\n"); +if (fs.existsSync(${JSON.stringify(failMarker)})) process.exitCode = 1;`, + ); + await NodeFSP.writeFile( + NodePath.join(state, "agent.json"), + JSON.stringify({ entryPath: entry }), + ); + await NodeFSP.writeFile(daemonFile, JSON.stringify({ httpPort: 1, token: "claims" })); + // An empty PATH leaves no platforms, so the script ends right after the daemon check. + const start = () => + NodeChildProcess.spawnSync(process.execPath, [], { + input: remoteDeviceScript("owner", "start"), + env: { HOME: home, PATH: NodePath.join(home, "bin") }, + encoding: "utf8", + }); + await NodeFSP.writeFile(failMarker, ""); + expect(start().stderr).toContain("Could not stop the agent-device daemon"); + expect(await NodeFSP.readFile(daemonFile, "utf8")).toContain("claims"); + await NodeFSP.rm(failMarker); + expect(start().stderr).toContain("Android SDK missing"); + await expect(NodeFSP.access(daemonFile)).rejects.toThrow(); + expect(await NodeFSP.readFile(calls, "utf8")).toBe( + `daemon stop --state-dir ${state}\ndaemon stop --state-dir ${state}\n`, + ); + } finally { + await NodeFSP.rm(home, { recursive: true, force: true }); + } + }); + }), +); + +it.effect("keeps a daemon record its missing agent CLI cannot stop", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + yield* Effect.promise(async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-start-")); + try { + const state = NodePath.join(home, ".t3/device/hosts/owner"); + const daemonFile = NodePath.join(state, "daemon.json"); + await NodeFSP.mkdir(NodePath.join(home, "bin")); + await NodeFSP.mkdir(state, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(state, "agent.json"), + JSON.stringify({ entryPath: NodePath.join(home, "missing/agent-device.mjs") }), + ); + const start = async (pid: number) => { + await NodeFSP.writeFile( + daemonFile, + JSON.stringify({ pid, httpPort: 1, token: "claims" }), + ); + return NodeChildProcess.spawnSync(process.execPath, [], { + input: remoteDeviceScript("owner", "start"), + env: { HOME: home, PATH: NodePath.join(home, "bin") }, + encoding: "utf8", + }); + }; + // This test process stands in for a daemon that is still running. + expect((await start(process.pid)).stderr).toContain( + "Could not stop the agent-device daemon", + ); + expect(await NodeFSP.readFile(daemonFile, "utf8")).toContain("claims"); + const exited = NodeChildProcess.spawnSync(process.execPath, ["-e", ""]).pid; + expect((await start(exited)).stderr).toContain("Android SDK missing"); + await expect(NodeFSP.access(daemonFile)).rejects.toThrow(); + } finally { + await NodeFSP.rm(home, { recursive: true, force: true }); + } + }); + }), +); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index ffe8097bae338..2e3e895293fa3 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -151,16 +151,29 @@ async function install(name, version, entry) { const hubFile = path.join(state, 'hub.json'); const daemonFile = path.join(state, 'daemon.json'); const agentFile = path.join(state, 'agent.json'); + const stopAgent = () => { + const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs'); + if (fs.existsSync(entry)) return run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]).status === 0; + // A running daemon outlives its deleted CLI, so without one only a recorded pid that is gone counts as stopped. + const pid = read(daemonFile)?.pid; + return Number.isSafeInteger(pid) && pid > 0 && !maintenanceAlive(pid); + }; if (mode === 'stop' || mode === 'stop-agent') { const hub = read(hubFile); if (mode === 'stop' && hub && hub.owner === owner) { stopHub(hub); fs.rmSync(hubFile, { force: true }); } - const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs'); - if (fs.existsSync(entry)) run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]); + stopAgent(); return; } + // A start without agent tools means the server holds no claims, so a daemon still here is + // orphaned. Agent access was turned off while the tunnel was down, or a server crashed. + // Close skips the release when start returns no endpoint, so the claims must end here. + if (mode === 'start' && read(daemonFile)) { + if (!stopAgent()) throw Error('Could not stop the agent-device daemon in ' + state); + fs.rmSync(daemonFile, { force: true }); + } if (!ios && !android) throw Error(platforms.map(p => p.reason).join(' ')); fs.mkdirSync(state, { recursive: true, mode: 0o700 }); const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs'); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index 97156800e3db9..35909007c395c 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -61,6 +61,35 @@ function readTraces(fileSystem: Partial) { } describe("TraceDiagnostics", () => { + it("includes browser failures and interruptions in diagnostics", () => { + const diagnostics = aggregateLines( + [ + { status: { code: "2", message: "Browser request failed" }, attributes: {} }, + { status: { code: "0" }, attributes: { "effect.fiber.interrupted": true } }, + { status: { code: "1", message: "Interrupted" }, attributes: {} }, + { status: { code: "1" }, attributes: {} }, + ].map((outcome, index) => + JSON.stringify({ + type: "otlp-span", + name: "browser.request", + traceId: `trace-${index}`, + spanId: `span-${index}`, + startTimeUnixNano: ns(index * 100), + endTimeUnixNano: ns(index * 100 + 10), + durationMs: 10, + ...outcome, + }), + ), + ); + + assert.equal(diagnostics.recordCount, 4); + assert.equal(diagnostics.failureCount, 1); + assert.equal(diagnostics.interruptionCount, 2); + assert.equal(diagnostics.topSpansByCount[0]?.failureCount, 1); + assert.equal(diagnostics.commonFailures[0]?.cause, "Browser request failed"); + assert.equal(diagnostics.latestFailures[0]?.cause, "Browser request failed"); + }); + it.effect("aggregates failures, slow spans, log levels, and parse errors", () => Effect.sync(() => { const diagnostics = aggregateLines([ diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index 0cd5ae9b3a750..6f25c424a7b83 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -18,17 +18,6 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -interface TraceRecordLike { - readonly name?: unknown; - readonly traceId?: unknown; - readonly spanId?: unknown; - readonly startTimeUnixNano?: unknown; - readonly endTimeUnixNano?: unknown; - readonly durationMs?: unknown; - readonly exit?: unknown; - readonly events?: unknown; -} - interface TraceEventLike { readonly name?: unknown; readonly timeUnixNano?: unknown; @@ -85,7 +74,7 @@ export function toRotatedTracePaths( return [...backups, traceFilePath]; } -function isRecordObject(value: unknown): value is TraceRecordLike { +function isRecordObject(value: unknown): value is Readonly> { return typeof value === "object" && value !== null; } @@ -253,8 +242,13 @@ export function makeTraceDiagnosticsAggregator( lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; const exitTag = readExitTag(parsed.exit); - const isFailure = exitTag === "Failure"; - const isInterrupted = exitTag === "Interrupted"; + const status = isRecordObject(parsed.status) ? parsed.status : {}; + const attributes = isRecordObject(parsed.attributes) ? parsed.attributes : {}; + const isFailure = exitTag === "Failure" || status.code === "2"; + const isInterrupted = + exitTag === "Interrupted" || + attributes["effect.fiber.interrupted"] === true || + status.message === "Interrupted"; if (isFailure) failureCount += 1; if (isInterrupted) interruptionCount += 1; @@ -277,7 +271,10 @@ export function makeTraceDiagnosticsAggregator( insertBounded(slowestSpans, spanItem, TOP_LIMIT, slowestFirst); if (isFailure) { - const cause = readExitCause(parsed.exit); + const cause = + exitTag === "Failure" + ? readExitCause(parsed.exit) + : (toStringValue(status.message)?.trim() ?? "Failure"); insertBounded(latestFailures, { ...spanItem, cause }, RECENT_LIMIT, latestEndedFirst); const failureKey = `${name}\0${cause}`; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 5e0bcfc742e08..c8bf344513093 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -224,6 +224,7 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, pullRequestChecks: true, + sideChats: true, inlineMessageContext: true, requiredWorktreeBootstrap: true, threadSettlement: true, @@ -265,6 +266,7 @@ export const make = Effect.gen(function* () { projectCloneTracking: true, ignoredFileListing: true, triage: true, + npmReleaseLookup: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 97d7e375f8012..8b513c4ca30eb 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -444,6 +444,61 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(layerKeybindings())), ); + it.effect("removes a hand-written keybinding targeted by its settings spelling", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { + key: "Shift+Cmd+R", + command: "script.run-tests.run", + when: "terminalFocus&&!previewFocus", + }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + { key: "x\u0000true", command: "script.run-tests.run" }, + { key: "x", command: "script.run-tests.run", when: "true" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + yield* keybindings.removeKeybindingRule({ + key: "meta+shift+r", + command: "script.run-tests.run", + when: "terminalFocus && !previewFocus", + }); + yield* keybindings.removeKeybindingRule({ + key: "x", + command: "script.run-tests.run", + when: "true", + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.deepEqual(persisted, [ + { key: "mod+shift+r", command: "script.run-tests.run" }, + { key: "x\u0000true", command: "script.run-tests.run" }, + ]); + }).pipe(Effect.provide(layerKeybindings())), + ); + + it.effect("replaces a hand-written keybinding targeted by its settings spelling", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "escape", command: "script.run-tests.run", when: "a && b && c" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+alt+r", + command: "script.run-tests.run", + replace: { key: "esc", command: "script.run-tests.run", when: "(a && b) && c" }, + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.deepEqual(persisted, [{ key: "mod+alt+r", command: "script.run-tests.run" }]); + }).pipe(Effect.provide(layerKeybindings())), + ); + it.effect("refuses to overwrite malformed keybindings config", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 44dae514dd029..1247428a56d82 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -41,6 +41,7 @@ import { compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, parseKeybindingShortcut, + parseKeybindingWhenExpression, } from "@t3tools/shared/keybindings"; export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( @@ -80,12 +81,18 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( ), ); +// Settings sends replace/remove targets in its own normalized spelling, which can differ from a +// hand-edited file ("cmd+k" vs "meta+k", "a&&b" vs "a && b"), so compare parsed forms. +function keybindingRuleIdentity(rule: KeybindingRule): string { + const shortcut = parseKeybindingShortcut(rule.key); + const key = (shortcut && encodeShortcut(shortcut)) ?? rule.key; + if (rule.when === undefined) return JSON.stringify([rule.command, key, null]); + const whenAst = parseKeybindingWhenExpression(rule.when); + return JSON.stringify([rule.command, key, whenAst ? encodeWhenAst(whenAst) : rule.when]); +} + function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean { - return ( - left.command === right.command && - left.key === right.key && - (left.when ?? undefined) === (right.when ?? undefined) - ); + return keybindingRuleIdentity(left) === keybindingRuleIdentity(right); } const LATE_DEFAULT_KEYBINDINGS: ReadonlyArray<{ diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts index 0f1bedc17c631..5d07f1a936298 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts @@ -5,8 +5,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import { vi } from "vite-plus/test"; -import { mcpToolSession, runAcpMcpStdioBridge } from "./AcpMcpStdioBridge.ts"; +import { mcpToolSession, runAcpMcpCliFastPath, runAcpMcpStdioBridge } from "./AcpMcpStdioBridge.ts"; function makeHarness(responder: (request: Request) => Promise | Response) { const input = new NodeStream.PassThrough(); @@ -38,6 +39,54 @@ function pendingFetch(started: Deferred.Deferred) { } describe("AcpMcpStdioBridge", () => { + it("prints structured results once in the terminal while retaining other content and errors", async () => { + const originalEnvironment = process.env; + using _environment = { + [Symbol.dispose]: () => { + process.env = originalEnvironment; + }, + }; + process.env = { + ...originalEnvironment, + T3_ACP_MCP_ENDPOINT: "http://127.0.0.1:1/mcp", + T3_ACP_MCP_AUTHORIZATION: "Bearer bridge-test", + }; + const structuredContent = { taskId: "task-1", status: "failed" }; + const explanation = { type: "text", text: "The command failed." }; + const image = { type: "image", data: "image-data", mimeType: "image/png" }; + const result = { + structuredContent, + content: [{ type: "text", text: JSON.stringify(structuredContent) }, explanation, image], + isError: true, + }; + using fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => { + if (init?.method === "DELETE") return new Response(null, { status: 204 }); + const message = JSON.parse(String(init?.body)) as { id?: string; method: string }; + if (message.method === "notifications/initialized") { + return new Response(null, { status: 202 }); + } + return Response.json( + { + jsonrpc: "2.0", + id: message.id, + result: message.method === "initialize" ? { protocolVersion: "2025-06-18" } : result, + }, + { headers: { "mcp-session-id": "session-42" } }, + ); + }); + using output = vi.spyOn(process.stdout, "write").mockReturnValue(true); + + await runAcpMcpCliFastPath("acp-mcp-call", ["task_status", '{"taskId":"task-1"}']); + + expect(output).toHaveBeenCalledExactlyOnceWith( + `${JSON.stringify({ ...result, content: [explanation, image] })}\n`, + ); + expect(fetch).toHaveBeenLastCalledWith( + "http://127.0.0.1:1/mcp", + expect.objectContaining({ method: "DELETE" }), + ); + }); + it.effect("forwards requests and replays the session id and protocol version", () => Effect.gen(function* () { const { input, written, requests, bridge } = makeHarness((request) => { diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index f3f331f04ac34..3f5ef1006c465 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -361,7 +361,7 @@ export async function runAcpMcpCliFastPath( process.exitCode = 2; return; } - const result = await Effect.runPromise( + let result = await Effect.runPromise( callAcpMcpTool({ endpoint, authorization, @@ -369,5 +369,30 @@ export async function runAcpMcpCliFastPath( arguments: parsed as Record, }), ); + // Terminal callers see both fields, unlike native MCP clients that choose one. + if ( + typeof result === "object" && + result !== null && + "structuredContent" in result && + result.structuredContent !== undefined && + "content" in result && + Array.isArray(result.content) + ) { + const encoded = JSON.stringify(result.structuredContent); + result = { + ...result, + content: result.content.filter( + (part: unknown) => + !( + typeof part === "object" && + part !== null && + "type" in part && + part.type === "text" && + "text" in part && + part.text === encoded + ), + ), + }; + } process.stdout.write(`${JSON.stringify(result)}\n`); } diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 1f77dbfcc087d..902785778a975 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -5,6 +5,8 @@ import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + DeviceId, + DeviceOperationError, EnvironmentId, OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, @@ -24,6 +26,7 @@ import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/http"; import * as ProjectService from "../project/ProjectService.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as PullRequestReviewGuideService from "../pullRequest/PullRequestReviewGuideService.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -140,6 +143,86 @@ const callTool = (name: string, args: Record) => const callSnapshot = (args: Record) => callTool("preview_snapshot", args); +it.effect.each([ + { input: {}, open: false, message: "No device is open in this thread. Call device_open first." }, + { + input: { deviceId: "missing", hostId: "local" }, + open: false, + message: "Device missing is not open in this thread. Call device_open first.", + }, + { + input: {}, + open: true, + message: + "Device screenshot failed: Could not communicate with device support. Try refreshing devices.", + }, + { + input: {}, + open: true, + rejected: true, + message: + "Device screenshot failed: The device hub could not complete the request. Call device_list to check the device state, and device_open if it needs to be reopened.", + }, +])( + "preserves actionable screenshot failure text: $message", + ({ input, open, message, ...options }) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server.callTool({ name: "device_screenshot", arguments: input }).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...invocation, + capabilities: new Set(["device"] as const), + }), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: "text", text: message }]); + expect(result.structuredContent).toMatchObject({ + error: { message, operation: "screenshot" }, + }); + expect(JSON.stringify(result)).not.toContain("private hub output"); + }).pipe( + Effect.provide( + McpHttpServer.layerDeviceToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide( + Layer.mock(DeviceService.DeviceService)({ + locateSession: () => Effect.succeedNone, + sessionsForThread: () => + Effect.succeed( + open + ? [ + { + threadId, + hostId: "local", + deviceId: DeviceId.make("phone"), + platform: "ios", + openedAt: "2026-10-23T00:00:00Z", + }, + ] + : [], + ), + screenshot: () => + Effect.fail( + new DeviceOperationError({ + operation: "screenshot", + reason: + "rejected" in options && options.rejected ? "hub_rejected" : "request_failed", + cause: new Error("private hub output"), + }), + ), + }), + ), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-device-mcp-test-" })), + Layer.provide(NodeServices.layer), + ), + ), + Effect.scoped, + ), +); + const layerRecordingTools = (calls: Array) => { const RecordingToolkit = Toolkit.make( OrchestratorToolkit.tools.delegate_task, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 4365267e2144d..e684316f48d9a 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -766,10 +766,14 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh Effect.provideService(ThreadManagementService.ThreadManagementService, threads), ), "screenshot", - (error) => - isDeviceToolError(error) || isOrchestratorMcpFailure(error) - ? error.message - : "Device screenshot failed.", + (error) => { + if (!isDeviceToolError(error)) { + return isOrchestratorMcpFailure(error) ? error.message : "Device screenshot failed."; + } + return error._tag === "DeviceOperationError" && error.reason === "hub_rejected" + ? `${error.message} Call device_list to check the device state, and device_open if it needs to be reopened.` + : error.message; + }, ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 45561e7670411..b290ad124c0f2 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -25,6 +25,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; @@ -116,7 +117,8 @@ function makeRun(input: { }; } -it("readThread prefers activity-run status over a newer cancelled queued run", async () => { +it("readThread preserves caller access errors and prefers activity-run status over a newer cancelled queued run", async () => { + let callerUnavailable = false; const projection = { thread: baseThread({ threadId: parentThreadId, @@ -142,15 +144,24 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a Layer.mock(ThreadManagementService.ThreadManagementService)({ getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), getThreadRecords: (threadId) => - threadId === parentThreadId - ? Effect.succeed(projection) - : Effect.die(`unexpected thread ${threadId}`), + callerUnavailable + ? Effect.fail( + new Orchestrator.OrchestratorProjectionError({ + threadId, + cause: "Caller unavailable", + }), + ) + : threadId === parentThreadId + ? Effect.succeed(projection) + : Effect.die(`unexpected thread ${threadId}`), getThreadShell: (threadId) => - Effect.succeed( - threadId === parentThreadId - ? (projection.thread as unknown as OrchestrationV2ThreadShell) - : null, - ), + callerUnavailable + ? Effect.die("A failed caller read must not reach the target") + : Effect.succeed( + threadId === parentThreadId + ? (projection.thread as unknown as OrchestrationV2ThreadShell) + : null, + ), getProjectThreadRecords: (input) => input.threadId === parentThreadId ? Effect.succeed(projection) @@ -180,6 +191,17 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a expect(result.thread.status).toBe("running"); expect(result.thread.latestRunId).toBe(cancelledRunId); expect(result.thread.activeRunId).toBe(activeRunId); + + callerUnavailable = true; + const denied = yield* service + .readThread({ ...makeScope(), capabilities: new Set() }, { threadId: parentThreadId }) + .pipe(Effect.flip); + expect(denied.code).toBe("capability_denied"); + const missingCaller = yield* service + .readThread(makeScope(), { threadId: parentThreadId }) + .pipe(Effect.flip); + expect(missingCaller.code).toBe("orchestration_error"); + expect(missingCaller.message).toContain(`Unable to read thread ${parentThreadId}:`); }).pipe(Effect.provide(layer), Effect.runPromise); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 6450f9acac2cb..9f27dc6936339 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1303,6 +1303,7 @@ describe("OrchestratorMcpService provider resolution", () => { instanceId: codexInstanceId, driver: ProviderDriverKind.make("codex"), }), + displayName: "Codex", models: [ model("gpt-6", { name: "Sol", @@ -1330,7 +1331,7 @@ describe("OrchestratorMcpService provider resolution", () => { ], }, }), - model("gpt-5", { isLegacy: true }), + model("gpt-5", { name: "Code Legacy", isLegacy: true }), model("gpt-4", { isLegacy: true }), model("gpt-3", { name: "GPT-3", isLegacy: true }), model("my-alpha", { isCustom: true }), @@ -1436,6 +1437,18 @@ describe("OrchestratorMcpService provider resolution", () => { { id: codexInstanceId, models: ["gpt-6", "gpt-4", "gpt-3", "my-alpha"], omitted: 1 }, { id: opencodeInstanceId, models: [], omitted: 0 }, ]); + assert.deepEqual(listing(yield* capabilitiesWith(" ")), listing(yield* capabilitiesWith())); + for (const search of ["code", "codex code", " CODE "]) { + assert.deepEqual(listing(yield* capabilitiesWith(search))[0], { + id: codexInstanceId, + models: ["gpt-5"], + omitted: 0, + }); + } + assert.deepEqual( + listing(yield* capabilitiesWith("open"))[1], + listing(yield* capabilitiesWith())[1], + ); assert.deepEqual(listing(yield* capabilitiesWith("codex gpt 5"))[0], { id: codexInstanceId, models: ["gpt-5"], diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 26e8fe102662b..83e5be6198039 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -286,10 +286,8 @@ const FULL_CATALOG_DRIVERS: ReadonlySet = new Set(["codex", "claudeAgent const LISTED_MODEL_LIMIT = 8; /** - * Picks the models orchestrator_capabilities shows for one provider. Hidden models never - * appear. Search words naming the provider are dropped; other words list matches. With no - * words left, lists pinned models (custom, favorite, the caller's own) plus non-legacy - * models, or only the first few in the user's model order for large catalogs. + * Lists visible models, keeping pinned models and limiting large catalogs by default. + * Whole provider words select the provider; partial words filter models when any match. */ function listCapabilityModels(input: { readonly provider: ServerProvider; @@ -305,10 +303,22 @@ function listCapabilityModels(input: { .toSorted((a, b) => (rank.get(a.slug) ?? rank.size) - (rank.get(b.slug) ?? rank.size)); const { instanceId, driver, displayName } = input.provider; const providerText = `${instanceId} ${driver} ${displayName ?? ""}`.toLowerCase(); - const modelTerms = input.searchTerms.filter((term) => !providerText.includes(term)); + // Whole ids like "codex-work" and their parts both name the provider. + const providerWords = new Set([ + ...providerText.split(/\s+/), + ...providerText.split(/[^\p{L}\p{N}]+/u), + ]); + const modelText = (model: ServerProviderModel) => + `${model.slug} ${model.name} ${model.subProvider ?? ""}`.toLowerCase(); + // Part of a provider word ("agent" in "claudeAgent") names the provider unless a model matches it. + const modelTerms = input.searchTerms.filter( + (term) => + !providerWords.has(term) && + (!providerText.includes(term) || visible.some((model) => modelText(model).includes(term))), + ); if (modelTerms.length > 0) { const matches = visible.filter((model) => { - const text = `${model.slug} ${model.name} ${model.subProvider ?? ""}`.toLowerCase(); + const text = modelText(model); return modelTerms.every((term) => text.includes(term)); }); const listed = matches.slice(0, LISTED_MODEL_LIMIT); @@ -517,6 +527,21 @@ function pendingTaskApproval( return approval; } +/** Every task `directAppOwnedChildTasks` can return, so callers load only those. */ +function directChildTaskIds( + target: Pick, + returnedRunIds: ReadonlySet, +): ReadonlyArray { + const ids = new Set(); + if (target.thread.forkedFrom?.type === "node") ids.add(target.thread.forkedFrom.nodeId); + for (const run of target.runs) { + if (returnedRunIds.has(run.id) && run.delegatedTaskId !== undefined) { + ids.add(run.delegatedTaskId); + } + } + return [...ids]; +} + function directAppOwnedChildTasks( parent: Pick, target: Pick, @@ -1036,21 +1061,20 @@ const make = Effect.gen(function* () { ), ); - const loadProjection = (threadId: ThreadId) => + const loadProjection = (threadId: ThreadId, includeUserMessages = true) => threadManagement .getThreadRecords( threadId, [ "runs", "messages", - "subagents", "providerThreads", "providerTurns", "runtimeRequests", "contextTransfers", "turnItems", ], - { turnItemTypes: [], messageRoles: ["user"] }, + { turnItemTypes: [], messageRoles: includeUserMessages ? ["user"] : [] }, ) .pipe( Effect.mapError((error) => @@ -1061,6 +1085,15 @@ const make = Effect.gen(function* () { ), ); + // A dispatcher thread holds thousands of tasks, each with its full prompt; read only the ones named. + const loadTasks = (threadId: ThreadId, taskIds: ReadonlyArray) => + threadManagement.getThreadRecords(threadId, ["subagents"], { subagentIds: taskIds }).pipe( + Effect.map(({ subagents }) => subagents), + Effect.mapError((error) => + failure("orchestration_error", `Unable to read thread ${threadId}: ${errorMessage(error)}`), + ), + ); + // Task waits read on every wake of a parent that may hold thousands of // tasks, so they read only the task they watch and what its status needs. const loadTaskParent = (threadId: ThreadId, taskId: NodeId) => @@ -1100,6 +1133,7 @@ const make = Effect.gen(function* () { const loadCaller = Effect.fn("OrchestratorMcpService.loadCaller")(function* ( scope: McpInvocationScope, + includeUserMessages = true, ) { yield* requireCapability(scope); if (scope.thread === undefined) { @@ -1108,7 +1142,7 @@ const make = Effect.gen(function* () { limits: clientModeCeiling(scope.client), } as const; } - const parent = yield* loadProjection(scope.thread.threadId); + const parent = yield* loadProjection(scope.thread.threadId, includeUserMessages); return { parent, limits: { @@ -1220,7 +1254,8 @@ const make = Effect.gen(function* () { scope: McpInvocationScope, threadId: ThreadId, ) { - const { parent } = yield* loadCaller(scope); + // Caller prompts are not used to read a timeline or acknowledge a child result. + const { parent } = yield* loadCaller(scope, false); const shell = yield* threadManagement .getThreadShell(threadId) .pipe(Effect.mapError(threadManagementFailure)); @@ -1231,13 +1266,19 @@ const make = Effect.gen(function* () { `Thread ${threadId} is no longer available.`, ); } - const target = yield* threadManagement - .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [ - "runs", - "runtimeRequests", - "contextTransfers", - ]) - .pipe(Effect.mapError(threadManagementFailure)); + const input = { projectId: shell.projectId, threadId }; + // Transfers are needed only to acknowledge a caller's child-task result. + const records = + parent === undefined + ? threadManagement + .getProjectThreadRecords(input, ["runs", "runtimeRequests"]) + .pipe(Effect.map((target) => ({ ...target, contextTransfers: [] }))) + : threadManagement.getProjectThreadRecords(input, [ + "runs", + "runtimeRequests", + "contextTransfers", + ]); + const target = yield* records.pipe(Effect.mapError(threadManagementFailure)); return { parent, target, shell } as const; }); @@ -2170,7 +2211,7 @@ const make = Effect.gen(function* () { Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), ); const inherited = parent?.thread.modelSelection; - const searchTerms = input.search?.toLowerCase().split(/\s+/) ?? []; + const searchTerms = input.search?.toLowerCase().split(/\s+/).filter(Boolean) ?? []; return { parentThreadId: parent?.thread.id ?? null, inheritedProviderInstanceId: inherited?.instanceId ?? null, @@ -2440,7 +2481,7 @@ const make = Effect.gen(function* () { const childThreadId = current.childThreadId; if (childThreadId === null) { // The task runs in a linked environment: its thread there is stopped through the link. - const { subagents } = yield* loadProjection(scope.thread.threadId); + const subagents = yield* loadTasks(scope.thread.threadId, [input.taskId]); const remoteTask = subagents.find( (task) => task.id === input.taskId && @@ -2483,7 +2524,7 @@ const make = Effect.gen(function* () { } }); yield* assertStoppable(childThreadId); - const parentTask = parentProjection.subagents.find( + const parentTask = (yield* loadTasks(scope.thread.threadId, [input.taskId])).find( (task) => task.id === input.taskId && task.origin === "app_owned", ); const disposeCompletionDelivery = @@ -2603,9 +2644,11 @@ const make = Effect.gen(function* () { runId: parentTask.runId, subagentId: parentTask.id, }); - const { subagents } = yield* threadManagement.getThreadRecords(scope.thread.threadId, [ - "subagents", - ]); + const { subagents } = yield* threadManagement.getThreadRecords( + scope.thread.threadId, + ["subagents"], + { subagentIds: [input.taskId] }, + ); return subagents.some((task) => task.id === input.taskId && task.status === "cancelled"); }).pipe(Effect.mapError(threadManagementFailure)); if (isTerminalTaskStatus(current.status)) { @@ -2837,7 +2880,11 @@ const make = Effect.gen(function* () { ), ); const nowMs = yield* Clock.currentTimeMillis; - const statuses = input.statuses === undefined ? null : new Set(input.statuses); + // An empty list is no filter: clients that fill every optional field send `[]`. + const statuses = + input.statuses === undefined || input.statuses.length === 0 + ? null + : new Set(input.statuses); const titleContains = input.titleContains?.toLocaleLowerCase(); const filtered = projectThreads .filter( @@ -2902,7 +2949,19 @@ const make = Effect.gen(function* () { page.filter((row) => row.sourceThreadId === target.thread.id).map((row) => row.item.runId), ); const tasks = - parent === undefined ? [] : directAppOwnedChildTasks(parent, target, returnedRunIds); + parent === undefined + ? [] + : directAppOwnedChildTasks( + { + thread: parent.thread, + subagents: yield* loadTasks( + parent.thread.id, + directChildTaskIds(target, returnedRunIds), + ), + }, + target, + returnedRunIds, + ); if ( parent !== undefined && scope.thread !== undefined && @@ -3033,14 +3092,16 @@ const make = Effect.gen(function* () { const ownedTask = result.timedOut && status !== "waiting" && - parent?.subagents.some( + parent !== undefined && + taskId !== undefined && + (yield* loadTasks(parent.thread.id, [taskId])).some( (task) => task.id === taskId && task.origin === "app_owned" && task.threadId === parent.thread.id && task.childThreadId === input.threadId && task.completionDelivery?.state !== "disposed", - ) === true; + ); // A child waiting on a question or approval needs an answer, not a wake. const childRequests = ownedTask ? (yield* threadManagement diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 498289b8f681a..8a67099fc6e0c 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1,3 +1,4 @@ +import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { @@ -22,6 +23,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; import { McpSchema, McpServer } from "effect/ai"; import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; @@ -96,9 +98,7 @@ const readDelegatedTaskStatusTranscript = Effect.fn("readDelegatedTaskStatusTran followupResult: string, ) { const fs = yield* FileSystem.FileSystem; - const text = yield* fs.readFileString( - decodeURIComponent(delegatedTaskStatusTranscriptFile.pathname), - ); + const text = yield* fs.readFileString(NodeURL.fileURLToPath(delegatedTaskStatusTranscriptFile)); const transcript = yield* decodeProviderReplayNdjson( text.replaceAll(queuedFollowupResult, followupResult), ); @@ -316,11 +316,11 @@ describe("orchestrator MCP toolkit", () => { capabilities: new Set(["orchestration"]), issuedAt: 1, }; - const invoke = (name: string, args: Record) => + const invoke = (name: string, args: Record, scope = invocation) => server .callTool({ name, arguments: args }) .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), Effect.provideService(McpSchema.McpServerClient, client), ); @@ -597,11 +597,44 @@ describe("orchestrator MCP toolkit", () => { expect(finalStatus.latestTerminalResultContextTransferId).not.toBe( delegated.resultContextTransferId, ); + const transferReads: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if ( + typeof query === "string" && + /SELECT[\s\S]*FROM orchestration_v2_projection_context_transfers/i.test(query) + ) { + transferReads.push(query); + } + }; + return span; + }, + }); + const parentlessRead = yield* invoke( + "t3_thread_read", + { threadId: delegated.childThreadId, limit: 100 }, + { ...invocation, thread: undefined }, + ).pipe(Effect.withTracer(tracer)); + expect(parentlessRead.isError).not.toBe(true); + expect(transferReads).toHaveLength(0); + const afterParentlessRead = yield* orchestrator.getThreadProjection(parentThreadId); + expect( + afterParentlessRead.subagents.find((task) => task.id === queuedFollowup.taskId) + ?.completionDelivery?.state, + ).toBe("claimed"); + const read = yield* invoke("t3_thread_read", { threadId: delegated.childThreadId, limit: 100, - }); + }).pipe(Effect.withTracer(tracer)); expect(read.isError).not.toBe(true); + expect(transferReads.length).toBeGreaterThan(0); + expect(read.structuredContent).toEqual(parentlessRead.structuredContent); const afterRead = yield* orchestrator.getThreadProjection(parentThreadId); expect( afterRead.subagents.find((task) => task.id === queuedFollowup.taskId) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index f9d618f0520e3..ae54eca6f983e 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -19,6 +19,7 @@ import * as Fiber from "effect/Fiber"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import { PreviewBrowserRootError } from "../preview/PreviewBrowserHost.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); @@ -288,9 +289,22 @@ it.effect("announces a live replacement stream before delivering requests", () = ); it.effect.each([ - { clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, shown: true }, - { clientId: "client-1", shown: false }, -])("tells the agent why its own server browser failed ($clientId)", ({ clientId, shown }) => + { + clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, + shown: true, + message: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/", + }, + { + clientId: "client-1", + shown: false, + message: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/", + }, + { + clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, + shown: true, + message: new PreviewBrowserRootError().message, + }, +])("preserves server browser failure guidance ($clientId)", ({ clientId, shown, message }) => Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; @@ -303,7 +317,7 @@ it.effect.each([ ok: false, error: { _tag: "PreviewAutomationExecutionError", - message: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/", + message, }, }), ).pipe(Effect.forkScoped); @@ -312,7 +326,7 @@ it.effect.each([ .invoke({ scope, operation: "open", input: {} }) .pipe(Effect.flip); expect(error._tag).toBe("PreviewAutomationExecutionError"); - expect(error.message.includes("ERR_CONNECTION_REFUSED")).toBe(shown); + expect(error.message.includes(message)).toBe(shown); }), ), ); diff --git a/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts b/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts new file mode 100644 index 0000000000000..e4176219244cf --- /dev/null +++ b/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts @@ -0,0 +1,353 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; +import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; +import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; + +const driver = ProviderDriverKind.make("claudeAgent"); +const instanceId = ProviderInstanceId.make("claudeAgent"); +const modelSelection = { instanceId, model: "claude-test" }; + +// A delegated child that fails on a usage limit is armed to continue at the reset. +// Once its parent cancels the task, nothing is waiting for that continuation. +it.effect.each([ + { cancel: "after its limit", cancelStatus: "failed", resumed: false }, + { cancel: "as its limit lands", cancelStatus: "cancel_requested", resumed: false }, + { cancel: "never", cancelStatus: null, resumed: true }, +] as const)( + "a delegated child whose task is cancelled $cancel resumes at the reset: $resumed", + ({ cancel, cancelStatus, resumed }) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("task-cancel-usage-limit"); + // Set while the provider's usage-limit result is already on its way. + let limitInFlight: Effect.Effect = Effect.void; + const turns: Array<{ + readonly turn: ProviderAdapter.ProviderAdapterV2TurnInput; + readonly events: Queue.Queue; + }> = []; + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(ClaudeProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Queue.unbounded(); + return { + instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: modelSelection.model, + capabilities: ClaudeProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: `native:${threadId}`, strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + turns.push({ turn, events }); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: null, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.suspend(() => limitInFlight), + respondToRuntimeRequest: () => Effect.die("unused"), + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + const layerAdapters = ProviderAdapterRegistry.layerSingle(adapter); + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "task-cancel-usage-limit" }, + layerAdapters, + { runEffectWorker: false }, + ); + const layerOrchestration = Layer.merge( + layerOrchestrator, + ThreadManagementService.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), + ), + ); + const sweeps = new Map>(); + const layerRecovery = UsageLimitRecoveryWorker.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Scheduler.Scheduler, { + register: (name: string, work: Effect.Effect) => + Effect.gen(function* () { + const context = yield* Effect.context(); + sweeps.set(name, work.pipe(Effect.provideContext(context), Effect.orDie)); + }), + }), + ProjectionStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ServerSettings.layerTest({ autoResumeLimitedThreads: true }), + ), + ), + ); + const layerTest = Layer.merge( + OrchestratorMcpService.layer.pipe( + Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + ), + layerRecovery, + ).pipe( + Layer.provideMerge(layerOrchestration), + Layer.provide( + Layer.mergeAll( + layerAdapters, + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + ServerSettings.layerTest(), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + NodeServices.layer, + ), + ), + ); + + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const sweep = Effect.andThen( + Effect.suspend(() => sweeps.get("usage-limit-recovery")!), + worker.drain(), + ); + const parentThreadId = ThreadId.make("thread:task-cancel-limit-parent"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const turnRunning = () => + watch( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-parent"), + threadId: parentThreadId, + projectId: ProjectId.make("project:task-cancel-limit"), + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const parentRunning = yield* turnRunning(); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-parent"), + threadId: parentThreadId, + messageId: MessageId.make("message:start-parent"), + text: "Delegate a review", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(parentRunning); + + const parentRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; + const childRunning = yield* turnRunning(); + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make("delegate"), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId!, + task: "Review the change", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + completionWake: "always", + createdBy: "agent", + creationSource: "mcp", + }); + yield* worker.drain(); + yield* Fiber.join(childRunning); + const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!; + const childThreadId = task.childThreadId!; + const child = turns.find((entry) => entry.turn.threadId === childThreadId)!; + const childTurn = (yield* orchestrator.getThreadProjection(childThreadId)) + .providerTurns[0]!; + + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:task-cancel-limit"), + requestNamespace: "provider-session:task-cancel-limit", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:task-cancel-limit", + providerInstanceId: instanceId, + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const cancelTask = Effect.gen(function* () { + const result = yield* service.cancelTask(scope, { + taskId: task.id, + clientRequestId: "cancel-task", + }); + yield* worker.drain(); + return result.status; + }); + + const now = yield* DateTime.now; + const childFailed = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === child.turn.runId && + event.payload.status === "failed", + ); + const hitLimit = Effect.forEach( + [ + { + type: "provider_turn.updated", + driver, + providerTurn: { ...childTurn, status: "failed", completedAt: now }, + }, + { + type: "turn.terminal", + driver, + providerThreadId: childTurn.providerThreadId, + providerTurnId: childTurn.id, + runOrdinal: child.turn.runOrdinal, + failureItemOrdinal: 1, + status: "failed", + failure: { + class: "usage_limit", + message: "Claude usage limit reached.", + code: null, + retryable: null, + resetAt: DateTime.formatIso(DateTime.add(now, { minutes: 1 })), + }, + threadDisposition: "reusable", + }, + ] satisfies ReadonlyArray, + (event) => Queue.offer(child.events, event), + { discard: true }, + ); + let cancelled: string | null = null; + if (cancel === "as its limit lands") { + limitInFlight = hitLimit; + cancelled = yield* cancelTask; + } else { + yield* hitLimit; + } + yield* Fiber.join(childFailed); + yield* worker.drain(); + + // The sweep runs every few seconds, so it arms the child before a parent reacts. + yield* sweep; + if (cancel === "after its limit") cancelled = yield* cancelTask; + assert.equal(cancelled, cancelStatus); + + yield* TestClock.adjust("1 minute"); + yield* sweep; + const childAfter = yield* orchestrator.getThreadProjection(childThreadId); + assert.deepEqual( + childAfter.messages.flatMap((message) => + message.role === "user" ? [message.text] : [], + ), + resumed ? ["Review the change", "Continue where you left off."] : ["Review the change"], + ); + }).pipe(Effect.provide(layerTest)); + }), + ), +); diff --git a/apps/server/src/mcp/linkOrigin.test.ts b/apps/server/src/mcp/linkOrigin.test.ts index c17f4ba579dd6..253905e1d6373 100644 --- a/apps/server/src/mcp/linkOrigin.test.ts +++ b/apps/server/src/mcp/linkOrigin.test.ts @@ -31,6 +31,7 @@ import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.t import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -157,6 +158,7 @@ const layerTools = Layer.mergeAll( }), }), ), + Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 93aee4d52f688..2e2bb4e7c76c0 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -955,3 +955,56 @@ it.effect("only the caller that prepared a pending upload can discard it", () => ), ), ); + +it.effect( + "t3_thread_list settled:false with an empty statuses list lists the unsettled threads", + () => { + const unsettled = McpToolAccessTestkit.liveThreadShell(ThreadId.make("unsettled-thread")); + const settled = { + ...McpToolAccessTestkit.liveThreadShell(ThreadId.make("settled-thread")), + settledOverride: "settled", + } as const; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: "t3_thread_list", + arguments: { projectId: unsettled.projectId, settled: false, statuses: [] }, + }) + .pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + clientScope("full-access"), + ), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + threads: [{ threadId: unsettled.id, settled: false }], + total: 1, + }); + }).pipe( + Effect.provide( + layerOrchestratorToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + listProjectThreads: () => Effect.succeed([unsettled, settled]), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})), + Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), + ), + ), + ); + }, +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 03b97c32af6db..748234387cf48 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -28,6 +28,7 @@ import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementSer import * as ServerConfig from "../../../config.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as Project from "../../../project/ProjectService.ts"; +import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; @@ -243,6 +244,62 @@ it.effect( }), ); +it.effect("a launch steps an inherited mode down to the provider's, and refuses one it lacks", () => + Effect.gen(function* () { + // Pi offers no Auto; a mode a provider lacks would run as Supervised. + const pi = ProviderInstanceId.make("pi"); + const launchPi = Effect.fn("test.launchPi")(function* (runtimeMode?: RuntimeMode) { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + providers: [ + { + instanceId: pi, + driver: ProviderDriverKind.make("pi"), + enabled: true, + installed: true, + status: "ready", + version: null, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], + }, + ], + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(dependencies), + ), + ), + ); + const result = yield* toolkit + .handle("t3_thread_launch", { + projectId, + title: "Fix", + modelSelection: { instanceId: pi, model: "pi-model" }, + ...(runtimeMode === undefined ? {} : { runtimeMode }), + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + return { result: result.at(-1)?.result, launched }; + }); + + const inherited = yield* launchPi(); + expect(inherited.launched[0]?.runtimeMode).toBe("auto-accept-edits"); + + const requested = yield* launchPi("auto"); + expect(requested.result).toMatchObject({ code: "invalid_request" }); + expect(requested.launched).toHaveLength(0); + + const offered = yield* launchPi("approval-required"); + expect(offered.launched[0]?.runtimeMode).toBe("approval-required"); + }), +); + it.effect("a launch retried with the same clientRequestId replays the first one", () => Effect.gen(function* () { const launched: Array = []; @@ -531,3 +588,47 @@ it.effect("a client launches with the fallback for an exhausted default", () => expect(launched[0]?.modelSelection).toEqual(fallbackModel); }), ); + +it.effect("deleting a project through MCP also discards its clone record", () => + Effect.gen(function* () { + const discarded: Array = []; + const harness = clientLaunchHarness({ runtimeModeCeiling: "full-access", launched: [] }); + const dependencies = Layer.merge( + harness.dependencies, + Layer.mergeAll( + Layer.mock(Project.ProjectService)({ + delete: (input) => + Effect.succeed({ + id: input.projectId, + title: "client-target", + workspaceRoot: "/projects/client-target", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + projectIcon: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: "2026-01-01T00:00:00.000Z", + }), + }), + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ + discard: (projectId) => Effect.sync(() => void discarded.push(projectId)), + }), + ), + ); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + + yield* toolkit + .handle("t3_project_delete", { projectId: harness.projectId }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + expect(discarded).toEqual([harness.projectId]); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 014b109448f34..7253bcedb0068 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -14,6 +14,8 @@ import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts" import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; +import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts"; +import { mutateProjectWithClone } from "../../../project/ProjectMutation.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; @@ -202,12 +204,21 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { message: "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", }); - const startedRuntimeMode = + let startedRuntimeMode = projectDefaults !== undefined && input.runtimeMode === undefined ? yield* resolveRuntimeMode(runtimeMode, projectDefaults.defaultRuntimeMode).pipe( Effect.orElseSucceed(() => runtimeMode), ) : runtimeMode; + if (!replaying) { + const providers = yield* (yield* ProviderRegistry.ProviderRegistry).getProviders; + startedRuntimeMode = yield* resolveRuntimeMode(startedRuntimeMode, input.runtimeMode, { + modelSelection, + supportedRuntimeModes: providers.find( + (provider) => provider.instanceId === modelSelection.instanceId, + )?.supportedRuntimeModes, + }); + } const result = yield* ThreadMessageIntake.launchThread({ commandId, threadId, @@ -343,9 +354,12 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { t3_project_delete: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { const projects = yield* Project.ProjectService; - return yield* projects - .delete({ ...input, commandId: yield* newCommandId() }) - .pipe(Effect.mapError(projectFailure)); + const clones = yield* ProjectCloneTracker.ProjectCloneTracker; + return yield* mutateProjectWithClone(projects, clones, { + ...input, + type: "project.delete", + commandId: yield* newCommandId(), + }).pipe(Effect.mapError(projectFailure)); }), ), t3_project_clone: McpToolAccess.writesEnvironment( diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 3ea5a8ea9e48f..bd7c986f4c48a 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -30,6 +30,7 @@ import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; import * as ProjectService from "../../../project/ProjectService.ts"; +import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; @@ -101,6 +102,7 @@ const ProjectDeleteTool = Tool.make("t3_project_delete", { projectId: ProjectId, force: Schema.optionalKey(Schema.Boolean), }), + dependencies: [...shared.dependencies, ProjectCloneTracker.ProjectCloneTracker], }).annotate(Tool.Destructive, true); const ProjectCloneTool = Tool.make("t3_project_clone", { ...shared, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index b4c38c31a2887..ecba555011c26 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -33,6 +33,7 @@ const RPC_AGGREGATES = { [WS_METHODS.projectsMutate]: "orchestration", [WS_METHODS.serverProbe]: "server", [WS_METHODS.serverGetConfig]: "server", + [WS_METHODS.serverGetNpmRelease]: "server", [WS_METHODS.serverRefreshProviders]: "server", [WS_METHODS.serverUpdateProvider]: "server", [WS_METHODS.providerAuthStart]: "provider", @@ -145,6 +146,7 @@ const RPC_AGGREGATES = { [WS_METHODS.sourceControlSignInWait]: "source-control", [WS_METHODS.sourceControlSignInCancel]: "source-control", [WS_METHODS.sourceControlAppAccess]: "source-control", + [WS_METHODS.sourceControlStarred]: "source-control", [WS_METHODS.projectCloneStart]: "source-control", [WS_METHODS.projectCloneCancel]: "source-control", [WS_METHODS.projectCloneRetry]: "source-control", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index b924f6efb3e70..bc2314201cca6 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -10,6 +10,7 @@ import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { AcpRegistryCatalog } from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import { decodeAcpReplayTranscript, makeAcpReplayRuntime } from "./AcpAdapterV2.testkit.ts"; // @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -76,6 +77,7 @@ import { makeTurnInput } from "./AcpAdapterV2.testkit.ts"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { AcpProviderCapabilitiesV2, + acpPerTurnPromptUsage, acpScopedNativeId, acpPostSettleContinuationOfferEvidence, acpPostSettleWakeEvidence, @@ -110,6 +112,124 @@ const layerTest = Layer.mergeAll( const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +describe("acpPerTurnPromptUsage", () => { + it("maps a per-turn sample straight onto TurnTokenUsage", () => { + assert.deepEqual( + acpPerTurnPromptUsage( + { + inputTokens: 2453, + outputTokens: 4, + totalTokens: 2457, + cachedReadTokens: 512, + cachedWriteTokens: 32, + thoughtTokens: 3, + }, + false, + "completed", + ), + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 2453, + outputTokens: 4, + cachedInputTokens: 512, + cacheCreationTokens: 32, + reasoningTokens: 3, + }, + ); + }); + + it("clamps subsets that exceed their parents and drops unset counters", () => { + assert.deepEqual( + acpPerTurnPromptUsage( + { + inputTokens: 100, + outputTokens: 10, + totalTokens: 110, + cachedReadTokens: 150, + cachedWriteTokens: 120, + thoughtTokens: 30, + }, + false, + "completed", + ), + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 100, + outputTokens: 10, + cachedInputTokens: 100, + cacheCreationTokens: 0, + reasoningTokens: 10, + }, + ); + assert.deepEqual( + acpPerTurnPromptUsage( + { inputTokens: 100, outputTokens: 10, totalTokens: 110 }, + false, + "completed", + ), + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 100, + outputTokens: 10, + }, + ); + }); + + it("caps cache writes at the input left over after cache reads", () => { + // Each counter fits under input alone, but together they exceed it. + assert.deepEqual( + acpPerTurnPromptUsage( + { + inputTokens: 100, + outputTokens: 10, + totalTokens: 110, + cachedReadTokens: 80, + cachedWriteTokens: 80, + }, + false, + "completed", + ), + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 100, + outputTokens: 10, + cachedInputTokens: 80, + cacheCreationTokens: 20, + }, + ); + }); + + it("marks present usage partial on a non-completed terminal and missing usage unavailable", () => { + assert.deepEqual( + acpPerTurnPromptUsage( + { inputTokens: 100, outputTokens: 10, totalTokens: 110 }, + true, + "interrupted", + ), + { + usageScope: "main_agent", + usageStatus: "partial", + hasSubagents: true, + inputTokens: 100, + outputTokens: 10, + }, + ); + assert.deepEqual(acpPerTurnPromptUsage(undefined, false, "completed"), { + usageScope: "main_agent", + usageStatus: "unavailable", + hasSubagents: false, + }); + }); +}); + describe("acpToolCallDiffPatch", () => { it("builds per-file patches from ACP hunks and recorded old/new text", () => { for (const operation of ["add", "delete"]) { @@ -308,117 +428,807 @@ function rawProtocolPromptTextBlocks( ? [{ type: "text" as const, text: Reflect.get(block, "text") as string }] : [], ); -} - -const pollProtocolMethods = (events: Queue.Queue) => - Effect.gen(function* () { - const methods: string[] = []; - let polled = 0; - let event = yield* Queue.poll(events); - while (Option.isSome(event) && polled < 256) { - polled += 1; - const method = rawProtocolMethod(event.value); - if (method !== undefined) methods.push(method); - event = yield* Queue.poll(events); - } - return methods; - }); +} + +const pollProtocolMethods = (events: Queue.Queue) => + Effect.gen(function* () { + const methods: string[] = []; + let polled = 0; + let event = yield* Queue.poll(events); + while (Option.isSome(event) && polled < 256) { + polled += 1; + const method = rawProtocolMethod(event.value); + if (method !== undefined) methods.push(method); + event = yield* Queue.poll(events); + } + return methods; + }); + +const makeInstructionCompactionHarness = Effect.fnUntraced(function* ( + environment: Readonly>, +) { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.unbounded(); + const instanceId = ProviderInstanceId.make("acp-test-instruction-compaction"); + const threadId = ThreadId.make("thread-acp-instruction-compaction"); + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("environment-acp-instruction-compaction"), + threadId, + providerSessionId: "mcp-session-acp-instruction-compaction", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer instruction-compaction-token", + browserToolsAvailable: true, + }); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + supportsCompaction: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + protocolEvents, + environment, + wrapRuntime: (runtime) => ({ + ...runtime, + // Make compaction during prompt completion deterministic at the adapter boundary. + prompt: (payload, options) => + runtime.prompt(payload, options).pipe(Effect.tap(() => runtime.drainEvents)), + }), + }), + }, + selfInvocation, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-instruction-compaction"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + const now = yield* DateTime.now; + let ordinal = 0; + return Effect.fnUntraced(function* (messageText: string, expectedStatus = "completed") { + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal: ++ordinal, + messageText, + }), + ); + const terminal = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.equal(Option.getOrThrow(terminal).status, expectedStatus); + while (true) { + const event = yield* Queue.take(protocolEvents); + if (event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt") { + return rawProtocolPromptTextBlocks(event); + } + } + }); +}); + +function assertReinjectedInstructions( + blocks: Array<{ type: "text"; text: string }>, + request: string, +) { + const text = blocks[0]?.text ?? ""; + assert.include(text, "T3 Code interaction mode: Default"); + assert.include(text, "## T3 Code browser"); + assert.include(text, "T3 Code orchestration"); + assert.include(text, `\n${request}\n`); +} + +describe("AcpAdapterV2", () => { + it.live("reports per-turn usage unavailable on a continuation turn that never prompts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-usage-continuation"); + const threadId = ThreadId.make("thread-acp-usage-continuation"); + let promptOrdinal = 0; + const promptUsages = [ + { inputTokens: 60, outputTokens: 15, totalTokens: 75 }, + { inputTokens: 130, outputTokens: 30, totalTokens: 160 }, + ]; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + continuationRequests: { offer: () => Effect.void }, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + enablePostSettleContinuation: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => { + promptOrdinal += 1; + const usage = promptUsages[promptOrdinal - 1]; + if (usage === undefined) return Effect.die("Unexpected prompt"); + return Effect.succeed({ stopReason: "end_turn" as const, usage }); + }, + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-usage-continuation"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + const completedUsage = (ordinal: number) => + Effect.gen(function* () { + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const completed = events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + return completed?.type === "provider_turn.updated" + ? completed.providerTurn.turnTokenUsage + : null; + }); + assert.deepEqual(yield* completedUsage(1), { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 60, + outputTokens: 15, + }); + // Continuation attach: drains wake traffic and finalizes without ever + // dispatching session/prompt, so it must report unavailable usage. + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + messageText: "Background task completed.", + }), + ); + const continuationEvents = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const continuation = continuationEvents.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + assert.deepEqual( + continuation?.type === "provider_turn.updated" + ? continuation.providerTurn.turnTokenUsage + : null, + { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents: false }, + ); + // The continuation must not disturb the next real prompt's sample. + assert.deepEqual(yield* completedUsage(3), { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 130, + outputTokens: 30, + }); + assert.equal(promptOrdinal, 2, "continuation turn must not dispatch session/prompt"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.live("retains returned usage when Stop finalizes before the prompt callback", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-prompt-usage-stop"); + const threadId = ThreadId.make("thread-acp-prompt-usage-stop"); + const wireSettled = yield* Deferred.make(); + const releaseCompletion = yield* Deferred.make(); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + testHooks: { + afterPromptWireSettled: () => + Deferred.succeed(wireSettled, undefined).pipe( + Effect.andThen(Deferred.await(releaseCompletion)), + ), + }, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + preserveRuntimeOnSettledInterrupt: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => + Effect.succeed({ + stopReason: "end_turn" as const, + usage: { inputTokens: 60, outputTokens: 15, totalTokens: 75 }, + }), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-prompt-usage-stop"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + yield* Deferred.await(wireSettled); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + yield* runtime.interruptTurn({ providerThread, providerTurnId }); + const completed = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ).find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "interrupted", + ); + assert.deepEqual( + completed?.type === "provider_turn.updated" ? completed.providerTurn.turnTokenUsage : null, + { + usageScope: "main_agent", + usageStatus: "partial", + hasSubagents: false, + inputTokens: 60, + outputTokens: 15, + }, + ); + yield* Deferred.succeed(releaseCompletion, undefined); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("drops a late prompt response that lands after the turn finalized", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-prompt-usage-late"); + const threadId = ThreadId.make("thread-acp-prompt-usage-late"); + const dispatched = yield* Deferred.make(); + const gate = yield* Deferred.make(); + const wireSettled = yield* Deferred.make(); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + testHooks: { + afterPromptWireSettled: () => + Deferred.succeed(wireSettled, undefined).pipe(Effect.asVoid), + }, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + terminateRuntimeProcessGroupOnInterrupt: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + ownDetachedProcessGroup: true, + processGroupTerminationGrace: 0, + wrapRuntime: (runtime) => ({ + ...runtime, + // The response only leaves the agent after the turn has been + // force stopped and finalized: teardown does not interrupt this fiber. + prompt: () => + Deferred.succeed(dispatched, undefined).pipe( + Effect.andThen(Deferred.await(gate)), + Effect.andThen( + Effect.succeed({ + stopReason: "cancelled" as const, + usage: { inputTokens: 60, outputTokens: 15, totalTokens: 75 }, + }), + ), + ), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-prompt-usage-late"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + let stopCollector = false; + const events: Array> = []; + const usageRecorded = yield* Deferred.make(); + const collector = yield* Stream.runForEach( + runtime.events.pipe(Stream.takeUntil(() => stopCollector)), + (event) => + Effect.suspend(() => { + const record = event as Record; + events.push(record); + return record.type === "provider_turn.updated" && + (record.providerTurn as Record).turnTokenUsage !== undefined + ? Deferred.succeed(usageRecorded, undefined) + : Effect.void; + }), + ).pipe(Effect.forkScoped); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + messageText: "Say hi.", + }), + ); + yield* Deferred.await(dispatched); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + // Force stop finalizes the turn without waiting for the gated prompt response. + yield* runtime.interruptTurn({ + providerThread, + providerTurnId, + requestRuntimeRestart: true, + }); + yield* Deferred.await(usageRecorded); + const usageAt = () => { + const turns = events.filter( + (event) => + event.type === "provider_turn.updated" && + (event.providerTurn as Record).turnTokenUsage !== undefined, + ); + const last = turns[turns.length - 1] as + | { providerTurn: Record } + | undefined; + return last?.providerTurn.turnTokenUsage as Record; + }; + assert.deepEqual(usageAt(), { + usageScope: "main_agent", + usageStatus: "unavailable", + hasSubagents: false, + }); + // The response lands now; the finalized turn must not adopt its counters. + yield* Deferred.succeed(gate, undefined); + yield* Deferred.await(wireSettled); + assert.deepEqual(usageAt(), { + usageScope: "main_agent", + usageStatus: "unavailable", + hasSubagents: false, + }); + stopCollector = true; + yield* Fiber.interrupt(collector); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("passes per-turn usage samples through", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-per-turn-usage"); + const threadId = ThreadId.make("thread-acp-per-turn-usage"); + let promptOrdinal = 0; + // Fixture shaped after the live glm-acp-agent probe: three sequential + // prompts whose inputTokens grow by tens, not by a context per turn. + // GLM never sends cachedWriteTokens; those values are added here to + // exercise the clamp path. + const promptUsages = [ + { + inputTokens: 2432, + outputTokens: 3, + totalTokens: 2435, + cachedReadTokens: 1024, + cachedWriteTokens: 64, + thoughtTokens: 2, + }, + { + inputTokens: 2453, + outputTokens: 4, + totalTokens: 2457, + cachedReadTokens: 1045, + cachedWriteTokens: 85, + thoughtTokens: 3, + }, + { + inputTokens: 2475, + outputTokens: 25, + totalTokens: 2500, + cachedReadTokens: 1067, + cachedWriteTokens: 107, + thoughtTokens: 20, + }, + ]; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => { + const usage = promptUsages[promptOrdinal++]; + if (usage === undefined) return Effect.die("Unexpected prompt"); + return Effect.succeed({ stopReason: "end_turn" as const, usage }); + }, + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-per-turn-usage"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + for (const [index, sample] of promptUsages.entries()) { + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal: index + 1, + }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const completed = events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + // Per-turn: each response's own sample maps straight onto its turn. + assert.deepEqual( + completed?.type === "provider_turn.updated" + ? completed.providerTurn.turnTokenUsage + : null, + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: sample.inputTokens, + outputTokens: sample.outputTokens, + cachedInputTokens: sample.cachedReadTokens, + cacheCreationTokens: sample.cachedWriteTokens, + reasoningTokens: sample.thoughtTokens, + }, + ); + } + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("clamps per-turn subsets that exceed their parent counters", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-per-turn-clamp"); + const threadId = ThreadId.make("thread-acp-per-turn-clamp"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => + Effect.succeed({ + stopReason: "end_turn" as const, + usage: { + inputTokens: 100, + outputTokens: 10, + totalTokens: 110, + cachedReadTokens: 150, + cachedWriteTokens: 120, + thoughtTokens: 30, + }, + }), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-per-turn-clamp"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const completed = events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + // TurnTokenUsage contract: input includes cache read + write and + // reasoning is a subset of output. Reads clamp to input, writes clamp + // to what reads leave of it (100 - 100 = 0 here). + assert.deepEqual( + completed?.type === "provider_turn.updated" ? completed.providerTurn.turnTokenUsage : null, + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 100, + outputTokens: 10, + cachedInputTokens: 100, + cacheCreationTokens: 0, + reasoningTokens: 10, + }, + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); -const makeInstructionCompactionHarness = Effect.fnUntraced(function* ( - environment: Readonly>, -) { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.unbounded(); - const instanceId = ProviderInstanceId.make("acp-test-instruction-compaction"); - const threadId = ThreadId.make("thread-acp-instruction-compaction"); - yield* (yield* McpProviderSessions.McpProviderSessions).set({ - environmentId: EnvironmentId.make("environment-acp-instruction-compaction"), - threadId, - providerSessionId: "mcp-session-acp-instruction-compaction", - providerInstanceId: instanceId, - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer instruction-compaction-token", - browserToolsAvailable: true, - }); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - supportsCompaction: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - protocolEvents, - environment, - wrapRuntime: (runtime) => ({ - ...runtime, - // Make compaction during prompt completion deterministic at the adapter boundary. - prompt: (payload, options) => - runtime.prompt(payload, options).pipe(Effect.tap(() => runtime.drainEvents)), - }), - }), - }, - selfInvocation, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-instruction-compaction"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); - const now = yield* DateTime.now; - let ordinal = 0; - return Effect.fnUntraced(function* (messageText: string, expectedStatus = "completed") { - yield* runtime.startTurn( - makeTurnInput({ + it.live("reports per-turn usage unavailable when the agent omits usage", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-per-turn-missing"); + const threadId = ThreadId.make("thread-acp-per-turn-missing"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => Effect.succeed({ stopReason: "end_turn" as const }), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ threadId, - providerThread, + providerSessionId: ProviderSessionId.make("provider-session-acp-per-turn-missing"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const completed = events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + assert.deepEqual( + completed?.type === "provider_turn.updated" ? completed.providerTurn.turnTokenUsage : null, + { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents: false }, + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("keeps usage unavailable for undeclared agents even when they report usage", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-usage-undeclared"); + const threadId = ThreadId.make("thread-acp-usage-undeclared"); + const adapter = yield* makeAcpAdapterV2({ instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => + Effect.succeed({ + stopReason: "end_turn" as const, + usage: { inputTokens: 60, outputTokens: 15, totalTokens: 75 }, + }), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-usage-undeclared"), + modelSelection, runtimePolicy, - now, - ordinal: ++ordinal, - messageText, - }), - ); - const terminal = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, - ); - assert.equal(Option.getOrThrow(terminal).status, expectedStatus); - while (true) { - const event = yield* Queue.take(protocolEvents); - if (event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt") { - return rawProtocolPromptTextBlocks(event); + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + for (const ordinal of [1, 2]) { + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const completed = events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + // Usage semantics are unverified for an undeclared agent, so its + // counters are never trusted, on any turn. + assert.deepEqual( + completed?.type === "provider_turn.updated" + ? completed.providerTurn.turnTokenUsage + : null, + { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents: false }, + ); } - } - }); -}); - -function assertReinjectedInstructions( - blocks: Array<{ type: "text"; text: string }>, - request: string, -) { - const text = blocks[0]?.text ?? ""; - assert.include(text, "T3 Code interaction mode: Default"); - assert.include(text, "## T3 Code browser"); - assert.include(text, "T3 Code orchestration"); - assert.include(text, `\n${request}\n`); -} + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); -describe("AcpAdapterV2", () => { it.live.each(["details", "none"] as const)( "fails the turn on an ACP v2 idle error with %s, settles its open tool, and accepts the next prompt", (details) => @@ -2697,6 +3507,105 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.live("does not poll the process ledger while a cgroup lease owns teardown", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) !== "linux") return; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + // A directory stands in for cgroupfs: the join wrapper appends the agent + // PID to cgroup.procs and stays in this process's cgroup, which is the + // cgroup the lease reports. + const leasePath = yield* fileSystem.makeTempDirectoryScoped(); + const procsPath = path.join(leasePath, "cgroup.procs"); + yield* fileSystem.writeFileString(procsPath, ""); + const relativePath = AcpSessionRuntime.parseUnifiedCgroupPath( + NodeFS.readFileSync("/proc/self/cgroup", "utf8"), + ); + // No unified cgroup on this host means no lease is possible, so there is + // nothing for this test to exercise. + if (relativePath === undefined) return; + const processTree = AcpSessionRuntime.makePosixProcessTreeController("linux"); + const leasePids = () => + NodeFS.readFileSync(procsPath, "utf8").split(/\s+/).filter(Boolean).map(Number); + const isLive = (pid: number) => { + const identity = processTree.identity(pid); + return identity !== undefined && !AcpSessionRuntime.posixProcessIsZombie(identity); + }; + let leaseExists = true; + const linuxCgroupController: AcpSessionRuntime.AcpLinuxCgroupController = { + create: () => ({ + contains: (pid) => leasePids().includes(pid), + exists: () => leaseExists, + path: leasePath, + relativePath, + kill: () => { + for (const pid of leasePids().filter(isLive)) process.kill(pid, "SIGKILL"); + }, + populated: () => leasePids().some(isLive), + remove: () => { + leaseExists = false; + }, + }), + }; + let childListReads = 0; + const posixProcessTreeController: AcpSessionRuntime.AcpPosixProcessTreeController = { + ...processTree, + childPidsOf: (pid) => { + childListReads += 1; + return processTree.childPidsOf(pid); + }, + }; + const instanceId = ProviderInstanceId.make("acp-test-cgroup-ledger-idle"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + linuxCgroupController, + posixProcessTreeController, + mockAgentPath, + ownDescendantProcessGroups: true, + ownDetachedProcessGroup: true, + }), + }, + selfInvocation, + }); + const threadId = ThreadId.make("thread-acp-cgroup-ledger-idle"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const sessionScope = yield* Scope.fork(yield* Scope.Scope); + const runtime = yield* adapter + .openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-cgroup-ledger-idle"), + modelSelection, + runtimePolicy, + }) + .pipe(Effect.provideService(Scope.Scope, sessionScope)); + yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + // The agent's children are listed once, to capture the root it may + // signal at teardown, and never again while the session is open. + assert.equal(childListReads, 1); + + const agentPid = leasePids()[0]; + assert.isDefined(agentPid); + yield* Scope.close(sessionScope, Exit.void); + assert.isFalse(isLive(agentPid!), "closing the session must still tear the agent down"); + assert.isFalse(leaseExists, "closing the session must remove the lease"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("negotiates and executes optional native session forks through the ACP runtime", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index e086f8e2d1a7c..7cfd1973ebed5 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -28,6 +28,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { @@ -41,6 +42,8 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { t3AcpPromptWithInstructions } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { decodeAcpReplayTranscript, @@ -466,6 +469,7 @@ describe("AcpRegistryAdapterV2", () => { readonly storedModel?: string; readonly storedModePick?: string; readonly turnModels?: ReadonlyArray; + readonly rejectedModel?: string; }) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; // Scope release gives the replay agent at most a second to exit, so the status @@ -603,7 +607,19 @@ describe("AcpRegistryAdapterV2", () => { configurations.push(published); current = target; } - }).pipe(Effect.scoped); + }).pipe( + Effect.match({ + onFailure: (error) => { + assert.isDefined(input.rejectedModel); + assert.equal( + makeProviderFailure({ cause: error }).message, + `The agent did not accept the model "${input.rejectedModel}". Choose another model for this provider, then retry.`, + ); + }, + onSuccess: () => assert.isUndefined(input.rejectedModel), + }), + Effect.scoped, + ); yield* Effect.all(agentExits, { discard: true }); yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript); return { configurations }; @@ -770,8 +786,70 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect.each([ + "config", + "legacy", + "updated-config", + "removed-config", + "configuration-update", + "unavailable-before-configuration", + ] as const)( + "rejects an unavailable explicit model in a %s catalog before inference", + (catalog) => { + const duringTurn = catalog === "updated-config" || catalog === "removed-config"; + const duringConfiguration = catalog === "configuration-update"; + const changesOptions = + duringConfiguration || catalog === "unavailable-before-configuration"; + const model = duringTurn || duringConfiguration ? "hermes" : "missing"; + const restrictedOption = { + ...modelOption("model", "other"), + options: [{ value: "other", name: "Other" }], + }; + return openWithStoredPicks({ + setup: { + ...(catalog === "legacy" || catalog === "removed-config" + ? { + models: { + currentModelId: "other", + availableModels: [{ modelId: "other", name: "Other" }], + }, + } + : {}), + configOptions: + catalog === "legacy" + ? [] + : [ + { + ...modelOption("model", "hermes"), + options: [{ value: "hermes", name: "Hermes" }], + }, + ...(changesOptions ? [permissionModeOption("ask")] : []), + ], + }, + storedModel: model, + ...(duringTurn ? { turnModels: [model, model] } : {}), + ...(changesOptions ? { storedModePick: "auto" } : {}), + rejectedModel: model, + pickFrames: duringTurn + ? prompt(configOptionUpdate(catalog === "removed-config" ? [] : [restrictedOption])) + : duringConfiguration + ? [ + outbound("session/set_config_option", { + sessionId: "agent-session", + configId: "permission-mode", + value: "auto", + }), + answer("session/set_config_option", { + configOptions: [restrictedOption, permissionModeOption("auto")], + }), + ] + : [], + }).pipe(Effect.provide(layerTest), Effect.scoped); + }, + ); + // regression: #19519 models come from session model state, not a hardcoded list - it.effect("leaves unadvertised model IDs alone in both model selection protocols", () => + it.effect("keeps unadvertised default and auto selections agent-controlled", () => Effect.gen(function* () { const { configurations } = yield* openWithStoredPicks({ setup: { @@ -781,15 +859,7 @@ describe("AcpRegistryAdapterV2", () => { }, }, storedModel: "default", - turnModels: [ - "auto", - "removed-model", - "other", - "default", - "auto", - "hermes", - "removed-model", - ], + turnModels: ["auto", "default", "other", "default", "auto", "default", "auto"], pickFrames: [ ...prompt(), ...prompt(), @@ -1043,6 +1113,98 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect.each(["before", "after"] as const)( + "reconciles a full prompt echo %s acknowledgment", + (ordering) => + Effect.gen(function* () { + const runtimeInstructions = { + type: "text", + text: buildRuntimeInstructions({ harness: "acpRegistry", model: "default" }), + }; + const promptBlocks = (ordinal: number) => [ + { + type: "text", + text: + ordinal === 1 + ? t3AcpPromptWithInstructions({ + prompt: "test prompt", + state: { interactionMode: "default", hasT3Mcp: false, hasDeviceTools: false }, + }) + : "test prompt", + }, + runtimeInstructions, + ]; + const echo = (messageId: string, content: ReadonlyArray) => + claudeFrames.update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "user_message", + messageId, + content, + }); + const promptText = (ordinal: number) => + promptBlocks(ordinal) + .map((block) => block.text) + .join("\n"); + const frames = (ordinal: number) => { + const [request, , acknowledgment] = claudeFrames.prompt(ordinal); + const matching = echo(`echo-${ordinal}`, promptBlocks(ordinal)); + return [ + request, + echo("different-blocks", [ + { + type: "text", + text: promptText(ordinal), + }, + ]), + ...(ordering === "before" ? [matching, acknowledgment] : [acknowledgment, matching]), + claudeFrames.update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "user_message_chunk", + messageId: `echo-${ordinal}`, + content: { type: "text", text: "updated echo" }, + }), + echo(`echo-${ordinal}`, [{ type: "text", text: "updated echo" }]), + echo("second-id", promptBlocks(ordinal)), + echo("unmatched", [{ type: "text", text: "Continue the review." }]), + claudeFrames.idle("end_turn"), + ]; + }; + const session = yield* openClaudeReplaySession( + [ + ...claudeFrames.open, + ...frames(1), + ...frames(2), + claudeFrames.closeRequest, + claudeFrames.closed, + ], + undefined, + false, + ); + for (const ordinal of [1, 2]) { + yield* session.runtime.startTurn(session.turnInput(ordinal)); + const events = yield* session.until((event) => event.type === "turn.terminal"); + const messages = events.flatMap((event) => + event.type === "message.updated" && event.message.role === "user" + ? [event.message] + : [], + ); + const items = events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_message" + ? [event.turnItem] + : [], + ); + const expected = new Set([promptText(ordinal), "Continue the review."]); + assert.deepEqual(new Set(messages.map((message) => message.text)), expected); + assert.equal(new Set(messages.map((message) => message.id)).size, 3); + assert.equal(new Set(items.map((item) => item.id)).size, 3); + for (const id of ["different-blocks", "second-id", "unmatched"]) { + assert.isTrue( + items.some((item) => item.nativeItemRef?.nativeId?.endsWith(`:user:message:${id}`)), + `${id} remains visible`, + ); + } + } + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("settles Claude's accepted foreground reply while retaining child output", () => Effect.gen(function* () { const gate = makeProviderReplayGate(["accepted", "child-output", "wake"]); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 0f5670f1d2de0..fd5951a148ba9 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -128,6 +128,49 @@ const layerSession = Layer.mergeAll( TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), ); +describe("AntigravityAdapterV2 compaction", () => { + it.effect("does not expose unsupported manual compaction on the provider session", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("antigravity-compaction-test"); + const adapter = yield* makeAntigravityAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeAntigravityAcpRuntime({ + ...input, + childProcessSpawner, + spawn: { + command: process.execPath, + args: [mockAgentPath], + cwd: input.cwd, + env: { T3_ACP_ANTIGRAVITY: "1" }, + }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)), + withProcess: (_stop, task) => task, + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + defaultModel: Effect.succeed(undefined), + }); + const session = yield* adapter.openSession({ + threadId: ThreadId.make("thread-antigravity-compaction"), + providerSessionId: ProviderSessionId.make("provider-session-antigravity-compaction"), + modelSelection: { instanceId, model: "gemini-test-low" }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }), + }); + assert.isUndefined(session.compactThread); + }).pipe(Effect.provide(layerSession), Effect.scoped), + ); +}); + describe("AntigravityAdapterV2 session open", () => { it.effect.each([ { @@ -289,6 +332,25 @@ describe("AntigravityAdapterV2 client file system", () => { context("fs/read_text_file"), ); assert.equal(pasted.content, "pasted"); + yield* fileSystem.writeFileString(insidePath, "\uFEFFfirst\né中🙂\nlast"); + const sliced = yield* readTextFile( + { sessionId: "mock-session-1", path: insidePath, line: 2, limit: 1 }, + context("fs/read_text_file"), + ); + assert.equal(sliced.content, "é中🙂"); + yield* fileSystem.writeFileString(insidePath, "inside"); + for (const [name, bytes] of [ + ["image.png", new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], + ["data.bin", new Uint8Array([0x61, 0, 0x62])], + ] as const) { + const binaryPath = path.join(workspace, name); + yield* fileSystem.writeFile(binaryPath, bytes); + const binaryRead = yield* readTextFile( + { sessionId: "mock-session-1", path: binaryPath }, + context("fs/read_text_file"), + ).pipe(Effect.flip); + assert.propertyVal(binaryRead, "code", -32602, `${name} is refused as binary`); + } const outsideRead = yield* readTextFile( { sessionId: "mock-session-1", path: outsideFile }, diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 9ccaca83b542f..bc842d2ec9724 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -238,7 +238,8 @@ export function makeAntigravityAcpAdapterFlavor( normalizeToolCall: normalizeAntigravityToolCall, extractSubagentUpdate: extractAntigravitySubagentUpdate, supportsImagePrompts: true, - supportsCompaction: true, + // ACP 1.3.0 treats /compact as ordinary model input. + supportsCompaction: false, requiresSignIn: isAntigravitySignInRequiredError, }; } diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 40f33b3a1a309..d2d41831f8c13 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8,6 +8,7 @@ import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import { beforeEach, vi } from "vite-plus/test"; import type { + ElicitationRequest, Query as ClaudeQuery, SDKMessage, SDKResultMessage, @@ -1187,6 +1188,82 @@ describe("ClaudeAdapterV2 approval cancellation", () => { ); }); +describe("ClaudeAdapterV2 MCP elicitation acceptance", () => { + const form = ( + properties: Record, + required?: ReadonlyArray, + ): ElicitationRequest => ({ + serverName: "deploys", + message: "Deploy web?", + mode: "form", + requestedSchema: { type: "object", properties, ...(required ? { required } : {}) }, + }); + + it("answers an empty-properties form with empty content", () => { + assert.deepEqual(ClaudeAdapterV2.claudeMcpElicitationAcceptance(form({})), { + action: "accept", + content: {}, + }); + }); + + it("picks the one-time choice however the server spells it", () => { + for (const choice of [ + "allow_once", + "Allow", + "accept_once", + "Approve once", + "allow_this_time", + "allow_once_only", + ]) { + assert.deepEqual( + ClaudeAdapterV2.claudeMcpElicitationAcceptance( + form({ decision: { type: "string", enum: ["disallow", choice, "always"] } }, [ + "decision", + ]), + ), + { action: "accept", content: { decision: choice } }, + ); + } + }); + + it("answers a persistence toggle with false", () => { + assert.deepEqual( + ClaudeAdapterV2.claudeMcpElicitationAcceptance( + form({ remember: { type: "boolean", title: "Always allow" } }, ["remember"]), + ), + { action: "accept", content: { remember: false } }, + ); + }); + + it("leaves out optional fields the card does not show", () => { + assert.deepEqual( + ClaudeAdapterV2.claudeMcpElicitationAcceptance( + form({ scope: { type: "string", enum: ["once", "always"], default: "always" } }), + ), + { action: "accept", content: { scope: "once" } }, + ); + assert.deepEqual( + ClaudeAdapterV2.claudeMcpElicitationAcceptance( + form({ limit: { type: "number", default: 500 } }), + ), + { action: "accept", content: {} }, + ); + }); + + it("has no answer for URL mode or a form asking for a value", () => { + for (const request of [ + { serverName: "deploys", message: "Sign in", mode: "url", url: "https://example.com" }, + form({ reason: { type: "string" } }, ["reason"]), + form({ grant: { type: "string", enum: ["always", "session"] } }, ["grant"]), + form({ grant: { type: "string", enum: ["deny", "always"], default: "always" } }, ["grant"]), + form({ limit: { type: "number", default: 500 } }, ["limit"]), + { serverName: "deploys", message: "Deploy web?", mode: "form" }, + ] satisfies ReadonlyArray) { + assert.isUndefined(ClaudeAdapterV2.claudeMcpElicitationAcceptance(request)); + } + }); +}); + const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( binaryPath: string, environment: ResolvedProviderInstanceEnvironment = [], @@ -3489,6 +3566,32 @@ describe("ClaudeAdapterV2 background wake turns", () => { const restricted = yield* open; assert.equal(restricted.permissionMode, "default"); assert.isUndefined(restricted.allowDangerouslySkipPermissions); + yield* fileSystem.writeFileString( + remoteFile, + JSON.stringify({ + permissions: { disableBypassPermissionsMode: false, disableAutoMode: false }, + disableAutoMode: false, + managedSourcesBehavior: "merge", + }), + ); + const unrestricted = yield* open; + assert.equal(unrestricted.permissionMode, "bypassPermissions"); + assert.equal(unrestricted.allowDangerouslySkipPermissions, true); + yield* fileSystem.writeFileString( + remoteFile, + JSON.stringify({ + permissions: { disableBypassPermissionsMode: "disable", disableAutoMode: "disable" }, + disableAutoMode: false, + }), + ); + const mixed = yield* open; + assert.equal(mixed.permissionMode, "default"); + assert.isUndefined(mixed.allowDangerouslySkipPermissions); + yield* fileSystem.writeFileString( + remoteFile, + JSON.stringify({ permissions: { disableBypassPermissionsMode: true } }), + ); + assert.equal((yield* open).permissionMode, "auto"); yield* fileSystem.remove(remoteFile); assert.equal((yield* open).permissionMode, "bypassPermissions"); const launchRestricted = yield* openWith( @@ -3508,6 +3611,77 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("drops false bypass switches from --settings before they reach the SDK", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-launch-" }); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-launch-cwd-" }); + const sdk = yield* Effect.promise(() => + vi.importActual("@anthropic-ai/claude-agent-sdk"), + ); + vi.mocked(ClaudeSdk.resolveSettings).mockImplementation(sdk.resolveSettings); + const openWith = (settings: Record) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + settings: { + ...DEFAULT_CLAUDE_SETTINGS, + homePath: home, + launchArgs: `--settings '${JSON.stringify(settings)}'`, + }, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-launch-arg-settings"), + text: "Run git status.", + attachments: [], + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd, + }), + }), + ); + const options = harness.getOpenedOptions()!; + return { + permissionMode: options.permissionMode, + settings: options.settings as { + disableAutoMode?: unknown; + permissions?: Record; + env?: Record; + }, + }; + }), + ); + const unrestricted = yield* openWith({ + disableAutoMode: false, + permissions: { disableBypassPermissionsMode: false, disableAutoMode: false, allow: [] }, + env: { KEPT: "1" }, + }); + assert.equal(unrestricted.permissionMode, "bypassPermissions"); + assert.notProperty(unrestricted.settings, "disableAutoMode"); + assert.deepEqual(unrestricted.settings.permissions, { allow: [] }); + assert.equal(unrestricted.settings.env?.KEPT, "1"); + const restricted = yield* openWith({ + disableAutoMode: false, + permissions: { disableBypassPermissionsMode: "disable", disableAutoMode: "disable" }, + }); + assert.equal(restricted.permissionMode, "default"); + assert.notProperty(restricted.settings, "disableAutoMode"); + assert.deepEqual(restricted.settings.permissions, { + disableBypassPermissionsMode: "disable", + disableAutoMode: "disable", + }); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("asks before restricted Full access tools and follows policy across turns", () => Effect.gen(function* () { const resolveSettings = vi @@ -4403,6 +4577,196 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + describe("MCP elicitations", () => { + const testLayer = Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + ); + const approvalOnly: ElicitationRequest = { + serverName: "deploys", + message: "Deploy web to production?", + mode: "form", + requestedSchema: { type: "object", properties: {} }, + title: "Deploy", + }; + + const startElicitationTurn = Effect.fnUntraced(function* ( + options?: Parameters[0], + ) { + const harness = yield* makeWakeHarnessWithOptions(options); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-elicitation"), + text: "Deploy web.", + attachments: [], + }), + ); + const onElicitation = harness.getOpenedOptions()?.onElicitation; + if (onElicitation === undefined) throw new Error("The Claude query has no onElicitation."); + const runtimeRequests = () => + harness.events.flatMap((event) => + event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], + ); + let requestNumber = 0; + const elicit = (request: ElicitationRequest, signal = new AbortController().signal) => + Effect.promise(() => + onElicitation(request, { signal, requestId: `sdk-${requestNumber++}` }), + ).pipe(Effect.forkScoped); + const awaitCard = Effect.fnUntraced(function* (count: number) { + const event = yield* Queue.take(harness.runtimeRequestReceipts); + assert.equal(runtimeRequests().length, count); + return event.runtimeRequest; + }); + return { harness, elicit, awaitCard, runtimeRequests }; + }); + + it.effect("shows an approval-only form and accepts only after Approve", () => + Effect.gen(function* () { + const { harness, elicit, awaitCard } = yield* startElicitationTurn(); + const answer = yield* elicit(approvalOnly); + const request = yield* awaitCard(1); + assert.equal(request.kind, "mcp-elicitation"); + assert.isTrue(request.responseCapability.type === "live"); + if (request.responseCapability.type === "live") { + assert.isUndefined(request.responseCapability.supportsFeedback); + } + const card = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ); + assert.deepInclude(card?.type === "turn_item.updated" ? card.turnItem : undefined, { + prompt: "Deploy web to production?", + appName: "Deploy", + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, + ], + }); + assert.isUndefined(answer.pollUnsafe()); + + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.id, + decision: "accept", + }); + assert.deepEqual(yield* Fiber.join(answer), { action: "accept", content: {} }); + + // The card is gone, so a second click is an unknown request. + const late = yield* Effect.flip( + harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision: "decline" }), + ); + assert.include(String(late.cause), "No pending Claude runtime request"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect.each(["decline", "cancel"] as const)("answers %s with that action", (decision) => + Effect.gen(function* () { + const { harness, elicit, awaitCard } = yield* startElicitationTurn(); + const answer = yield* elicit(approvalOnly); + const request = yield* awaitCard(1); + yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); + assert.deepEqual(yield* Fiber.join(answer), { action: decision }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect.each(["acceptForSession", "acceptAlways"] as const)( + "declines an unsupported %s decision", + (decision) => + Effect.gen(function* () { + const { harness, elicit, awaitCard } = yield* startElicitationTurn(); + const answer = yield* elicit(approvalOnly); + const request = yield* awaitCard(1); + yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); + assert.deepEqual(yield* Fiber.join(answer), { action: "decline" }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("declines URL and value forms without a card", () => + Effect.gen(function* () { + const { elicit, runtimeRequests } = yield* startElicitationTurn(); + for (const request of [ + { ...approvalOnly, mode: "url", url: "https://example.com/auth" }, + { + ...approvalOnly, + requestedSchema: { + type: "object", + properties: { reason: { type: "string" } }, + required: ["reason"], + }, + }, + ] satisfies ReadonlyArray) { + assert.deepEqual(yield* Fiber.join(yield* elicit(request)), { action: "decline" }); + } + assert.isEmpty(runtimeRequests()); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("cancels an already-aborted request without a card", () => + Effect.gen(function* () { + const { harness, elicit, awaitCard, runtimeRequests } = yield* startElicitationTurn(); + const aborted = yield* Effect.sync(() => { + const controller = new AbortController(); + controller.abort(); + return controller.signal; + }); + const answer = yield* elicit(approvalOnly, aborted); + assert.deepEqual(yield* Fiber.join(answer), { action: "cancel" }); + assert.isEmpty(runtimeRequests()); + const next = yield* elicit(approvalOnly); + const card = yield* awaitCard(1); + assert.equal(card.nativeRequestRef?.nativeId, "mcp-elicitation:sdk-1"); + yield* harness.runtime.respondToRuntimeRequest({ requestId: card.id, decision: "accept" }); + assert.deepEqual(yield* Fiber.join(next), { action: "accept", content: {} }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("withdraws an open card when the SDK cancels while the session continues", () => + Effect.gen(function* () { + const { harness, elicit, awaitCard } = yield* startElicitationTurn(); + const controller = yield* Effect.sync(() => new AbortController()); + const answer = yield* elicit(approvalOnly, controller.signal); + const request = yield* awaitCard(1); + yield* Effect.sync(() => controller.abort()); + assert.deepEqual(yield* Fiber.join(answer), { action: "cancel" }); + const cancelled = yield* Queue.take(harness.runtimeRequestReceipts); + assert.equal(cancelled.runtimeRequest.id, request.id); + assert.equal(cancelled.runtimeRequest.status, "cancelled"); + assert.equal( + harness.events.findLast((e) => e.type === "node.updated")?.node.status, + "cancelled", + ); + assert.equal( + harness.events.findLast((e) => e.type === "turn_item.updated")?.turnItem.status, + "cancelled", + ); + yield* Effect.flip( + harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision: "accept" }), + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("cancels an open card when the session stops", () => + Effect.gen(function* () { + // Like the SDK, the fake query aborts in-flight callbacks when closed. + const inFlight = yield* Effect.sync(() => new AbortController()); + const session = yield* Scope.make(); + const { elicit, awaitCard } = yield* startElicitationTurn({ + close: () => Effect.sync(() => inFlight.abort()), + }).pipe(Scope.provide(session)); + const answer = yield* elicit(approvalOnly, inFlight.signal); + yield* awaitCard(1); + assert.isUndefined(answer.pollUnsafe()); + + yield* Scope.close(session, Exit.void); + assert.deepEqual(yield* Fiber.join(answer), { action: "cancel" }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + }); + it.effect( "applies model, effort and Fast changes to a process running a background shell, but not thinking or a cleared effort", () => @@ -4686,6 +5050,69 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("completes a turn after the resume dialog without a failed tool row", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-resume-dialog"), + text: "continue", + attachments: [], + }), + ); + const dialog = yield* Effect.promise(() => + harness.getOpenedOptions()!.onUserDialog!( + { + dialogKind: "resume_return", + payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 }, + }, + { signal: new AbortController().signal, requestId: "dialog-resume" }, + ), + ).pipe(Effect.forkScoped); + const request = yield* Queue.take(harness.runtimeRequestReceipts); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "node.updated" && event.node.id === request.runtimeRequest.nodeId + ? [event.node.parentNodeId] + : [], + ), + [NodeId.make("node-attempt-resume-dialog")], + ); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + answers: { + [formatClaudeResumeCompactionQuestion({ ageMinutes: 90, estimatedTokens: 120000 })]: + "Keep full history", + }, + }); + assert.deepEqual(yield* Fiber.join(dialog), { behavior: "completed", result: "continue" }); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "result-resume-dialog", result: "Done" }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === "dialog-resume" + ? [event.turnItem.status] + : [], + ), + [], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect.each( (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) => [true, false].map((steered) => ({ terminalReason, steered })), @@ -6077,7 +6504,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ? [event.providerTurn.tokenUsage.autoCompactThreshold] : [], ), - [150_000, 150_000, 150_000, 150_000], + [117_000, 117_000, 117_000, 117_000], ); }).pipe( Effect.provide( @@ -6088,10 +6515,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect.each([ { autoCompactWindow: "", model: "claude-sonnet-4-6", threshold: undefined }, - { autoCompactWindow: "150000", model: "claude-sonnet-4-6", threshold: 150_000 }, + { autoCompactWindow: "150000", model: "claude-sonnet-4-6", threshold: 117_000 }, { autoCompactWindow: "200000", model: "claude-sonnet-4-6", threshold: undefined }, { autoCompactWindow: "650000", model: "claude-sonnet-4-6", threshold: undefined }, - { autoCompactWindow: "650000", model: "uncatalogued-model", threshold: 650_000 }, + { autoCompactWindow: "650000", model: "uncatalogued-model", threshold: 617_000 }, ])( "ignores stale snapshots after compaction and reports threshold $autoCompactWindow for $model", ({ autoCompactWindow, model, threshold }) => @@ -6598,6 +7025,90 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("names the notebook a Claude NotebookEdit changed", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-notebook-edit"), + text: "Fix the notebook", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + uuid: "00000000-0000-4000-8000-000000000604", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + id: "msg_notebook_edit", + model: "claude-sonnet-4-6", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "notebook", + name: "NotebookEdit", + input: { + notebook_path: "/workspace/analysis.ipynb", + cell_id: "c1", + new_source: "print(1)", + }, + }, + ], + stop_reason: "tool_use", + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + uuid: "00000000-0000-4000-8000-000000000605", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "notebook", content: "Updated cell c1" }], + }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000606", result: "Done" }), + ); + yield* Queue.take(harness.terminalReceipts); + const item = harness.events + .flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "notebook" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(item?.type, "file_change"); + if (item?.type === "file_change") assert.equal(item.fileName, "/workspace/analysis.ipynb"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), + ); + it.effect.each(["claude-haiku-4-5-20251001", "claude-sonnet-4-6"])( "renegotiates auto only when switching API models after fallback to default: %s", (nextModel) => @@ -7971,6 +8482,116 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.skipIf(process.getuid === undefined)( + "tails foreground Bash output under the physical cwd the query launched in", + () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-task-output-cwd-", + }); + const physicalCwd = path.join(root, "real", "project"); + const launchCwd = path.join(root, "link"); + yield* fileSystem.makeDirectory(physicalCwd, { recursive: true }); + yield* fileSystem.symlink(physicalCwd, launchCwd); + // The session opens in /workspace; a relative TMPDIR puts Claude's + // task files under whichever cwd the CLI was launched in. Linux, + // because Claude Code's macOS build ignores TMPDIR. + const harness = yield* makeWakeHarnessWithOptions({ environment: { TMPDIR: "../tmp" } }); + const now = yield* DateTime.now; + const toolUseId = "toolu_foreground_bash"; + const taskId = "foreground-bash-task"; + const tasks = path.join( + root, + "real", + "tmp", + `claude-${process.getuid?.()}`, + "project", + WAKE_NATIVE_SESSION, + "tasks", + ); + yield* fileSystem.makeDirectory(tasks, { recursive: true }); + yield* fileSystem.writeFileString(path.join(tasks, `${taskId}.output`), "line 1\n"); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-task-output-cwd"), + text: "Run the build.", + attachments: [], + runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd: launchCwd }, + }), + ); + assert.equal(harness.getOpenedOptions()?.cwd, launchCwd); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_foreground_bash", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Bash", + input: { command: "vp run build", description: "Build" }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000a01", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + description: "vp run build", + is_backgrounded: false, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000a02", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content: "line 1" }], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000a03", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { stdout: "line 1\n", stderr: "", interrupted: false }, + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a04", result: "Built." }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + + const chunks = harness.events.flatMap((event) => + event.type === "command_output.delta" ? [event.chunk] : [], + ); + assert.deepEqual(chunks, ["line 1\n"]); + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("preserves settled background work until an explicit stop", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 082cae55de829..c975e022b37df 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -992,7 +992,12 @@ const writeReplayTaskOutputFiles = Effect.fn("ClaudeReplay.writeTaskOutputFiles" const stderr = typeof result.stderr === "string" ? result.stderr : ""; outputByToolUseId.set(toolResult.tool_use_id, `${result.stdout}${stderr}`); } - const root = claudeTaskOutputRoot({ CLAUDE_CODE_TMPDIR: tmpRoot }, path); + const root = claudeTaskOutputRoot( + { CLAUDE_CODE_TMPDIR: tmpRoot }, + path, + null, + yield* HostProcess.Platform, + ); for (const frame of frames) { if (frame.type !== "system" || frame.subtype !== "task_started") continue; if (frame.task_type !== "local_bash" || frame.is_backgrounded !== false) continue; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 428e44e19020c..08a103b386847 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -22,8 +22,11 @@ import { import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; +import { toMcpElicitationResponse } from "../../provider/CodexMcpElicitation.ts"; import { type CanUseTool, + type ElicitationRequest, + type ElicitationResult, forkSession as forkClaudeSession, type EffortLevel, type ForkSessionOptions, @@ -231,10 +234,14 @@ function claudePromptCacheTtlMs( } // Claude caps the window at the model's context, so a setting at or above it never applies. +// Below that, Claude compacts 33,000 tokens short of the window: Claude Code 2.1.305's +// getContextUsage reports a 150,000 window as a 117,000 threshold, for every catalog model. +const CLAUDE_AUTO_COMPACT_RESERVE_TOKENS = 33_000; + function claudeAutoCompactThreshold(autoCompactWindow: string, maxTokens: number | null) { - const threshold = Number(autoCompactWindow); - return threshold > 0 && (maxTokens === null || threshold < maxTokens) - ? { autoCompactThreshold: threshold } + const windowTokens = Number(autoCompactWindow); + return windowTokens > 0 && (maxTokens === null || windowTokens < maxTokens) + ? { autoCompactThreshold: windowTokens - CLAUDE_AUTO_COMPACT_RESERVE_TOKENS } : {}; } @@ -1470,6 +1477,7 @@ export function makeClaudeQueryOptions(input: { readonly permissionMode?: PermissionMode; readonly canUseTool?: CanUseTool; readonly onUserDialog?: ClaudeQueryOptions["onUserDialog"]; + readonly onElicitation?: ClaudeQueryOptions["onElicitation"]; readonly supportedDialogKinds?: ClaudeQueryOptions["supportedDialogKinds"]; readonly allowDangerouslySkipPermissions?: boolean; readonly appendSystemPromptFileText?: string; @@ -1568,6 +1576,7 @@ export function makeClaudeQueryOptions(input: { ? {} : { settings: effectiveQuerySettings }), ...(input.onUserDialog === undefined ? {} : { onUserDialog: input.onUserDialog }), + ...(input.onElicitation === undefined ? {} : { onElicitation: input.onElicitation }), ...(input.supportedDialogKinds === undefined ? {} : { supportedDialogKinds: input.supportedDialogKinds }), @@ -2591,12 +2600,12 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( const ClaudeBypassSettings = Schema.StructWithRest( Schema.Struct({ - disableAutoMode: Schema.optionalKey(Schema.Literal("disable")), + disableAutoMode: Schema.optionalKey(Schema.Literals(["disable", false])), permissions: Schema.optionalKey( Schema.StructWithRest( Schema.Struct({ - disableBypassPermissionsMode: Schema.optionalKey(Schema.Literal("disable")), - disableAutoMode: Schema.optionalKey(Schema.Literal("disable")), + disableBypassPermissionsMode: Schema.optionalKey(Schema.Literals(["disable", false])), + disableAutoMode: Schema.optionalKey(Schema.Literals(["disable", false])), }), [Schema.Record(Schema.String, Schema.Unknown)], ), @@ -2609,6 +2618,39 @@ const decodeClaudeBypassSettings = Schema.decodeUnknownEffect( ); const decodeClaudeLaunchArgBypassSettings = Schema.decodeUnknownEffect(ClaudeBypassSettings); +function claudeSdkBypassSettings(settings: typeof ClaudeBypassSettings.Type) { + const { disableAutoMode, permissions, ...rest } = settings; + const { + disableBypassPermissionsMode, + disableAutoMode: nestedAutoMode, + ...permissionRest + } = permissions ?? {}; + return { + ...rest, + ...(disableAutoMode === "disable" ? { disableAutoMode } : {}), + ...(permissions + ? { + permissions: { + ...permissionRest, + ...(disableBypassPermissionsMode === "disable" ? { disableBypassPermissionsMode } : {}), + ...(nestedAutoMode === "disable" ? { disableAutoMode: nestedAutoMode } : {}), + }, + } + : {}), + }; +} + +// --settings travels to the SDK as options.settings, whose type only allows "disable". +// Shapes the schema rejects keep their current pass-through; resolveBypassAvailability +// already treats them as a bypass failure. +const decodeClaudeLaunchArgBypassSettingsOption = Schema.decodeUnknownOption(ClaudeBypassSettings); +function claudeSdkLaunchArgSettings(settings: ClaudeSdkSettings): ClaudeSdkSettings { + const decoded = decodeClaudeLaunchArgBypassSettingsOption(settings); + return Option.isNone(decoded) + ? settings + : (claudeSdkBypassSettings(decoded.value) as ClaudeSdkSettings); +} + const ClaudePromptCacheTtl = Schema.Literals(["5m", "1h"]); const decodeClaudePromptCacheTtl = Schema.decodeUnknownOption(ClaudePromptCacheTtl); const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( @@ -2918,7 +2960,7 @@ function parseClaudeBackgroundTaskEntry( function fileNameFromClaudeTool(toolName: string, input: ClaudeNativeToolInput): string { return ( - firstStringInputField(input, ["file_path", "path", "filename", "fileName"]) ?? + firstStringInputField(input, ["file_path", "notebook_path", "path", "filename", "fileName"]) ?? `${toolName} result` ); } @@ -3443,6 +3485,69 @@ export const awaitClaudeApprovalDecision = Effect.fn("awaitClaudeApprovalDecisio return yield* Effect.raceFirst(Deferred.await(decision), cancellation); }); +// Web and mobile offer a session grant when options are omitted, and this +// path only ever answers once. +const CLAUDE_MCP_ELICITATION_OPTIONS: ReadonlyArray = [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, +]; + +type ClaudeMcpElicitationContent = NonNullable; + +const isClaudeMcpElicitationValue = ( + value: unknown, +): value is ClaudeMcpElicitationContent[string] => + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + (Array.isArray(value) && value.every((item) => typeof item === "string")); + +const withoutSchemaDefaults = (schema: Record): Record => { + const properties = schema.properties; + if (typeof properties !== "object" || properties === null || Array.isArray(properties)) { + return schema; + } + return { + ...schema, + properties: Object.fromEntries( + Object.entries(properties).map(([key, field]) => [ + key, + typeof field === "object" && field !== null + ? Object.fromEntries(Object.entries(field).filter(([name]) => name !== "default")) + : field, + ]), + ), + }; +}; + +// The card collects consent only; schema defaults must never grant unseen permissions. +export function claudeMcpElicitationAcceptance( + request: ElicitationRequest, +): ElicitationResult | undefined { + if (request.mode === "url" || request.requestedSchema === undefined) return undefined; + const response = toMcpElicitationResponse( + { mode: "form", requestedSchema: withoutSchemaDefaults(request.requestedSchema) }, + "accept", + ); + // The helper leaves content out when the schema is not a form it can read. + const filled = response.content; + if ( + response.action !== "accept" || + typeof filled !== "object" || + filled === null || + Array.isArray(filled) + ) { + return undefined; + } + const content: ClaudeMcpElicitationContent = {}; + for (const [key, value] of Object.entries(filled)) { + if (!isClaudeMcpElicitationValue(value)) return undefined; + content[key] = value; + } + return { action: "accept", content }; +} + const awaitClaudeUserInputAnswers = Effect.fn("awaitClaudeUserInputAnswers")(function* ( answers: Deferred.Deferred, signal: AbortSignal, @@ -3974,6 +4079,9 @@ interface ClaudeLiveQueryContext { permissionMode: PermissionMode; stopping: boolean; readonly subagentsFromEarlierProcesses: ReadonlySet; + // Per process, not per session: a relative temp dir follows the cwd this CLI + // was launched in, and a later turn's cwd can differ. + readonly taskOutputRoot: string | undefined; } interface ActiveClaudeToolCall { @@ -4402,6 +4510,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( adapterOptions.environment, ).pipe(Effect.provideService(Path.Path, path)); const hostEnvironment = yield* HostProcess.Environment; + const hostPlatform = yield* HostProcess.Platform; const customHome = adapterOptions.settings.homePath.trim().length > 0 || adapterOptions.environment.CLAUDE_CONFIG_DIR !== hostEnvironment.CLAUDE_CONFIG_DIR; @@ -4409,7 +4518,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const readBypassSettings = (filePath: string) => fileSystem.readFileString(filePath).pipe( Effect.flatMap(decodeClaudeBypassSettings), - Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed({})), + Effect.catchReason("PlatformError", "NotFound", () => + Effect.succeed({} as typeof ClaudeBypassSettings.Type), + ), ); const resolveBypassAvailability = Effect.fn("ClaudeAdapterV2.resolveBypassAvailability")( function* (cwd: string | null) { @@ -4423,7 +4534,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resolveClaudeSettings({ ...(cwd === null ? {} : { cwd }), settingSources: customHome ? ["project", "local"] : ["user", "project", "local"], - serverManagedSettings: remote, + serverManagedSettings: claudeSdkBypassSettings(remote), }), ); const launchArgSettings = yield* readClaudeLaunchArgSettings({ @@ -4579,7 +4690,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( string, { readonly fiber: Fiber.Fiber; readonly stop: Deferred.Deferred } >(); - const taskOutputRoot = claudeTaskOutputRoot(adapterOptions.environment, path); // Waits for the tail's last read so its output lands before the call settles. const stopTaskOutputTail = (toolUseId: string) => Effect.suspend(() => { @@ -4944,7 +5054,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); // Claude has no recovered frame; the next assistant message proves recovery. - const completeProviderRetry = Effect.fn("ClaudeAdapterV2.completeProviderRetry")(function* ( + // Runs on every model output frame and is almost always a no-op; only a pending retry gets a span. + const completeProviderRetry = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, updatedAt: DateTime.Utc, ) { @@ -4977,7 +5088,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( startedAt: providerRetry.startedAt, updatedAt, }), - }); + }).pipe(Effect.withSpan("ClaudeAdapterV2.completeProviderRetry")); }); const rememberProviderThread = @@ -7012,9 +7123,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly context: ActiveClaudeTurnContext; readonly nativeItemId: string; readonly nativeRequestId: string; + readonly parentNodeId?: OrchestrationV2ExecutionNode["id"]; readonly requestKind: OrchestrationV2RuntimeRequest["kind"]; readonly prompt?: string; + readonly appName?: string; + readonly options?: ReadonlyArray; readonly presentation?: ReturnType; + readonly questions?: ReadonlyArray; }) { const createdAt = yield* DateTime.now; @@ -7044,10 +7159,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( id: nodeId, threadId: input.context.input.threadId, runId: input.context.input.runId, - parentNodeId: idAllocator.derive.nodeFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: input.nativeItemId, - }), + parentNodeId: + input.parentNodeId ?? + idAllocator.derive.nodeFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: input.nativeItemId, + }), rootNodeId: input.context.input.rootNodeId, kind: input.questions === undefined ? "approval_request" : "user_input_request", status: "waiting", @@ -7074,7 +7191,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( responseCapability: { type: "live", providerSessionId, - ...(input.questions === undefined ? { supportsFeedback: true } : {}), + ...(input.questions === undefined && input.requestKind !== "mcp-elicitation" + ? { supportsFeedback: true } + : {}), }, createdAt, resolvedAt: null, @@ -7100,6 +7219,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( requestId, requestKind: input.requestKind as ProviderRequestKind, ...(input.prompt === undefined ? {} : { prompt: input.prompt }), + ...(input.appName === undefined ? {} : { appName: input.appName }), + ...(input.options === undefined ? {} : { options: input.options }), ...input.presentation, } : { @@ -8706,7 +8827,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? undefined : findToolCall(context, message.tool_use_id); if ( - taskOutputRoot !== undefined && + liveQuery.taskOutputRoot !== undefined && message.task_type === "local_bash" && // Older Claude Code builds omit the flag on foreground calls. message.is_backgrounded !== true && @@ -8719,7 +8840,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const stop = yield* Deferred.make(); const fiber = yield* tailClaudeTaskOutput({ - root: taskOutputRoot, + root: liveQuery.taskOutputRoot, sessionId: message.session_id, taskId: message.task_id, stop, @@ -9480,6 +9601,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolName: Parameters[0], toolInput: Parameters[1], callbackOptions: Parameters[2], + options?: { readonly startToolCall?: boolean }, ) { const context = yield* Ref.get(activeTurn); if (context === null) { @@ -9493,6 +9615,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const nativeRequestId = callbackOptions.toolUseID; const nativeToolInput = claudeNativeToolInputFromRecord(toolInput); const heldForEcho = context.heldRootFrames.length > 0; + const startToolCall = toolName !== "Agent" && options?.startToolCall !== false; if (toolName === "Agent") { rememberClaudeSubagentLaunch( subagentModelCatalog, @@ -9502,7 +9625,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeToolInput, null, ); - } else if (!heldForEcho) { + } else if (startToolCall && !heldForEcho) { yield* ensureToolCallStarted({ context, nativeItemId: nativeRequestId, @@ -9519,7 +9642,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { // The SDK blocks prompt echoes on permission answers; release held output except for ExitPlanMode. yield* releaseHeldRootFrames(context); - if (toolName !== "Agent") { + if (startToolCall) { yield* ensureToolCallStarted({ context, nativeItemId: nativeRequestId, @@ -9536,6 +9659,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context, nativeItemId: nativeRequestId, nativeRequestId, + // No tool node exists for a control request to hang under. + ...(options?.startToolCall === false + ? { parentNodeId: context.input.rootNodeId } + : {}), requestKind: "user_input", questions, }); @@ -9714,6 +9841,125 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); + const onElicitationEffect = Effect.fn("ClaudeAdapterV2.onElicitation")(function* ( + request: ElicitationRequest, + callbackOptions: Parameters>[1], + ) { + if (callbackOptions.signal.aborted) + return { action: "cancel" } satisfies ElicitationResult; + const acceptance = claudeMcpElicitationAcceptance(request); + const context = yield* Ref.get(activeTurn); + if (acceptance === undefined || context === null) { + yield* Effect.logWarning( + "Declined an MCP elicitation the approval card cannot answer.", + { + serverName: request.serverName, + mode: request.mode, + activeTurn: context !== null, + }, + ); + return { action: "decline" } satisfies ElicitationResult; + } + // The CLI waits on this answer, so a turn held for its prompt echo + // must show its output now, as canUseTool does. + if (context.heldRootFrames.length > 0) { + yield* releaseHeldRootFrames(context); + } + + // The SDK's requestId names this control request; T3 allocates its own + // runtime request id from it. + const nativeRequestId = `mcp-elicitation:${callbackOptions.requestId}`; + const artifacts = yield* buildApprovalRequestArtifacts({ + context, + nativeItemId: nativeRequestId, + nativeRequestId, + requestKind: "mcp-elicitation", + prompt: request.message, + appName: request.displayName ?? request.title ?? request.serverName, + options: CLAUDE_MCP_ELICITATION_OPTIONS, + }); + const decision = yield* Deferred.make(); + yield* Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.set(String(artifacts.request.id), { + type: "approval", + requestKind: "mcp-elicitation", + decision, + }); + return updated; + }); + yield* Effect.all( + [ + emitProviderEvent({ + type: "node.updated", + driver: CLAUDE_PROVIDER, + node: artifacts.node, + }), + emitProviderEvent({ + type: "runtime_request.updated", + driver: CLAUDE_PROVIDER, + runtimeRequest: artifacts.request, + }), + emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: artifacts.turnItem, + }), + ], + { concurrency: 1 }, + ); + + // Closing the query aborts the signal, so a stopped session settles + // this as cancel and a late response finds no pending request. + const { decision: resolved } = yield* awaitClaudeApprovalDecision( + decision, + callbackOptions.signal, + ).pipe( + Effect.ensuring( + Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.delete(String(artifacts.request.id)); + return updated; + }), + ), + ); + if (resolved === "cancel" && callbackOptions.signal.aborted) { + const now = yield* DateTime.now; + yield* emitProviderEvent({ + type: "node.updated", + driver: CLAUDE_PROVIDER, + node: { ...artifacts.node, status: "cancelled", completedAt: now }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + ...artifacts.turnItem, + status: "cancelled", + completedAt: now, + updatedAt: now, + }, + }); + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver: CLAUDE_PROVIDER, + runtimeRequest: { ...artifacts.request, status: "cancelled", resolvedAt: now }, + }); + } + // Never null: the SDK reads null as answered out of band and leaves + // the server waiting. Every accept is one-time; no grant is offered. + return resolved === "accept" + ? acceptance + : ({ + action: resolved === "cancel" ? "cancel" : "decline", + } satisfies ElicitationResult); + }); + + const onElicitation: NonNullable = ( + request, + callbackOptions, + ) => runPromise(onElicitationEffect(request, callbackOptions)); + const onUserDialog: NonNullable = ( request, callbackOptions, @@ -9778,6 +10024,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( requestId: callbackOptions.requestId, toolUseID: request.toolUseID ?? callbackOptions.requestId, }, + // The dialog is a control request, not a tool call, so no + // tool_result would ever settle a tool row started for it. + { startToolCall: false }, ); if (result.behavior !== "allow") return { behavior: "cancelled" as const }; const answers = @@ -9963,7 +10212,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( cwd: turnInput.runtimePolicy.cwd, attachmentsDir, settings: adapterOptions.settings, - ...(launchArgSettings === undefined ? {} : { sdkSettings: launchArgSettings }), + ...(launchArgSettings === undefined + ? {} + : { sdkSettings: claudeSdkLaunchArgSettings(launchArgSettings) }), environment: claudeQueryEnvironment( adapterOptions.environment, turnInput.appThread, @@ -9987,10 +10238,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), canUseTool, onUserDialog, + onElicitation, supportedDialogKinds: ["resume_return"], ...(appendSystemPromptFileText === undefined ? {} : { appendSystemPromptFileText }), ...(toolSearchOverride === undefined ? {} : { toolSearchOverride }), }); + const resolvedTurnCwd = yield* fileSystem + .realPath(turnInput.runtimePolicy.cwd ?? ".") + .pipe(Effect.orElseSucceed(() => turnInput.runtimePolicy.cwd)); const querySession = yield* queryRunner .open({ threadId: turnInput.threadId, @@ -10040,6 +10295,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (subagent) => subagent.task.status === "running", ), ), + taskOutputRoot: claudeTaskOutputRoot( + adapterOptions.environment, + path, + resolvedTurnCwd, + hostPlatform, + ), }; yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 87992bab89169..8da21b20236e2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -7,7 +7,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; -import { tailClaudeTaskOutput } from "./ClaudeTaskOutputTail.ts"; +import { claudeTaskOutputRoot, tailClaudeTaskOutput } from "./ClaudeTaskOutputTail.ts"; const SESSION_ID = "0c91bb28-9e89-4889-bd22-3de9880233d3"; const TASK_ID = "bk6xreeye"; @@ -103,3 +103,39 @@ describe("tailClaudeTaskOutput", () => { ), ); }); + +describe("claudeTaskOutputRoot", () => { + it.effect.skipIf(process.getuid === undefined)( + "uses the temp dir Claude Code resolves from its environment on each platform", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const user = `claude-${process.getuid?.()}`; + const root = ( + environment: Record, + platform: NodeJS.Platform, + cwd: string | null = null, + ) => claudeTaskOutputRoot(environment, path, cwd, platform); + assert.equal(root({ TMPDIR: "/var/folders/x/T/" }, "linux"), `/var/folders/x/T/${user}`); + assert.equal(root({ TMP: "/tmp/user/1000" }, "linux"), `/tmp/user/1000/${user}`); + assert.equal(root({}, "linux"), `/tmp/${user}`); + assert.equal(root({ TMPDIR: "/var/folders/x/T/" }, "darwin"), `/tmp/${user}`); + assert.equal( + root({ CLAUDE_CODE_TMPDIR: "/srv/claude", TMPDIR: "/var/folders/x/T/" }, "linux"), + `/srv/claude/${user}`, + ); + assert.equal( + root({ CLAUDE_CODE_TMPDIR: "/srv/claude", TMPDIR: "/var/folders/x/T/" }, "darwin"), + `/srv/claude/${user}`, + ); + assert.equal( + root({ TMPDIR: "tmp" }, "linux", "/work/project"), + `/work/project/tmp/${user}`, + ); + assert.equal( + root({ CLAUDE_CODE_TMPDIR: "../claude" }, "darwin", "/work/project"), + `/work/claude/${user}`, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts index ff096cdad2913..0bd369eb4df3a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts @@ -17,11 +17,21 @@ const MAX_READ_BYTES = 256 * 1024; export function claudeTaskOutputRoot( environment: Readonly> | undefined, path: Path.Path, + cwd: string | null, + platform: NodeJS.Platform, ): string | undefined { const uid = process.getuid?.(); if (uid === undefined) return undefined; - const base = environment?.CLAUDE_CODE_TMPDIR?.trim() || "/tmp"; - return path.join(base, `claude-${uid}`); + // Claude Code's macOS build falls back to /tmp and ignores the TMPDIR that + // launchd always sets. Its other builds use `os.tmpdir()`, which reads these + // from the environment it was launched with. A relative value is relative + // to Claude's working directory, not this server's. + const fallback = + platform === "darwin" + ? "/tmp" + : environment?.TMPDIR || environment?.TMP || environment?.TEMP || "/tmp"; + const base = environment?.CLAUDE_CODE_TMPDIR?.trim() || fallback; + return path.resolve(cwd ?? ".", base, `claude-${uid}`); } const locate = Effect.fn("ClaudeTaskOutputTail.locate")(function* (input: { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index dd90f02f5dd11..12d9ef997d22c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -22,6 +22,7 @@ import { type ProviderApprovalDecision, ProviderInstanceId, ProviderSessionId, + ProviderSetupError, ProviderThreadId, ProviderTurnId, RunAttemptId, @@ -66,6 +67,8 @@ import * as Orchestrator from "../Orchestrator.ts"; import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as ModelManifest from "../../provider/ModelManifest.ts"; +import { resolveProviderCompatibility } from "../../provider/providerCompatibility.ts"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; import { withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; @@ -77,6 +80,7 @@ const encodeStringJson = Schema.encodeEffect(Schema.fromJsonString(Schema.String const isProviderAdapterEventStreamError = Schema.is( ProviderAdapter.ProviderAdapterEventStreamError, ); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); describe("CodexAdapterV2 context usage", () => { it("uses the current context rather than cumulative processed tokens", () => { @@ -964,6 +968,7 @@ describe("CodexAdapterV2 session initialize", () => { const openReplaySession = ( transcript: CodexReplay.CodexAppServerReplayTranscript, beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], + compatibility?: CodexAdapterV2.CodexAdapterV2Options["compatibility"], ) => Effect.gen(function* () { const driver = yield* CodexReplay.makeReplayDriver( @@ -975,6 +980,7 @@ describe("CodexAdapterV2 session initialize", () => { instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, settings: DEFAULT_CODEX_SETTINGS, environment: {}, + ...(compatibility === undefined ? {} : { compatibility }), clientFactory: { open: (openInput) => Layer.build(CodexReplay.layerReplayWithDriver(driver)).pipe( @@ -1106,6 +1112,54 @@ describe("CodexAdapterV2 session initialize", () => { ), ); + it.effect("refuses to open a session on a Codex version marked broken", () => + Effect.gen(function* () { + const [initialize] = replayPreamble("broken-version"); + const failure = yield* openReplaySession( + makeCodexReplayTranscript({ + scenario: "broken-version", + entries: [ + initialize!, + { + type: "emit_inbound", + label: "initialize", + frame: { + id: 1, + result: { + userAgent: "T3 Code/0.144.3 (Mac OS 15.5.0; x86_64) unknown (T3 Code; 0.1.5)", + codexHome: "/tmp/codex-home", + platformFamily: "unix", + platformOs: "macos", + }, + }, + }, + ], + }), + undefined, + (version) => + Effect.succeed( + resolveProviderCompatibility( + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + CodexAdapterV2.CODEX_DRIVER_KIND, + version, + ), + ), + ).pipe(Effect.flip); + + assert.instanceOf(failure, ProviderAdapter.ProviderAdapterOpenSessionError); + assert.instanceOf(failure.cause, ProviderSetupError); + assert.equal( + failure.cause.message, + "Codex 0.144.3 does not work with this T3 Code release. Update Codex to >=0.159.0, then retry.", + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("completes the handshake after a caller is interrupted mid-initialize", () => Effect.gen(function* () { const initializeAwaitingResponse = yield* Deferred.make(); @@ -1175,6 +1229,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { Effect.void, onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, readChildMetadata?: Parameters[2], + beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1185,9 +1240,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { const changes = yield* makeChangeSignal; const continuationRequests: Array = []; + const driver = yield* CodexReplay.makeReplayDriver( + transcript, + beforeEmitInbound === undefined ? {} : { beforeEmitInbound }, + ); const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = { open: (openInput) => - Layer.build(CodexReplay.layerReplay(transcript)).pipe( + Layer.build(CodexReplay.layerReplayWithDriver(driver)).pipe( Effect.mapError( (cause) => new ProviderAdapter.ProviderAdapterOpenSessionError({ @@ -1298,6 +1357,105 @@ describe("CodexAdapterV2 post-settle continuation", () => { }; }); + it.effect.each(["before", "after"] as const)( + "rejects a previous run's native turn when it completes %s the new start response", + (completionOrder) => + Effect.gen(function* () { + const nativeThreadId = "reused-turn-thread"; + const nativeTurnId = "reused-turn"; + const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }); + const retry = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "retry-turn", + prompt: "work", + }); + const completion: CodexReplay.CodexAppServerReplayEntry = { + type: "emit_inbound", + label: "original turn completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: `reused-turn-${completionOrder}`, + entries: [ + ...preamble, + withReplayRequestId(preamble[5]!, 4), + ...(completionOrder === "before" ? [completion] : []), + withReplayRequestId(preamble[6]!, 4), + ...(completionOrder === "after" ? [completion] : []), + ...retry.slice(5, 7).map((entry) => withReplayRequestId(entry, 5)), + { + type: "emit_inbound", + label: "retry completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "retry-turn", status: "completed" }), + }, + }, + }, + ], + }), + ); + const first = makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("original"), + text: "work", + }); + yield* harness.runtime.startTurn(first); + const result = yield* harness.runtime + .startTurn({ + ...first, + runId: RunId.make("replacement"), + attemptId: RunAttemptId.make("replacement"), + rootNodeId: NodeId.make("replacement"), + runOrdinal: 2, + providerTurnOrdinal: 2, + }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure", "a different run must not adopt the original turn"); + if (result._tag !== "Failure") return; + const failure = result.failure; + assert.equal(failure._tag, "ProviderAdapterTurnStartError"); + assert.instanceOf(failure.cause, ProviderAdapter.ProviderAdapterProtocolError); + yield* harness.firstTerminal; + assert.deepEqual( + harness.terminalEvents().map((event) => event.runOrdinal), + [1], + ); + yield* harness.runtime.startTurn({ + ...first, + runId: RunId.make("retry"), + attemptId: RunAttemptId.make("retry"), + rootNodeId: NodeId.make("retry"), + runOrdinal: 3, + providerTurnOrdinal: 3, + }); + yield* harness.awaitTerminal(2); + assert.deepEqual( + harness.terminalEvents().map((event) => [event.runOrdinal, event.status]), + [ + [1, "completed"], + [3, "completed"], + ], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each(["supported", "unsupported", "invalid"] as const)( "delivers native history with %s app-server protocol", (response) => @@ -3231,7 +3389,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { answers: ReadonlyArray<{ readonly id: string; readonly text: string; - readonly phase?: "commentary" | "final_answer" | null; + readonly phase?: "commentary" | "partial_answer" | "final_answer" | null; readonly omitPhase?: boolean; readonly streamed?: boolean; readonly completionDelayMs?: number; @@ -3326,6 +3484,243 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); }; + it.effect.each([false, true])( + "preserves a final answer that repeats a preceding partial answer (streamed: %s)", + (streamed) => + Effect.scoped( + Effect.gen(function* () { + const transcript = finalAnswerTranscript("codex-partial-then-final", [ + { id: "partial", text: "CODEX_RECOVERY_OK", phase: "partial_answer", streamed }, + { id: "final", text: "CODEX_RECOVERY_OK", phase: "final_answer", streamed }, + ]); + const harness = yield* makeCodexReplayHarness(transcript); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-partial-then-final"), + text: "Reply with the requested recovery marker.", + }), + ); + yield* harness.awaitTerminal(1); + assert.deepEqual( + assistantMessages(harness.events).map((event) => event.message.text), + ["CODEX_RECOVERY_OK", "CODEX_RECOVERY_OK"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each([ + { + name: "a text reply gains each saved image once", + reply: "Done.", + images: [ + { savedPath: "/tmp/a.png" }, + { savedPath: "/tmp/a.png" }, + { savedPath: "/tmp/failed.png", status: "failed" }, + { + savedPath: "/tmp/limited.png", + failure: { type: "usageLimitExceeded", limitId: "image-generation" }, + }, + { savedPath: null }, + ], + expected: "Done.\n\n![]()", + }, + { + name: "an image-only result becomes the reply", + reply: null, + images: [{ savedPath: "/tmp/a.png" }], + expected: "![]()", + }, + ])("projects generated images: $name", ({ reply, images, expected }) => + Effect.gen(function* () { + const scenario = "codex-generated-images"; + const result = "aW1hZ2UtYnl0ZXMtbXVzdC1uZXZlci1yZWFjaC1jbGllbnRz"; + const base = finalAnswerTranscript( + scenario, + reply === null ? [] : [{ id: "answer", text: reply }], + ); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario, + entries: [ + ...base.entries.slice(0, -1), + ...images.map((image, index) => ({ + type: "emit_inbound" as const, + frame: { + method: "item/completed", + params: { + threadId: `native-${scenario}-thread`, + turnId: `native-${scenario}-turn`, + item: { + type: "imageGeneration", + id: `image-${index}`, + status: "completed", + result, + ...image, + }, + }, + }, + })), + ...base.entries.slice(-1), + ], + }), + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-generated-images"), + text: "Reply with the requested recovery marker.", + }), + ); + yield* harness.firstTerminal; + + const messages = assistantMessages(harness.events); + assert.strictEqual(messages.at(-1)?.message.text, expected); + assert.strictEqual(messages.at(-1)?.message.streaming, false); + assert.strictEqual(new Set(messages.map(({ message }) => message.id)).size, 1); + // Image bytes stay with Codex; clients load the saved file. + assert.notInclude(encodeUnknownJson(harness.events), result); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each(["complete", "stop"] as const)( + "retains generating images after settlement until %s", + (outcome) => + Effect.gen(function* () { + const scenario = "codex-retained-image"; + const base = finalAnswerTranscript(scenario, [{ id: "answer", text: "Done." }]); + const completions = { + first: yield* Deferred.make(), + second: yield* Deferred.make(), + }; + const projected = yield* Deferred.make(); + const drained = yield* Deferred.make(); + const firstText = "Done.\n\n![]()"; + const imageEvent = (image: "first" | "second", phase: "started" | "completed") => ({ + type: "emit_inbound" as const, + label: `${image}-${phase}`, + frame: { + method: `item/${phase}`, + params: { + threadId: `native-${scenario}-thread`, + turnId: `native-${scenario}-turn`, + item: { + type: "imageGeneration", + id: image, + status: phase === "started" ? "inProgress" : "completed", + result: "", + ...(phase === "completed" ? { savedPath: `/tmp/${image}.png` } : {}), + }, + }, + }, + }); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario, + entries: [ + ...base.entries.slice(0, -2), + imageEvent("first", "started"), + imageEvent("second", "started"), + ...base.entries.slice(-2), + imageEvent("first", "completed"), + imageEvent("second", "completed"), + ], + }), + (event) => + event.type === "message.updated" && event.message.text === firstText + ? Deferred.succeed(projected, undefined) + : event.type === "background_work.updated" && !event.pending + ? Deferred.succeed(drained, undefined) + : Effect.void, + undefined, + undefined, + (entry) => + entry.label === "first-completed" + ? Deferred.await(completions.first) + : entry.label === "second-completed" + ? Deferred.await(completions.second) + : Effect.void, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-retained-image"), + text: "Reply with the requested recovery marker.", + }), + ); + yield* harness.firstTerminal; + const pending = Effect.all([ + harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread), + harness.hasPendingBackgroundWork, + ]); + assert.deepEqual(yield* pending, [true, true]); + assert.strictEqual(assistantMessages(harness.events).at(-1)?.message.text, "Done."); + const cleared = () => + harness.events.some((e) => e.type === "background_work.updated" && !e.pending); + + if (outcome === "complete") { + yield* Deferred.succeed(completions.first, undefined); + yield* Deferred.await(projected); + // The second image is still generating, so the first must not release the turn. + assert.deepEqual(yield* pending, [true, true]); + assert.isFalse(cleared()); + yield* Deferred.succeed(completions.second, undefined); + } else { + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: harness.terminalEvents()[0]!.providerTurnId, + requestRuntimeRestart: true, + }); + } + yield* Deferred.await(drained); + assert.deepEqual(yield* pending, [false, false]); + assert.lengthOf(harness.terminalEvents(), 1); + // Run ingestion follows these updates, so they must bracket the terminal and the images. + const indexOf = (predicate: (event: ProviderAdapter.ProviderAdapterV2Event) => boolean) => + harness.events.findIndex(predicate); + const started = indexOf((e) => e.type === "background_work.updated" && e.pending); + const terminal = indexOf((e) => e.type === "turn.terminal"); + const finished = indexOf((e) => e.type === "background_work.updated" && !e.pending); + assert.isAtLeast(started, 0); + assert.isAbove(terminal, started); + assert.isAbove(finished, terminal); + if (outcome === "complete") { + const messages = assistantMessages(harness.events); + const projectedBoth = indexOf( + (e) => + e.type === "message.updated" && + e.message.text === `${firstText}\n\n![]()`, + ); + assert.isAbove(projectedBoth, terminal); + assert.isAbove(finished, projectedBoth); + assert.strictEqual(messages.at(-1)?.message.id, messages[0]?.message.id); + assert.strictEqual(messages.at(-1)?.message.streaming, false); + } + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("suppresses a trailing empty final answer after a non-empty final answer", () => Effect.scoped( Effect.gen(function* () { @@ -4603,6 +4998,132 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + const interruptSubagentTurnAfterStopTranscript = makeCodexReplayTranscript({ + scenario: "codex-interrupt-subagent-turn-after-stop", + entries: [ + ...interruptSubagentCommandTranscript.entries.slice(0, -1), + { + type: "emit_inbound", + label: "turn/started/child-after-stop", + frame: { + method: "turn/started", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_LATE_CHILD_NATIVE_TURN, + status: "inProgress", + }), + }, + }, + }, + { + type: "expect_outbound", + label: "turn/interrupt/child-after-stop", + frame: { + id: 7, + method: "turn/interrupt", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/interrupt/child-after-stop", + frame: { id: 7, result: {} }, + }, + { + type: "emit_inbound", + label: "thread/goal/updated/root", + frame: { + method: "thread/goal/updated", + params: { + threadId: INTERRUPT_NATIVE_THREAD, + turnId: null, + goal: { + threadId: INTERRUPT_NATIVE_THREAD, + objective: INTERRUPT_PROMPT, + status: "paused", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1782622440, + updatedAt: 1782622450, + }, + }, + }, + }, + { type: "runtime_exit", status: "success" }, + ], + }); + + it.effect( + "keeps reading notifications while interrupting a child turn that starts after Stop", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness(interruptSubagentTurnAfterStopTranscript); + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-turn-after-stop"), + text: INTERRUPT_PROMPT, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && + event.turnItem.status === "running", + ), + ); + const providerTurnId = harness.events.find( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, + )?.providerTurn.id; + assert.isDefined(providerTurnId); + + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, + }); + yield* harness.awaitTerminal(1); + + // The TestClock never reaches the interrupt's timeout, so this arrives only + // if the reader is free to read the interrupt response and the frame after it. + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.goal?.status === "paused", + ), + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect.each([ { name: "usage", diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 635b274a99ab6..e5b567c6948ac 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -36,7 +36,8 @@ import { OrchestrationV2ProviderGoal, parseGoalCommand, ProviderDriverKind, - type ProviderSetupError, + ProviderSetupError, + type ServerProviderCompatibilityAdvisory, } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -47,6 +48,7 @@ import { dynamicToolTitle, } from "@t3tools/shared/toolActivity"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; +import { appendGeneratedImageEmbeds } from "@t3tools/shared/generatedImageMarkdown"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import type { ChatAttachment, @@ -1548,7 +1550,7 @@ export type CodexAdapterV2DriverEnv = export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapterV2")(function* ( { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick = {}, ) { const clientFactory = yield* CodexAppServerClientFactory; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; @@ -1599,6 +1601,9 @@ export interface CodexAdapterV2Options { readonly clientFactory: CodexAppServerClientFactoryShape; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; readonly resolveRuntime?: Effect.Effect; + readonly compatibility?: ( + version: string, + ) => Effect.Effect; readonly crypto: Crypto.Crypto; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; @@ -1705,24 +1710,41 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return; } - yield* client + const userAgent = yield* client .request("initialize", { clientInfo: buildCodexInitializeParams().clientInfo, capabilities: CODEX_CLIENT_CAPABILITIES, }) .pipe( + Effect.map((response) => response.userAgent), Effect.catchTags({ // Interruption can leave Codex initialized before the local flag is set. CodexAppServerRequestError: (error) => error.code === -32600 && error.errorMessage === "Already initialized" - ? Effect.void + ? Effect.succeed(null) : Effect.fail(error), }), ); + const version = userAgent && /\/(\S+)/u.exec(userAgent)?.[1]; + const advisory = + version && adapterOptions.compatibility + ? yield* adapterOptions.compatibility(version) + : undefined; + if (advisory?.status === "broken") { + const target = + advisory.recommendedVersion ?? advisory.recommendedRange ?? "a newer version"; + return yield* new ProviderSetupError({ + instanceId: adapterOptions.instanceId, + operation: "runtime", + detail: `Codex ${version} does not work with this T3 Code release. Update Codex to ${target}, then retry.`, + }); + } yield* client.notify("initialized", undefined); yield* Ref.set(initialized, true); }), ); + // A failed open is discarded, so the next turn spawns the updated binary. + if (adapterOptions.compatibility !== undefined) yield* ensureInitialized; const now = yield* DateTime.now; const session = providerSession({ providerSessionId: input.providerSessionId, @@ -1842,6 +1864,23 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const offeredContinuationItemsByTurn = yield* Ref.make(new Map>()); const finalAnswerItemIdsByTurn = yield* Ref.make(new Map>()); const completedFinalAnswerTextsByTurn = yield* Ref.make(new Map>()); + const imageTurns = new Map< + string, + { + readonly running: Set; + readonly saved: Map; + pending: boolean; + answer?: { readonly id: string; readonly text: string }; + } + >(); + const imageTurn = (nativeTurnId: string) => { + let state = imageTurns.get(nativeTurnId); + if (state === undefined) { + state = { running: new Set(), saved: new Map(), pending: false }; + imageTurns.set(nativeTurnId, state); + } + return state; + }; // Serialize native completion and interrupt timeout so only one publishes terminal events. const turnTerminalizationPermit = yield* Semaphore.make(1); const goalsByNativeThread = new Map(); @@ -1868,6 +1907,27 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); + const emitImageWorkState = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (context: ActiveCodexTurnContext, pending: boolean) { + const state = imageTurns.get(context.nativeTurnId); + if (state === undefined || state.pending === pending) return; + state.pending = pending; + yield* emitProviderEvent({ + type: "background_work.updated", + driver: CODEX_PROVIDER, + providerTurnId: context.providerTurnId, + pending, + }); + }); + + const clearImageWork = Effect.fn("CodexAdapterV2.clearImageWork")(function* ( + context: ActiveCodexTurnContext, + ) { + yield* emitImageWorkState(context, false); + imageTurns.delete(context.nativeTurnId); + }); + // Setting a goal passes through cleared and paused states; publish only the result. const goalUpdatesHeld = new Set(); const emitGoalUpdate = Effect.fnUntraced(function* (nativeThreadId: string) { @@ -1906,7 +1966,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); // Only new model output proves recovery; local item/completed can arrive during upstream retries. - const completeProviderRetry = Effect.fn("CodexAdapterV2.completeProviderRetry")(function* ( + // Runs on every model output frame and is almost always a no-op; only a pending retry gets a span. + const completeProviderRetry = Effect.fnUntraced(function* ( context: ActiveCodexTurnContext, updatedAt: DateTime.Utc, ) { @@ -1926,7 +1987,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( type: "turn_item.updated", driver: CODEX_PROVIDER, turnItem: codexRetryTurnItem(context, providerRetry, "completed", updatedAt), - }); + }).pipe(Effect.withSpan("CodexAdapterV2.completeProviderRetry")); }); const registerRootTurn = Effect.fn("CodexAdapterV2.registerRootTurn")(function* (input: { @@ -2136,6 +2197,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if ((yield* Ref.get(pendingMcpAppCaptures)).has(nativeTurnId)) { return true; } + const images = imageTurns.get(nativeTurnId); + if (images !== undefined && (images.running.size > 0 || images.saved.size > 0)) { + return true; + } const tools = (yield* Ref.get(runningDynamicToolsByTurn)).get(nativeTurnId); if (tools === undefined) { return false; @@ -2178,6 +2243,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( updated.delete(nativeTurnId); return updated; }); + imageTurns.delete(nativeTurnId); yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { if (!current.has(nativeTurnId)) { return current; @@ -2499,31 +2565,33 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( while (ancestor !== undefined) { if (terminalizedNativeTurns.has(ancestor.nativeTurnId)) { const nativeThreadId = yield* getNativeThreadId(subagent.providerThread); - const interrupted = yield* client + // Fork requests off the notification reader so it can read their responses. + yield* client .request("turn/interrupt", { threadId: nativeThreadId, turnId: turn.nativeTurnId, }) .pipe( - Effect.as(true), Effect.catch((cause) => Effect.logWarning("orchestration-v2.codex-late-subagent-interrupt-failed", { nativeThreadId, nativeTurnId: turn.nativeTurnId, cause, - }).pipe(Effect.as(false)), + }), ), - Effect.timeoutOption("10 seconds"), - ); - if (Option.isNone(interrupted)) { - yield* Effect.logWarning( - "orchestration-v2.codex-late-subagent-interrupt-timeout", - { - nativeThreadId, - nativeTurnId: turn.nativeTurnId, - }, + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.logWarning( + "orchestration-v2.codex-late-subagent-interrupt-timeout", + { + nativeThreadId, + nativeTurnId: turn.nativeTurnId, + }, + ), + }), + Effect.forkIn(scope), ); - } return; } ancestor = ancestor.subagent?.parentContext; @@ -3389,6 +3457,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( turnItem: artifacts.turnItem, }); if (finalAnswerItem && update.completed) { + imageTurn(update.turnId).answer = { id: update.itemId, text: update.text }; yield* Ref.update(completedFinalAnswerTextsByTurn, (current) => { const updated = new Map(current); const texts = new Set(updated.get(update.turnId) ?? []); @@ -4702,7 +4771,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } if (payload.item.type === "agentMessage") { - if (payload.item.phase !== "commentary") { + if (payload.item.phase == null || payload.item.phase === "final_answer") { yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { const updated = new Map(current); const itemIds = new Set(updated.get(payload.turnId) ?? []); @@ -4714,6 +4783,14 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return; } + if (payload.item.type === "imageGeneration") { + if (!(yield* Ref.get(interruptingNativeTurns)).has(payload.turnId)) { + imageTurn(payload.turnId).running.add(payload.item.id); + yield* emitImageWorkState(context, true); + } + return; + } + if (payload.item.type === "commandExecution") { if (!codexItemStatus(payload.item.status).completed) { yield* trackRunningCommandItem(payload.turnId, { @@ -4992,6 +5069,25 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return; } + if (payload.item.type === "imageGeneration") { + if ((yield* Ref.get(interruptingNativeTurns)).has(payload.turnId)) return; + const { id, savedPath, status, failure } = payload.item; + const state = imageTurn(payload.turnId); + if (savedPath?.trim() && status === "completed" && failure == null) { + state.saved.set(id, savedPath); + } + // Idle release runs concurrently, so keep this work pending through projection. + if (settled) { + yield* projectGeneratedImages(context, payload.turnId); + } + state.running.delete(id); + yield* emitImageWorkState(context, state.running.size > 0 || state.saved.size > 0); + if (settled) { + yield* releaseSettledTurnIfIdle(payload.turnId); + } + return; + } + if (payload.item.type !== "agentMessage") { return; } @@ -5038,7 +5134,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return; } - const finalAnswer = payload.item.phase !== "commentary"; + const finalAnswer = payload.item.phase == null || payload.item.phase === "final_answer"; if (finalAnswer) { yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { const updated = new Map(current); @@ -5846,6 +5942,39 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, ); + const projectGeneratedImages = Effect.fn("CodexAdapterV2.projectGeneratedImages")( + function* (context: ActiveCodexTurnContext, nativeTurnId: string) { + const state = imageTurns.get(nativeTurnId); + const firstImageItemId = state?.saved.keys().next().value; + if (state === undefined || firstImageItemId === undefined) return; + const finalAnswer = state.answer; + const text = appendGeneratedImageEmbeds(finalAnswer?.text ?? "", state.saved.values()); + if (text === (finalAnswer?.text ?? "")) { + state.saved.clear(); + return; + } + const message = { id: finalAnswer?.id ?? firstImageItemId, text }; + state.answer = message; + const artifacts = yield* buildAgentMessageArtifacts(context, message, true); + yield* emitProviderEvent({ + type: "node.updated", + driver: CODEX_PROVIDER, + node: artifacts.node, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: CODEX_PROVIDER, + message: artifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); + state.saved.clear(); + }, + ); + const finalizeCodexTurn = (input: { readonly context: ActiveCodexTurnContext; readonly nativeTurnId: string; @@ -5919,6 +6048,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( input.completedAt, ); } + yield* projectGeneratedImages(input.context, input.nativeTurnId); + if (input.status !== "completed") { + yield* clearImageWork(input.context); + } else { + const images = imageTurns.get(input.nativeTurnId); + if (images !== undefined) { + yield* emitImageWorkState(input.context, images.running.size > 0); + } + } const nativeThreadId = input.context.providerThread.nativeThreadRef?.nativeId; const activation = nativeThreadId == null ? undefined : goalActivations.get(nativeThreadId); @@ -6139,6 +6277,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( updated.delete(input.nativeTurnId); return updated; }); + imageTurns.delete(input.nativeTurnId); yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { if (!current.has(input.nativeTurnId)) { return current; @@ -6513,7 +6652,25 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( else next.delete(threadId); return next; }); + // Completion can remove the old context while turn/start is in flight. + const previousTurns = new Map([ + ...(yield* Ref.get(settledTurns)), + ...(yield* Ref.get(activeTurns)), + ]); const started = yield* client.request("turn/start", turnStartParams); + const previousTurn = + previousTurns.get(started.turn.id) ?? + (yield* Ref.get(activeTurns)).get(started.turn.id); + if ( + previousTurn !== undefined && + previousTurn.input.attemptId !== turnInput.attemptId + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CODEX_PROVIDER, + detail: + "Codex returned a turn belonging to an earlier run. Stop the existing turn before retrying this message.", + }); + } // A turn/started notification may have registered and completed this turn already. if ((yield* Ref.get(pendingRootTurns)).get(threadId) !== turnInput) return; yield* registerRootTurn({ @@ -6748,6 +6905,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if ((yield* Ref.get(pendingMcpAppCaptures)).size > 0) { return true; } + for (const state of imageTurns.values()) { + if (state.running.size > 0 || state.saved.size > 0) return true; + } for (const subagent of (yield* Ref.get(subagentThreads)).values()) { if (subagent.task.status === "running") { return true; @@ -7418,6 +7578,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( updated.delete(context.nativeTurnId); return updated; }); + yield* clearImageWork(context); yield* releaseSettledTurnIfIdle(context.nativeTurnId); } }), diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 37350b4925cf9..bb26a5dd53a30 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2265,6 +2265,49 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect("continues a reply still streaming at reconnect from the text already shown", () => + Effect.gen(function* () { + const delta = (text: string) => + event("session.text.delta", { + sessionID: SESSION, + assistantMessageID: "msg_assistant_gap", + ordinal: 0, + delta: text, + }); + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + delta("Sent before the stream dropped"), + // OpenCode saves a block's text only when the block ends. + ...reconnected({ [SESSION]: { type: "running" } }, undefined, { + content: [{ type: "text", text: "" }], + finish: undefined, + }), + ...openRequests([]), + delta(" and after it came back."), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const texts = collected.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem.text] + : [], + ); + assert.deepEqual(texts, [ + "Sent before the stream dropped", + "Sent before the stream dropped", + "Sent before the stream dropped and after it came back.", + "Sent before the stream dropped and after it came back.", + ]); + }).pipe(Effect.scoped), + ); + it.effect("shows a permission asked while the stream was down, and answers it", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed( diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index ff19634917f31..17fe7224773d5 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -719,4 +719,190 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { assert.deepEqual([...projected.readyCheckpointOrdinals].toSorted(), [0, 1]); }), ); + + it.effect("completes a side chat run with a missing checkpoint and never touches git", () => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const sideChatThreadId = ThreadId.make("thread:checkpoint-capture-side-chat"); + const sideChatRunId = RunId.make("run:checkpoint-capture-side-chat"); + const sideChatScopeId = CheckpointScopeId.make("scope:checkpoint-capture-side-chat"); + const sideChatRootNodeId = NodeId.make("node:checkpoint-capture-side-chat-root"); + const sideChatProviderThreadId = ProviderThreadId.make( + "provider-thread:checkpoint-capture-side-chat", + ); + const event = { threadId: sideChatThreadId, occurredAt: now }; + const events: ReadonlyArray = [ + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:thread"), + type: "thread.created", + payload: { + createdBy: "user", + creationSource: "web", + id: sideChatThreadId, + projectId, + title: "Side chat", + providerInstanceId, + modelSelection, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: "/repo", + activeProviderThreadId: null, + lineage: { + parentThreadId: threadId, + relationshipToParent: "fork", + rootThreadId: threadId, + sideChat: true, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:provider-thread"), + type: "provider-thread.updated", + driver, + providerInstanceId, + payload: { + id: sideChatProviderThreadId, + driver, + providerInstanceId, + providerSessionId: null, + appThreadId: sideChatThreadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:run"), + type: "run.updated", + runId: sideChatRunId, + payload: { + id: sideChatRunId, + threadId: sideChatThreadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: sideChatProviderThreadId, + userMessageId: MessageId.make("message:checkpoint-capture-side-chat"), + rootNodeId: sideChatRootNodeId, + activeAttemptId: null, + status: "waiting", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:node"), + type: "node.updated", + runId: sideChatRunId, + nodeId: sideChatRootNodeId, + payload: { + id: sideChatRootNodeId, + threadId: sideChatThreadId, + runId: sideChatRunId, + parentNodeId: null, + rootNodeId: sideChatRootNodeId, + kind: "root_turn", + status: "waiting", + countsForRun: true, + providerThreadId: sideChatProviderThreadId, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: sideChatScopeId, + startedAt: now, + completedAt: null, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:scope"), + type: "checkpoint-scope.created", + runId: sideChatRunId, + nodeId: sideChatRootNodeId, + payload: { + id: sideChatScopeId, + threadId: sideChatThreadId, + runId: sideChatRunId, + nodeId: sideChatRootNodeId, + parentScopeId: null, + providerThreadId: sideChatProviderThreadId, + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: "/repo", + createdAt: now, + }, + }, + ]; + yield* Effect.forEach(events, (candidate) => projectionStore.apply(candidate)); + const gitUnavailable = () => Effect.die("side chats must not snapshot the shared worktree"); + const layerCapture = CheckpointCaptureService.layer.pipe( + Layer.provide( + Layer.mergeAll( + IdAllocator.layer, + CheckpointService.layer.pipe( + Layer.provide( + Layer.mergeAll( + IdAllocator.layer, + NodeCrypto.layer, + Layer.mock(CheckpointStore.CheckpointStore)({ + isGitRepository: gitUnavailable, + captureCheckpoint: gitUnavailable, + hasCheckpointRef: gitUnavailable, + }), + ), + ), + ), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => + Effect.forEach(input.events, (committed) => projectionStore.apply(committed)).pipe( + Effect.as({ committed: true } as never), + Effect.orDie, + ), + }), + ), + ), + ); + + yield* CheckpointCaptureService.CheckpointCaptureServiceV2.use((service) => + service.execute({ + threadId: sideChatThreadId, + runId: sideChatRunId, + scopeId: sideChatScopeId, + }), + ).pipe(Effect.provide(layerCapture)); + + const { checkpoints, runs } = yield* projectionStore.getThreadRecords(sideChatThreadId, [ + "runs", + "checkpoints", + ]); + assert.equal(runs[0]?.status, "completed"); + const checkpoint = checkpoints.find((candidate) => candidate.id === runs[0]?.checkpointId); + assert.equal(checkpoint?.status, "missing"); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts index 8924943fda29f..9d7d3cfd4ae2a 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts @@ -65,7 +65,7 @@ export const layer: Layer.Layer< readonly runId: RunId; readonly scopeId: CheckpointScopeId; }) { - const { run, rootNode, scope, providerThread, readyCheckpointOrdinals } = + const { run, rootNode, scope, providerThread, readyCheckpointOrdinals, sideChat } = yield* projections.getCheckpointCaptureContext(input.threadId, input); // Stopped runs remain terminal; their checkpoint is the next message's rollback point. const stopped = run?.status === "interrupted" || run?.status === "cancelled"; @@ -109,12 +109,14 @@ export const layer: Layer.Layer< : yield* checkpoints.materializeBaselineCheckpoint({ scope, ordinalWithinScope: 0, + forceMissing: sideChat, }); const baselineCheckpoint = hasReadyCheckpoint(baselineOrdinalWithinScope) ? null : yield* checkpoints.materializeBaselineCheckpoint({ scope, ordinalWithinScope: baselineOrdinalWithinScope, + forceMissing: sideChat, }); const checkpoint = yield* checkpoints.capture({ scope, @@ -123,6 +125,7 @@ export const layer: Layer.Layer< ordinalWithinScope: run.ordinal, appRunOrdinal: run.ordinal, capturedAt, + forceMissing: sideChat, }); // Omit delegatedCompletion to preserve cohort writes made after capture loaded the run. const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = run; diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index f7290faf9c9c3..145de122f387a 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -64,6 +64,7 @@ it.effect.each([ id: otherId, projectId, deletedAt: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: otherId }, worktreePath: owner === "ancestor" || owner === "conversation" ? parent @@ -202,3 +203,52 @@ it.effect("lets rollback recreate a worktree storage cleanup removed", () => assert.isTrue(isolated); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect.each([ + { sideChat: "answering questions", running: false, toolItems: 0, isolated: true }, + { sideChat: "running", running: true, toolItems: 0, isolated: false }, + { sideChat: "after running tools", running: false, toolItems: 1, isolated: false }, +] as const)( + "a side chat $sideChat shares the parent's worktree: $isolated", + ({ running, toolItems, isolated }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreePath = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-v2-restore-side-chat-", + }); + const parentId = ThreadId.make("restore-side-chat-parent"); + const sideChat = { + id: ThreadId.make("restore-side-chat"), + projectId: ProjectId.make("restore-project"), + deletedAt: null, + worktreePath, + activeRunId: running ? "side-chat-run" : null, + lineage: { + parentThreadId: parentId, + relationshipToParent: "fork", + rootThreadId: parentId, + sideChat: true, + }, + }; + const result = yield* isCheckpointRestoreIsolated( + { id: parentId, worktreePath }, + { cwd: worktreePath }, + { + fileSystem, + path, + projections: { + getShellSnapshot: () => Effect.succeed({ threads: [sideChat], archivedThreads: [] }), + getThreadRecords: () => + Effect.succeed({ + turnItems: Array.from({ length: toolItems }, () => ({ type: "command_execution" })), + }), + getCheckpointContext: () => Effect.succeed({ checkpointScopes: [] }), + getThreadProviderContext: () => Effect.succeed({ providerSessions: [] }), + } as never, + projects: {} as never, + }, + ); + assert.equal(result, isolated); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts index 268549d7bf7fe..b7dc08018a896 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts @@ -7,7 +7,7 @@ import type { ProjectionStoreV2 } from "./ProjectionStore.ts"; import type * as ProjectStore from "./ProjectStore.ts"; export const SHARED_WORKSPACE_RESTORE_MESSAGE = - "File restore requires an isolated worktree. This workspace may contain changes from another thread. Rewind the conversation without restoring files instead."; + "File restore requires an isolated worktree. This workspace may contain changes from another thread or a side chat. Rewind the conversation without restoring files instead, or delete side chats you no longer need."; // Check isolation at admission and rollback; checkpoints restore whole checkouts, which may become shared. export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpointRestoreIsolated")( @@ -48,6 +48,21 @@ export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpoi const checkedPaths = new Set(); for (const otherThread of [...shell.threads, ...shell.archivedThreads]) { if (otherThread.id === thread.id || otherThread.deletedAt !== null) continue; + // An idle side chat that only answered questions never touched the shared worktree. + if ( + otherThread.lineage.sideChat === true && + otherThread.activeRunId === null && + otherThread.worktreePath === worktreePath + ) { + const { turnItems } = yield* projections.getThreadRecords( + otherThread.id, + ["turnItems"], + { + turnItemTypes: ["command_execution", "file_change", "dynamic_tool"], + }, + ); + if (turnItems.length === 0) continue; + } const other = yield* projections.getCheckpointContext(otherThread.id); const providerContext = yield* projections.getThreadProviderContext(otherThread.id); const paths = [ diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 2747fcefac79a..5990456e64fdf 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -118,6 +118,8 @@ export interface CheckpointServiceV2Shape { readonly materializeBaselineCheckpoint: (input: { readonly scope: OrchestrationV2CheckpointScope; readonly ordinalWithinScope: number; + /** Records a missing checkpoint without touching git, for side chats sharing a parent's worktree. */ + readonly forceMissing?: boolean; }) => Effect.Effect; readonly capture: (input: { readonly scope: OrchestrationV2CheckpointScope; @@ -126,6 +128,7 @@ export interface CheckpointServiceV2Shape { readonly ordinalWithinScope: number; readonly appRunOrdinal: number | null; readonly capturedAt: DateTime.Utc; + readonly forceMissing?: boolean; }) => Effect.Effect; readonly restore: (input: { readonly scope: OrchestrationV2CheckpointScope; @@ -299,7 +302,8 @@ export const layer: Layer.Layer< scopeId: input.scope.id, ordinalWithinScope: input.ordinalWithinScope, }); - const checkpointable = yield* isGitCheckpointable(input.scope.cwd); + const checkpointable = + input.forceMissing !== true && (yield* isGitCheckpointable(input.scope.cwd)); const available = checkpointable ? yield* checkpointStore .hasCheckpointRef({ @@ -364,7 +368,7 @@ export const layer: Layer.Layer< ordinalWithinScope: Math.max(0, input.ordinalWithinScope - 1), }); - if (!(yield* isGitCheckpointable(input.scope.cwd))) { + if (input.forceMissing === true || !(yield* isGitCheckpointable(input.scope.cwd))) { return makeCheckpoint({ id: checkpointId, scope: input.scope, diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index cf982c9699f77..8ed4de3e9dea0 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -260,9 +260,31 @@ layer("CommandPolicyV2", (it) => { hasStrongNativeSource: true, sourceRunStatus: "completed", fromSpecificTurn: true, + sourceBusy: false, }); assert.equal(result, "portable_context"); }), ); + + it.effect("forks a busy source through portable context", () => + Effect.gen(function* () { + const policy = yield* CommandPolicy.CommandPolicyV2; + const decide = (sourceBusy: boolean) => + policy.decideForkExecution({ + commandId, + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: CodexProviderCapabilitiesV2, + sameProvider: true, + hasStrongNativeSource: true, + sourceRunStatus: "completed", + fromSpecificTurn: true, + sourceBusy, + }); + + assert.equal(yield* decide(false), "native_fork"); + assert.equal(yield* decide(true), "portable_context"); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 869851790177a..02f329e536feb 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -115,7 +115,8 @@ export function resolveMessageDispatchIntent( : mode; } -function isActiveRun(run: OrchestrationV2Run): boolean { +/** A run that still owns the thread's provider turn. */ +export function isActiveRun(run: OrchestrationV2Run): boolean { return ( run.status === "preparing" || run.status === "starting" || @@ -210,6 +211,8 @@ export interface CommandPolicyV2Shape { readonly hasStrongNativeSource: boolean; readonly sourceRunStatus: OrchestrationV2Run["status"]; readonly fromSpecificTurn: boolean; + /** Later source runs are still active; providers cannot fork a thread mid-turn. */ + readonly sourceBusy: boolean; }, ) => Effect.Effect; readonly ensureRollback: ( @@ -373,6 +376,7 @@ const decideForkExecution: CommandPolicyV2Shape["decideForkExecution"] = (input) (input.sourceRunStatus === "completed" || input.sourceRunStatus === "waiting") && input.sameProvider && input.hasStrongNativeSource && + !input.sourceBusy && input.capabilities.threads.canForkThread && (!input.fromSpecificTurn || input.capabilities.threads.canForkFromTurn) && input.capabilities.identity.nativeThreadIds === "strong"; diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index ff2ca1fbec441..adff6f65d8e75 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -12,6 +12,10 @@ import { selectHistory, } from "@t3tools/provider-core/server/handoffBudget"; +/** The provider prompt for a turn that carries inline context ahead of the user's text. */ +export const withInlineContext = (context: string, userText: string): string => + context === "" ? userText : `${context}\n\nUser message:\n${userText}`; + /** Persist around injection; ambiguous delivery requires a fresh native thread. Call unsent if refused before prompt consumption. */ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextHandoffs")( function* (input: { diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts index aa13f23b9508b..d9005e6b6402e 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts @@ -1,11 +1,13 @@ import { assert, it } from "@effect/vitest"; import { MessageId, + ProjectId, ProviderInstanceId, ProviderThreadId, RunId, ThreadId, TurnItemId, + type OrchestrationV2AppThread, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -14,6 +16,7 @@ import * as Layer from "effect/Layer"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProjectionStoreThreadNotFoundError } from "./ProjectionStore.ts"; const layerTest = ContextHandoffService.layer.pipe(Layer.provide(IdAllocator.layer)); @@ -156,3 +159,91 @@ it.layer(layerTest)("ContextHandoffService legacy import", (it) => { }), ); }); + +it("caps the side chat context block, keeping the newest parent items", () => { + const parentThreadId = ThreadId.make("thread:side-chat-parent"); + const digest = Array.from({ length: 60 }, (_, index) => + ContextHandoffService.summarizeDeltaItem( + importedItem({ + role: index % 2 === 0 ? "user" : "assistant", + id: `parent-${index}`, + text: `item-${index} ${"x".repeat(300)}`, + ordinal: index, + }), + )!, + ); + const context = ContextHandoffService.makeSideChatContext({ parentThreadId, digest }); + + assert.include(context, "- Earlier items truncated."); + assert.notInclude(context, "item-0 "); + assert.include(context, "- Assistant: item-59 "); + assert.isAtMost(context.length, 8_000); + assert.isTrue( + context.endsWith( + `Use t3_thread_read on thread ${parentThreadId} to see the main thread's live state.`, + ), + ); + + const framingOnly = ContextHandoffService.makeSideChatContext({ parentThreadId, digest: [] }); + assert.isTrue(framingOnly.startsWith("You are in a side chat beside the main thread.")); + assert.notInclude(framingOnly, "Main thread activity"); +}); + +it("covers the run range of a very long delta", () => { + const runs = [ + { id: RunId.make("run:long-delta-early"), ordinal: 2 }, + { id: RunId.make("run:long-delta-late"), ordinal: 5 }, + ]; + const items = Array.from({ length: 150_000 }, (_, index) => ({ + runId: runs[index % 2]!.id, + })); + + assert.deepEqual( + ContextHandoffService.visibleDeltaRunOrdinals({ runs }, [...items, { runId: null }]), + { from: 2, to: 5 }, + ); +}); + +it.effect("inherited history stops at a missing fork ancestor", () => + Effect.gen(function* () { + const now = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); + const threadId = ThreadId.make("thread:fork-of-missing"); + const thread: OrchestrationV2AppThread = { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:fork-of-missing"), + title: "Fork of a missing thread", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: { + type: "run", + threadId: ThreadId.make("thread:missing-ancestor"), + runId: RunId.make("run:missing-ancestor"), + }, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + snoozedUntil: null, + snoozedAt: null, + deletedAt: null, + }; + const items = yield* ContextHandoffService.readInheritedHandoffItems( + { + getThreadRecords: (missing) => + Effect.fail(new ProjectionStoreThreadNotFoundError({ threadId: missing })), + }, + thread, + ); + assert.deepEqual(items, []); + }), +); diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index 1a1767b4df59f..67a474d963bb0 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -1,7 +1,9 @@ import { + type OrchestrationV2AppThread, OrchestrationV2ContextHandoff, type OrchestrationV2TurnItem, type OrchestrationV2Run, + type OrchestrationV2ThreadProjection, ProviderInstanceId, ProviderThreadId, RunId, @@ -22,6 +24,8 @@ import { selectHistory, } from "@t3tools/provider-core/server/handoffBudget"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { createOrchestrationV2TurnItemVisibility } from "@t3tools/shared/orchestrationV2Timeline"; +import type { ProjectionStoreV2Shape } from "./ProjectionStore.ts"; export class ContextHandoffPrepareError extends Schema.TaggedError()( "ContextHandoffPrepareError", @@ -80,6 +84,19 @@ export interface ContextHandoffServiceV2Shape { readonly items: ReadonlyArray; readonly createdAt: DateTime.Utc; }) => Effect.Effect; + /** Parent activity after the fork point, prepended to a side chat's first provider turn. */ + readonly prepareSideChatContext: (input: { + readonly threadId: ThreadId; + readonly parentThreadId: ThreadId; + readonly targetRunId: RunId; + readonly transferId: NonNullable; + readonly toProviderThreadId: ProviderThreadId; + readonly fromProviderInstanceId: ProviderInstanceId; + readonly toProviderInstanceId: ProviderInstanceId; + readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; + readonly parentItems: ReadonlyArray; + readonly createdAt: DateTime.Utc; + }) => Effect.Effect; } export class ContextHandoffServiceV2 extends Context.Service< @@ -95,7 +112,97 @@ function compactText(text: string, maxLength = 240): string { return `${compacted.slice(0, maxLength - 3)}...`; } -function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null { +export function visibleDeltaRunOrdinals( + projection: { readonly runs: ReadonlyArray> }, + items: ReadonlyArray>, +): OrchestrationV2ContextHandoff["coveredRunOrdinals"] { + const ordinalByRunId = new Map(projection.runs.map((run) => [run.id, run.ordinal])); + let from = Infinity; + let to = -Infinity; + for (const item of items) { + const ordinal = item.runId === null ? undefined : ordinalByRunId.get(item.runId); + if (ordinal === undefined) continue; + from = Math.min(from, ordinal); + to = Math.max(to, ordinal); + } + return from === Infinity ? { from: 1, to: 1 } : { from, to }; +} + +/** Drops rolled-back runs and cancelled queued messages, which are not part of the conversation. */ +export function visibleConversationItems( + projection: Pick, + items: ReadonlyArray, +): ReadonlyArray { + const visible = createOrchestrationV2TurnItemVisibility({ + runs: projection.runs, + attempts: projection.attempts, + items, + }); + return items.filter(visible); +} + +type ForkHistorySource = Pick; + +/** Visible rows of `source`'s own runs through `throughRun`, plus its v1 imported rows. */ +const readOwnHistoryItems = Effect.fn("orchestrationV2.contextHandoff.readOwnHistoryItems")( + function* ( + projectionStore: Pick, + source: ForkHistorySource, + throughRun: Pick, + ) { + const { turnItems } = yield* projectionStore.getThreadRecords(source.thread.id, ["turnItems"], { + turnItemRunIds: [ + ...source.runs.filter((run) => run.ordinal <= throughRun.ordinal).map((run) => run.id), + ...(source.thread.historyOrigin === "v1_import" ? [null] : []), + ], + }); + return visibleConversationItems(source, turnItems); + }, +); + +/** History `thread` shows from its fork ancestors, oldest first. Side chats inherit no rows. */ +export const readInheritedHandoffItems = Effect.fn( + "orchestrationV2.contextHandoff.readInheritedHandoffItems", +)(function* ( + projectionStore: Pick, + thread: OrchestrationV2AppThread, +) { + const segments: Array> = []; + const seen = new Set([thread.id]); + let current = thread; + while ( + current.forkedFrom?.type === "run" && + current.lineage.sideChat !== true && + !seen.has(current.forkedFrom.threadId) + ) { + const { threadId, runId } = current.forkedFrom; + seen.add(threadId); + const source = yield* projectionStore + .getThreadRecords(threadId, ["runs", "attempts"]) + .pipe(Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) })); + const forkRun = source?.runs.find((run) => run.id === runId); + if (source === null || forkRun === undefined) break; + segments.unshift(yield* readOwnHistoryItems(projectionStore, source, forkRun)); + current = source.thread; + } + return segments.flat(); +}); + +/** What a portable fork from `throughRun` of `source` carries: inherited rows, then its own. */ +export const readForkHistoryItems = Effect.fn( + "orchestrationV2.contextHandoff.readForkHistoryItems", +)(function* ( + projectionStore: Pick, + source: ForkHistorySource, + throughRun: Pick, +) { + return [ + ...(yield* readInheritedHandoffItems(projectionStore, source.thread)), + ...(yield* readOwnHistoryItems(projectionStore, source, throughRun)), + ]; +}); + +export function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null { switch (item.type) { case "user_message": return `- User: ${compactText(item.text)}`; @@ -135,6 +242,41 @@ function makeForkDeltaSummary(input: { ].join("\n"); } +const SIDE_CHAT_CONTEXT_MAX_CHARS = 8_000; + +/** `digest` holds `summarizeDeltaItem` lines for parent items after the fork point, oldest first. */ +export function makeSideChatContext(input: { + readonly parentThreadId: ThreadId; + readonly digest: ReadonlyArray; +}): string { + const framing = + "You are in a side chat beside the main thread. The main thread may still be running in the same worktree. Answer the user's questions; do not continue or redo the main thread's task."; + const header = "Main thread activity since this side chat's starting point:"; + const truncated = "- Earlier items truncated."; + const footer = `Use t3_thread_read on thread ${input.parentThreadId} to see the main thread's live state.`; + const kept: Array = []; + // The cap covers the whole block, so the fixed lines are reserved before any digest line. + let remaining = + SIDE_CHAT_CONTEXT_MAX_CHARS - [framing, "", header, truncated, "", footer].join("\n").length; + for (const line of input.digest.toReversed()) { + if (line.length + 1 > remaining) break; + kept.unshift(line); + remaining -= line.length + 1; + } + return [ + framing, + ...(kept.length === 0 + ? [] + : ["", header, ...(kept.length < input.digest.length ? [truncated] : []), ...kept]), + "", + footer, + ].join("\n"); +} + +/** Side chat context is prepended to the provider prompt, never replayed as budgeted history. */ +export const isSideChatContextHandoff = (handoff: OrchestrationV2ContextHandoff): boolean => + handoff.strategy === "manual_context" && handoff.transferId != null; + function makeLegacyImportSummary(items: ReadonlyArray): string { const sections = items.flatMap((item) => { switch (item.type) { @@ -412,10 +554,56 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi } satisfies OrchestrationV2ContextHandoff; }); + const prepareSideChatContext = Effect.fn( + "orchestrationV2.contextHandoff.prepareSideChatContext", + )(function* (input: Parameters[0]) { + const handoffId = yield* idAllocator.allocate + .contextHandoff({ + threadId: input.threadId, + fromProviderInstanceId: input.fromProviderInstanceId, + toProviderInstanceId: input.toProviderInstanceId, + }) + .pipe( + Effect.mapError( + (cause) => + new ContextHandoffPrepareError({ + threadId: input.threadId, + targetRunId: input.targetRunId, + fromProviderThreadIds: [], + toProviderThreadId: input.toProviderThreadId, + cause, + }), + ), + ); + return { + id: handoffId, + transferId: input.transferId, + threadId: input.threadId, + targetRunId: input.targetRunId, + fromProviderThreadIds: [], + toProviderThreadId: input.toProviderThreadId, + coveredRunOrdinals: input.coveredRunOrdinals, + strategy: "manual_context", + status: "ready", + summaryMessageId: null, + summaryText: makeSideChatContext({ + parentThreadId: input.parentThreadId, + digest: input.parentItems.flatMap((item) => { + const line = summarizeDeltaItem(item); + return line === null ? [] : [line]; + }), + }), + createdByProviderInstanceId: null, + createdAt: input.createdAt, + updatedAt: input.createdAt, + } satisfies OrchestrationV2ContextHandoff; + }); + return ContextHandoffServiceV2.of({ prepareLegacyImport, prepareForkDelta, prepareProviderHandoff, + prepareSideChatContext, }); }, ); diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index ad2bc395d083f..5053e8d568767 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -159,6 +159,11 @@ const seedParentWithTerminalTask = (input: { readonly deliveryState: "delivered" | "claimed" | "acknowledged" | "disposed"; readonly completionWake?: "always" | "settled_only"; readonly deliveryTaskIds?: ReadonlyArray; + readonly parentRunStatus?: "running" | "completed"; + readonly siblingTasks?: ReadonlyArray<{ + readonly id: NodeId; + readonly deliveryState: "delivered" | "claimed"; + }>; readonly now: DateTime.Utc; }) => Effect.gen(function* () { @@ -241,10 +246,10 @@ const seedParentWithTerminalTask = (input: { userMessageId: MessageId.make(`message:seed-user:${input.threadId}`), rootNodeId: input.rootNodeId, activeAttemptId: null, - status: "running", + status: input.parentRunStatus ?? "running", requestedAt: input.now, startedAt: input.now, - completedAt: null, + completedAt: input.parentRunStatus === "completed" ? input.now : null, checkpointId: null, contextHandoffId: null, delegatedCompletion: { @@ -261,22 +266,25 @@ const seedParentWithTerminalTask = (input: { }, }, }, - { - id: EventId.make(`event:seed-task:${input.threadId}`), - type: "subagent.updated", + ...[ + { id: input.taskId, deliveryState: input.deliveryState }, + ...(input.siblingTasks ?? []), + ].map((task) => ({ + id: EventId.make(`event:seed-task:${input.threadId}:${task.id}`), + type: "subagent.updated" as const, threadId: input.threadId, runId: input.runId, - nodeId: input.taskId, + nodeId: task.id, driver, providerInstanceId: modelSelection.instanceId, occurredAt: input.now, payload: { - id: input.taskId, + id: task.id, threadId: input.threadId, runId: input.runId, parentNodeId: input.rootNodeId, - origin: "app_owned", - createdBy: "agent", + origin: "app_owned" as const, + createdBy: "agent" as const, driver, providerInstanceId: modelSelection.instanceId, providerThreadId: null, @@ -287,16 +295,16 @@ const seedParentWithTerminalTask = (input: { model: null, completionWake: input.completionWake ?? "settled_only", completionDelivery: { - state: input.deliveryState, - observedByRunId: input.deliveryState === "acknowledged" ? input.runId : null, + state: task.deliveryState, + observedByRunId: task.deliveryState === "acknowledged" ? input.runId : null, }, - status: "completed", + status: "completed" as const, result: "child finished", startedAt: input.now, completedAt: input.now, updatedAt: input.now, }, - }, + })), ], }); }); @@ -683,6 +691,57 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { }), ); + it.effect("counts already delivered siblings in the completion notice", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:delegated-delivery-notice-count"); + const runId = RunId.make("run:delegated-delivery-notice-count"); + const firstTaskId = NodeId.make("node:delegated-delivery-notice-count-first"); + const secondTaskId = NodeId.make("node:delegated-delivery-notice-count-second"); + + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make("project:delegated-delivery-notice-count"), + runId, + rootNodeId: NodeId.make("node:delegated-delivery-notice-count-root"), + taskId: firstTaskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [firstTaskId, secondTaskId], + parentRunStatus: "completed", + siblingTasks: [ + { id: secondTaskId, deliveryState: "claimed" }, + { + id: NodeId.make("node:delegated-delivery-notice-count-earlier"), + deliveryState: "delivered", + }, + ], + now, + }); + + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:delegated-delivery-notice-count"), + threadId, + messageId: MessageId.make(`message:delegated-delivery:${threadId}`), + text: "Delegated tasks reached a terminal state.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [firstTaskId] }, + }); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const notice = projection.turnItems.find((item) => item.type === "notification"); + assert.match( + notice?.type === "notification" ? notice.summary : "", + /^2 of 3 delegated tasks finished:/, + ); + }), + ); + it.effect.each([ { name: "oversized UTF-8 result", diff --git a/apps/server/src/orchestration-v2/IdleCompaction.test.ts b/apps/server/src/orchestration-v2/IdleCompaction.test.ts index 73b9a8b54caf5..65360492bd2df 100644 --- a/apps/server/src/orchestration-v2/IdleCompaction.test.ts +++ b/apps/server/src/orchestration-v2/IdleCompaction.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import { + DEFAULT_SERVER_SETTINGS, + EventId, MessageId, + type OrchestrationV2ThreadProjection, NodeId, ProjectId, ProviderDriverKind, @@ -11,6 +14,17 @@ import { ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as IdleCompactionService from "./IdleCompactionService.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; import { idleCompactionWindow, type IdleCompactionRecords } from "./IdleCompaction.ts"; @@ -134,7 +148,7 @@ function records( } describe("idleCompactionWindow", () => { - it("skips small contexts, unknown expiry, other providers, switched threads, and compactions", () => { + it("skips small contexts, unknown expiry, other providers, switched threads, compactions, and side chats", () => { const cache = { cacheExpiresInMs: 60 * MINUTE_MS }; expect(idleCompactionWindow(records({ ...cache, usedTokens: 199_999 }), NOW_MS)).toBeNull(); expect(idleCompactionWindow(records(), NOW_MS)).toBeNull(); @@ -152,5 +166,146 @@ describe("idleCompactionWindow", () => { const archived = records(cache); archived.thread = { ...archived.thread, archivedAt: at() }; expect(idleCompactionWindow(archived, NOW_MS)).toBeNull(); + const sideChat = records(cache); + expect(idleCompactionWindow(sideChat, NOW_MS)).not.toBeNull(); + sideChat.thread = { + ...sideChat.thread, + lineage: { ...sideChat.thread.lineage, relationshipToParent: "fork", sideChat: true }, + }; + expect(idleCompactionWindow(sideChat, NOW_MS)).toBeNull(); }); }); + +describe("IdleCompactionService", () => { + it.effect.each([ + { mode: "onReturn", reads: 0 }, + { mode: "off", reads: 0 }, + { mode: "beforeCacheExpires", reads: 1 }, + { mode: "beforeCacheExpires", override: "off", reads: 0 }, + { mode: "onReturn", override: "beforeCacheExpires", reads: 1 }, + { mode: "beforeCacheExpires", projectDisabled: true, reads: 0 }, + { mode: "beforeCacheExpires", enabled: false, reads: 0 }, + { mode: "beforeCacheExpires", driver: "codex", instance: "codex", reads: 0 }, + { mode: "beforeCacheExpires", driver: "codex", instance: "custom-codex", reads: 0 }, + { mode: "beforeCacheExpires", driver: "claudeAgent", instance: "custom-claude", reads: 1 }, + { mode: "beforeCacheExpires", unconfigured: true, reads: 1 }, + ] as const)( + "loads history only for eligible idle compaction: $mode/$override/$enabled/$projectDisabled/$instance/$unconfigured", + (scenario) => { + const test = { + override: "default", + enabled: true, + projectDisabled: false, + ...scenario, + } as const; + return Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const selectedInstance = ProviderInstanceId.make( + "instance" in test ? test.instance : "claudeAgent", + ); + const driver = ProviderDriverKind.make("driver" in test ? test.driver : "claudeAgent"); + const base = records({ cacheExpiresInMs: 2 * MINUTE_MS }); + const data: OrchestrationV2ThreadProjection = { + ...base, + thread: { + ...base.thread, + providerInstanceId: selectedInstance, + modelSelection: { ...base.thread.modelSelection, instanceId: selectedInstance }, + }, + runs: base.runs.map((run) => ({ + ...run, + providerInstanceId: selectedInstance, + modelSelection: { ...run.modelSelection, instanceId: selectedInstance }, + })), + providerThreads: base.providerThreads.map((thread) => ({ + ...thread, + driver, + providerInstanceId: selectedInstance, + })), + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + plans: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + visibleTurnItems: [], + updatedAt: at(), + }; + let historyReads = 0; + let dispatches = 0; + const done = yield* Deferred.make(); + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if (span.name === "IdleCompactionService.compactWhenDue") + Deferred.doneUnsafe(done, Effect.void); + }; + return span; + }, + }); + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.make({ + id: EventId.make("idle-completed"), + type: "run.updated", + threadId, + runId, + occurredAt: at(), + payload: data.runs[0]!, + }), + dispatch: () => + Effect.sync(() => { + dispatches++; + return { sequence: 1, storedEvents: [] }; + }), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThread: () => Effect.succeed(data.thread), + getThreadRecords: () => + Effect.sync(() => { + historyReads++; + return data; + }), + }), + Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + idleCompaction: test.mode, + providerInstances: + "unconfigured" in test + ? {} + : { + [selectedInstance]: { + driver, + enabled: test.enabled, + config: { idleCompaction: test.override }, + }, + }, + projectSettingsOverrides: test.projectDisabled + ? { + [data.thread.projectId]: { + providerInstances: { [selectedInstance]: { enabled: false } }, + }, + } + : {}, + }), + }), + ); + yield* Effect.gen(function* () { + const service = yield* IdleCompactionService.make; + yield* service.start(); + yield* Deferred.await(done); + }).pipe(Effect.provide(dependencies), Effect.withTracer(tracer)); + expect(historyReads).toBe(test.reads); + expect(dispatches).toBe(test.reads); + }), + ); + }, + ); +}); diff --git a/apps/server/src/orchestration-v2/IdleCompaction.ts b/apps/server/src/orchestration-v2/IdleCompaction.ts index b142dd4efb19d..a89894045c298 100644 --- a/apps/server/src/orchestration-v2/IdleCompaction.ts +++ b/apps/server/src/orchestration-v2/IdleCompaction.ts @@ -1,4 +1,4 @@ -import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts"; +import { isListedThreadLineage, type OrchestrationV2ThreadProjection } from "@t3tools/contracts"; import { COMPACTION_SUGGESTION_MIN_TOKENS } from "@t3tools/shared/claudeCompaction"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as DateTime from "effect/DateTime"; @@ -50,7 +50,7 @@ export function idleCompactionWindow( if (thread.settledAt !== null || thread.settledOverride === "settled") return null; if (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) return null; - if (thread.lineage.relationshipToParent === "subagent") return null; + if (!isListedThreadLineage(thread.lineage)) return null; const latestRun = runs.reduce<(typeof runs)[number] | null>( (latest, run) => (latest === null || run.ordinal > latest.ordinal ? run : latest), null, diff --git a/apps/server/src/orchestration-v2/IdleCompactionService.ts b/apps/server/src/orchestration-v2/IdleCompactionService.ts index 2b16cb8c1d73d..415bb14fa1a88 100644 --- a/apps/server/src/orchestration-v2/IdleCompactionService.ts +++ b/apps/server/src/orchestration-v2/IdleCompactionService.ts @@ -35,6 +35,20 @@ export const make = Effect.gen(function* () { // Loops once after the wait: the thread may have moved on. while (true) { const current = yield* settings.getSettings; + const thread = yield* projections.getThread(threadId); + const claudeInstance = Object.hasOwn(current.providerInstances, thread.providerInstanceId) + ? current.providerInstances[thread.providerInstanceId]?.driver === "claudeAgent" + : thread.providerInstanceId === "claudeAgent"; + if ( + !claudeInstance || + resolveIdleCompaction(current, thread.providerInstanceId) !== "beforeCacheExpires" || + !resolveProjectProviderInstanceEnabled( + current, + thread.projectId, + thread.providerInstanceId, + ) + ) + return; const records = yield* projections.getThreadRecords( threadId, [ diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index d6bf51c6205b7..482c13d0a461f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -59,6 +59,7 @@ import { latestProviderTurnForAttempt, parseGoalCommand, orchestrationV2RunWorkStartedAt, + orchestrationV2WorktreePending, ProviderInstanceId, ProviderInteractionMode, resolveProviderInstanceEnabled, @@ -99,11 +100,18 @@ import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2, goalCommandStopsActiveRun, + isActiveRun, isNativeMaintenanceCommand, resolveMessageDispatchIntent, } from "./CommandPolicy.ts"; import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; -import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; +import { + ContextHandoffServiceV2, + readForkHistoryItems, + readInheritedHandoffItems, + visibleConversationItems, + visibleDeltaRunOrdinals, +} from "./ContextHandoffService.ts"; import { notificationTurnItem } from "@t3tools/provider-core/server/notification"; import { cancelledBeforeStart, @@ -637,15 +645,6 @@ function mayStartSubagentFollowup( return command.createdBy === "user" || command.creationSource === "mcp"; } -function isBlockingRun(run: OrchestrationV2Run): boolean { - return ( - run.status === "preparing" || - run.status === "starting" || - run.status === "running" || - run.status === "waiting" - ); -} - /** Waiting runs only drain post-terminal output; their agent turn is over and still needs a wake. */ function isLiveRun(run: OrchestrationV2Run): boolean { return run.status === "preparing" || run.status === "starting" || run.status === "running"; @@ -919,26 +918,6 @@ function latestContextTransfer( }, undefined); } -function visibleDeltaRunOrdinals( - projection: Pick, - items: ReadonlyArray, -): OrchestrationV2ContextHandoff["coveredRunOrdinals"] { - const ordinals = items.flatMap((item) => { - if (item.runId === null) { - return []; - } - const run = projection.runs.find((candidate) => candidate.id === item.runId); - return run === undefined ? [] : [run.ordinal]; - }); - if (ordinals.length === 0) { - return { from: 1, to: 1 }; - } - return { - from: Math.min(...ordinals), - to: Math.max(...ordinals), - }; -} - export function shouldPrepareLegacyImportHandoff(input: { readonly hasCompletedRun: boolean; readonly historyOrigin: OrchestrationV2AppThread["historyOrigin"]; @@ -1153,6 +1132,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio turnItemTypes: ["user_message", "error"], messageRoles: ["user"], omitSettledLeafNodes: true, + // Commands here read only undelivered or active tasks; a dispatcher thread keeps + // thousands of delivered ones, each carrying its full prompt. + omitSettledSubagents: true, }, ) .pipe( @@ -1167,6 +1149,23 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), ); + // Command reads omit settled tasks, but a completion notice counts its whole cohort. + const notificationTasks = ( + threadId: ThreadId, + message: Pick, + loaded: ReadonlyArray, + ) => + message.delegatedCompletion === undefined + ? Effect.succeed(loaded) + : projectionStore + .getThreadRecords(threadId, ["subagents"], { + subagentRunIds: [message.delegatedCompletion.parentRunId], + }) + .pipe( + Effect.map((records) => records.subagents), + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), + ); + const readHandoffItems = (threadId: ThreadId, runIds?: ReadonlyArray) => projectionStore .getThreadRecords( @@ -1179,6 +1178,46 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), ); + const readInheritedItems = (thread: OrchestrationV2AppThread) => + readInheritedHandoffItems(projectionStore, thread).pipe( + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId: thread.id, cause })), + ); + + /** Includes tombstones, which still reserve their id. */ + const findThread = (threadId: ThreadId) => + projectionStore.getThread(threadId).pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), + ); + + /** Side chats live inside their parent, so deleting the parent deletes them too. */ + const liveSideChatIds = Effect.fn("Orchestrator.liveSideChatIds")( + function* (threadId: ThreadId) { + const { contextTransfers } = yield* projectionStore + .getThreadRecords(threadId, ["contextTransfers"]) + .pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed({ contextTransfers: [] }), + }), + ); + const sideChatIds: Array = []; + for (const transfer of contextTransfers) { + if (transfer.type !== "fork" || transfer.sourceThreadId !== threadId) continue; + const child = yield* projectionStore + .getThread(transfer.targetThreadId) + .pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + ); + if (child?.lineage.sideChat === true && child.deletedAt === null) { + sideChatIds.push(child.id); + } + } + return sideChatIds; + }, + (effect, threadId) => + effect.pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), + ); + const getProjectionWithPendingEvents = /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ Effect.fnUntraced(function* ( @@ -1507,7 +1546,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || - projection.runs.some(isBlockingRun) + projection.runs.some(isActiveRun) ) { return; } @@ -2174,7 +2213,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio nodeId: rootNodeId, providerInstanceId: queuedRun.providerInstanceId, occurredAt: now, - payload: notificationTurnItem(userTurnItem, queuedMessage, projection.subagents), + payload: notificationTurnItem( + userTurnItem, + queuedMessage, + yield* notificationTasks(threadId, queuedMessage, projection.subagents), + ), }, { type: "run.updated", @@ -4222,26 +4265,55 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - // The source is unlocked; validate the modes copied here, not an earlier read. + // Only side chat forks lock the source; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); + if (command.sideChat === true && sourceProjection.thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.sourceThreadId} was deleted.`, + }); + } + // The fork would copy no worktree path and run in the project checkout. + if ( + command.sideChat === true && + orchestrationV2WorktreePending(sourceProjection.thread.worktreePath, sourceProjection.runs) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.sourceThreadId} has no worktree yet. Start the side chat once its worktree is set up.`, + }); + } + if ((yield* findThread(command.targetThreadId)) !== null) { + return yield* new OrchestratorThreadAlreadyExistsError({ + commandId: command.commandId, + threadId: command.targetThreadId, + }); + } const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); - if (sourceRun === null) { + // A side chat may open during the parent's first turn; it starts from a digest instead. + if ( + sourceRun === null && + !(command.sideChat === true && command.sourcePoint.type === "latest_stable") + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, cause: `No stable source run was found for fork source ${command.sourcePoint.type}.`, }); } - if (!isForkableSourceRunStatus(sourceRun.status)) { + if (sourceRun !== null && !isForkableSourceRunStatus(sourceRun.status)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, cause: forkableSourceRunStatusError(sourceRun), }); } - const sourceProviderThread = providerThreadForRun(sourceProjection, sourceRun); + const sourceProviderThread = + sourceRun === null ? undefined : providerThreadForRun(sourceProjection, sourceRun); const now = command.createdAt ?? (yield* DateTime.now); const emitEvent = emit(events, command); const transferId = yield* mapDispatchError(command)( @@ -4256,11 +4328,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio sourceProjection, sourceRun, sourceProviderThread, - sourceTokenUsage: providerTurnForRun(sourceProjection, sourceRun)?.tokenUsage, - canonicalSourcePoint: contextSourcePointForRun(sourceProjection, sourceRun), + sourceTokenUsage: + sourceRun === null + ? undefined + : providerTurnForRun(sourceProjection, sourceRun)?.tokenUsage, + canonicalSourcePoint: + sourceRun === null + ? { threadId: sourceProjection.thread.id } + : contextSourcePointForRun(sourceProjection, sourceRun), transferId, targetThreadId: command.targetThreadId, ...(command.title === undefined ? {} : { title: command.title }), + ...(command.sideChat ? { sideChat: true } : {}), createdBy: command.createdBy, creationSource: command.creationSource, createdAt: now, @@ -4277,7 +4356,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* emitEvent({ type: "context-transfer.created", threadId: command.targetThreadId, - providerInstanceId: sourceRun.providerInstanceId, + providerInstanceId: transfer.sourceProviderInstanceId ?? targetThread.providerInstanceId, occurredAt: now, payload: transfer, }); @@ -4316,7 +4395,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // The source is unlocked; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const targetProjection = yield* projectionStore - .getThreadRecords(command.targetThreadId, ["contextTransfers"]) + .getThreadRecords(command.targetThreadId, ["runs", "contextTransfers"]) .pipe( Effect.mapError( (cause) => @@ -4337,6 +4416,30 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.sourceThreadId} is not a fork of ${command.targetThreadId}.`, }); } + // Queued runs cannot consume merge-back context. Refuse under the target's + // command lock before a pending transfer can block its ordinary messages. + if (targetProjection.runs.some((run) => isActiveRun(run) || run.status === "queued")) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "Finish or stop the target thread's active work and clear its queue before merging back.", + }); + } + const otherPendingMergeBack = targetProjection.contextTransfers.some( + (transfer) => + transfer.type === "merge_back" && + transfer.status === "pending" && + transfer.targetThreadId === command.targetThreadId && + transfer.sourceThreadId !== command.sourceThreadId, + ); + if (otherPendingMergeBack) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.targetThreadId} already has pending context from another side chat or fork. Send a message there first.`, + }); + } const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); if (sourceRun === null) { @@ -4677,7 +4780,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio nodeId: messageInput.nodeId, providerInstanceId: messageInput.providerInstanceId, occurredAt: now, - payload: notificationTurnItem(turnItem, message, input.projection.subagents), + payload: notificationTurnItem( + turnItem, + message, + yield* notificationTasks(input.command.threadId, message, input.projection.subagents), + ), }); }, ); @@ -5613,7 +5720,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const activeProviderThread = projection.providerThreads.find( (candidate) => candidate.id === projection.thread.activeProviderThreadId, ); - const activeRun = projection.runs.find(isBlockingRun); + const activeRun = projection.runs.find(isActiveRun); // A message queued during worktree setup can arrive after that setup failed // without a worktree. It waits, held, like the messages queued before the // failure, instead of starting in the project checkout. @@ -6340,7 +6447,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: notificationTurnItem(turnItem, message, projection.subagents), + payload: notificationTurnItem( + turnItem, + message, + yield* notificationTasks(command.threadId, message, projection.subagents), + ), }); if (preparationTurnItem !== null) { yield* emitEvent({ @@ -6401,8 +6512,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio sourceProjection.attempts.find((candidate) => candidate.id === sourceRun.activeAttemptId) ?.providerTurnId ?? undefined); + const sideChatFork = + pendingForkTransfer !== undefined && projection.thread.lineage.sideChat === true; + // Opened during the parent's first turn: the digest is the whole context. + const sideChatWithoutSource = + sideChatFork && pendingForkTransfer.sourcePoint.runId === undefined; if (pendingForkTransfer !== undefined) { - if (sourceRun === null || sourceProviderThread === undefined) { + if (!sideChatWithoutSource && (sourceRun === null || sourceProviderThread === undefined)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -6467,6 +6583,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + const sourceRunsAfterFork = + sourceProjection === null + ? [] + : sourceProjection.runs.filter( + (candidate) => sourceRun === null || candidate.ordinal > sourceRun.ordinal, + ); const forkExecution = pendingForkTransfer === undefined || sourceRun === null ? null @@ -6481,6 +6603,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio hasStrongNativeSource: sourceProviderThread?.nativeThreadRef?.strength === "strong", sourceRunStatus: sourceRun.status, fromSpecificTurn: sourceRun !== null, + sourceBusy: sourceRunsAfterFork.some(isActiveRun), }), ); const canResolveForkNatively = forkExecution === "native_fork"; @@ -6537,12 +6660,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const portableForkItems = !requiresPortableFork || sourceProjection === null || sourceRun === null ? [] - : yield* readHandoffItems(sourceProjection.thread.id, [ - ...sourceProjection.runs - .filter((run) => run.ordinal <= sourceRun.ordinal) - .map((run) => run.id), - ...(sourceProjection.thread.historyOrigin === "v1_import" ? [null] : []), - ]); + : yield* readForkHistoryItems(projectionStore, sourceProjection, sourceRun).pipe( + Effect.mapError( + (cause) => + new OrchestratorProjectionError({ threadId: sourceProjection.thread.id, cause }), + ), + ); const portableForkHandoff = !requiresPortableFork || pendingForkTransfer === undefined || @@ -6575,6 +6698,46 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + // Without a source run the digest covers the parent's whole history: inherited fork rows + // first, so the cap truncates them first, then imported and run items. + const sideChatParentRunIds = [ + // Queued messages have not reached the main thread yet. + ...sourceRunsAfterFork.filter((run) => run.status !== "queued").map((run) => run.id), + ...(sourceRun === null && sourceProjection?.thread.historyOrigin === "v1_import" + ? [null] + : []), + ]; + const sideChatParentItems = + !sideChatFork || sourceProjection === null + ? [] + : [ + ...(sideChatWithoutSource ? yield* readInheritedItems(sourceProjection.thread) : []), + ...(sideChatParentRunIds.length === 0 + ? [] + : visibleConversationItems( + sourceProjection, + yield* readHandoffItems(sourceProjection.thread.id, sideChatParentRunIds), + )), + ]; + // The framing note goes in even when the parent has no activity after the fork point. + const sideChatContextHandoff = + !sideChatFork || pendingForkTransfer === undefined || sourceProjection === null + ? null + : yield* contextHandoffService + .prepareSideChatContext({ + threadId: command.threadId, + parentThreadId: sourceProjection.thread.id, + targetRunId: runId, + transferId: pendingForkTransfer.id, + toProviderThreadId: ensuredProviderThread.id, + fromProviderInstanceId: + pendingForkTransfer.sourceProviderInstanceId ?? modelSelection.instanceId, + toProviderInstanceId: modelSelection.instanceId, + coveredRunOrdinals: visibleDeltaRunOrdinals(sourceProjection, sideChatParentItems), + parentItems: sideChatParentItems, + createdAt: now, + }) + .pipe(mapDispatchError(command)); const requiresFullProviderSwitchContext = isProviderSwitch && pendingMergeBackTransfer !== undefined; const targetLastCompletedRun = @@ -6691,9 +6854,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio lastRunOrdinal: ordinal, handoffIds: [ ...ensuredProviderThread.handoffIds, - ...[portableForkHandoff, providerSwitchHandoff, legacyImportRecoveryHandoff].flatMap( - (handoff) => (handoff === null ? [] : [handoff.id]), - ), + ...[ + portableForkHandoff, + sideChatContextHandoff, + providerSwitchHandoff, + legacyImportRecoveryHandoff, + ].flatMap((handoff) => (handoff === null ? [] : [handoff.id])), ], updatedAt: now, }; @@ -6759,11 +6925,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const mergeBackDeltaItems = mergeBackSourceProjection === null || mergeBackSourceRun === null ? [] - : yield* readHandoffItems( - mergeBackSourceProjection.thread.id, - mergeBackSourceProjection.runs - .filter((run) => run.ordinal <= mergeBackSourceRun.ordinal) - .map((run) => run.id), + : visibleConversationItems( + mergeBackSourceProjection, + yield* readHandoffItems( + mergeBackSourceProjection.thread.id, + mergeBackSourceProjection.runs + .filter((run) => run.ordinal <= mergeBackSourceRun.ordinal) + .map((run) => run.id), + ), ); const mergeBackHandoff = pendingMergeBackTransfer === undefined || @@ -6996,12 +7165,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio strategy: "native_fork", providerThreadRef: providerThread.nativeThreadRef, }; + const portableForkContext = + portableForkHandoff ?? (sideChatWithoutSource ? sideChatContextHandoff : null); const portableForkResolution: OrchestrationV2ContextTransferResolution | null = - pendingForkTransfer === undefined || portableForkHandoff === null + pendingForkTransfer === undefined || portableForkContext === null ? null : { strategy: "portable_context", - contextHandoffId: portableForkHandoff.id, + contextHandoffId: portableForkContext.id, }; const mergeBackResolution: OrchestrationV2ContextTransferResolution | null = pendingMergeBackTransfer === undefined || mergeBackHandoff === null @@ -7064,6 +7235,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: portableForkHandoff, }); } + if (sideChatContextHandoff !== null) { + yield* emitEvent({ + type: "context-handoff.updated", + threadId: command.threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: sideChatContextHandoff, + }); + } if (legacyImportRecoveryHandoff !== null) { yield* emitEvent({ type: "context-handoff.updated", @@ -7234,7 +7415,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: notificationTurnItem(turnItem, message, projection.subagents), + payload: notificationTurnItem( + turnItem, + message, + yield* notificationTasks(command.threadId, message, projection.subagents), + ), }); const forkResolution = nativeForkResolution ?? portableForkResolution; if (pendingForkTransfer !== undefined && forkResolution !== null) { @@ -7301,14 +7486,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio effects: Ref.Ref>, ) { const parentProjection = yield* projectionStore - .getThreadRecords(command.parentThreadId, [ - "runs", - "nodes", - "subagents", - "providerThreads", - "providerTurns", - "attempts", - ]) + .getThreadRecords( + command.parentThreadId, + ["runs", "nodes", "providerThreads", "providerTurns", "attempts"], + // The parent node must belong to the active run, whose nodes this keeps. + { omitSettledLeafNodes: true }, + ) .pipe( Effect.mapError( (cause) => @@ -7321,7 +7504,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const parentRun = parentProjection.runs.find( (candidate) => candidate.id === command.parentRunId, ); - if (parentRun === undefined || !isBlockingRun(parentRun)) { + if (parentRun === undefined || !isActiveRun(parentRun)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -7378,7 +7561,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio parentTitle: parentProjection.thread.title, prompt: command.task, ...(command.title === undefined ? {} : { title: command.title }), - ordinal: parentProjection.subagents.length + 1, + ordinal: (yield* subagentCount(command.parentThreadId)) + 1, }); const childThread: OrchestrationV2AppThread = { ...makeSubagentChildThread({ @@ -7613,7 +7796,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (run === undefined || run.delegatedTaskId !== undefined) return; const parent = yield* projectionStore - .getThreadRecords(parentThreadId, ["runs", "subagents", "providerTurns", "attempts"]) + .getThreadRecords(parentThreadId, ["runs", "subagents", "providerTurns", "attempts"], { + subagentIds: child.forkedFrom?.type === "node" ? [child.forkedFrom.nodeId] : [], + }) .pipe(mapDispatchError(command)); const original = parent.subagents.find( (task) => @@ -7623,7 +7808,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (original === undefined || original.result === null) return; const owner = - parent.runs.filter(isBlockingRun).toSorted((a, b) => b.ordinal - a.ordinal)[0] ?? + parent.runs.filter(isActiveRun).toSorted((a, b) => b.ordinal - a.ordinal)[0] ?? latestExecutedRun(parent.runs); if (owner === null || owner.rootNodeId === null) return; const now = message.createdAt; @@ -7741,13 +7926,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events: Ref.Ref>, ) { const parentProjection = yield* projectionStore - .getThreadRecords(command.parentThreadId, [ - "runs", - "nodes", - "subagents", - "providerTurns", - "attempts", - ]) + .getThreadRecords(command.parentThreadId, ["runs", "providerTurns", "attempts"]) .pipe( Effect.mapError( (cause) => new OrchestratorProjectionError({ threadId: command.parentThreadId, cause }), @@ -7756,7 +7935,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const parentRun = parentProjection.runs.find( (candidate) => candidate.id === command.parentRunId, ); - if (parentRun === undefined || !isBlockingRun(parentRun) || parentRun.rootNodeId === null) { + if (parentRun === undefined || !isActiveRun(parentRun) || parentRun.rootNodeId === null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -7779,7 +7958,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio parentTitle: parentProjection.thread.title, prompt: command.task, ...(command.title === undefined ? {} : { title: command.title }), - ordinal: parentProjection.subagents.length + 1, + ordinal: (yield* subagentCount(command.parentThreadId)) + 1, }); const common = { threadId: command.parentThreadId, @@ -7884,11 +8063,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio >, events: Ref.Ref>, ) { + // Delivery planning reads only this task and the undelivered tasks of the queued delivery; + // the task's own node and turn item are all it updates besides. const parentProjection = yield* projectionStore .getThreadRecords( command.parentThreadId, ["runs", "messages", "subagents", "providerTurns", "nodes", "turnItems"], - { turnItemTypes: ["subagent"], messageRoles: ["user"] }, + { + turnItemTypes: ["subagent"], + messageRoles: ["user"], + omitSettledSubagents: { except: [command.taskId] }, + nodeIds: [command.taskId], + turnItemNodeIds: [command.taskId], + }, ) .pipe( Effect.mapError( @@ -8114,8 +8301,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command: Extract, events: Ref.Ref>, ) { + // Delivery planning reads only this task and the undelivered tasks of the queued delivery. const parentProjection = yield* projectionStore - .getThreadRecords(command.parentThreadId, ["subagents", "runs", "messages", "providerTurns"]) + .getThreadRecords( + command.parentThreadId, + ["subagents", "runs", "messages", "providerTurns"], + { + omitSettledSubagents: { except: [command.taskId] }, + }, + ) .pipe( Effect.mapError( (cause) => @@ -8463,7 +8657,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } if ( existing === undefined && - (!isBlockingRun(run) || (yield* stopReachedRun(command, command.threadId, run.id))) + (!isActiveRun(run) || (yield* stopReachedRun(command, command.threadId, run.id))) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -9558,6 +9752,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + const subagentCount = (threadId: ThreadId) => + projectionStore + .getSubagentCount(threadId) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + const loadProjectionForCommand = ( command: OrchestrationV2Command, fields: ReadonlyArray, @@ -9867,7 +10066,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Run ${command.runId} has no failed workspace preparation to retry.`, }); } - if (projection.runs.some((run) => run.id !== state.run.id && isBlockingRun(run))) { + if (projection.runs.some((run) => run.id !== state.run.id && isActiveRun(run))) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -11367,7 +11566,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio continue; const idle = idleChildren.get(task.childThreadId) ?? - (!child.runs.some((run) => run.status === "queued" || isBlockingRun(run)) && + (!child.runs.some((run) => run.status === "queued" || isActiveRun(run)) && !child.providerThreads.some( (thread) => thread.status === "active" || (thread.pendingBackgroundTasks?.length ?? 0) > 0, @@ -12640,6 +12839,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchOnce = Effect.fn("orchestrationV2.dispatch.once")(function* ( command: OrchestrationV2ServerCommand, + sideChatIds: ReadonlyArray, ): Effect.fn.Return< { readonly events: ReadonlyArray; @@ -12706,31 +12906,64 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio break; } case "thread.delete": { - const projection = yield* projectionStore - .getThreadRecords(command.threadId, [ - "runs", - "attempts", - "nodes", - "runtimeRequests", - "subagents", - "providerSessions", - ]) - .pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), + const now = yield* DateTime.now; + const planDeletion = Effect.fn("Orchestrator.planDeletion")(function* ( + deletion: typeof command, + ) { + const projection = yield* projectionStore + .getThreadRecords(deletion.threadId, [ + "runs", + "attempts", + "nodes", + "runtimeRequests", + "subagents", + "providerSessions", + ]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: deletion.threadId, cause }), + ), + ); + return yield* mapDispatchError(command)( + planThreadDeletion({ + command: deletion, + projection, + attachmentIds: yield* projectionStore + .getThreadAttachmentIds(deletion.threadId) + .pipe(mapDispatchError(command)), + now, + idAllocator, + }), ); - return yield* mapDispatchError(command)( - planThreadDeletion({ - command, - projection, - attachmentIds: yield* projectionStore - .getThreadAttachmentIds(command.threadId) - .pipe(mapDispatchError(command)), - now: yield* DateTime.now, - idAllocator, - }), - ); + }); + // Checked under the thread lock: a message sent since the client looked keeps the thread. + if (command.onlyIfUnused === true) { + const usage = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "messages"]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (usage.runs.length > 0 || usage.messages.length > 0) { + return { events: [], effects: [] }; + } + } + // The dispatch wrapper holds each side chat's lock, so none can commit after this plan. + const plans = [ + yield* planDeletion(command), + ...(yield* Effect.forEach(sideChatIds, (threadId) => + planDeletion({ + ...command, + commandId: CommandId.make(`${command.commandId}:side-chat:${threadId}`), + threadId, + }), + )), + ]; + return { + events: plans.flatMap((plan) => plan.events), + effects: plans.flatMap((plan) => plan.effects), + }; } case "thread.archive": case "thread.unarchive": @@ -12894,7 +13127,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio selectedMessage === undefined || selectedMessage.delegatedCompletion !== undefined || selectedMessage.notification !== undefined || - projection.runs.some(isBlockingRun) + projection.runs.some(isActiveRun) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -13018,8 +13251,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; }); + /** `sideChatIds` are the live side chats a `thread.delete` cascades to, read under their locks. */ const dispatchWithReceiptEffect = Effect.fn("orchestrationV2.dispatch.withReceipt")(function* ( command: OrchestrationV2ServerCommand, + sideChatIds: ReadonlyArray = [], ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -13095,7 +13330,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell); } - const plan = yield* dispatchOnce(command).pipe( + const plan = yield* dispatchOnce(command, sideChatIds).pipe( Effect.filterOrFail( (planned) => planned.events.length > 0 || @@ -13104,7 +13339,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.type === "thread.stop" || command.type === "subagent.stop" || // Closing a share again, or after it ended, is not an error. - command.type === "thread.guest-share.close", + command.type === "thread.guest-share.close" || + (command.type === "thread.delete" && command.onlyIfUnused === true), (_planned) => new OrchestratorDispatchError({ commandId: command.commandId, @@ -13246,6 +13482,32 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio commandThreadId(command), dispatchWithReceiptEffect(command), ); + // A parent's lock comes before its side chats' locks, so a delete cascade and a side chat + // fork or command never interleave. A fork from a side chat or onto an existing thread would + // invert that order; dispatch rejects both, so they skip the source lock. + if ( + command.type === "thread.fork" && + command.sideChat === true && + command.sourceThreadId !== command.targetThreadId + ) { + const source = yield* findThread(command.sourceThreadId); + const target = yield* findThread(command.targetThreadId); + return yield* source === null || source.lineage.sideChat === true || target !== null + ? dispatch + : threadDispatch.withLock(command.sourceThreadId, dispatch); + } + if (command.type === "thread.delete") + return yield* threadDispatch.withLock( + command.threadId, + liveSideChatIds(command.threadId).pipe( + Effect.flatMap((sideChatIds) => + sideChatIds.reduce( + (locked, sideChatId) => threadDispatch.withLock(sideChatId, locked), + dispatchWithReceiptEffect(command, sideChatIds), + ), + ), + ), + ); // Answering a message-mode question can also start a follow-up. if ( command.type !== "runtime-request.respond" && @@ -13349,7 +13611,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio stored.event.payload.status === "rolled_back"), ), Stream.runForEach(handleRunUpdate), - Effect.forkDetach, + Effect.forkScoped, ); // A child whose run ends while its background tasks or its own subagents still run has no @@ -13391,7 +13653,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ), ), - Effect.forkDetach, + Effect.forkScoped, ); const recoverDelegatedTasks = Effect.gen(function* () { @@ -13569,7 +13831,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio thread.limitRecovery.resetAt === input.limitRecovery.resetAt ) return; - yield* dispatchWithReceiptEffect({ type: "thread.metadata.update", ...input }); + const command = { type: "thread.metadata.update", ...input } as const; + // A Stop that reached the run before its limit failure landed had nothing to disarm. + const stopped = yield* stopReachedRun(command, input.threadId, input.limitRecovery.runId); + yield* dispatchWithReceiptEffect( + stopped + ? { + ...command, + limitRecovery: { ...input.limitRecovery, autoResume: false, snooze: false }, + } + : command, + ); }), ); }), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 1dc7c912e7765..a29d698d76daf 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -850,6 +850,121 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { nodeNames(yield* store.getThreadRecords(threadId, ["nodes"])), nodes.map(([name]) => name).toSorted(), ); + + const subagents = [ + ["delivered", settledRun, "completed", "delivered"], + ["acknowledged", settledRun, "failed", "acknowledged"], + ["disposed", settledRun, "cancelled", "disposed"], + ["pending-delivery", settledRun, "completed", "pending"], + ["claimed", settledRun, "completed", "claimed"], + ["no-delivery", settledRun, "interrupted", null], + ["running", settledRun, "running", null], + ["active-run-delivered", activeRun, "completed", "delivered"], + ] as const; + for (const [index, [name, run, status, delivery]] of subagents.entries()) { + const id = NodeId.make(`node:settled-subagents:${name}`); + yield* store.apply({ + id: EventId.make(`event:settled-subagents:item:${name}`), + type: "turn-item.updated", + threadId, + runId: run.id, + nodeId: id, + driver, + occurredAt: now, + payload: { + id: TurnItemId.make(`item:settled-subagents:${name}`), + type: "subagent", + threadId, + runId: run.id, + nodeId: id, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 100 + index, + status: status === "running" ? "running" : "completed", + title: name, + startedAt: now, + completedAt: status === "running" ? null : now, + updatedAt: now, + subagentId: id, + origin: "app_owned", + driver, + providerInstanceId, + childThreadId: null, + prompt: "Do the work", + result: null, + }, + }); + yield* store.apply({ + id: EventId.make(`event:settled-subagents:${name}`), + type: "subagent.updated", + threadId, + runId: run.id, + nodeId: id, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id, + threadId, + runId: run.id, + parentNodeId: run.rootNodeId!, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Do the work", + title: name, + model: null, + ...(delivery === null + ? {} + : { completionDelivery: { state: delivery, observedByRunId: null } }), + status, + result: null, + startedAt: now, + completedAt: status === "running" ? null : now, + updatedAt: now, + }, + }); + } + const subagentNames = (records: { + readonly subagents: ReadonlyArray<{ readonly title: string | null }>; + }) => records.subagents.map((subagent) => subagent.title ?? "").toSorted(); + assert.deepEqual( + subagentNames( + yield* store.getThreadRecords(threadId, ["subagents"], { omitSettledSubagents: true }), + ), + ["active-run-delivered", "claimed", "no-delivery", "pending-delivery", "running"], + ); + assert.deepEqual( + subagentNames(yield* store.getThreadRecords(threadId, ["subagents"])), + subagents.map(([name]) => name).toSorted(), + ); + // A command about one settled task keeps it, and reads only its node and turn item. + const deliveredId = NodeId.make("node:settled-subagents:delivered"); + const forTask = yield* store.getThreadRecords(threadId, ["subagents", "nodes", "turnItems"], { + omitSettledSubagents: { except: [deliveredId] }, + nodeIds: [NodeId.make("node:settled-leaf-nodes:settled-tool")], + turnItemNodeIds: [deliveredId], + }); + assert.deepEqual(subagentNames(forTask), [ + "active-run-delivered", + "claimed", + "delivered", + "no-delivery", + "pending-delivery", + "running", + ]); + assert.deepEqual(nodeNames(forTask), ["settled-tool"]); + assert.deepEqual( + forTask.turnItems.map((item) => item.title), + ["delivered"], + ); + assert.strictEqual(yield* store.getSubagentCount(threadId), subagents.length); }); const backgroundThreadUpdatesKeepTimestamp = Effect.gen(function* () { @@ -3535,6 +3650,12 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const projection = yield* projectionStore.getThreadProjection(threadId); assert.deepEqual(projection.runs[0]?.delegatedCompletion, run.delegatedCompletion); assert.deepEqual(projection.subagents[0]?.completionDelivery, task.completionDelivery); + assert.deepEqual( + (yield* projectionStore.getThreadRecords(threadId, ["subagents"], { + omitSettledSubagents: true, + })).subagents, + [], + ); }), ); @@ -4428,6 +4549,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { (row) => row.id === threadId, ), ); + assert.strictEqual(yield* store.getLimitRecoveryWakeAt(now), DateTime.toEpochMillis(reset)); + assert.isNull(yield* store.getLimitRecoveryWakeAt(reset)); const due = (yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, now: reset, @@ -4450,9 +4573,14 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { (row) => row.id === threadId, ), ); + assert.strictEqual( + yield* store.getLimitRecoveryWakeAt(reset), + DateTime.toEpochMillis(DateTime.add(reset, { minutes: 1 })), + ); yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = json_set(payload_json, '$.snoozedUntil', NULL, '$.limitRecovery.autoResume', json('false')) WHERE thread_id = ${threadId}`; + assert.isNull(yield* store.getLimitRecoveryWakeAt(now)); assert.isUndefined( (yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, @@ -4958,13 +5086,14 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); - it.effect("counts imported runless history inherited by fork shells", () => + it.effect("counts history inherited by fork shells, but not by side chats", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; const projectId = ProjectId.make("project:projection-imported-fork-shell"); const sourceThreadId = ThreadId.make("thread:projection-imported-fork-shell:source"); const targetThreadId = ThreadId.make("thread:projection-imported-fork-shell:target"); + const sideChatThreadId = ThreadId.make("thread:projection-imported-fork-shell:side-chat"); const sourceRunId = RunId.make("run:projection-imported-fork-shell:source"); const rootNodeId = NodeId.make("node:projection-imported-fork-shell:source"); @@ -5002,43 +5131,49 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { deletedAt: null, }, }); - yield* projectionStore.apply({ - id: EventId.make("event:projection-imported-fork-shell:target-thread"), - type: "thread.created", - threadId: targetThreadId, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: targetThreadId, - projectId, - title: "Imported fork target", - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { - parentThreadId: sourceThreadId, - relationshipToParent: "fork", - rootThreadId: sourceThreadId, - }, - forkedFrom: { - type: "run", - threadId: sourceThreadId, - runId: sourceRunId, + for (const [threadId, sideChat] of [ + [targetThreadId, false], + [sideChatThreadId, true], + ] as const) { + yield* projectionStore.apply({ + id: EventId.make(`event:projection-imported-fork-shell:created:${threadId}`), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Imported fork target", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: sourceThreadId, + relationshipToParent: "fork", + rootThreadId: sourceThreadId, + sideChat, + }, + forkedFrom: { + type: "run", + threadId: sourceThreadId, + runId: sourceRunId, + }, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, }, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }); + }); + } yield* projectionStore.apply({ id: EventId.make("event:projection-imported-fork-shell:source-run"), type: "run.created", @@ -5107,6 +5242,16 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.equal(targetShell.itemCount, 0); assert.equal(targetShell.visibleItemCount, 4); assert.equal(targetProjection.visibleTurnItems.length, 4); + + const sideChatShell = shell.threads.find((thread) => thread.id === sideChatThreadId); + assert.equal(sideChatShell?.visibleItemCount, 0); + const sideChatProjection = yield* projectionStore.getThreadProjection(sideChatThreadId); + assert.deepEqual(sideChatProjection.visibleTurnItems, []); + const sideChatTimeline = yield* projectionStore.getTimelinePage(sideChatThreadId, { + limit: 20, + view: "activity", + }); + assert.equal(sideChatTimeline.totalItems, 0); }), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index e1c55b6062e9f..f410655d5cd0e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -256,6 +256,8 @@ export interface ProjectionCheckpointCaptureContext { readonly scope: OrchestrationV2CheckpointScope | undefined; readonly providerThread: OrchestrationV2ProviderThread | undefined; readonly readyCheckpointOrdinals: ReadonlyArray; + /** Side chats share their parent's worktree and never snapshot it. */ + readonly sideChat: boolean; } const RollbackTargetAttempts = Schema.Array( @@ -332,11 +334,19 @@ export interface ProjectionRecordFilter { readonly messageIds?: ReadonlyArray; readonly messageRunIds?: ReadonlyArray; readonly turnItemRunIds?: ReadonlyArray; + readonly turnItemNodeIds?: ReadonlyArray; readonly runIds?: ReadonlyArray; readonly subagentIds?: ReadonlyArray; + readonly subagentRunIds?: ReadonlyArray; + readonly nodeIds?: ReadonlyArray; readonly turnItemTypes?: ReadonlyArray; readonly turnItemStatuses?: ReadonlyArray; readonly omitSettledLeafNodes?: boolean; + /** + * Skips ended subagents whose completion was acknowledged, delivered or disposed, unless their + * run is active or `except` names them. + */ + readonly omitSettledSubagents?: true | { readonly except: ReadonlyArray }; } // A fork inherits its source's history before it has messages of its own, @@ -436,6 +446,7 @@ export interface ProjectionStoreV2Shape { options: ProjectionTimelinePageOptions, ) => Effect.Effect; readonly getMessageCount: (threadId: ThreadId) => Effect.Effect; + readonly getSubagentCount: (threadId: ThreadId) => Effect.Effect; readonly getNextTurnItemOrdinal: ( threadId: ThreadId, ) => Effect.Effect; @@ -496,6 +507,10 @@ export interface ProjectionStoreV2Shape { readonly autoResume: boolean; readonly snooze: boolean; }) => Effect.Effect, ProjectionStoreV2Error>; + /** Epoch ms of the next armed reset or snooze end after `now`, when a waiting thread becomes a candidate. */ + readonly getLimitRecoveryWakeAt: ( + now: DateTime.Utc, + ) => Effect.Effect; readonly getSettlementCandidates: ( threadId?: ThreadId, ) => Effect.Effect, ProjectionStoreV2Error>; @@ -1322,14 +1337,21 @@ function visibleTurnItemsThroughRun(input: { return [...inheritedPrefix, ...localPrefix]; } +// Side chats show only their own rows; the fork transfer carries the parent's context to the model. +function inheritedRunFork(thread: Pick) { + return thread.forkedFrom?.type === "run" && thread.lineage.sideChat !== true + ? thread.forkedFrom + : null; +} + function buildVisibleTurnItems(input: { readonly projection: OrchestrationV2ThreadProjection; readonly sourceProjection: OrchestrationV2ThreadProjection | null; readonly inheritedRowCount?: number; }): Array { - const forkedFrom = input.projection.thread.forkedFrom; + const forkedFrom = inheritedRunFork(input.projection.thread); if ( - forkedFrom?.type !== "run" || + forkedFrom === null || (input.sourceProjection === null && input.inheritedRowCount === undefined) ) { return localVisibleTurnItems(input.projection); @@ -1708,8 +1730,8 @@ function visibleItemCountForShell(input: { return 0; } - const forkedFrom = state.thread.forkedFrom; - if (forkedFrom?.type !== "run") { + const forkedFrom = inheritedRunFork(state.thread); + if (forkedFrom === null) { return state.itemCount; } @@ -1723,9 +1745,8 @@ function visibleItemCountForShell(input: { return state.itemCount; } - const sourceForkedFrom = sourceState.thread.forkedFrom; const inheritedPrefixCount = - sourceForkedFrom?.type === "run" + inheritedRunFork(sourceState.thread) !== null ? visibleItemCountForShell({ threadId: sourceState.thread.id, statesByThreadId: input.statesByThreadId, @@ -2107,6 +2128,7 @@ export const layer: Layer.Layer = started_at, completed_at, updated_at, + completion_delivery_state, payload_json ) VALUES ( @@ -2124,6 +2146,7 @@ export const layer: Layer.Layer = ${nullableStringField(payload, "startedAt")}, ${nullableStringField(payload, "completedAt")}, ${stringField(payload, "updatedAt")}, + ${event.payload.completionDelivery?.state ?? null}, ${payloadJson} ) ON CONFLICT(subagent_id) @@ -2141,6 +2164,10 @@ export const layer: Layer.Layer = started_at = excluded.started_at, completed_at = excluded.completed_at, updated_at = excluded.updated_at, + completion_delivery_state = COALESCE( + excluded.completion_delivery_state, + orchestration_v2_projection_subagents.completion_delivery_state + ), payload_json = CASE WHEN json_type(excluded.payload_json, '$.completionDelivery') IS NULL AND json_type(orchestration_v2_projection_subagents.payload_json, '$.completionDelivery') IS NOT NULL @@ -2980,7 +3007,12 @@ export const layer: Layer.Layer = const boundedTurnItemRows: ReadonlyArray = (fields !== undefined && !fields.includes("turnItems")) || (window === undefined && - matchesNothing(filter?.turnItemTypes, filter?.turnItemStatuses, filter?.turnItemRunIds)) + matchesNothing( + filter?.turnItemTypes, + filter?.turnItemStatuses, + filter?.turnItemRunIds, + filter?.turnItemNodeIds, + )) ? [] : window === undefined ? yield* sql` @@ -2990,6 +3022,7 @@ export const layer: Layer.Layer = ${filter?.turnItemTypes === undefined ? sql`` : sql`AND type IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemTypes)}))`} ${filter?.turnItemStatuses === undefined ? sql`` : sql`AND status IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemStatuses)}))`} ${filter?.turnItemRunId === undefined ? sql`` : sql`AND run_id = ${filter.turnItemRunId}`} + ${filter?.turnItemNodeIds === undefined ? sql`` : sql`AND node_id IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemNodeIds)}))`} ${filter?.turnItemRunIds === undefined ? sql`` : sql`AND (run_id IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemRunIds.filter((id): id is RunId => id !== null))})) OR (${filter.turnItemRunIds.includes(null) ? 1 : 0} = 1 AND run_id IS NULL))`} ORDER BY ordinal ASC, turn_item_id ASC ` @@ -3216,13 +3249,15 @@ export const layer: Layer.Layer = OR run_id IN (SELECT value FROM json_each(${cohortRunIds}))) ORDER BY run_id ASC, attempt_ordinal ASC `, - fields !== undefined && !fields.includes("nodes") + (fields !== undefined && !fields.includes("nodes")) || + (window === undefined && matchesNothing(filter?.nodeIds)) ? Effect.succeed([]) : window === undefined ? sql` SELECT payload_json FROM orchestration_v2_projection_nodes AS node WHERE node.thread_id = ${threadId} + ${filter?.nodeIds === undefined ? sql`` : sql`AND node.node_id IN (SELECT value FROM json_each(${encodeIdList(filter.nodeIds)}))`} ${ filter?.omitSettledLeafNodes === true ? sql`AND NOT ( @@ -3253,14 +3288,34 @@ export const layer: Layer.Layer = ), ), (fields !== undefined && !fields.includes("subagents")) || - (window === undefined && matchesNothing(filter?.subagentIds)) + (window === undefined && + (matchesNothing(filter?.subagentIds) || matchesNothing(filter?.subagentRunIds))) ? Effect.succeed([]) : window === undefined ? sql` SELECT payload_json - FROM orchestration_v2_projection_subagents + FROM orchestration_v2_projection_subagents AS subagent WHERE thread_id = ${threadId} ${filter?.subagentIds === undefined ? sql`` : sql`AND subagent_id IN (SELECT value FROM json_each(${encodeIdList(filter.subagentIds)}))`} + ${filter?.subagentRunIds === undefined ? sql`` : sql`AND run_id IN (SELECT value FROM json_each(${encodeIdList(filter.subagentRunIds)}))`} + ${ + filter?.omitSettledSubagents !== undefined + ? sql`AND (${ + filter.omitSettledSubagents === true + ? sql`0` + : sql`subagent_id IN (SELECT value FROM json_each(${encodeIdList(filter.omitSettledSubagents.except)}))` + } OR NOT ( + subagent.status IN ('completed', 'failed', 'cancelled', 'interrupted') + AND COALESCE(subagent.completion_delivery_state, '') + IN ('acknowledged', 'delivered', 'disposed') + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs AS run + WHERE run.run_id = subagent.run_id + AND run.status IN ('preparing', 'queued', 'starting', 'running', 'waiting') + ) + ))` + : sql`` + } ORDER BY COALESCE(started_at, ''), subagent_id ASC ` : sql` @@ -3581,8 +3636,8 @@ export const layer: Layer.Layer = ? ["runs", "attempts", "turnItems"] : undefined, ); - const forkedFrom = projection.thread.forkedFrom; - if (forkedFrom?.type !== "run" || seenThreadIds.has(forkedFrom.threadId)) { + const forkedFrom = inheritedRunFork(projection.thread); + if (forkedFrom === null || seenThreadIds.has(forkedFrom.threadId)) { return withLocalVisibleTurnItems(projection); } @@ -3863,6 +3918,34 @@ export const layer: Layer.Layer = Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), ); + const getLimitRecoveryWakeAt = Effect.fn("ProjectionStore.getLimitRecoveryWakeAt")( + function* (now: DateTime.Utc) { + const nowIso = DateTime.formatIso(now); + const rows = yield* sql<{ + readonly reset_ms: number | null; + readonly snooze_ms: number | null; + }>` + SELECT + MIN(CASE WHEN julianday(reset_at) > julianday(${nowIso}) + THEN (julianday(reset_at) - 2440587.5) * 86400000 END) AS reset_ms, + MIN(CASE WHEN julianday(snoozed_until) > julianday(${nowIso}) + THEN (julianday(snoozed_until) - 2440587.5) * 86400000 END) AS snooze_ms + FROM ( + SELECT json_extract(payload_json, '$.limitRecovery.resetAt') AS reset_at, + json_extract(payload_json, '$.snoozedUntil') AS snoozed_until + FROM orchestration_v2_projection_threads + WHERE deleted_at IS NULL + AND json_extract(payload_json, '$.limitRecovery.autoResume') = 1 + ) + `; + const wakes = [rows[0]?.reset_ms, rows[0]?.snooze_ms].filter( + (value): value is number => value != null, + ); + return wakes.length === 0 ? null : Math.round(Math.min(...wakes)); + }, + Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), + ); + const getRecoveryThreadIds = Effect.fn("ProjectionStore.getRecoveryThreadIds")( function* (kind: ProjectionRecoveryKind) { const candidates = (() => { @@ -4230,6 +4313,8 @@ export const layer: Layer.Layer = message_id IN (SELECT json_extract(payload_json, '$.userMessageId') FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND run_id = ${runId}) OR (role = 'user' AND lower(trim(json_extract(payload_json, '$.text'), ${javascriptTrimWhitespace})) = '/compact') + -- Earlier /goal turns defer context too; parseGoalCommand narrows this prefix match. + OR (role = 'user' AND trim(json_extract(payload_json, '$.text'), ${javascriptTrimWhitespace}) GLOB '/goal*') ) ORDER BY created_at ASC, message_id ASC `.pipe(Effect.flatMap(decodeRows(decodeMessagePayload, threadId))); const conversation = yield* sql<{ present: number }>` @@ -4989,7 +5074,12 @@ export const layer: Layer.Layer = sql .withTransaction( Effect.gen(function* () { - yield* requireThread(threadId); + const threadRows = yield* sql<{ readonly side_chat: number | null }>` + SELECT json_extract(payload_json, '$.lineage.sideChat') AS side_chat + FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId} LIMIT 1`; + if (threadRows[0] === undefined) { + return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + } const runRows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND run_id = ${target.runId}`; @@ -5026,6 +5116,7 @@ export const layer: Layer.Layer = ? undefined : yield* decodeProviderThreadPayload(providerRows[0].payload_json), readyCheckpointOrdinals: readyRows.map(({ ordinal }) => ordinal), + sideChat: threadRows[0].side_chat === 1, }; }), ) @@ -5204,6 +5295,13 @@ export const layer: Layer.Layer = Effect.map((rows) => rows[0]?.count ?? 0), Effect.mapError(controlReadError(threadId)), ); + const getSubagentCount: ProjectionStoreV2Shape["getSubagentCount"] = (threadId) => + sql<{ + count: number; + }>`SELECT COUNT(*) AS count FROM orchestration_v2_projection_subagents WHERE thread_id = ${threadId}`.pipe( + Effect.map((rows) => rows[0]?.count ?? 0), + Effect.mapError(controlReadError(threadId)), + ); const getNextTurnItemOrdinal: ProjectionStoreV2Shape["getNextTurnItemOrdinal"] = (threadId) => sql<{ ordinal: number | null }>`SELECT MAX(ordinal) AS ordinal FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId}`.pipe( @@ -5237,6 +5335,14 @@ export const layer: Layer.Layer = WHERE thread_id = ${threadId} AND type = 'dynamic_tool' AND (payload_json LIKE '%htmlRender%' OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`}) + `, + sql<{ id: string }>` + SELECT DISTINCT json_extract(attachment.value, '$.id') AS id + FROM orchestration_v2_projection_turn_items AS item, + json_each(item.payload_json, '$.questionAnswer.attachmentsByQuestionId') AS question, + json_each(question.value) AS attachment + WHERE item.thread_id = ${threadId} + AND item.type = 'user_input_request' `, // A guest's waiting files are pending uploads that no message row holds yet. sql<{ id: string }>` @@ -5248,10 +5354,11 @@ export const layer: Layer.Layer = AND json_extract(message.value, '$.status') = 'pending' `, ]).pipe( - Effect.map(([messages, renders, guestFiles]) => [ + Effect.map(([messages, renders, answerFiles, guestFiles]) => [ ...new Set([ ...messages.map((row) => row.id), ...guestFiles.map((row) => row.id), + ...answerFiles.map((row) => row.id), ...threadHtmlRenderAttachmentIds( threadId, renders.map((row) => { @@ -5413,8 +5520,8 @@ export const layer: Layer.Layer = items, }); const visible = local.filter((row) => isVisible(row.item)); - const fork = records.thread.forkedFrom; - if (fork?.type !== "run" || seen.has(fork.threadId)) return { records, local, visible }; + const fork = inheritedRunFork(records.thread); + if (fork === null || seen.has(fork.threadId)) return { records, local, visible }; const source = yield* readTimelineIndex( fork.threadId, new Set([...seen, threadId]), @@ -5774,6 +5881,7 @@ export const layer: Layer.Layer = t.payload_json, CASE WHEN json_extract(t.payload_json, '$.forkedFrom.type') = 'run' + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1 THEN json_extract(t.payload_json, '$.forkedFrom.threadId') ELSE NULL END AS forked_from_run_source_thread_id, @@ -6658,6 +6766,7 @@ export const layer: Layer.Layer = readonly relationship_to_parent: string | null; readonly parent_thread_id: string | null; readonly root_thread_id: string; + readonly side_chat: number | null; readonly settled_override: string | null; readonly settled_at: string | null; readonly pinned_at: string | null; @@ -6670,6 +6779,7 @@ export const layer: Layer.Layer = json_extract(t.payload_json, '$.lineage.relationshipToParent') AS relationship_to_parent, json_extract(t.payload_json, '$.lineage.parentThreadId') AS parent_thread_id, json_extract(t.payload_json, '$.lineage.rootThreadId') AS root_thread_id, + json_extract(t.payload_json, '$.lineage.sideChat') AS side_chat, json_extract(t.payload_json, '$.settledOverride') AS settled_override, json_extract(t.payload_json, '$.settledAt') AS settled_at, json_extract(t.payload_json, '$.pinnedAt') AS pinned_at, @@ -6724,6 +6834,7 @@ export const layer: Layer.Layer = ? row.relationship_to_parent : null, rootThreadId: ThreadId.make(row.root_thread_id), + ...(row.side_chat === null ? {} : { sideChat: row.side_chat === 1 }), }, settledOverride: row.settled_override === "settled" || row.settled_override === "active" @@ -6759,6 +6870,7 @@ export const layer: Layer.Layer = WHERE t.deleted_at IS NULL AND json_extract(t.payload_json, '$.archivedAt') IS NULL AND json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1 AND json_extract(t.payload_json, '$.settledOverride') = 'settled' AND json_extract(t.payload_json, '$.settledAt') IS NOT NULL${ projectIds === undefined ? sql`` : sql` AND t.project_id IN ${sql.in(projectIds)}` @@ -6918,6 +7030,7 @@ export const layer: Layer.Layer = hasUnpairedRunInterruptRequest: readOnReader(hasUnpairedRunInterruptRequest), isNodeStored: readOnReader(isNodeStored), getMessageCount: readOnReader(getMessageCount), + getSubagentCount: readOnReader(getSubagentCount), getNextTurnItemOrdinal: readOnReader(getNextTurnItemOrdinal), getTurnItem: readOnReader(getTurnItem), getThreadRecords: (threadId, fields, filter) => @@ -6926,6 +7039,7 @@ export const layer: Layer.Layer = getPlan: readOnReader(getPlan), getProviderControlContext: readOnReader(getProviderControlContext), getLimitRecoveryCandidates: readOnReader(getLimitRecoveryCandidates), + getLimitRecoveryWakeAt: readOnReader(getLimitRecoveryWakeAt), getRecoveryThreadIds: readOnReader(getRecoveryThreadIds), getUnreadableThreadIds: readOnReader(getUnreadableThreadIds), getThreadSnapshot: readOnReader(getThreadSnapshot), diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 06f444776cb3c..5a2aa39faece4 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -700,6 +700,8 @@ export const layer: Layer.Layer< }), ]; } + case "background_work.updated": + return []; case "command_output.delta": // Live output is routed to the command output hub before ingestion. return []; diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 65da8b23676b0..7932a03809c94 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -210,6 +210,7 @@ it.effect( ProjectionStore.ProjectionStoreV2.of({ apply: () => Effect.void, getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), + getLimitRecoveryWakeAt: () => Effect.die("unused getLimitRecoveryWakeAt"), getOpenRemoteDelegatedTasks: Effect.die("unused getOpenRemoteDelegatedTasks"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), @@ -235,6 +236,7 @@ it.effect( getThreadHistoryPage: () => Effect.die("unused"), getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), + getSubagentCount: () => Effect.die("unused subagent count"), getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"), getTurnItem: () => Effect.die("unused turn item read"), getThreadRecords: () => Effect.die("unused record read"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index beaf7961e3782..05fc1b8545bb2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -3,6 +3,8 @@ import { expect, it, vi } from "vite-plus/test"; import { it as effectIt } from "@effect/vitest"; import { CheckpointScopeId, + ContextHandoffId, + ContextTransferId, GitCommandError, MessageId, NodeId, @@ -40,6 +42,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; +import { withInlineContext } from "./ContextHandoffDelivery.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; @@ -96,6 +99,7 @@ function startWithMissingWorktree( projectId: ProjectId.make("project_provider_turn_start_projection_failure"), branch: "feature/restore", worktreePath, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, }, runs: [ { @@ -286,6 +290,16 @@ function makeLocalCommandHarness(input: { readonly interruptFailure?: boolean; readonly interruptCurrentRun?: boolean; }; + /** + * A side chat whose context targets this run, an earlier run that failed or was cancelled before + * the provider, or an earlier run that delivered it. `nativeForkFrom` plans a native fork of the + * parent. `switchedProvider` puts the earlier run and its context on another provider thread. + */ + readonly sideChat?: { + readonly contextRun: "current" | "failed" | "cancelled" | "delivered"; + readonly nativeForkFrom?: "idle" | "busy"; + readonly switchedProvider?: boolean; + }; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -354,6 +368,7 @@ function makeLocalCommandHarness(input: { projectId: ProjectId.make("project-native-account-command"), branch: input.worktreePath === undefined ? null : "feature/restore", worktreePath: input.worktreePath ?? null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, } as OrchestrationV2ThreadProjection["thread"], runs: [ ...(input.previousNativeSession @@ -458,6 +473,139 @@ function makeLocalCommandHarness(input: { checkpoints: [], updatedAt: now, }; + const parentThreadId = ThreadId.make("side-chat-parent"); + const parentRunId = RunId.make("side-chat-parent-run"); + const parentProviderThread = { + ...providerThread, + id: ProviderThreadId.make("side-chat-parent-provider-thread"), + appThreadId: parentThreadId, + nativeThreadRef: { + driver: providerThread.driver, + nativeId: "side-chat-parent-native", + strength: "strong" as const, + }, + }; + const parentRecords = { + ...projection, + thread: { ...projection.thread, id: parentThreadId }, + runs: [ + { + ...run, + id: parentRunId, + threadId: parentThreadId, + ordinal: 1, + providerThreadId: parentProviderThread.id, + activeAttemptId: null, + status: "completed" as const, + completedAt: now, + }, + ...(input.sideChat?.nativeForkFrom === "busy" + ? [ + { + ...run, + id: RunId.make("side-chat-parent-busy-run"), + threadId: parentThreadId, + ordinal: 2, + providerThreadId: parentProviderThread.id, + status: "running" as const, + }, + ] + : []), + ], + providerThreads: [parentProviderThread], + attempts: [], + providerTurns: [], + turnItems: [], + }; + if (input.sideChat !== undefined) { + const earlierRunId = RunId.make("side-chat-earlier-run"); + projection = { + ...projection, + thread: { + ...projection.thread, + runtimeMode: "full-access", + lineage: { + parentThreadId, + relationshipToParent: "fork", + rootThreadId: parentThreadId, + sideChat: true, + }, + }, + runs: + input.sideChat.contextRun === "current" + ? projection.runs + : [ + { + ...run, + id: earlierRunId, + ordinal: 1, + userMessageId: MessageId.make("side-chat-earlier-message"), + activeAttemptId: RunAttemptId.make("side-chat-earlier-attempt"), + ...(input.sideChat.switchedProvider + ? { providerThreadId: oldProviderThreadId } + : {}), + status: + input.sideChat.contextRun === "delivered" + ? "completed" + : input.sideChat.contextRun, + completedAt: now, + }, + run, + ], + contextTransfers: + input.sideChat.nativeForkFrom === undefined + ? [] + : [ + { + id: ContextTransferId.make("side-chat-fork"), + type: "fork", + sourceThreadId: parentThreadId, + targetThreadId: threadId, + sourcePoint: { threadId: parentThreadId, runId: parentRunId }, + basePoint: null, + sourceProviderInstanceId: newInstanceId, + targetProviderInstanceId: newInstanceId, + targetRunId: runId, + status: "pending", + resolution: null, + createdBy: "user", + error: null, + createdAt: now, + updatedAt: now, + consumedAt: null, + }, + ], + contextHandoffs: [ + { + id: ContextHandoffId.make("side-chat-context"), + transferId: ContextTransferId.make("side-chat-fork"), + threadId, + targetRunId: input.sideChat.contextRun === "current" ? runId : earlierRunId, + fromProviderThreadIds: [], + toProviderThreadId: input.sideChat.switchedProvider + ? oldProviderThreadId + : providerThreadId, + coveredRunOrdinals: { from: 1, to: 1 }, + strategy: "manual_context", + status: "ready", + summaryMessageId: null, + summaryText: "SIDE_CHAT_CONTEXT", + createdByProviderInstanceId: null, + createdAt: now, + updatedAt: now, + ...(input.sideChat.contextRun === "delivered" + ? { + delivery: { + nativeThreadId: "side-chat-native", + status: "inline" as const, + itemIds: [], + }, + } + : {}), + }, + ], + }; + } if ("historyReadFailureAfterFallback" in input) { const nativeThreadRef = { driver: providerThread.driver, @@ -577,55 +725,78 @@ function makeLocalCommandHarness(input: { ), ensureThread: () => Effect.succeed(providerThread), }; + const providerSession = { + id: providerSessionId, + driver: providerThread.driver, + providerInstanceId: newInstanceId, + status: "ready", + cwd: "/tmp/native-account-command", + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const startTurn = vi.fn((_turn: ProviderAdapter.ProviderAdapterV2TurnInput) => Effect.void); + const sideChatThread = { + ...providerThread, + nativeThreadRef: { + driver: providerThread.driver, + nativeId: "side-chat-native", + strength: "strong" as const, + }, + }; + const forkThread = vi.fn(() => Effect.succeed(sideChatThread)); + const sideChatSession = { + driver: providerThread.driver, + providerSession, + ensureThread: () => Effect.succeed(sideChatThread), + resumeThread: () => Effect.succeed(sideChatThread), + forkThread, + startTurn, + }; const open = vi.fn(() => - input.interruptOpen === true - ? Effect.interrupt - : "historyReadFailureAfterFallback" in input - ? Effect.succeed(resumeFallbackSession as never) - : "ensureThreadFailure" in input - ? Effect.succeed({ driver: providerThread.driver, ensureThread } as never) - : "openFailure" in input - ? Effect.sync(() => { - if (input.interruptRunBeforeOpenFailure === true) interruptRun(); - }).pipe( - Effect.andThen( - Effect.fail( - new ProviderSessionManager.ProviderSessionOpenError({ - instanceId: newInstanceId, - providerSessionId, - cause: input.openFailure, - }), + input.sideChat !== undefined + ? Effect.succeed(sideChatSession as never) + : input.interruptOpen === true + ? Effect.interrupt + : "historyReadFailureAfterFallback" in input + ? Effect.succeed(resumeFallbackSession as never) + : "ensureThreadFailure" in input + ? Effect.succeed({ driver: providerThread.driver, ensureThread } as never) + : "openFailure" in input + ? Effect.sync(() => { + if (input.interruptRunBeforeOpenFailure === true) interruptRun(); + }).pipe( + Effect.andThen( + Effect.fail( + new ProviderSessionManager.ProviderSessionOpenError({ + instanceId: newInstanceId, + providerSessionId, + cause: input.openFailure, + }), + ), ), - ), - ) - : input.failReadsAfterRunning === true || input.previousTurn !== undefined - ? Effect.succeed({ - driver: providerThread.driver, - providerSession: { - id: providerSessionId, + ) + : input.failReadsAfterRunning === true || input.previousTurn !== undefined + ? Effect.succeed({ driver: providerThread.driver, - providerInstanceId: newInstanceId, - status: "ready", - cwd: "/tmp/native-account-command", - model: null, - capabilities: CodexProviderCapabilitiesV2, - createdAt: now, - updatedAt: now, - lastError: null, - }, - ensureThread: () => Effect.succeed(providerThread), - resumeThread: () => Effect.succeed(projection.providerThreads[0]), - } as never) - : Effect.die("A local command must not open a native session."), + providerSession, + ensureThread: () => Effect.succeed(providerThread), + resumeThread: () => Effect.succeed(projection.providerThreads[0]), + } as never) + : Effect.die("A local command must not open a native session."), ); const startRootRun = vi.fn< (input: RunExecutionService.RunExecutionServiceV2StartRootRunInput) => Effect.Effect - >(() => - input.failReadsAfterRunning === true || input.previousTurn !== undefined - ? Effect.suspend(() => - providerBusy ? Effect.die("The previous provider turn is still running.") : Effect.void, - ) - : Effect.die("A local command must not start a native turn."), + >((runInput) => + input.sideChat !== undefined + ? runInput.session.startTurn(runInput as never).pipe(Effect.orDie) + : input.failReadsAfterRunning === true || input.previousTurn !== undefined + ? Effect.suspend(() => + providerBusy ? Effect.die("The previous provider turn is still running.") : Effect.void, + ) + : Effect.die("A local command must not start a native turn."), ); const failReadIfRunning = Effect.suspend(() => input.failReadsAfterRunning === true && @@ -672,13 +843,16 @@ function makeLocalCommandHarness(input: { ); const resolveRuntimePolicy = vi.fn( ({ thread }: Parameters[0]) => - Effect.succeed({ cwd: thread.worktreePath ?? "/tmp/native-account-command" } as never), + Effect.succeed({ + cwd: thread.worktreePath ?? "/tmp/native-account-command", + runtimeMode: thread.runtimeMode, + } as never), ); const createWorktree = vi.fn(() => Effect.succeed({} as never), ); - const getThreadRecords = vi.fn(() => - Effect.succeed(projection), + const getThreadRecords = vi.fn((id) => + Effect.succeed(id === parentThreadId ? parentRecords : projection), ); const getTurnStartContext = vi.fn(() => Effect.succeed({ @@ -694,9 +868,30 @@ function makeLocalCommandHarness(input: { Layer.provide( Layer.mergeAll( Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({ - prepareProviderHandoff: () => Effect.die("history read must fail first"), + prepareProviderHandoff: (handoff) => + input.sideChat?.nativeForkFrom === undefined + ? Effect.die("history read must fail first") + : Effect.succeed({ + ...handoff, + id: ContextHandoffId.make("side-chat-fork-context"), + createdByProviderInstanceId: handoff.toProviderInstanceId, + status: "ready", + summaryMessageId: null, + summaryText: "PARENT_HISTORY", + updatedAt: handoff.createdAt, + }), + }), + Layer.mock(EventSink.EventSinkV2)({ + writeIfRunCurrent, + write: ({ events: incoming }) => + Effect.sync(() => { + for (const event of incoming) { + events.push(event); + projection = ProjectionStore.applyToProjection(projection, event); + } + return []; + }), }), - Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, layerWorktreeLocation(input.worktreesDir), input.fileSystem === undefined @@ -728,12 +923,14 @@ function makeLocalCommandHarness(input: { ), }), getTurnStartHistory: () => - Effect.fail( - new ProjectionStore.ProjectionStoreReadError({ - threadId, - cause: input.historyReadFailureAfterFallback, - }), - ), + input.sideChat !== undefined + ? Effect.succeed([]) + : Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: input.historyReadFailureAfterFallback, + }), + ), }), Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open, @@ -767,6 +964,8 @@ function makeLocalCommandHarness(input: { open, writeIfRunCurrent, startRootRun, + startTurn, + forkThread, interruptTurn, tryHandlePromptCommand, events, @@ -788,7 +987,7 @@ function makeLocalCommandHarness(input: { } effectIt.effect( - "recovers the thread branch tip, falls back to the current project branch, and rejects detached HEAD", + "recovers the local or pushed thread tip, else the current branch, remote default, or detached HEAD", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -797,6 +996,7 @@ effectIt.effect( const driver = yield* GitVcsDriver.GitVcsDriver; const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-recreate-repo-" }); const workspaceRoot = path.join(root, "project"); + const originRoot = path.join(root, "origin.git"); const worktreesDir = path.join(root, "worktrees"); const worktreePath = path.join(worktreesDir, "project", "restore"); yield* fileSystem.makeDirectory(workspaceRoot); @@ -804,10 +1004,19 @@ effectIt.effect( driver .execute({ operation: "test.worktreeRecovery", cwd, args }) .pipe(Effect.map((result) => result.stdout.trim())); + const branchConfig = (key: string) => + git(workspaceRoot, ["config", "--get", `branch.feature/restore.${key}`]).pipe( + Effect.orElseSucceed(() => ""), + ); + yield* git(root, ["init", "--bare", "-b", "main", originRoot]); yield* git(workspaceRoot, ["init", "-b", "main"]); yield* git(workspaceRoot, ["config", "user.name", "Test"]); yield* git(workspaceRoot, ["config", "user.email", "test@example.com"]); yield* git(workspaceRoot, ["commit", "--allow-empty", "-m", "base"]); + const baseTip = yield* git(workspaceRoot, ["rev-parse", "HEAD"]); + yield* git(workspaceRoot, ["remote", "add", "origin", originRoot]); + yield* git(workspaceRoot, ["push", "origin", "main"]); + yield* git(workspaceRoot, ["remote", "set-head", "origin", "main"]); yield* git(workspaceRoot, ["worktree", "add", "-b", "feature/restore", worktreePath]); yield* fileSystem.writeFileString(path.join(worktreePath, "thread.txt"), "thread work"); yield* git(worktreePath, ["add", "."]); @@ -825,18 +1034,48 @@ effectIt.effect( // Tags must neither hide the thread branch nor select a different recovery tip. yield* git(workspaceRoot, ["tag", "feature/restore", projectTip]); yield* git(workspaceRoot, ["tag", "project-current", threadTip]); + yield* git(workspaceRoot, ["tag", "origin/feature/restore", threadTip]); + const expectedTips = { + retained: { tip: threadTip, file: "thread.txt", upstream: null, base: "" }, + deleted: { tip: projectTip, file: "project.txt", upstream: null, base: "project-current" }, + pushed: { + tip: threadTip, + file: "thread.txt", + upstream: "refs/remotes/origin/feature/restore", + base: "", + }, + "pushed-detached": { + tip: threadTip, + file: "thread.txt", + upstream: "refs/remotes/origin/feature/restore", + base: "", + }, + detached: { tip: baseTip, file: null, upstream: null, base: "main" }, + "detached-no-remote": { tip: projectTip, file: "project.txt", upstream: null, base: "" }, + } as const; // Reuse the workflow across turns so recovery cannot select a cached project branch. - for (const scenario of ["retained", "deleted", "detached"] as const) { + for (const scenario of Object.keys(expectedTips) as Array) { + const expected = expectedTips[scenario]; + if (scenario === "pushed") { + yield* git(workspaceRoot, ["push", "origin", `${threadTip}:refs/heads/feature/restore`]); + yield* git(workspaceRoot, ["fetch", "origin"]); + } + if (scenario === "pushed-detached") { + yield* git(workspaceRoot, ["switch", "--detach"]); + expect(yield* git(workspaceRoot, ["branch", "--show-current"])).toBe(""); + } + if (scenario === "detached") { + yield* git(workspaceRoot, ["push", "origin", "--delete", "feature/restore"]); + } + if (scenario === "detached-no-remote") { + yield* git(workspaceRoot, ["remote", "remove", "origin"]); + } yield* fileSystem.remove(worktreePath, { recursive: true }); if (scenario !== "retained") { yield* git(workspaceRoot, ["worktree", "prune"]); yield* git(workspaceRoot, ["branch", "-D", "feature/restore"]); } - if (scenario === "detached") { - yield* git(workspaceRoot, ["switch", "--detach"]); - expect(yield* git(workspaceRoot, ["branch", "--show-current"])).toBe(""); - } const harness = makeLocalCommandHarness({ text: "Continue", workspaceRoot, @@ -850,38 +1089,34 @@ effectIt.effect( harness.open.mockImplementation(() => Effect.gen(function* () { expect(yield* git(worktreePath, ["branch", "--show-current"])).toBe("feature/restore"); - expect(yield* git(worktreePath, ["rev-parse", "HEAD"])).toBe( - scenario === "retained" ? threadTip : projectTip, + expect(yield* git(worktreePath, ["rev-parse", "HEAD"])).toBe(expected.tip); + for (const file of ["thread.txt", "project.txt"]) { + expect(yield* fileSystem.exists(path.join(worktreePath, file))).toBe( + file === expected.file, + ); + } + expect(yield* branchConfig("merge")).toBe( + expected.upstream === null ? "" : "refs/heads/feature/restore", ); - expect( - yield* fileSystem.readFileString( - path.join(worktreePath, scenario === "retained" ? "thread.txt" : "project.txt"), - ), - ).toBe(scenario === "retained" ? "thread work" : "new project code"); + if (expected.upstream !== null) { + expect( + yield* git(worktreePath, ["rev-parse", "--symbolic-full-name", "@{upstream}"]), + ).toBe(expected.upstream); + } + expect(yield* branchConfig("t3-merge-base")).toBe(expected.base); return yield* open(); }).pipe(Effect.orDie), ); yield* harness.start; - if (scenario === "detached") { - expect(harness.open).not.toHaveBeenCalled(); - expect(harness.startRootRun).not.toHaveBeenCalled(); - expect(harness.projection().runs.at(-1)?.status).toBe("failed"); - expect(harness.projection().turnItems).toMatchObject([ - { type: "error", failure: { message: expect.stringContaining("no current branch") } }, - ]); - expect(yield* fileSystem.exists(worktreePath)).toBe(false); - expect(yield* git(workspaceRoot, ["branch", "--list", "feature/restore"])).toBe(""); - } else { - expect(harness.open).toHaveBeenCalledOnce(); - expect(harness.startRootRun).toHaveBeenCalledWith( - expect.objectContaining({ - runtimePolicy: expect.objectContaining({ cwd: worktreePath }), - }), - ); - expect(harness.projection().runs.at(-1)?.status).toBe("running"); - } + expect(harness.open).toHaveBeenCalledOnce(); + expect(harness.startRootRun).toHaveBeenCalledWith( + expect.objectContaining({ + runtimePolicy: expect.objectContaining({ cwd: worktreePath }), + }), + ); + expect(harness.projection().runs.at(-1)?.status).toBe("running"); } }).pipe(Effect.scoped, Effect.provide(layerRealGit)), ); @@ -970,6 +1205,75 @@ effectIt.effect("recovers an abandoned failed turn before continuing its native }), ); +effectIt.effect.each([ + { contextRun: "current", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, + { contextRun: "failed", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, + { contextRun: "cancelled", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, + { + contextRun: "failed", + switchedProvider: true, + runtimeMode: "approval-required", + context: "SIDE_CHAT_CONTEXT", + }, + { contextRun: "delivered", runtimeMode: "full-access", context: "" }, +] as const)( + "sends side chat context inline once, for the first turn that reaches the provider: %j", + ({ runtimeMode, context, ...sideChat }) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "What is the main thread doing?", + sideChat, + }); + + yield* harness.start; + + // The turn that delivers the context is clamped; a later one follows the thread's mode. + expect(harness.open).toHaveBeenCalledWith( + expect.objectContaining({ runtimePolicy: expect.objectContaining({ runtimeMode }) }), + ); + expect(harness.startTurn.mock.calls.map(([turn]) => turn.message.text)).toEqual([ + withInlineContext(context, "What is the main thread doing?"), + ]); + expect(harness.projection().contextHandoffs).toMatchObject([ + { + id: "side-chat-context", + toProviderThreadId: "new-provider-thread", + delivery: { nativeThreadId: "side-chat-native", status: "inline" }, + }, + ]); + }), +); + +effectIt.effect.each([ + { nativeForkFrom: "idle", forks: true, transferStatus: "consumed", handoffIds: [] }, + { + nativeForkFrom: "busy", + forks: false, + transferStatus: "resolved_portable", + handoffIds: ["side-chat-fork-context"], + }, +] as const)( + "runs a planned native side chat fork when the parent is $nativeForkFrom", + ({ nativeForkFrom, forks, transferStatus, handoffIds }) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "What is the main thread doing?", + sideChat: { contextRun: "current", nativeForkFrom }, + }); + + yield* harness.start; + + expect(harness.forkThread).toHaveBeenCalledTimes(forks ? 1 : 0); + expect(harness.projection().contextTransfers).toMatchObject([ + { id: "side-chat-fork", status: transferStatus }, + ]); + expect(harness.startTurn.mock.calls[0]?.[0].message.text.includes("PARENT_HISTORY")).toBe( + !forks, + ); + expect(harness.projection().providerThreads[0]?.handoffIds).toEqual(handoffIds); + }), +); + effectIt.effect.each([ { runStatus: "failed", turnStatus: "completed" }, { runStatus: "completed", turnStatus: "completed" }, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index fad06732fb48e..402b2eaf3e04e 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -34,6 +34,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; import * as EventSink from "./EventSink.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; +import { isActiveRun } from "./CommandPolicy.ts"; import { DEFAULT_HANDOFF_TOKEN_CAP, handoffTokenCapConfig, @@ -43,7 +44,7 @@ import { historicalMessage, latestNativeContextUsage, } from "@t3tools/provider-core/server/handoffBudget"; -import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; +import { deliverContextHandoffs, withInlineContext } from "./ContextHandoffDelivery.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -87,7 +88,7 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError transfer.type === "subagent_result") .map((transfer) => transfer.id), ); + const isPendingForRun = (handoff: OrchestrationV2ContextHandoff, anyProviderThread = false) => + handoff.status === "ready" && + (handoff.targetRunId === run.id || + ((anyProviderThread || handoff.toProviderThreadId === run.providerThreadId) && + projection.runs.some( + (source) => + source.id === handoff.targetRunId && + (source.status === "failed" || + source.status === "interrupted" || + source.status === "cancelled" || + (source.status === "completed" && + handoff.delivery === undefined && + // Compaction and /goal turns defer history to the next turn. + projection.messages.some( + (message) => + message.id === source.userMessageId && + (isCompactionCommandMessage(message) || parseGoalCommand(message) !== null), + ))), + ))); + const isSideChatHandoff = (handoff: OrchestrationV2ContextHandoff) => + projection.thread.lineage.sideChat === true && + ContextHandoffService.isSideChatContextHandoff(handoff); const handoffs = projection.contextHandoffs.filter( (handoff) => + !isSideChatHandoff(handoff) && (handoff.transferId == null || !subagentResultTransfers.has(handoff.transferId)) && - handoff.status === "ready" && - (handoff.targetRunId === run.id || - (handoff.toProviderThreadId === run.providerThreadId && - projection.runs.some( - (source) => - source.id === handoff.targetRunId && - (source.status === "failed" || - source.status === "interrupted" || - (source.status === "completed" && - handoff.delivery === undefined && - // Compaction and /goal turns defer history to the next turn. - projection.messages.some( - (message) => - message.id === source.userMessageId && - (isCompactionCommandMessage(message) || - parseGoalCommand(message) !== null), - ))), - ))), + isPendingForRun(handoff), + ); + // Carried forward until a provider turn accepts it, also after a provider switch. + const sideChatHandoff = projection.contextHandoffs.find( + (handoff) => + isSideChatHandoff(handoff) && + handoff.delivery === undefined && + isPendingForRun(handoff, true), ); const nativeForkTransfer = projection.contextTransfers.find( (transfer) => @@ -641,8 +689,14 @@ export const layer: Layer.Layer< }); const { isCurrentAttemptInStatus } = runControls; + // A side chat shares its parent's worktree; the turn that gets its context asks before acting. const resolvedRuntimePolicy = yield* runtimePolicy.resolve({ - thread: projection.thread, + thread: + projection.thread.lineage.sideChat === true && + (sideChatHandoff !== undefined || + !projection.runs.some((previous) => previous.ordinal < run.ordinal)) + ? { ...projection.thread, runtimeMode: "approval-required" } + : projection.thread, modelSelection: run.modelSelection, }); if (!(yield* isCurrentAttemptInStatus("starting"))) return; @@ -1038,6 +1092,7 @@ export const layer: Layer.Layer< }; let resumedRetryHandoff: ReadonlyArray | undefined; let resumedHandoffContext: ReturnType | undefined; + let portableForkFallback: OrchestrationV2ContextHandoff | undefined; const loadedProviderThread = yield* Effect.gen(function* () { if (nativeForkTransfer !== undefined) { const sourceProjection = yield* projectionStore.getThreadRecords( @@ -1064,18 +1119,91 @@ export const layer: Layer.Layer< cause: `Native fork transfer ${nativeForkTransfer.id} has no source provider execution.`, }); } - return yield* loadFromProvider( - session.forkThread({ - sourceProviderThread, - sourceProviderTurns: sourceProjection.providerTurns, - targetThreadId: projection.thread.id, + // Providers refuse to fork a busy thread, so a source that started a turn after + // dispatch planned this fork gets the portable context dispatch gives a busy source. + const sourceBusy = sourceProjection.runs.some( + (candidate) => candidate.ordinal > sourceRun.ordinal && isActiveRun(candidate), + ); + if (!sourceBusy) { + return yield* loadFromProvider( + session.forkThread({ + sourceProviderThread, + sourceProviderTurns: sourceProjection.providerTurns, + targetThreadId: projection.thread.id, + modelSelection: run.modelSelection, + runtimePolicy: resolvedRuntimePolicy, + ...(sourceProviderTurn === undefined + ? {} + : { providerTurnId: sourceProviderTurn.id }), + }), + ); + } + const sourceItems = yield* ContextHandoffService.readForkHistoryItems( + projectionStore, + sourceProjection, + sourceRun, + ); + const createdAt = yield* DateTime.now; + const handoff = yield* contextHandoffService.prepareProviderHandoff({ + threadId: projection.thread.id, + targetRunId: run.id, + transferId: nativeForkTransfer.id, + fromProviderThreadIds: [sourceProviderThread.id], + toProviderThreadId: providerThread.id, + fromProviderInstanceId: sourceRun.providerInstanceId, + toProviderInstanceId: run.providerInstanceId, + coveredRunOrdinals: ContextHandoffService.visibleDeltaRunOrdinals( + sourceProjection, + sourceItems, + ), + strategy: "full_thread_summary", + items: sourceItems, + runs: sourceProjection.runs, + createdAt, + }); + const loaded = yield* loadFromProvider( + session.ensureThread({ + threadId: projection.thread.id, + title: projection.thread.title, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, - ...(sourceProviderTurn === undefined - ? {} - : { providerTurnId: sourceProviderTurn.id }), + providerSessionId, + existingProviderThread: { ...providerThread, forkedFrom: null }, }), ); + if (loaded === undefined) return undefined; + portableForkFallback = handoff; + effectiveHandoffs = [handoff, ...effectiveHandoffs]; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "context-handoff.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: createdAt, + payload: handoff, + }, + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "context-transfer.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: createdAt, + payload: { + ...nativeForkTransfer, + targetProviderInstanceId: run.providerInstanceId, + status: "resolved_portable", + resolution: { strategy: "portable_context", contextHandoffId: handoff.id }, + error: null, + updatedAt: createdAt, + }, + }, + ], + }); + return loaded; } if (providerThread.nativeThreadRef === null) { // Pass the stored provider row for adoption; adapter-minted duplicates make activeProviderThreadId flap. @@ -1265,8 +1393,11 @@ export const layer: Layer.Layer< ownerNodeId: providerThread.ownerNodeId, firstRunOrdinal: providerThread.firstRunOrdinal ?? run.ordinal, lastRunOrdinal: run.ordinal, - handoffIds: providerThread.handoffIds, - forkedFrom: providerThread.forkedFrom, + handoffIds: + portableForkFallback === undefined + ? providerThread.handoffIds + : [...providerThread.handoffIds, portableForkFallback.id], + forkedFrom: portableForkFallback === undefined ? providerThread.forkedFrom : null, status: "active", createdAt: providerThread.createdAt, updatedAt: now, @@ -1311,7 +1442,9 @@ export const layer: Layer.Layer< occurredAt: now, payload: runningProviderThread, }, - ...(nativeForkTransfer === undefined || runningProviderThread.nativeThreadRef === null + ...(nativeForkTransfer === undefined || + portableForkFallback !== undefined || + runningProviderThread.nativeThreadRef === null ? [] : [ { @@ -1381,6 +1514,26 @@ export const layer: Layer.Layer< const routableSubagents = projection.subagents.filter((subagent) => RunExecutionService.canRouteRelatedSubagent(subagent.status), ); + const persistHandoff = Effect.fn("ProviderTurnStartService.persist")(function* ( + handoff: OrchestrationV2ContextHandoff, + ) { + const updatedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ + threadId: projection.thread.id, + }), + type: "context-handoff.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: updatedAt, + payload: { ...handoff, updatedAt }, + }, + ], + }); + }); const startWithHandoffs = Effect.fn("ProviderTurnStartService.startWithHandoffs")( function* (turnInput: Parameters[0], compact = false) { const delivery = yield* deliverContextHandoffs({ @@ -1398,28 +1551,16 @@ export const layer: Layer.Layer< ...history, }), }), - persist: Effect.fn("ProviderTurnStartService.persist")(function* (handoff) { - const updatedAt = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: yield* idAllocator.allocate.event({ - threadId: projection.thread.id, - }), - type: "context-handoff.updated", - threadId: projection.thread.id, - runId: run.id, - providerInstanceId: run.providerInstanceId, - occurredAt: updatedAt, - payload: { ...handoff, updatedAt }, - }, - ], - }); - }), + persist: persistHandoff, }); if (!(yield* isCurrentAttemptInStatus("running"))) return; const start = compact ? session.compactThread! : session.startTurn; - const context = [delivery.context, restartNote] + // Like history, the side chat context waits for a turn that sends a prompt. + const sideChatContext = + sideChatHandoff === undefined || compact || goalCommand + ? "" + : sideChatHandoff.summaryText; + const context = [delivery.context, sideChatContext, restartNote] .filter((part) => part !== "") .join("\n\n"); const { restartContinuationOfRunId: _resumedRunId, ...promptedInput } = turnInput; @@ -1427,10 +1568,7 @@ export const layer: Layer.Layer< ...(noteContinuation ? promptedInput : turnInput), message: { ...turnInput.message, - text: - context === "" || goalCommand - ? userText - : `${context}\n\nUser message:\n${userText}`, + text: goalCommand ? userText : withInlineContext(context, userText), }, }).pipe( // Clear pending handoff markers for refused prompts so the next turn retains the native session. @@ -1447,13 +1585,30 @@ export const layer: Layer.Layer< ), ); // After provider acceptance, failed delivery bookkeeping must not stop live ingestion. - yield* delivery.delivered.pipe( - Effect.catchCause(() => - Effect.logWarning("Failed to record accepted context handoff delivery", { - runId: run.id, - deliveryStatus: "pending", - }), - ), + const nativeThreadId = runningProviderThread.nativeThreadRef?.nativeId; + yield* Effect.forEach( + [ + delivery.delivered, + ...(sideChatContext === "" || sideChatHandoff === undefined || nativeThreadId == null + ? [] + : [ + persistHandoff({ + ...sideChatHandoff, + toProviderThreadId: runningProviderThread.id, + delivery: { nativeThreadId, status: "inline" as const, itemIds: [] }, + }), + ]), + ], + (record) => + record.pipe( + Effect.catchCause(() => + Effect.logWarning("Failed to record accepted context handoff delivery", { + runId: run.id, + deliveryStatus: "pending", + }), + ), + ), + { discard: true }, ); }, Effect.mapError((cause) => @@ -1471,6 +1626,7 @@ export const layer: Layer.Layer< const deliverySession = effectiveHandoffs.length === 0 && retryHandoff.length === 0 && + sideChatHandoff === undefined && restartNote === "" && !noteContinuation ? session diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index eccb107bc2516..a0a284b5db5d4 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -222,6 +222,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const layerDependencies = Layer.mergeAll( Layer.mock(PullRequestService.PullRequestService)({ + withSharedProjectReads: (effect) => effect, summary, stack, invalidate: options.invalidate ?? (() => Effect.void), diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index ab3c0dca08adc..2c97b1867ef48 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -599,7 +599,7 @@ export const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker((scope: "all" | "requested") => Effect.suspend(() => { if (scope === "requested") requestedSweepQueued = false; - return sweep(scope); + return pullRequests.withSharedProjectReads(sweep(scope)); }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), ); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 394cc1527cb7d..dee246855b815 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -36,7 +36,9 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; +import { SqlError, classifySqliteError } from "effect/sql/SqlError"; +import { toPersistenceSqlError } from "../persistence/Errors.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as CheckpointService from "./CheckpointService.ts"; import type { PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; @@ -1780,6 +1782,74 @@ it.effect("ends a nested native subagent and its thread when the root run is int }), ); +it.effect("keeps ingesting until queued background work clears after the probe goes idle", () => + Effect.gen(function* () { + // The adapter's probe runs ahead of its event queue, so only the queued update may release. + // A child transferred to another run takes its pending work with it. + const pending = yield* Ref.make(true); + const consumed = yield* Deferred.make(); + const result = yield* Effect.raceFirst( + runBackgroundItemScenario( + "queued-background-work", + (ids) => { + const childProviderThreadId = ProviderThreadId.make(`${ids.providerThreadId}:child`); + const childProviderTurnId = ProviderTurnId.make(`${ids.rootProviderTurnId}:child`); + const running = subagentEvent(ids, "running"); + if (running.type !== "subagent.updated") throw new Error("Missing subagent"); + const linked = { + ...running, + subagent: { + ...running.subagent, + origin: "provider_native" as const, + childThreadId: ids.childThreadId, + providerThreadId: childProviderThreadId, + }, + }; + const backgroundWork = (providerTurnId: ProviderTurnId, pending: boolean) => + ({ + type: "background_work.updated", + driver, + providerTurnId, + pending, + }) satisfies ProviderAdapter.ProviderAdapterV2Event; + return [ + childThreadCreatedEvent(ids), + linked, + { + type: "provider_turn.updated", + driver, + threadId: ids.childThreadId, + providerTurn: { + id: childProviderTurnId, + providerThreadId: childProviderThreadId, + runAttemptId: null, + status: "completed", + }, + } as ProviderAdapter.ProviderAdapterV2Event, + backgroundWork(childProviderTurnId, true), + backgroundWork(ids.rootProviderTurnId, true), + rootTerminalEvent(ids, "completed"), + { ...linked, subagent: { ...linked.subagent, runId: RunId.make("run:replacement") } }, + backgroundTurnItemEvent(ids, "dynamic_tool", "completed", 1), + backgroundWork(ids.rootProviderTurnId, false), + ]; + }, + { + keepEventStreamOpen: true, + hasPendingBackgroundWorkForThread: () => Ref.get(pending), + onFinalized: () => Ref.set(pending, false), + onEventsConsumed: Deferred.succeed(consumed, undefined).pipe(Effect.asVoid), + }, + ).pipe(Effect.asSome), + Deferred.await(consumed).pipe(Effect.as(Option.none())), + ); + assert.deepEqual( + result, + Option.some(["subagent:running", "root-finalized", "turn_item:completed"]), + ); + }), +); + it.effect("keeps ingesting a child thread's late background item completion", () => Effect.gen(function* () { const observed = yield* runBackgroundItemScenario("bg-child-item", (ids) => [ @@ -2585,7 +2655,7 @@ it.effect.each(["failure", "defect", "interrupt-failure", "ownership-lost"] as c ReadonlyArray >([]); const ids = backgroundScenarioIds(`ingestion:${failureMode}`); - const { observed, finalEvents } = yield* captureRootRunTermination({ + const { written, observed, finalEvents } = yield* captureRootRunTermination({ key: `ingestion:${failureMode}`, initialProviderTurnId: null, rejectTerminalWrite: failureMode === "ownership-lost", @@ -2618,7 +2688,13 @@ it.effect.each(["failure", "defect", "interrupt-failure", "ownership-lost"] as c const error = new ProviderEventIngestor.ProviderEventPublishError({ providerSessionId: ProviderSessionId.make(`session:${failureMode}`), eventCount: 1, - cause: new Error("SQLITE_FULL: database or disk is full"), + cause: toPersistenceSqlError("OrchestrationEventStore.appendAgentEvents:insert")( + new SqlError({ + reason: classifySqliteError( + Object.assign(new Error("unable to open database file"), { errcode: 14 }), + ), + }), + ), }); return failureMode === "defect" ? Effect.die(error) : Effect.fail(error); }, @@ -2662,6 +2738,8 @@ it.effect.each(["failure", "defect", "interrupt-failure", "ownership-lost"] as c failureMode === "interrupt-failure", ); assert.deepEqual(observed, ["run:failed", "pull-requests-refreshed"]); + const error = written.find((item) => item.type === "error"); + assert.include(error?.failure.message ?? "", "(SQLITE_CANTOPEN)"); const repaired = finalEvents.find((event) => event.type === "provider-turn.updated"); if (failureMode === "interrupt-failure") { assert.isUndefined(repaired); @@ -3528,6 +3606,9 @@ function runBackgroundItemScenario( makeEvents: (ids: BackgroundScenarioIds) => ReadonlyArray, options?: { readonly keepEventStreamOpen?: boolean; + readonly hasPendingBackgroundWorkForThread?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; readonly loadInheritedBackgroundTurnItems?: () => Effect.Effect< ReadonlyArray<{ readonly id: TurnItemId; readonly runId: RunId }> >; @@ -3605,6 +3686,9 @@ function runBackgroundItemScenario( providerSessionId: ProviderSessionId.make(`session:${key}`), session: { events: Stream.empty, + ...(options?.hasPendingBackgroundWorkForThread === undefined + ? {} + : { hasPendingBackgroundWorkForThread: options.hasPendingBackgroundWorkForThread }), subscribeEvents: Effect.gen(function* () { yield* options?.onSubscribe ?? Effect.void; const events = Stream.fromIterable(makeEvents(ids)).pipe( diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 3e3f37a15a936..b9b2b09bd227e 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -48,11 +48,13 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter" import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import type { ProjectionStoreV2Error } from "./ProjectionStore.ts"; import { + knownProviderFailureMessage, makeProviderFailure, makeProviderFailureTurnItem, } from "@t3tools/provider-core/server/failure"; import * as RunFinalizationService from "./RunFinalizationService.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; +import { userFacingDatabaseErrorMessage } from "./UserFacingErrors.ts"; export interface ProviderEventRoutingState { readonly ownedThreadIds: ReadonlySet; @@ -507,6 +509,8 @@ export function routeProviderEvent( state.ownedProviderTurns.has(event.runtimeRequest.providerTurnId)), state, ]; + case "background_work.updated": + return [state.ownedProviderTurns.has(event.providerTurnId), state]; case "command_output.delta": // The session manager hands live output to the command output hub; runs never see it. return [false, state]; @@ -916,21 +920,24 @@ export const layer: Layer.Layer< .responseStreamingMode, ), ); - yield* checkpointService - .captureBaseline({ - scope: input.checkpointScope, - ordinalWithinScope: Math.max(0, input.run.ordinal - 1), - }) - .pipe( - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (_cause) => - Effect.logWarning( - "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", - { runId: input.run.id }, - ), - ), - ); + // Side chats share the parent's worktree; a snapshot would capture the parent's edits. + yield* ( + input.appThread.lineage.sideChat === true + ? Effect.void + : checkpointService.captureBaseline({ + scope: input.checkpointScope, + ordinalWithinScope: Math.max(0, input.run.ordinal - 1), + }) + ).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (_cause) => + Effect.logWarning( + "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", + { runId: input.run.id }, + ), + ), + ); if ( input.shouldStartProviderTurn !== undefined && !(yield* input.shouldStartProviderTurn()) @@ -1030,6 +1037,7 @@ export const layer: Layer.Layer< const rootTerminalSeen = yield* Ref.make(false); const rootRunFinalized = yield* Ref.make(false); const providerThreadOwnerLost = yield* Ref.make(false); + const pendingBackgroundProviderTurns = new Set(); const activeChildProviderTurns = yield* Ref.make>(new Set()); const activeChildSubagents = yield* Ref.make>(new Map()); const activeBackgroundTurnItems = yield* Ref.make< @@ -1231,6 +1239,9 @@ export const layer: Layer.Layer< ) { return true; } + if (pendingBackgroundProviderTurns.size > 0) { + return false; + } const childProviderTurns = yield* Ref.get(activeChildProviderTurns); if (childProviderTurns.size > 0) { return false; @@ -1350,6 +1361,11 @@ export const layer: Layer.Layer< (current) => new Set([...current].filter((id) => routing.ownedProviderTurns.has(id))), ); + for (const id of pendingBackgroundProviderTurns) { + if (!routing.ownedProviderTurns.has(id)) { + pendingBackgroundProviderTurns.delete(id); + } + } yield* Ref.update( activeBackgroundTurnItems, (current) => new Set([...current].filter((id) => !detached.itemIds.has(id))), @@ -1454,6 +1470,13 @@ export const layer: Layer.Layer< yield* Ref.set(rootTerminalSeen, true); yield* finalizeRootRun(event); } + if (event.type === "background_work.updated") { + if (event.pending) { + pendingBackgroundProviderTurns.add(event.providerTurnId); + } else { + pendingBackgroundProviderTurns.delete(event.providerTurnId); + } + } yield* trackChildLifecycle(event, deliveredEvent !== null); }), ), @@ -1536,6 +1559,7 @@ export const layer: Layer.Layer< : null; const latestItemOrdinal = yield* Ref.get(latestTurnItemOrdinal); const openSubagents = yield* Ref.get(openRunOwnedSubagents); + const squashed = Cause.squash(cause); let nextRetryWarningAt = completedAt; yield* writeFinalRunEvents({ run: input.run, @@ -1551,7 +1575,13 @@ export const layer: Layer.Layer< ...(abandonedProviderTurn === null ? {} : { abandonedProviderTurn }), completedAt, terminal: makeFailedTerminalEvent( - makeProviderFailure({ cause: Cause.squash(cause), class: "unknown" }), + makeProviderFailure({ + cause: squashed, + message: + knownProviderFailureMessage(squashed) ?? + userFacingDatabaseErrorMessage(squashed), + class: "unknown", + }), latestItemOrdinal + 1, ), failureItemPersisted: false, diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index e308c74a00ff9..336f9b12f96bf 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -235,9 +235,24 @@ describe("omitOlderSettledShells", () => { omitOlderSettledShells({ threads, snapshotSequence: 7, now }); it("keeps the 25 newest settled threads and counts the rest per project", () => { - const result = omit([...newest, row("old", 30), row("b-old", 40), row("unsettled", null)]); + const sideChat = row("side-chat", 0, { + lineage: { + parentThreadId: ThreadId.make("new-0"), + relationshipToParent: "fork", + rootThreadId: ThreadId.make("new-0"), + sideChat: true, + }, + }); + const result = omit([ + ...newest, + sideChat, + row("old", 30), + row("b-old", 40), + row("unsettled", null), + ]); expect(result.threads.map((thread) => thread.id)).toEqual([ ...newest.map((thread) => thread.id), + "side-chat", "unsettled", ]); expect(result.olderSettled).toEqual({ @@ -262,7 +277,7 @@ describe("omitOlderSettledShells", () => { expect(result.olderSettled.counts).toEqual([]); }); - it("keeps older rows that are snoozed, live, or above a kept subagent, and still counts them", () => { + it("keeps older rows that are snoozed, live, or above a kept subagent or side chat, and still counts them", () => { const threads = [ ...newest, row("snoozed", 30, { snoozedUntil: DateTime.makeUnsafe(101 * DAY) }), @@ -280,6 +295,15 @@ describe("omitOlderSettledShells", () => { settledOverride: null, settledAt: null, }), + row("side-chat-parent", 30), + row("side-chat", null, { + lineage: { + parentThreadId: ThreadId.make("side-chat-parent"), + relationshipToParent: "fork", + rootThreadId: ThreadId.make("side-chat-parent"), + sideChat: true, + }, + }), ]; const result = omit(threads); expect(result.threads.map((thread) => thread.id).slice(25)).toEqual([ @@ -287,8 +311,10 @@ describe("omitOlderSettledShells", () => { "running", "parent", "child", + "side-chat-parent", + "side-chat", ]); - expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 4 }]); + expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 5 }]); }); }); diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 67d07d6684c08..0fa5251a7ef73 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -12,6 +12,7 @@ import type { ThreadId, } from "@t3tools/contracts"; import { + isListedThreadLineage, isLiveSubagentShell, isOlderSettledThreadShell, OrchestrationProjectShell as ProjectShellSchema, @@ -98,7 +99,11 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { if (row.lineage.relationshipToParent === "subagent") { subagentThreadIds.add(row.id); if (row.lineage.parentThreadId) subagentParentIds.add(row.lineage.parentThreadId); - } else if (row.settledOverride === "settled" && row.settledAt !== null) { + } else if ( + isListedThreadLineage(row.lineage) && + row.settledOverride === "settled" && + row.settledAt !== null + ) { settledTimes.push(DateTime.toEpochMillis(row.settledAt)); } } @@ -121,10 +126,12 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { }; } -function subagentAncestorIds(threads: ReadonlyArray>) { +function hiddenChildAncestorIds( + threads: ReadonlyArray>, +) { const ancestorIds = new Set(); for (const thread of threads) { - if (thread.lineage.relationshipToParent !== "subagent") continue; + if (isListedThreadLineage(thread.lineage)) continue; if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); ancestorIds.add(thread.lineage.rootThreadId); } @@ -156,7 +163,7 @@ function omitOlderSettledShellsWith( } { const nowMs = DateTime.toEpochMillis(input.now); const beforeMs = facts.olderSettledBeforeMs ?? nowMs; - const ancestorIds = subagentAncestorIds(input.threads); + const ancestorIds = hiddenChildAncestorIds(input.threads); const threads = input.threads.filter( (thread) => !isOlderSettledThreadShell(thread, beforeMs) || @@ -207,7 +214,8 @@ export function activeShellRowsForSubscriber( /** * Opted-in snapshots keep the 25 newest settled top-level threads (and ties) and count the rest per * project in `olderSettled`; clients page them with `getOlderSettledThreads`. Older rows stay while - * pinned, snoozed, live, or above a kept subagent, so walks over shell rows still find them. + * pinned, snoozed, live, or above a kept subagent or side chat, so walks over shell rows still find + * them. */ export function omitOlderSettledShells(input: { readonly threads: ReadonlyArray; @@ -255,7 +263,10 @@ function shellSubscriberThreadIds( entry.lineage.relationshipToParent === "subagent" && (!options.omitIdleSubagents || entry.mayBeLive || facts.subagentParentIds.has(entry.id)), ); - const ancestorIds = subagentAncestorIds(subagents); + const ancestorIds = hiddenChildAncestorIds([ + ...subagents, + ...index.filter((entry) => entry.lineage.sideChat === true), + ]); const keptSubagentIds = new Set(subagents.map((entry) => entry.id)); const beforeMs = facts.olderSettledBeforeMs; return index.flatMap((entry) => diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 1015e44694c81..d4867a264ec9c 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1547,3 +1547,82 @@ it.effect("stops the running turn when a goal clear ends the goal driving it", ( }), ), ); + +it.effect("a side chat that opens with /goal gets its context on the next question", () => + Effect.scoped( + Effect.gen(function* () { + const { events, layer, started, startFirstTurn } = yield* goalHarness("side-chat-goal"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const parentId = yield* startFirstTurn; + const sideChatId = ThreadId.make("thread:side-chat-goal-child"); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("side-chat"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + const ask = (id: string, text: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId: sideChatId, + messageId: MessageId.make(`message:${id}`), + text, + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const sideChatEvent = (matches: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter((event) => event.threadId === sideChatId && matches(event)), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ); + const running = yield* sideChatEvent( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + const settled = yield* sideChatEvent( + (event) => event.type === "run.updated" && event.payload.status === "waiting", + ); + yield* ask("goal", "/goal clear"); + yield* worker.drain(); + yield* Fiber.join(running); + const turn = (yield* orchestrator.getThreadProjection(sideChatId)).providerTurns[0]!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: started[1]!.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + yield* ask("question", "What is the main thread doing?"); + yield* worker.drain(); + + const [first, goal, question] = started.map((turn) => turn.message.text); + assert.deepEqual([first, goal], ["first", "/goal clear"]); + assert.include(question, "You are in a side chat beside the main thread."); + assert.include(question, "- User: first"); + assert.isTrue(question?.endsWith("What is the main thread doing?")); + }).pipe(Effect.provide(layer)); + }), + ), +); diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index 95abcbaf5c1a8..c410d6682da38 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -120,14 +120,17 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { }); }); -it.effect.each([ - ["sql", ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory))], -] as const)( - "%s: a pinned parent's subagent child is born unpinned and settles once idle", - ([, testLayer]) => +it.effect.each(["provider", "mcp"] as const)( + "a %s child does not inherit parent settlement protections and settles once idle", + (creationSource) => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; - const parentThread = { ...makeParentThread(), pinnedAt: parentCreatedAt, pinOrderKey: "a0" }; + const parentThread = { + ...makeParentThread(), + pinnedAt: parentCreatedAt, + pinOrderKey: "a0", + autoSettleDisabledAt: parentCreatedAt, + }; const childThread = makeSubagentChildThread({ parentThread, childThreadId, @@ -138,13 +141,14 @@ it.effect.each([ title: "Child", now: childCreatedAt, createdBy: "agent", - creationSource: "provider", + creationSource, }); assert.equal(parentThread.pinnedAt, parentCreatedAt); assert.equal(parentThread.pinOrderKey, "a0"); assert.isNull(childThread.pinnedAt); assert.isNull(childThread.pinOrderKey); + assert.isNull(childThread.autoSettleDisabledAt); for (const thread of [parentThread, childThread]) { yield* store.apply({ @@ -158,6 +162,7 @@ it.effect.each([ const savedParent = yield* store.getThread(parentThreadId); assert.deepEqual(savedParent.pinnedAt, parentCreatedAt); assert.equal(savedParent.pinOrderKey, "a0"); + assert.deepEqual(savedParent.autoSettleDisabledAt, parentCreatedAt); const candidates = yield* store.getSettlementCandidates(); assert.deepEqual( candidates.map((candidate) => candidate.id), @@ -171,7 +176,9 @@ it.effect.each([ autoSettleOnMerge: false, }); assert.equal(settledAt && DateTime.formatIso(settledAt), DateTime.formatIso(childCreatedAt)); - }).pipe(Effect.provide(testLayer)), + }).pipe( + Effect.provide(ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory))), + ), ); function taskFixture() { diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 3350d31fb708d..71d40332f2d7a 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + CheckpointId, CommandId, EventId, MessageId, @@ -214,13 +215,15 @@ it.effect.each(forkCases)( const attemptId = RunAttemptId.make("interrupted-source-attempt"); const providerTurnId = ProviderTurnId.make("interrupted-source-turn"); const rootNodeId = NodeId.make("interrupted-source-root"); + const grandparentThreadId = ThreadId.make("fork-boundary-grandparent"); + const grandparentRunId = RunId.make("fork-boundary-grandparent-run"); yield* orchestrator.dispatch({ type: "thread.create", - commandId: CommandId.make("create-source"), - threadId: sourceThreadId, + commandId: CommandId.make("create-grandparent"), + threadId: grandparentThreadId, projectId: ProjectId.make("fork-boundary-project"), - title: "Fork boundary source", + title: "Fork boundary grandparent", modelSelection, runtimeMode: "full-access", interactionMode: "default", @@ -229,6 +232,29 @@ it.effect.each(forkCases)( createdBy: "user", creationSource: "web", }); + yield* writeParentRun({ + threadId: grandparentThreadId, + runId: RunId.make("fork-boundary-grandparent-rolled-back-run"), + ordinal: 1, + status: "rolled_back", + text: "ROLLED_BACK_MARKER", + }); + yield* writeParentRun({ + threadId: grandparentThreadId, + runId: grandparentRunId, + ordinal: 2, + status: "completed", + text: "GRANDPARENT_MARKER", + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("fork-grandparent"), + sourceThreadId: grandparentThreadId, + targetThreadId: sourceThreadId, + sourcePoint: { type: "run", runId: grandparentRunId }, + createdBy: "user", + creationSource: "web", + }); yield* eventSink.write({ events: [ { @@ -246,8 +272,8 @@ it.effect.each(forkCases)( nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" }, nativeConversationHeadRef: null, status: "idle", - firstRunOrdinal: 1, - lastRunOrdinal: 2, + firstRunOrdinal: 2, + lastRunOrdinal: 3, handoffIds: [], forkedFrom: null, createdAt: now, @@ -299,8 +325,15 @@ it.effect.each(forkCases)( ], }); } - for (const ordinal of [1, 2]) { - const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run"); + yield* writeParentRun({ + threadId: sourceThreadId, + runId: RunId.make("fork-boundary-source-rolled-back-run"), + ordinal: 1, + status: "rolled_back", + text: "SOURCE_ROLLED_BACK_MARKER", + }); + for (const ordinal of [2, 3]) { + const runId = ordinal === 2 ? sourceRunId : RunId.make("later-run"); const messageId = MessageId.make(`source-message-${ordinal}`); yield* eventSink.write({ events: [ @@ -319,8 +352,8 @@ it.effect.each(forkCases)( providerThreadId, userMessageId: messageId, rootNodeId: null, - activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null, - status: ordinal === 1 ? status : "completed", + activeAttemptId: ordinal === 2 && status === "interrupted" ? attemptId : null, + status: ordinal === 2 ? status : "completed", queuePosition: null, requestedAt: now, startedAt: now, @@ -355,7 +388,7 @@ it.effect.each(forkCases)( creationSource: "web", inputIntent: "turn_start", messageId, - text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", + text: ordinal === 2 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", attachments: [], }, }, @@ -388,6 +421,9 @@ it.effect.each(forkCases)( assert.lengthOf(target.contextHandoffs, 1); const handoff = target.contextHandoffs[0]!; const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? ""; + assert.include(`${handoff.summaryText}\n${history}`, "GRANDPARENT_MARKER"); + assert.notInclude(`${handoff.summaryText}\n${history}`, "SOURCE_ROLLED_BACK_MARKER"); + assert.notInclude(`${handoff.summaryText}\n${history}`, "ROLLED_BACK_MARKER"); assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER"); assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER"); assert.isNull(target.providerThreads[0]?.forkedFrom); @@ -607,3 +643,561 @@ it.effect.each([ ); }, ); + +const writeParentRun = (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly ordinal: number; + /** Preparing, failed, and interrupted runs asked for a new worktree. */ + readonly status: + | "preparing" + | "running" + | "queued" + | "completed" + | "rolled_back" + | "failed" + | "interrupted"; + readonly text: string; + /** A checkpointed run on a native provider thread, which `latest_stable` forks start from. */ + readonly stable?: boolean; +}) => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const { driver, instanceId, modelSelection } = forkCases[0]!; + const messageId = MessageId.make(`${input.runId}:message`); + const providerThreadId = input.stable + ? ProviderThreadId.make(`${input.runId}:provider-thread`) + : null; + yield* eventSink.write({ + events: [ + ...(providerThreadId === null + ? [] + : [ + { + id: EventId.make(`${input.runId}:provider-thread`), + type: "provider-thread.updated" as const, + threadId: input.threadId, + occurredAt: now, + payload: { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: input.threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: `${input.runId}:native`, + strength: "strong" as const, + }, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: input.ordinal, + lastRunOrdinal: input.ordinal, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + ]), + { + id: EventId.make(`${input.runId}:created`), + type: "run.created", + threadId: input.threadId, + runId: input.runId, + occurredAt: now, + payload: { + id: input.runId, + threadId: input.threadId, + ordinal: input.ordinal, + providerInstanceId: instanceId, + modelSelection, + providerThreadId, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: input.status, + queuePosition: null, + requestedAt: now, + startedAt: now, + completedAt: input.status === "completed" ? now : null, + checkpointId: input.stable ? CheckpointId.make(`${input.runId}:checkpoint`) : null, + contextHandoffId: null, + ...(input.status === "preparing" || + input.status === "failed" || + input.status === "interrupted" + ? { workspacePreparation: { type: "worktree" as const, baseRef: "main" } } + : {}), + }, + }, + { + id: EventId.make(`${input.runId}:item`), + type: "turn-item.updated", + threadId: input.threadId, + runId: input.runId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${input.runId}:item`), + threadId: input.threadId, + runId: input.runId, + nodeId: null, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: input.ordinal * 100, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId, + text: input.text, + attachments: [], + }, + }, + ], + }); + }); + +/** Marks `threadId` as imported from V1 and gives it one runless imported user message. */ +const writeImportedHistory = (threadId: ThreadId, text: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const { thread } = yield* orchestrator.getThreadProjection(threadId); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:imported`), + type: "thread.metadata-updated", + threadId, + occurredAt: now, + payload: { ...thread, historyOrigin: "v1_import" }, + }, + { + id: EventId.make(`${threadId}:imported-item`), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:imported-item`), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId: MessageId.make(`${threadId}:imported-message`), + text, + attachments: [], + }, + }, + ], + }); + }); + +const createParent = (threadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make("side-chat-project"), + title: "Side chat parent", + modelSelection: forkCases[0]!.modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + }); + +const forkSideChat = (commandId: string, sourceThreadId: ThreadId, targetThreadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + return yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(commandId), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + }); + +const askSideChat = (threadId: ThreadId, commandId: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(commandId), + threadId, + messageId: MessageId.make(commandId), + text: "What is the main thread doing?", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + }); + +it.effect("a side chat of an idle parent still starts with the framing note", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parentId = ThreadId.make("side-chat-idle-parent"); + const sideChatId = ThreadId.make("side-chat-idle-child"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-idle-parent-run"), + ordinal: 1, + status: "completed", + text: "Parent work", + stable: true, + }); + + yield* forkSideChat("side-chat-idle-fork", parentId, sideChatId); + yield* askSideChat(sideChatId, "side-chat-idle-first-message"); + + const started = yield* orchestrator.getThreadProjection(sideChatId); + assert.isNotNull(started.providerThreads[0]?.forkedFrom); + assert.lengthOf(started.contextHandoffs, 1); + const [handoff] = started.contextHandoffs; + assert.isTrue( + handoff!.summaryText.startsWith("You are in a side chat beside the main thread."), + ); + assert.notInclude(handoff!.summaryText, "Main thread activity"); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("a side chat waits for the parent's new worktree, also after its setup ended", () => + Effect.gen(function* () { + for (const status of ["preparing", "failed", "interrupted"] as const) { + const parentId = ThreadId.make(`side-chat-worktree-parent-${status}`); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make(`side-chat-worktree-parent-run-${status}`), + ordinal: 1, + status, + text: "Parent work", + }); + + const rejected = yield* forkSideChat( + `side-chat-worktree-fork-${status}`, + parentId, + ThreadId.make(`side-chat-worktree-child-${status}`), + ).pipe(Effect.flip); + assert.include(String(rejected.cause), "has no worktree yet", status); + } + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("closing a side chat deletes it only while unused; a deleted parent takes no more", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parentId = ThreadId.make("side-chat-close-parent"); + const unused = ThreadId.make("side-chat-close-unused"); + const used = ThreadId.make("side-chat-close-used"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-close-parent-run"), + ordinal: 1, + status: "completed", + text: "Parent work", + stable: true, + }); + yield* forkSideChat("side-chat-close-fork-unused", parentId, unused); + yield* forkSideChat("side-chat-close-fork-used", parentId, used); + yield* askSideChat(used, "side-chat-close-used-message"); + const close = (threadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`side-chat-close:${threadId}`), + threadId, + onlyIfUnused: true, + }); + + yield* close(unused); + yield* close(used); + assert.isNotNull((yield* orchestrator.getThreadProjection(unused)).thread.deletedAt); + assert.isNull((yield* orchestrator.getThreadProjection(used)).thread.deletedAt); + + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make("side-chat-close-delete-parent"), + threadId: parentId, + }); + assert.isNotNull((yield* orchestrator.getThreadProjection(used)).thread.deletedAt); + const orphan = yield* forkSideChat( + "side-chat-close-orphan", + parentId, + ThreadId.make("side-chat-close-orphan"), + ).pipe(Effect.flip); + assert.include(String(orphan.cause), "was deleted"); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("a side chat opened during the parent's first turn starts from a digest", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const grandparentId = ThreadId.make("side-chat-busy-grandparent"); + const grandparentRunId = RunId.make("side-chat-busy-grandparent-run"); + const parentId = ThreadId.make("side-chat-busy-parent"); + const sideChatId = ThreadId.make("side-chat-busy-child"); + yield* createParent(grandparentId); + yield* writeParentRun({ + threadId: grandparentId, + runId: grandparentRunId, + ordinal: 1, + status: "completed", + text: "GRANDPARENT_MARKER", + stable: true, + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("side-chat-busy-parent-fork"), + sourceThreadId: grandparentId, + targetThreadId: parentId, + sourcePoint: { type: "run", runId: grandparentRunId }, + createdBy: "user", + creationSource: "web", + }); + yield* writeImportedHistory(parentId, "PARENT_IMPORTED_MARKER"); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-rewound-parent-run"), + ordinal: 1, + status: "rolled_back", + text: "PARENT_ROLLED_BACK_MARKER", + }); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-busy-parent-run"), + ordinal: 2, + status: "running", + text: "PARENT_RUNNING_MARKER", + }); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-queued-parent-run"), + ordinal: 3, + status: "queued", + text: "PARENT_QUEUED_MARKER", + }); + const fork = (commandId: string, sourceThreadId: ThreadId, targetThreadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(commandId), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + + yield* fork("side-chat-busy-fork", parentId, sideChatId); + const created = yield* orchestrator.getThreadProjection(sideChatId); + assert.isTrue(created.thread.lineage.sideChat); + assert.equal(created.thread.runtimeMode, "approval-required"); + assert.isNull(created.thread.forkedFrom); + assert.deepEqual(created.contextTransfers[0]?.sourcePoint, { threadId: parentId }); + + const nested = yield* fork( + "side-chat-busy-nested", + sideChatId, + ThreadId.make("side-chat-busy-nested"), + ).pipe(Effect.flip); + assert.equal(nested._tag, "OrchestratorDispatchError"); + // Either would take the side chat's lock before the parent's, against the delete cascade. + const ontoParent = yield* fork("side-chat-busy-onto-parent", sideChatId, parentId).pipe( + Effect.flip, + ); + assert.equal(ontoParent._tag, "OrchestratorThreadAlreadyExistsError"); + const ontoExisting = yield* fork("side-chat-busy-onto-existing", parentId, sideChatId).pipe( + Effect.flip, + ); + assert.equal(ontoExisting._tag, "OrchestratorThreadAlreadyExistsError"); + const plainOntoExisting = yield* orchestrator + .dispatch({ + type: "thread.fork", + commandId: CommandId.make("side-chat-busy-plain-onto-existing"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.flip); + assert.equal(plainOntoExisting._tag, "OrchestratorThreadAlreadyExistsError"); + const busyMergeBack = yield* orchestrator + .dispatch({ + type: "thread.merge_back", + commandId: CommandId.make("side-chat-busy-merge-back"), + sourceThreadId: sideChatId, + targetThreadId: parentId, + sourcePoint: { type: "latest_stable" }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.flip); + assert.include(String(busyMergeBack.cause), "active work"); + + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("side-chat-busy-first-message"), + threadId: sideChatId, + messageId: MessageId.make("side-chat-busy-first-message"), + text: "What is the main thread doing?", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const started = yield* orchestrator.getThreadProjection(sideChatId); + const [handoff] = started.contextHandoffs; + assert.lengthOf(started.contextHandoffs, 1); + assert.deepEqual(started.contextTransfers[0]?.resolution, { + strategy: "portable_context", + contextHandoffId: handoff!.id, + }); + assert.include(handoff!.summaryText, "You are in a side chat beside the main thread."); + // Inherited rows come first, so the cap truncates them first. + assert.isAbove( + handoff!.summaryText.indexOf("- User: PARENT_IMPORTED_MARKER"), + handoff!.summaryText.indexOf("- User: GRANDPARENT_MARKER"), + ); + assert.include(handoff!.summaryText, "- User: GRANDPARENT_MARKER"); + assert.notInclude(handoff!.summaryText, "PARENT_ROLLED_BACK_MARKER"); + assert.notInclude(handoff!.summaryText, "PARENT_QUEUED_MARKER"); + assert.include(handoff!.summaryText, "- User: PARENT_IMPORTED_MARKER"); + assert.include(handoff!.summaryText, "- User: PARENT_RUNNING_MARKER"); + assert.include(handoff!.summaryText, `t3_thread_read on thread ${parentId}`); + assert.equal( + started.messages.find((message) => message.role === "user")?.text, + "What is the main thread doing?", + ); + + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make("side-chat-busy-delete-parent"), + threadId: parentId, + }); + assert.isNotNull((yield* orchestrator.getThreadProjection(sideChatId)).thread.deletedAt); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("merge-back rejects a second fork's context until the first is consumed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parentId = ThreadId.make("merge-back-guard-parent"); + const parentRunId = RunId.make("merge-back-guard-parent-run"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: parentRunId, + ordinal: 1, + status: "completed", + text: "Parent work", + }); + const forkAndRun = (name: string) => + Effect.gen(function* () { + const threadId = ThreadId.make(`merge-back-guard-${name}`); + const runId = RunId.make(`merge-back-guard-${name}-run`); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(`merge-back-guard-fork-${name}`), + sourceThreadId: parentId, + targetThreadId: threadId, + sourcePoint: { type: "run", runId: parentRunId }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + yield* writeParentRun({ threadId, runId, ordinal: 1, status: "completed", text: name }); + return { threadId, runId }; + }); + const mergeBack = (commandId: string, fork: { threadId: ThreadId; runId: RunId }) => + orchestrator.dispatch({ + type: "thread.merge_back", + commandId: CommandId.make(commandId), + sourceThreadId: fork.threadId, + targetThreadId: parentId, + sourcePoint: { type: "run", runId: fork.runId }, + createdBy: "user", + creationSource: "web", + }); + const first = yield* forkAndRun("first"); + const second = yield* forkAndRun("second"); + + yield* mergeBack("merge-back-guard-first", first); + const rejected = yield* mergeBack("merge-back-guard-second", second).pipe(Effect.flip); + assert.include(String(rejected.cause), "pending context from another side chat or fork"); + yield* mergeBack("merge-back-guard-first-again", first); + // That pending merge-back targets the parent, so it does not block one into `first`. + const nested = { threadId: ThreadId.make("merge-back-guard-nested") }; + const nestedRunId = RunId.make("merge-back-guard-nested-run"); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("merge-back-guard-fork-nested"), + sourceThreadId: first.threadId, + targetThreadId: nested.threadId, + sourcePoint: { type: "run", runId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* writeParentRun({ + threadId: nested.threadId, + runId: nestedRunId, + ordinal: 1, + status: "completed", + text: "nested", + }); + yield* orchestrator.dispatch({ + type: "thread.merge_back", + commandId: CommandId.make("merge-back-guard-nested"), + sourceThreadId: nested.threadId, + targetThreadId: first.threadId, + sourcePoint: { type: "run", runId: nestedRunId }, + createdBy: "user", + creationSource: "web", + }); + + const parent = yield* orchestrator.getThreadProjection(parentId); + assert.deepEqual( + parent.contextTransfers + .filter((transfer) => transfer.type === "merge_back") + .map((transfer) => [transfer.sourceThreadId, transfer.status]), + [ + [first.threadId, "superseded"], + [first.threadId, "pending"], + ], + ); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts index b6b89758f7b5d..6d401d968491d 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts @@ -1,6 +1,8 @@ import { assert, it } from "@effect/vitest"; import { + CommandId, ContextTransferId, + EnvironmentId, MessageId, type ModelSelection, type OrchestrationV2AppThread, @@ -110,6 +112,7 @@ function makeSourceProjection(sourceRun: OrchestrationV2Run): OrchestrationV2Thr const planFork = ( sourceRun: OrchestrationV2Run, sourceProjection = makeSourceProjection(sourceRun), + options: { readonly sideChat?: boolean } = {}, ) => Effect.gen(function* () { const service = yield* ThreadForkService.ThreadForkServiceV2; @@ -124,6 +127,7 @@ const planFork = ( transferId: ContextTransferId.make("context-transfer:fork-snoozed-source"), targetThreadId, title: "Awake fork", + ...options, createdBy: "user", creationSource: "mobile", createdAt: forkCreatedAt, @@ -159,6 +163,24 @@ it.effect("keeps a fork awake when its source thread is snoozed", () => }), ); +it.effect("starts a fork without the source thread's pending title regeneration", () => + Effect.gen(function* () { + const sourceRun = makeSourceRun("completed"); + const titleRegeneration = { + requestId: CommandId.make("command:fork-source-title"), + startedAt: snoozedAt, + }; + const sourceProjection = { + ...makeSourceProjection(sourceRun), + thread: { ...makeSourceThread(), titleRegeneration }, + }; + const result = yield* planFork(sourceRun, sourceProjection); + + assert.isNull(result.targetThread.titleRegeneration); + assert.deepEqual(sourceProjection.thread.titleRegeneration, titleRegeneration); + }), +); + it.effect("preserves pull request context without inheriting any watches", () => Effect.gen(function* () { const sourceRun = makeSourceRun("completed"); @@ -242,3 +264,63 @@ it.effect("rejects in-progress and rolled-back fork sources", () => } }), ); + +it.effect("marks only requested forks as supervised side chats", () => + Effect.gen(function* () { + const sourceRun = makeSourceRun("completed"); + const sideChat = yield* planFork(sourceRun, makeSourceProjection(sourceRun), { + sideChat: true, + }); + assert.isTrue(sideChat.targetThread.lineage.sideChat); + assert.equal(sideChat.targetThread.runtimeMode, "approval-required"); + + const sideChatSource = makeSourceProjection(sourceRun); + const sideChatProjection = { + ...sideChatSource, + thread: { + ...sideChatSource.thread, + lineage: { ...sideChatSource.thread.lineage, sideChat: true }, + }, + }; + const forkOfSideChat = yield* planFork(sourceRun, sideChatProjection); + assert.isUndefined(forkOfSideChat.targetThread.lineage.sideChat); + assert.equal(forkOfSideChat.targetThread.runtimeMode, "full-access"); + + const nested = yield* planFork(sourceRun, sideChatProjection, { sideChat: true }).pipe( + Effect.flip, + ); + assert.equal(nested.cause, "A side chat cannot start another side chat."); + + const subagentSource = makeSourceProjection(sourceRun); + const fromSubagent = yield* planFork( + sourceRun, + { + ...subagentSource, + thread: { + ...subagentSource.thread, + lineage: { ...subagentSource.thread.lineage, relationshipToParent: "subagent" }, + }, + }, + { sideChat: true }, + ).pipe(Effect.flip); + assert.equal(fromSubagent.cause, "A subagent cannot start a side chat."); + + const delegatedSource = makeSourceProjection(sourceRun); + const fromDelegated = yield* planFork( + sourceRun, + { + ...delegatedSource, + thread: { + ...delegatedSource.thread, + delegatedFrom: { + environmentId: EnvironmentId.make("linked-environment"), + threadId: ThreadId.make("linked-parent"), + title: "Linked parent", + }, + }, + }, + { sideChat: true }, + ).pipe(Effect.flip); + assert.equal(fromDelegated.cause, "A subagent cannot start a side chat."); + }), +); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index a660de9a4e6a1..4717daf75e30c 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -51,13 +51,15 @@ export function forkableSourceRunStatusError( export interface ThreadForkServiceV2Shape { readonly plan: (input: { readonly sourceProjection: Pick; - readonly sourceRun: OrchestrationV2Run; + /** Null only for a side chat whose parent has no stable run yet. */ + readonly sourceRun: OrchestrationV2Run | null; readonly sourceProviderThread: OrchestrationV2ProviderThread | undefined; readonly sourceTokenUsage?: OrchestrationV2ProviderTurnTokenUsage | undefined; readonly canonicalSourcePoint: OrchestrationV2ContextSourcePoint; readonly transferId: ContextTransferId; readonly targetThreadId: ThreadId; readonly title?: string; + readonly sideChat?: boolean; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; readonly createdAt: DateTime.Utc; @@ -73,11 +75,28 @@ export const layer: Layer.Layer = Layer.succeed( ThreadForkServiceV2, ThreadForkServiceV2.of({ plan: Effect.fn("ThreadForkService.plan")(function* (input) { - if (!isForkableSourceRunStatus(input.sourceRun.status)) { + const { sourceRun } = input; + const sourceLineage = input.sourceProjection.thread.lineage; + // A task a linked environment delegated here is a subagent of a remote thread. + if ( + input.sideChat && + (sourceLineage.sideChat || + sourceLineage.relationshipToParent === "subagent" || + input.sourceProjection.thread.delegatedFrom !== undefined) + ) { return yield* new ThreadForkPlanError({ sourceThreadId: input.sourceProjection.thread.id, targetThreadId: input.targetThreadId, - cause: forkableSourceRunStatusError(input.sourceRun), + cause: sourceLineage.sideChat + ? "A side chat cannot start another side chat." + : "A subagent cannot start a side chat.", + }); + } + if (sourceRun !== null && !isForkableSourceRunStatus(sourceRun.status)) { + return yield* new ThreadForkPlanError({ + sourceThreadId: input.sourceProjection.thread.id, + targetThreadId: input.targetThreadId, + cause: forkableSourceRunStatusError(sourceRun), }); } const targetThread: OrchestrationV2AppThread = { @@ -90,17 +109,21 @@ export const layer: Layer.Layer = Layer.succeed( creationSource: input.creationSource, id: input.targetThreadId, title: input.title ?? `${input.sourceProjection.thread.title} fork`, + // The source's title job completes only on the source thread. + titleRegeneration: null, + // Side chats share the parent's worktree, so they start supervised. + ...(input.sideChat ? { runtimeMode: "approval-required" as const } : {}), activeProviderThreadId: null, lineage: { parentThreadId: input.sourceProjection.thread.id, relationshipToParent: "fork", rootThreadId: input.sourceProjection.thread.lineage.rootThreadId, + ...(input.sideChat ? { sideChat: true } : {}), }, - forkedFrom: { - type: "run", - threadId: input.sourceProjection.thread.id, - runId: input.sourceRun.id, - }, + forkedFrom: + sourceRun === null + ? null + : { type: "run", threadId: input.sourceProjection.thread.id, runId: sourceRun.id }, createdAt: input.createdAt, updatedAt: input.createdAt, archivedAt: null, @@ -122,10 +145,11 @@ export const layer: Layer.Layer = Layer.succeed( targetThreadId: input.targetThreadId, sourcePoint: input.canonicalSourcePoint, basePoint: null, - sourceProviderInstanceId: input.sourceRun.providerInstanceId, + sourceProviderInstanceId: + sourceRun?.providerInstanceId ?? input.sourceProjection.thread.providerInstanceId, // Only native forks retain this context; portable handoffs rebuild it. sourceTokenUsage: - (input.sourceRun.status === "completed" || input.sourceRun.status === "waiting") && + (sourceRun?.status === "completed" || sourceRun?.status === "waiting") && input.sourceProviderThread?.nativeThreadRef?.strength === "strong" ? input.sourceTokenUsage : undefined, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index dea4562a0f7f8..657ab5b02f311 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1159,6 +1159,100 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails }).pipe(Effect.provide(harness.layer)); }); +it.effect("rejects a preparation retry after the project disables its provider", () => { + let fetchAttempts = 0; + const harness = makeHarness({ + fetchWorktreeBaseCommit: () => + fetchAttempts++ === 0 + ? Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.fetchRemote", + command: "git", + cwd: project.workspaceRoot, + detail: "Git could not reach the remote.", + exitCode: 128, + }), + ) + : Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const settings = yield* ServerSettings.ServerSettingsService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:disabled-preparation-retry", + thread: "thread:launch:disabled-preparation-retry", + message: "Start from origin", + workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, + }), + ); + yield* preparationSettled(launched.threadId); + yield* settings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, + }, + }); + + const error = yield* launches + .retryPreparation({ + commandId: CommandId.make("command:launch:disabled-preparation-retry:retry"), + threadId: launched.threadId, + runId: launched.projection.runs[0]!.id, + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "ThreadManagementProviderInstanceDisabledError"); + assert.equal((yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status, "failed"); + assert.equal(fetchAttempts, 1); + }).pipe(Effect.provide(harness.layer)); +}); + +it.effect("does not release a prepared run after the project disables its provider", () => { + return Effect.gen(function* () { + const setupEntered = yield* Deferred.make(); + const allowSetup = yield* Deferred.make(); + const harness = makeHarness({ + runSetup: () => + Deferred.succeed(setupEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowSetup)), + Effect.as({ status: "no-script" as const }), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const settings = yield* ServerSettings.ServerSettingsService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:disable-during-preparation", + thread: "thread:launch:disable-during-preparation", + message: "Prepare a worktree", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* Deferred.await(setupEntered); + yield* settings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, + }, + }); + yield* Deferred.succeed(allowSetup, undefined); + const projection = yield* preparationSettled(launched.threadId); + assert.equal(projection.runs[0]?.status, "failed"); + const effects = yield* (yield* EffectOutbox.EffectOutboxV2).listByCommandId( + CommandId.make("command:launch:disable-during-preparation:release"), + ); + assert.isFalse( + effects.some( + (effect) => + effect.request.type === "provider-turn.start" && + effect.request.runId === launched.projection.runs[0]?.id, + ), + ); + }).pipe(Effect.provide(harness.layer)); + }); +}); + it.effect.each(["fails", "is interrupted"] as const)( "holds a message queued during setup when the workspace preparation %s", (ending) => diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 192ed4007fc17..276d673a39d48 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -11,6 +11,7 @@ import type { import { type ChatAttachment, CommandId, + isListedThreadLineage, MessageId, OrchestrationV2StopWorkflowError, type OrchestrationV2StopWorkflowInput, @@ -418,7 +419,7 @@ export interface ThreadManagementServiceShape { Orchestrator.OrchestratorV2Error >; readonly getThreadShell: Orchestrator.OrchestratorV2["Service"]["getThreadShell"]; - /** Omit `projectId` to list every project's threads. */ + /** Omit `projectId` to list every project's threads. Side chats are never listed. */ readonly listProjectThreads: (input: { readonly projectId?: ProjectId | undefined; readonly includeSubagents: boolean; @@ -724,6 +725,22 @@ const make = Effect.gen(function* () { }), ), ); + case "prepared-run.release": + case "prepared-run.retry": + return orchestrator + .getThreadRecords(command.threadId, ["runs"], { runIds: [command.runId] }) + .pipe( + Effect.flatMap((projection) => { + const run = projection.runs.find((candidate) => candidate.id === command.runId); + return run === undefined + ? Effect.void + : assertProviderInstanceEnabled({ + projectId: projection.thread.projectId, + commandId: command.commandId, + instanceId: run.providerInstanceId, + }); + }), + ); default: return Effect.void; } @@ -777,9 +794,10 @@ const make = Effect.gen(function* () { ), Effect.map((snapshot) => snapshot.threads - .filter( - (thread) => - input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", + .filter((thread) => + input.includeSubagents + ? thread.lineage.sideChat !== true + : isListedThreadLineage(thread.lineage), ) .toSorted( (left, right) => diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 399943c7b1b92..b8d75340ec6bc 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -18,6 +18,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -65,6 +66,7 @@ const thread = ( readonly archivedAt?: DateTime.Utc; readonly deletedAt?: DateTime.Utc; readonly subagentOf?: ThreadId; + readonly sideChatOf?: ThreadId; readonly title?: string; readonly settledAt?: DateTime.Utc; readonly linkedPullRequest?: ThreadLinkedPullRequest; @@ -97,7 +99,14 @@ const thread = ( relationshipToParent: "subagent", rootThreadId: overrides.subagentOf, } - : { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + : overrides.sideChatOf + ? { + parentThreadId: overrides.sideChatOf, + relationshipToParent: "fork", + rootThreadId: overrides.sideChatOf, + sideChat: true, + } + : { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, forkedFrom: null, createdAt: at(0), updatedAt: at(0), @@ -141,6 +150,119 @@ const message = ( }, }); +it.effect.each([true, false])( + "ThreadSearch excludes bootstrap text before the hit and candidate limits (indexed: %s)", + (indexed) => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:attachments"); + const threadId = ThreadId.make("thread:attachments"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(threadId, project), + message(threadId, "ordinary", "user", "Review the attached files in context", { + minute: 1, + }), + message(threadId, "attachment", "user", ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, { minute: 2 }), + message( + threadId, + "ultrathink", + "user", + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + { minute: 3 }, + ), + ], + projections.apply, + { discard: true }, + ); + if (!indexed) { + yield* sql`DROP TRIGGER orchestration_v2_message_search_insert`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_update`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_delete`; + yield* sql`DROP TABLE orchestration_v2_message_search`; + } + assert.deepEqual((yield* search.search({ query: "conversation context" })).matches, []); + assert.deepEqual((yield* search.search({ query: "ultrathink" })).matches, []); + assert.deepEqual( + (yield* search.search({ query: "attached files" })).matches.map((match) => [ + match.messageId, + match.snippet, + ]), + [[MessageId.make("ordinary"), "Review the attached files in context"]], + ); + for (const text of [ + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + `uLtRaThInK:\r\n\t\u00a0${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + ]) { + yield* sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${indexed ? 10_000 : 2000}) + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, role, streaming, created_at, updated_at, payload_json) + SELECT 'attachment-limit:' || i, ${threadId}, 'user', 0, + ${DateTime.formatIso(at(5))}, ${DateTime.formatIso(at(5))}, json_object('text', ${text}) + FROM n + `; + assert.deepEqual( + (yield* search.search({ query: "context" })).matches.map((match) => match.messageId), + [MessageId.make("ordinary")], + ); + yield* sql` + DELETE FROM orchestration_v2_projection_messages WHERE message_id LIKE 'attachment-limit:%' + `; + } + }).pipe(Effect.provide(layerTest)), +); + +it.effect("ThreadSearch excludes legacy placeholders still in the index", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:legacy-attachments"); + const threadId = ThreadId.make("thread:legacy-attachments"); + yield* createProject(project); + // Simulate the pre-upgrade trigger, which indexed attachment placeholders. + yield* sql`DROP TRIGGER orchestration_v2_message_search_insert`; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_insert + AFTER INSERT ON orchestration_v2_projection_messages + BEGIN + INSERT INTO orchestration_v2_message_search (rowid, text) + VALUES (new.rowid, json_extract(new.payload_json, '$.text')); + END + `; + yield* Effect.forEach( + [ + thread(threadId, project), + message(threadId, "ordinary", "user", "Review the attached files in context"), + message(threadId, "attachment", "user", ATTACHMENT_ONLY_BOOTSTRAP_PROMPT), + message( + threadId, + "ultrathink", + "user", + `uLtRaThInK:\r\n\t\u00a0${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + ), + ], + projections.apply, + { discard: true }, + ); + assert.lengthOf( + yield* sql`SELECT rowid FROM orchestration_v2_message_search WHERE orchestration_v2_message_search MATCH '"context"'`, + 3, + ); + assert.deepEqual((yield* search.search({ query: "conversation context" })).matches, []); + assert.deepEqual((yield* search.search({ query: "ultrathink" })).matches, []); + assert.deepEqual( + (yield* search.search({ query: "context" })).matches.map((match) => match.messageId), + [MessageId.make("ordinary")], + ); + }).pipe(Effect.provide(layerTest)), +); + it.layer(layerTest)("ThreadSearch", (it) => { it.effect("reads the newest hits without sorting all of them", () => Effect.gen(function* () { @@ -153,6 +275,15 @@ it.layer(layerTest)("ThreadSearch", (it) => { plan.some((row) => row.detail.includes("TEMP B-TREE")), plan.map((row) => row.detail).join("\n"), ); + if (match !== null) { + assert.lengthOf( + plan.filter((row) => + row.detail.startsWith("SEARCH messages USING INTEGER PRIMARY KEY"), + ), + 1, + plan.map((row) => row.detail).join("\n"), + ); + } } }), ); @@ -243,13 +374,14 @@ it.layer(layerTest)("ThreadSearch", (it) => { }), ); - it.effect("matches inside identifiers and skips subagent threads and short tokens", () => + it.effect("matches inside identifiers and skips hidden threads and short tokens", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const search = yield* ThreadSearch.ThreadSearch; const project = ProjectId.make("project:search-tokens"); const parent = ThreadId.make("thread:tokens-parent"); const subagent = ThreadId.make("thread:tokens-subagent"); + const sideChat = ThreadId.make("thread:tokens-side-chat"); yield* createProject(project); yield* Effect.forEach( [ @@ -257,6 +389,8 @@ it.layer(layerTest)("ThreadSearch", (it) => { message(parent, "parent", "user", "Rename ThreadSearchService before shipping"), thread(subagent, project, { subagentOf: parent }), message(subagent, "subagent", "assistant", "ThreadSearchService renamed"), + thread(sideChat, project, { sideChatOf: parent, title: "ThreadSearchService question" }), + message(sideChat, "side-chat", "user", "Why rename ThreadSearchService?"), ], projections.apply, { discard: true }, diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index 37a1b9e726f9b..3c97122c1c63e 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -33,7 +33,7 @@ import { type ThreadSearchSubject, } from "@t3tools/shared/paletteSearch"; import { threadSearchPullRequests } from "@t3tools/shared/threadPullRequests"; -import { indexedMessageCondition } from "./messageSearchIndex.ts"; +import { indexedMessageCondition, nonPlaceholderMessageCondition } from "./messageSearchIndex.ts"; import { loadOlderSettledThreadsPage } from "./ShellStream.ts"; // When the user last wrote in the thread aliased `threads`, as the clients @@ -154,7 +154,7 @@ export function buildSearchSnippet(text: string, matchStart: number): string { return `${start > 0 ? "…" : ""}${shown.slice(0, end)}${cut ? "…" : ""}`; } -// Hidden subagent threads must be filtered before the hit limit so visible matches aren't lost. +// Filter hidden subagents and side chats before the hit limit so visible matches aren't lost. // Word starts and ranking follow the title rules in JS. // Without FTS5 trigrams, or for two-character CJK words, scan the same eligible messages. // The scan folds ASCII case only, so accent variants and non-ASCII case variants may not match. @@ -174,6 +174,7 @@ export const messageHitsQuery = ( AND threads.thread_id NOT IN (SELECT value FROM json_each(${excludedThreadIds})) AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 ) ${ match === null @@ -209,11 +210,12 @@ export const messageHitsQuery = ( INNER JOIN eligible ON eligible.thread_id = messages.thread_id WHERE orchestration_v2_message_search MATCH ${match} AND orchestration_v2_message_search.rowid >= coalesce(( - SELECT rowid FROM orchestration_v2_message_search + SELECT orchestration_v2_message_search.rowid FROM orchestration_v2_message_search WHERE orchestration_v2_message_search MATCH ${match} - ORDER BY rowid DESC + ORDER BY orchestration_v2_message_search.rowid DESC LIMIT 1 OFFSET ${MESSAGE_CANDIDATE_LIMIT - 1} ), 0) + AND ${nonPlaceholderMessageCondition(sql)} ` } -- Answers to an agent's question show inside its question card, where @@ -331,6 +333,7 @@ export const make = Effect.gen(function* () { AND projects.deleted_at IS NULL AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 AND (${projectId} IS NULL OR threads.project_id = ${projectId}) `, }); @@ -361,6 +364,7 @@ export const make = Effect.gen(function* () { AND projects.deleted_at IS NULL AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 AND json_extract(threads.payload_json, '$.settledOverride') = 'settled' AND json_extract(threads.payload_json, '$.settledAt') < ${before} AND (${projectIds} IS NULL @@ -383,6 +387,7 @@ export const make = Effect.gen(function* () { AND json_extract(threads.payload_json, '$.archivedAt') IS NULL AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 AND json_extract(threads.payload_json, '$.settledOverride') = 'settled' AND json_extract(threads.payload_json, '$.settledAt') < ${before} AND (${projectIds} IS NULL diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index a5059eada443b..5c9fd310e93ae 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -1,8 +1,13 @@ import { + ContextHandoffId, + ContextTransferId, EventId, OrchestrationV2ThreadStreamItem, + ProviderThreadId, + RunId, ThreadId, TurnItemId, + type OrchestrationV2ContextHandoff, type OrchestrationV2ProjectedTurnItem, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, @@ -149,11 +154,31 @@ describe("decideThreadResume", () => { }); it("builds socket fallback snapshots with a bounded timeline and history cursor", () => { + const projection = timelineProjection(80); + // A delivered side chat digest stays "ready", which bounded snapshots keep. + const sideChatHandoff = { + id: ContextHandoffId.make("side-chat-handoff"), + transferId: ContextTransferId.make("side-chat-transfer"), + threadId: THREAD_ID, + targetRunId: RunId.make("side-chat-run"), + fromProviderThreadIds: [], + toProviderThreadId: ProviderThreadId.make("side-chat-provider-thread"), + coveredRunOrdinals: { from: 1, to: 1 }, + strategy: "manual_context", + status: "ready", + summaryMessageId: null, + summaryText: "x".repeat(8_000), + delivery: { nativeThreadId: "native", status: "inline", itemIds: [] }, + createdByProviderInstanceId: null, + createdAt: NOW, + updatedAt: NOW, + } satisfies OrchestrationV2ContextHandoff; const snapshot = buildBoundedThreadStreamSnapshot({ snapshotSequence: 44, - projection: timelineProjection(80), + projection: { ...projection, contextHandoffs: [sideChatHandoff] }, }); + expect(snapshot.projection.contextHandoffs.map((handoff) => handoff.summaryText)).toEqual([""]); expect(snapshot.kind).toBe("snapshot"); expect(snapshot.snapshotSequence).toBe(44); expect(snapshot.projection.visibleTurnItems).toHaveLength(75); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 8759917344060..8300d50acb021 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -1,6 +1,8 @@ import { assert, describe, it, vi } from "@effect/vitest"; import { CommandId, + ChatAttachmentId, + type ChatAttachment, EventId, MessageId, ProjectId, @@ -14,6 +16,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectStore from "./ProjectStore.ts"; @@ -134,6 +137,7 @@ function dispatchUserMessage(input: { readonly command: string; readonly threadId: ThreadId; readonly text: string; + readonly attachments?: ReadonlyArray; }) { return Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; @@ -143,7 +147,7 @@ function dispatchUserMessage(input: { threadId: input.threadId, messageId: MessageId.make(`${input.command}:message`), text: input.text, - attachments: [], + attachments: input.attachments ?? [], modelSelection, dispatchMode: { type: "defer_start" }, createdBy: "user", @@ -270,6 +274,44 @@ describe("formatThreadTitleContext", () => { }); describe("ThreadTitleRegenerationService", () => { + it.effect( + "titles an image-only first message without sending bootstrap text to the title model", + () => + Effect.gen(function* () { + for (const prefix of ["", "Ultrathink:\n"]) { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const service = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; + const threadId = yield* createThread({ + command: "image:create", + thread: "image:thread", + }); + const text = prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT; + const attachments = [ + { + type: "image" as const, + id: ChatAttachmentId.make("diagram"), + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ]; + yield* dispatchUserMessage({ command: "image:message", threadId, text, attachments }); + const requestId = yield* armRegeneration({ command: "image:title", threadId }); + const messageId = MessageId.make("image:message:message"); + yield* service.execute({ threadId, requestId, kind: { type: "initial", messageId } }); + const call = harness.generateThreadTitle.mock.calls[0]?.[0]; + assert.equal(call?.message, ""); + assert.deepEqual(call?.attachments, attachments); + const projection = yield* threads.getThreadProjection(threadId); + assert.equal(projection.messages[0]?.text, text); + assert.equal(projection.thread.title, "Generated title"); + }).pipe(Effect.provide(harness.layer)); + } + }), + ); + it.effect("arms and clears the regeneration marker through metadata commands", () => Effect.gen(function* () { const harness = makeHarness(); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index b73f6a2ab061a..601b783675d0e 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -1,4 +1,5 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { CommandId, type ChatAttachment, @@ -138,7 +139,10 @@ const make = Effect.gen(function* () { context = message === undefined ? { message: "", attachments: [] } - : { message: message.text, attachments: message.attachments }; + : { + message: isAttachmentOnlyBootstrapPrompt(message.text) ? "" : message.text, + attachments: message.attachments, + }; } else { context = formatThreadTitleContext( projection.messages.filter((message) => !message.streaming), diff --git a/apps/server/src/orchestration-v2/TurnStartReads.test.ts b/apps/server/src/orchestration-v2/TurnStartReads.test.ts index caa941396a77b..6db6b4c308827 100644 --- a/apps/server/src/orchestration-v2/TurnStartReads.test.ts +++ b/apps/server/src/orchestration-v2/TurnStartReads.test.ts @@ -9,6 +9,7 @@ import { ProviderDriverKind, ProviderInstanceId, RunId, + RuntimeRequestId, ThreadId, TurnItemId, type OrchestrationV2AppThread, @@ -360,6 +361,57 @@ it.effect.each(["sqlite"] as const)( yield* store.getTurnStartContext(ThreadId.make("missing"), runId).pipe(Effect.flip), ProjectionStore.ProjectionStoreThreadNotFoundError, ); + const file = { + type: "file" as const, + id: "message-file", + name: "notes.pdf", + mimeType: "application/pdf", + sizeBytes: 12, + }; + yield* store.apply({ + id: EventId.make("attachment:message-event"), + type: "message.updated", + threadId, + runId, + occurredAt: now, + payload: { ...message, attachments: [file] }, + }); + yield* store.apply({ + id: EventId.make("attachment:question-event"), + type: "turn-item.updated", + threadId, + runId: oldRunId, + occurredAt: now, + payload: { + ...item, + id: TurnItemId.make("attachment:question"), + type: "user_input_request", + requestId: RuntimeRequestId.make("attachment:request"), + questions: [], + questionAnswer: { + requestId: "attachment:request", + answers: { document: ["See document"], image: ["See image"] }, + attachmentsByQuestionId: { + document: [{ ...file, id: "answer-document" }], + image: [ + { + type: "image", + id: "answer-image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 8, + }, + ], + }, + }, + }, + }); + assert.deepEqual((yield* store.getThreadAttachmentIds(threadId)).toSorted(), [ + "answer-document", + "answer-image", + "attachment:old", + "message-file", + ]); }).pipe(Effect.provide(layerDatabase)), ); diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts new file mode 100644 index 0000000000000..2e077314b8212 --- /dev/null +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts @@ -0,0 +1,70 @@ +import { assert, it } from "@effect/vitest"; +import type { OrchestrationV2DomainEvent } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; +import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; + +it.effect("idle sweeps skip SQL until the next wake time or a relevant event", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z")); + const startMs = Date.parse("2026-07-01T09:00:00.000Z"); + const queries = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); + const layer = UsageLimitRecoveryWorker.layer.pipe( + Layer.provide( + Layer.mergeAll( + Scheduler.layer, + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getLimitRecoveryCandidates: () => + Clock.currentTimeMillis.pipe( + Effect.flatMap((nowMs) => Queue.offer(queries, nowMs - startMs)), + Effect.as([]), + ), + // One armed thread resets at 60s. + getLimitRecoveryWakeAt: (now) => + Effect.succeed( + DateTime.toEpochMillis(now) < startMs + 60_000 ? startMs + 60_000 : null, + ), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.fromQueue(events), + }), + Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: Effect.succeed({ + autoResumeLimitedThreads: true, + snoozeLimitedThreads: false, + } as never), + streamChanges: Stream.never, + }), + ), + ), + ); + yield* Effect.gen(function* () { + assert.equal(yield* Queue.take(queries), 0); + // Eleven idle ticks before the recorded wake time, then the wake tick itself. + yield* TestClock.adjust("60 seconds"); + assert.equal(yield* Queue.take(queries), 60_000); + yield* Queue.offer(events, { + type: "turn-item.updated", + payload: { type: "assistant_message" }, + } as never); + yield* TestClock.adjust("30 seconds"); + yield* Queue.offer(events, { type: "run.updated", payload: { status: "failed" } } as never); + yield* TestClock.adjust("5 seconds"); + // A streaming item at 90s does not wake it; the failed run does on the 95s tick. + assert.equal(yield* Queue.take(queries), 95_000); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index b6ebedfec4ca5..224eecbbe5f69 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -2,7 +2,11 @@ import { CommandId, MessageId, type OrchestrationV2Command } from "@t3tools/cont import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import type { OrchestrationV2DomainEvent } from "@t3tools/contracts"; +import * as DueHint from "../scheduling/DueHint.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; +import { forkParked } from "../serverActivation.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; @@ -71,20 +75,39 @@ export function limitRecoveryCommand( }; } +// Events that can make a thread a candidate or change its command; streaming items cannot. +const affectsLimitRecovery = (event: OrchestrationV2DomainEvent) => + event.type.startsWith("thread.") || + event.type === "run.created" || + event.type === "run.updated" || + event.type === "runtime-request.updated" || + event.type.startsWith("provider-session.") || + (event.type === "turn-item.updated" && event.payload.type === "error"); + const makeSweep = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const threads = yield* ThreadManagement.ThreadManagementService; const orchestrator = yield* Orchestrator.OrchestratorV2; const settings = yield* ServerSettings.ServerSettingsService; + const due = DueHint.make(); + yield* forkParked( + Stream.runForEach(orchestrator.streamDomainEvents, (event) => + affectsLimitRecovery(event) ? due.invalidate : Effect.void, + ), + ); + yield* forkParked(Stream.runForEach(settings.streamChanges, () => due.invalidate)); return Effect.fn("UsageLimitRecoveryWorker.sweep")(function* () { - const preferences = yield* settings.getSettings; const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + if (yield* due.isIdle(nowMs)) return; + const readVersion = yield* due.version; + const preferences = yield* settings.getSettings; const candidates = yield* projections.getLimitRecoveryCandidates({ now, autoResume: preferences.autoResumeLimitedThreads, snooze: preferences.snoozeLimitedThreads, }); - const nowMs = DateTime.toEpochMillis(now); + let acted = false; for (const thread of candidates) { const command = limitRecoveryCommand( thread, @@ -93,6 +116,7 @@ const makeSweep = Effect.gen(function* () { preferences.snoozeLimitedThreads, ); if (command === null) continue; + acted = true; yield* Effect.gen(function* () { if (command.type === "thread.metadata.update" && command.limitRecovery != null) { yield* orchestrator.autoArmLimitRecovery({ @@ -112,6 +136,10 @@ const makeSweep = Effect.gen(function* () { ), ); } + // A sweep that acted, or failed to, re-checks on the next tick. + if (acted) return; + const wakeAt = yield* projections.getLimitRecoveryWakeAt(now); + yield* due.record(readVersion, wakeAt ?? Infinity, nowMs); }); }); diff --git a/apps/server/src/orchestration-v2/UserFacingErrors.ts b/apps/server/src/orchestration-v2/UserFacingErrors.ts index c47f1c2227d62..7d47dffd1a762 100644 --- a/apps/server/src/orchestration-v2/UserFacingErrors.ts +++ b/apps/server/src/orchestration-v2/UserFacingErrors.ts @@ -37,6 +37,18 @@ function databaseErrorMessage(value: unknown): string | undefined { return "The T3 Code server could not complete this database request. Retry once; if it still fails, check the server logs for the database error."; } +export function userFacingDatabaseErrorMessage(cause: unknown): string | undefined { + const seen = new Set(); + let value = cause; + while (Predicate.isObject(value) && !seen.has(value)) { + seen.add(value); + const message = databaseErrorMessage(value); + if (message !== undefined) return message; + value = value.cause; + } + return undefined; +} + const CAPABILITY_REJECTION_MESSAGES: Record string> = { queued_messages: (p) => `${p} cannot queue messages behind an active run.`, active_steering: (p) => diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index 0d63d832f0ca9..6379cad45dbd0 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -261,6 +261,21 @@ describe("orchestration V2 wire projection", () => { }); }); + it.each([ + "logs/app.log:12: Error: ENOENT: no such file or directory", + "fixtures/ci.txt:3: Process exited with exit code 2", + ])("trusts a zero exit code over failure text in the output: %s", (output) => { + const projected = projectTurnItemForWire({ + ...base, + type: "command_execution" as const, + input: "rg -n error logs fixtures", + output, + exitCode: 0, + }); + expect(projected).not.toHaveProperty("outputIndicatesFailure"); + expect(projected).toMatchObject({ exitCode: 0, outputOmitted: true }); + }); + it("omits inline file bodies but preserves file identity and change counts", () => { const item = { ...base, diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 2181b7feec5fa..1495a3effa677 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -157,11 +157,14 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra } case "command_execution": { const { output, ...projected } = item; + // A successful search can print "ENOENT" or "exit code 2", so output text + // decides only when the provider reported no exit code. const failed = - item.outputIndicatesFailure === true || - (item.exitCode !== undefined && item.exitCode !== 0) || - (output !== undefined && - toolOutputIndicatesFailure(output.slice(0, MAX_DETAIL_STRING_BYTES))); + item.exitCode === undefined + ? item.outputIndicatesFailure === true || + (output !== undefined && + toolOutputIndicatesFailure(output.slice(0, MAX_DETAIL_STRING_BYTES))) + : item.exitCode !== 0; return { ...projected, ...(failed ? { outputIndicatesFailure: true } : {}), diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts index 3b2351b28a1e1..87059b7c1d0ae 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts @@ -2,6 +2,8 @@ import { assert, it } from "@effect/vitest"; import { ComposerContextId, EventId, + ProjectId, + ProviderInstanceId, type OrchestrationMessageContext, ThreadId, } from "@t3tools/contracts"; @@ -150,6 +152,69 @@ it.effect("propagates interruption from transcript reads and retry-marker writes }).pipe(Effect.provide(layerTest)), ); +it.effect("caches the absent legacy transcript for a native V2 thread", () => + Effect.gen(function* () { + const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; + const eventSink = yield* EventSink.EventSinkV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:v2-born"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const now = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:v2-born"), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + id: threadId, + projectId: ProjectId.make("project:v2-born"), + title: "Native V2 thread", + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-6" }, + createdBy: "user", + creationSource: "web", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + ], + }); + const before = yield* projections.getThreadProjection(threadId); + let legacyLookups = 0; + for (let read = 0; read < 3; read++) { + const summary = yield* importer.ensureTranscript(threadId).pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + const [query, parameters] = statement.compile(); + if ( + query.includes("FROM orchestration_v2_legacy_imports") && + parameters[0] === threadId + ) { + legacyLookups++; + } + return Effect.succeed(statement); + }), + ); + assert.deepStrictEqual(summary, { importedThreadCount: 0, importedMessageCount: 0 }); + } + assert.equal(legacyLookups, 1); + assert.deepStrictEqual(yield* projections.getThreadProjection(threadId), before); + }).pipe(Effect.provide(layerTest)), +); + it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { it.effect("imports lightweight shells, hydrates transcripts, and remains idempotent", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index ee4baf0abf83c..d82a9e80a23b5 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -677,7 +677,7 @@ const make = Effect.gen(function* () { ), ); - // transcript_imported_at never resets; cache confirmed imports for the process lifetime to skip repeated locks and reads. + // Both results are final for this process: transcript_imported_at never resets, and only startup shell reconciliation creates legacy rows. const confirmedTranscriptThreadIds = new Set(); const ensureTranscriptBase = (threadId: ThreadId) => @@ -692,9 +692,7 @@ const make = Effect.gen(function* () { `.pipe(NodeSqliteClient.readOnly); const imported = imports[0]; if (imported === undefined || imported.transcript_imported_at !== null) { - if (imported !== undefined) { - confirmedTranscriptThreadIds.add(threadId); - } + confirmedTranscriptThreadIds.add(threadId); return { importedThreadCount: 0, importedMessageCount: 0 }; } const messages = yield* listMessages(threadId); diff --git a/apps/server/src/orchestration-v2/messageSearchIndex.ts b/apps/server/src/orchestration-v2/messageSearchIndex.ts index 318a92e9efd5c..dbd3545e5906b 100644 --- a/apps/server/src/orchestration-v2/messageSearchIndex.ts +++ b/apps/server/src/orchestration-v2/messageSearchIndex.ts @@ -1,13 +1,28 @@ +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as Effect from "effect/Effect"; import type * as SqlClient from "effect/sql/SqlClient"; +// SQLite's ltrim needs the characters matched by isAttachmentOnlyBootstrapPrompt's \s explicitly. +const BOOTSTRAP_PREFIX_WHITESPACE = + "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"; + +export const nonPlaceholderMessageCondition = (sql: SqlClient.SqlClient, row = "messages") => + sql.literal(` + CASE + WHEN substr(json_extract(${row}.payload_json, '$.text'), 1, 11) = 'Ultrathink:' COLLATE NOCASE + THEN ltrim(substr(json_extract(${row}.payload_json, '$.text'), 12), '${BOOTSTRAP_PREFIX_WHITESPACE}') + ELSE json_extract(${row}.payload_json, '$.text') + END COLLATE BINARY <> '${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT.replaceAll("'", "''")}' +`); + /** - * Exclude generated notices and subagent threads: search hides them, and ACP subagents rewrite - * their finished answer on every streamed chunk. + * Exclude attachment placeholders, generated notices and subagent threads. ACP subagents + * rewrite their finished answer on every streamed chunk. * Migration triggers, backfill, rebuild and scans share this predicate; `row` may be `new`. */ export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "messages") => - sql.literal(` + sql` + ${sql.literal(` ${row}.streaming = 0 AND ( ${row}.role = 'assistant' @@ -21,7 +36,9 @@ export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "message WHERE subagent_threads.thread_id = ${row}.thread_id AND json_extract(subagent_threads.payload_json, '$.lineage.relationshipToParent') = 'subagent' ) -`); +`)} + AND ${nonPlaceholderMessageCondition(sql, row)} +`; /** * What the index stores for a message: its text with curly apostrophes @@ -32,6 +49,52 @@ export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "message export const indexedMessageText = (sql: SqlClient.SqlClient, payload: string) => sql.literal(`replace(replace(json_extract(${payload}, '$.text'), '’', ''''), '‘', '''')`); +export const createMessageSearchTriggers = Effect.fn("MessageSearchIndex.createTriggers")( + function* (sql: SqlClient.SqlClient) { + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_insert + AFTER INSERT ON orchestration_v2_projection_messages + WHEN ${indexedMessageCondition(sql, "new")} + BEGIN + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} + WHERE json_extract(new.payload_json, '$.text') IS NOT NULL; + END + `; + // Streaming deltas rewrite the row many times a second; skip them until the + // message settles. Upserts rewrite every column, so a finished message + // re-indexes only when what the index reads changed. Deleting a rowid the + // index never held is a no-op. + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_update + AFTER UPDATE OF payload_json, streaming, role ON orchestration_v2_projection_messages + WHEN (old.streaming = 0 OR new.streaming = 0) + AND ( + old.streaming IS NOT new.streaming + OR old.role IS NOT new.role + OR json_extract(old.payload_json, '$.text') IS NOT json_extract(new.payload_json, '$.text') + OR json_extract(old.payload_json, '$.createdBy') + IS NOT json_extract(new.payload_json, '$.createdBy') + ) + BEGIN + DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid AND old.streaming = 0; + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} + WHERE ${indexedMessageCondition(sql, "new")} + AND json_extract(new.payload_json, '$.text') IS NOT NULL; + END + `; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_delete + AFTER DELETE ON orchestration_v2_projection_messages + WHEN old.streaming = 0 + BEGIN + DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid; + END + `; + }, +); + /** * Refills the search index from the messages table. The index is keyed by * message rowid, so anything that may renumber or copy rows (VACUUM, a dev diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 20158a80d3ff6..037f2a394f28a 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -595,6 +595,220 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i ); }), ); + + it.effect( + "deletes a side chat with its parent only after the side chat's in-flight command", + () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const projectId = ProjectId.make("runtime-side-chat-delete"); + const parentId = ThreadId.make("runtime-side-chat-delete-parent"); + const sideChatId = ThreadId.make("runtime-side-chat-delete-child"); + const updateCommandId = CommandId.make("runtime-side-chat-delete-update"); + yield* projects.create({ + commandId: CommandId.make("runtime-side-chat-delete-project"), + projectId, + title: "Side chat deletion", + workspaceRoot: "/work/side-chat-deletion", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("runtime-side-chat-delete-create"), + createdBy: "user", + creationSource: "web", + threadId: parentId, + projectId, + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("runtime-side-chat-delete-fork"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + + const updateReady = yield* Deferred.make(); + const releaseUpdate = yield* Deferred.make(); + const deletionQueued = yield* Deferred.make(); + const commitCommand = eventSink.commitCommand; + const withLock = executor.withLock; + let sideChatLockRequests = 0; + const commitSpy = vi + .spyOn(eventSink, "commitCommand") + .mockImplementation((input) => + input.commandId === updateCommandId + ? Deferred.succeed(updateReady, undefined).pipe( + Effect.andThen(Deferred.await(releaseUpdate)), + Effect.andThen(commitCommand(input)), + ) + : commitCommand(input), + ); + const observeLock: ThreadCommandExecutor.ThreadCommandExecutor["Service"]["withLock"] = ( + key, + effect, + ) => { + if (key !== sideChatId || ++sideChatLockRequests !== 2) return withLock(key, effect); + return Deferred.succeed(deletionQueued, undefined).pipe( + Effect.andThen(withLock(key, effect)), + ); + }; + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation(observeLock); + yield* Effect.gen(function* () { + const updateFiber = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: updateCommandId, + threadId: sideChatId, + title: "Updated before deletion", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.raceFirst( + Deferred.await(updateReady), + Fiber.join(updateFiber).pipe( + Effect.andThen( + Effect.die("The update completed before reaching its commit barrier."), + ), + ), + ); + const deleteFiber = yield* orchestrator + .dispatch({ + type: "thread.delete", + commandId: CommandId.make("runtime-side-chat-delete-parent"), + threadId: parentId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.raceFirst( + Deferred.await(deletionQueued), + Fiber.join(deleteFiber).pipe( + Effect.andThen(Effect.die("Parent deletion bypassed the side chat's command.")), + ), + ); + yield* Deferred.succeed(releaseUpdate, undefined); + yield* Fiber.join(updateFiber); + yield* Fiber.join(deleteFiber); + const sideChat = yield* orchestrator.getThreadProjection(sideChatId); + assert.isNotNull(sideChat.thread.deletedAt); + assert.equal(sideChat.thread.title, "Updated before deletion"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + commitSpy.mockRestore(); + lockSpy.mockRestore(); + }), + ), + ); + }), + ); + + // A fork from a side chat onto its parent used to lock the side chat, then the parent: the + // reverse of the parent delete's order. + it.effect("a parent delete and a fork from its side chat onto the parent do not deadlock", () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const projectId = ProjectId.make("runtime-side-chat-lock-order"); + const parentId = ThreadId.make("runtime-side-chat-lock-order-parent"); + const sideChatId = ThreadId.make("runtime-side-chat-lock-order-child"); + yield* projects.create({ + commandId: CommandId.make("runtime-side-chat-lock-order-project"), + projectId, + title: "Side chat lock order", + workspaceRoot: "/work/side-chat-lock-order", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("runtime-side-chat-lock-order-create"), + createdBy: "user", + creationSource: "web", + threadId: parentId, + projectId, + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("runtime-side-chat-lock-order-fork"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + + const deleteHoldsParent = yield* Deferred.make(); + const releaseDelete = yield* Deferred.make(); + const forkWaitsForParent = yield* Deferred.make(); + const withLock = executor.withLock; + let parentLockRequests = 0; + const observeLock: ThreadCommandExecutor.ThreadCommandExecutor["Service"]["withLock"] = ( + key, + effect, + ) => { + if (key !== parentId) return withLock(key, effect); + // Dispatch builds locks it may not run, so count when one runs. + return Effect.suspend(() => + ++parentLockRequests === 1 + ? withLock( + key, + Deferred.succeed(deleteHoldsParent, undefined).pipe( + Effect.andThen(Deferred.await(releaseDelete)), + Effect.andThen(effect), + ), + ) + : Deferred.succeed(forkWaitsForParent, undefined).pipe( + Effect.andThen(withLock(key, effect)), + ), + ); + }; + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation(observeLock); + yield* Effect.gen(function* () { + const deleteFiber = yield* orchestrator + .dispatch({ + type: "thread.delete", + commandId: CommandId.make("runtime-side-chat-lock-order-delete"), + threadId: parentId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(deleteHoldsParent); + const forkFiber = yield* orchestrator + .dispatch({ + type: "thread.fork", + commandId: CommandId.make("runtime-side-chat-lock-order-fork-back"), + sourceThreadId: sideChatId, + targetThreadId: parentId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(forkWaitsForParent); + yield* Deferred.succeed(releaseDelete, undefined); + yield* Fiber.join(deleteFiber); + yield* Fiber.join(forkFiber); + assert.isNotNull((yield* orchestrator.getThreadProjection(parentId)).thread.deletedAt); + assert.isNotNull((yield* orchestrator.getThreadProjection(sideChatId)).thread.deletedAt); + }).pipe(Effect.ensuring(Effect.sync(() => lockSpy.mockRestore()))); + }), + ); }); const layerSharedApplicationDataPlaneTest = Layer.mergeAll( @@ -1726,7 +1940,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { }), ); - it.effect("merges an explicit provider-finished run while checkpoint capture is pending", () => + it.effect("refuses busy merge-back targets without blocking the queue", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const eventSink = yield* EventSink.EventSinkV2; @@ -1741,6 +1955,13 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { ); const forkTransferId = ContextTransferId.make("runtime-layer-waiting-merge-fork-transfer"); + yield* seedProject({ + projectId, + title: "Merge-back project", + workspaceRoot: process.cwd(), + defaultModelSelection: modelSelection, + createdAt: DateTime.formatIso(now), + }); yield* orchestrator.dispatch({ type: "thread.create", createdBy: "user", @@ -1868,6 +2089,87 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { ], }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-merge-target-start"), + threadId: targetThreadId, + messageId: MessageId.make("runtime-layer-merge-target-message"), + text: "Keep working in the parent.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + const activeTarget = yield* orchestrator.getThreadProjection(targetThreadId); + const targetRun = activeTarget.runs[0]!; + for (const status of ["preparing", "starting", "waiting", "queued", "running"] as const) { + yield* eventSink.write({ + commandId: CommandId.make(`runtime-layer-merge-target-${status}`), + events: [ + { + id: EventId.make(`runtime-layer-merge-target-${status}-event`), + type: "run.updated", + threadId: targetThreadId, + runId: targetRun.id, + occurredAt: now, + payload: { ...targetRun, status }, + }, + ], + }); + const error = yield* orchestrator + .dispatch({ + type: "thread.merge_back", + createdBy: "user", + creationSource: "mobile", + commandId: CommandId.make(`runtime-layer-merge-refused-${status}`), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "run", runId: sourceRunId }, + }) + .pipe(Effect.flip); + assert.instanceOf(error, Orchestrator.OrchestratorDispatchError); + assert.equal( + error.cause, + "Finish or stop the target thread's active work and clear its queue before merging back.", + ); + const refusedTarget = yield* orchestrator.getThreadProjection(targetThreadId); + assert.deepEqual(refusedTarget.contextTransfers, activeTarget.contextTransfers); + assert.equal(refusedTarget.runs[0]?.status, status); + } + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-merge-target-queue"), + threadId: targetThreadId, + messageId: MessageId.make("runtime-layer-merge-target-queued-message"), + text: "Run this after the current turn.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const queuedTarget = yield* orchestrator.getThreadProjection(targetThreadId); + const queuedRun = queuedTarget.runs.find((run) => run.status === "queued"); + assert.isDefined(queuedRun); + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("runtime-layer-merge-target-cancel-queue"), + threadId: targetThreadId, + runId: queuedRun.id, + }); + yield* eventSink.write({ + commandId: CommandId.make("runtime-layer-merge-target-complete"), + events: [ + { + id: EventId.make("runtime-layer-merge-target-completed-event"), + type: "run.updated", + threadId: targetThreadId, + runId: targetRun.id, + occurredAt: now, + payload: { ...targetRun, status: "completed", completedAt: now }, + }, + ], + }); + yield* orchestrator.dispatch({ type: "thread.merge_back", createdBy: "user", diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson index 68989e43e5aa3..f9d3a0ede6cbf 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6b-707e-7000-9bb5-4079c7772ad0/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:30:11.238412Z","updatedAt":"2026-10-08T07:30:11.238416Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444611408}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6b-707e-7000-9bb5-4079c7772ad0/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:30:11.238412Z","updatedAt":"2026-10-08T07:30:11.238416Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"37b19bd1-b212-4532-a0c9-a6677f303e26","sequence":5},"last":{"id":"37b19bd1-b212-4532-a0c9-a6677f303e26","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6b-707e-7001-b2f4-23db74e60097","clientName":"t3_code"},"emittedAtMs":1791444611412}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with exactly: first fixture turn complete"}],"displayText":"Respond with exactly: first fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with exactly: first fixture turn complete"}],"displayText":"Respond with exactly: first fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2c530748-943b-4467-8473-da8b04c511c3","sequence":16},"last":{"id":"2c530748-943b-4467-8473-da8b04c511c3","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444611661}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","status":"accepted","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"running","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:3"},"emittedAtMs":1791444611674}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"},"emittedAtMs":1791444611675}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:11.66263Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611675}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20},"last":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:11.680942Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: first fixture turn complete","displayText":"Respond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611689}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:11.66263Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611675}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20},"last":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:11.680942Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: first fixture turn complete","displayText":"Respond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611689}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","sequence":37},"last":{"id":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","sequence":37}},"item":{"itemId":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","kind":"reminderChild","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:30:11.790301Z","fallbackText":"Reminder child session","childSessionId":"c13379db-6761-40b4-a974-b0f1a58717aa","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6b-72ac-7972-a1df-b343b7804b38"}},"emittedAtMs":1791444611795}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6.1","item":{"itemId":"f99b90e1-9d8b-4bf2-8e7a-e240e1cf9321","kind":"agentMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444618068}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6.2","itemId":"f99b90e1-9d8b-4bf2-8e7a-e240e1cf9321","field":"text","delta":"first fixture turn complete"},"emittedAtMs":1791444618068}} @@ -29,12 +29,12 @@ {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"idle","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:16"},"emittedAtMs":1791444621046}} {"type":"emit_inbound","label":"turn/completed","frame":{"jsonrpc":"2.0","method":"turn/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:16","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"688a69c5-e353-4db9-bd8c-1beb033902ba","sequence":79},"last":{"id":"688a69c5-e353-4db9-bd8c-1beb033902ba","sequence":79}},"turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","terminal":"completed","durationMs":9378,"timeToFirstTokenMs":6406},"emittedAtMs":1791444621047}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:17","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"36753b8a-26ce-4935-8127-bc9191cb328b","sequence":80},"last":{"id":"36753b8a-26ce-4935-8127-bc9191cb328b","sequence":80}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444621054}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":4,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"displayText":"Respond with exactly: second fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":4,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"displayText":"Respond with exactly: second fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":4,"result":{"commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","status":"accepted","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"running","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:18"},"emittedAtMs":1791444621333}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:18","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"},"emittedAtMs":1791444621334}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:19","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:21.321391Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621334}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:20","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89},"last":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:21.338944Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: second fixture turn complete","displayText":"Respond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621347}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:19","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:21.321391Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621334}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:20","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89},"last":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:21.338944Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: second fixture turn complete","displayText":"Respond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621347}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","sequence":106},"last":{"id":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","sequence":106}},"item":{"itemId":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","kind":"reminderChild","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:30:21.446966Z","fallbackText":"Reminder child session","childSessionId":"cd97261a-3ea9-480f-9e54-5e59bb077da2","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6b-9869-7061-a4f3-42daa3e3a8f1"}},"emittedAtMs":1791444621455}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21.1","item":{"itemId":"2de26c0d-26b4-4533-8810-918ee44fcd82","kind":"agentMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444623127}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21.2","itemId":"2de26c0d-26b4-4533-8810-918ee44fcd82","field":"text","delta":"second fixture turn complete"},"emittedAtMs":1791444623127}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson index a056e5ff06d49..0d6e3e282feb7 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-39bb-7000-8c93-4594c98e6a11/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:08.30084Z","updatedAt":"2026-10-08T07:32:08.300844Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444728496}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-39bb-7000-8c93-4594c98e6a11/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:08.30084Z","updatedAt":"2026-10-08T07:32:08.300844Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b05fb8ec-7b58-4cf7-a369-379938395230","sequence":5},"last":{"id":"b05fb8ec-7b58-4cf7-a369-379938395230","sequence":5}},"mode":"promptUnmatched","source":"approvalReconfigure","commandId":"01a11a6d-39bb-7001-923e-425cc6129ce7","clientName":"t3_code"},"emittedAtMs":1791444728501}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE."}],"displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE."}],"displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"6159e84a-ae1e-4fae-ae8b-49f41ca34509","sequence":16},"last":{"id":"6159e84a-ae1e-4fae-ae8b-49f41ca34509","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444728769}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","status":"accepted","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"running","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:3"},"emittedAtMs":1791444728783}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"},"emittedAtMs":1791444728783}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:08.769894Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728784}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20},"last":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:08.791728Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728800}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:08.769894Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728784}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20},"last":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:08.791728Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728800}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"29180105-84c4-4f9e-8bfd-003392b93b73","sequence":38},"last":{"id":"29180105-84c4-4f9e-8bfd-003392b93b73","sequence":38}},"item":{"itemId":"29180105-84c4-4f9e-8bfd-003392b93b73","kind":"reminderChild","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:08.905989Z","fallbackText":"Reminder child session","childSessionId":"7c9485f9-26aa-4b56-8ca2-f36cb9cfb8c4","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-3c27-73f2-8c27-cd06a3b7dadf"}},"emittedAtMs":1791444728910}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:7","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54},"last":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32618,"outputTokens":315,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":227},"promptTokens":32618,"totalTokens":32933,"finishReason":"tool_calls","durationMs":2684,"cumulative":{"promptTokens":32618,"outputTokens":315,"totalTokens":32933,"cost":{"usd":0.0033248,"partial":false}}},"emittedAtMs":1791444731684}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:8","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54},"last":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54}},"windowTokens":1007997,"usedTokens":32933,"pressure":"normal"},"emittedAtMs":1791444731684}} @@ -42,12 +42,12 @@ {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"idle","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:23"},"emittedAtMs":1791444747386}} {"type":"emit_inbound","label":"turn/completed","frame":{"jsonrpc":"2.0","method":"turn/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:23","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"7c29ac76-7e09-48c1-a18f-f336126e27d1","sequence":120},"last":{"id":"7c29ac76-7e09-48c1-a18f-f336126e27d1","sequence":120}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","terminal":"completed","durationMs":18608,"timeToFirstTokenMs":4791},"emittedAtMs":1791444747387}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:24","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"1e4afe30-d924-444c-8665-5e763f524e70","sequence":121},"last":{"id":"1e4afe30-d924-444c-8665-5e763f524e70","sequence":121}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444747394}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":5,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED."}],"displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":5,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED."}],"displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":5,"result":{"commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29","status":"accepted","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"running","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:25"},"emittedAtMs":1791444747660}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:25","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"},"emittedAtMs":1791444747660}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:26","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:27.650913Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747660}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:27","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130},"last":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:27.66745Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747676}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:26","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:27.650913Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747660}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:27","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130},"last":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:27.66745Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747676}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:28","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"3b28955d-a87a-4acb-97e9-14cad87fd41f","sequence":148},"last":{"id":"3b28955d-a87a-4acb-97e9-14cad87fd41f","sequence":148}},"item":{"itemId":"3b28955d-a87a-4acb-97e9-14cad87fd41f","kind":"reminderChild","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:27.787099Z","fallbackText":"Reminder child session","childSessionId":"efd21bf1-4fd1-49a2-9c55-91b7fc099765","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-85ed-7b91-8d91-c4ce76b6dfef"}},"emittedAtMs":1791444747794}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:29","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164},"last":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164}},"turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":33401,"outputTokens":201,"cachedTokens":33009,"cacheWriteTokens":0,"cacheReadTokens":33009,"reasoningTokens":108},"promptTokens":33401,"totalTokens":33602,"finishReason":"tool_calls","durationMs":2522,"cumulative":{"promptTokens":99045,"outputTokens":538,"totalTokens":99583,"cacheReadTokens":65506,"cost":{"usd":0.003592512,"partial":false}}},"emittedAtMs":1791444750395}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:30","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164},"last":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164}},"windowTokens":1007997,"usedTokens":33602,"pressure":"normal"},"emittedAtMs":1791444750395}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson index 36c02aeb361f0..1766b2ce67d8b 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","path":"~/.local/share/muse/sessions/2026/10/11/01a12d1d-a1b7-7000-9697-d430c2e2afd8/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:37:59.14136Z","updatedAt":"2026-10-11T22:37:59.141364Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791758279294}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","path":"~/.local/share/muse/sessions/2026/10/11/01a12d1d-a1b7-7000-9697-d430c2e2afd8/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:37:59.14136Z","updatedAt":"2026-10-11T22:37:59.141364Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:1","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"ed95a9db-eada-4751-a7e6-22ccc9bdabd5","sequence":5},"last":{"id":"ed95a9db-eada-4751-a7e6-22ccc9bdabd5","sequence":5}},"mode":"promptUnmatched","source":"approvalReconfigure","commandId":"01a12d1d-a1b8-7000-8d6c-f1324b724cdb","clientName":"t3_code"},"emittedAtMs":1791758279297}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:2","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"4f170229-d428-4a64-9516-7a355ff3bcdb","sequence":15},"last":{"id":"4f170229-d428-4a64-9516-7a355ff3bcdb","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791758279531}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","status":"accepted","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","status":"running","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:3"},"emittedAtMs":1791758279541}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:3","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"},"emittedAtMs":1791758279542}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:4","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"completed","recordedAt":"2026-10-11T22:37:59.531477Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279542}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:5","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19},"last":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":2,"status":"completed","recordedAt":"2026-10-11T22:37:59.545908Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279553}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:4","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"completed","recordedAt":"2026-10-11T22:37:59.531477Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279542}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:5","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19},"last":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":2,"status":"completed","recordedAt":"2026-10-11T22:37:59.545908Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279553}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","sequence":37},"last":{"id":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","sequence":37}},"item":{"itemId":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","kind":"reminderChild","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:37:59.660065Z","fallbackText":"Reminder child session","childSessionId":"0779d605-54b8-45dc-90b6-91dc58b9a52c","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d1d-a3d4-7712-8b98-26f3c4f9bf38"}},"emittedAtMs":1791758279666}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6.1","item":{"itemId":"21b74b34-fe9f-457c-83f0-cf49257b5948","kind":"reasoning","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"inProgress","summary":[""]}},"emittedAtMs":1791758282481}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6.2","itemId":"21b74b34-fe9f-457c-83f0-cf49257b5948","field":"summary.0","delta":"Spawning a subagent via muse_subagent_spawn to run a shell command and return its exact output, while avoiding direct command execution."},"emittedAtMs":1791758282481}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson index d09ec04397fcb..84d1a816f8af3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","path":"~/.local/share/muse/sessions/2026/10/11/01a12d28-7788-7000-a946-c407f380868e/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:49:49.235205Z","updatedAt":"2026-10-11T22:49:49.235209Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791758989388}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","path":"~/.local/share/muse/sessions/2026/10/11/01a12d28-7788-7000-a946-c407f380868e/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:49:49.235205Z","updatedAt":"2026-10-11T22:49:49.235209Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:1","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"29d4e769-78f7-48fa-90cd-3ac9f8fa04bf","sequence":5},"last":{"id":"29d4e769-78f7-48fa-90cd-3ac9f8fa04bf","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d28-7788-7001-a223-c10e52187cdf","clientName":"t3_code"},"emittedAtMs":1791758989391}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:2","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"660e2cfd-318a-4d2d-87bb-0b1375d79fdf","sequence":15},"last":{"id":"660e2cfd-318a-4d2d-87bb-0b1375d79fdf","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791758989619}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d28-7912-7000-993a-afd019e6ac5c","status":"accepted","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","status":"running","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:3"},"emittedAtMs":1791758989632}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:3","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"},"emittedAtMs":1791758989632}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:4","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"completed","recordedAt":"2026-10-11T22:49:49.620067Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989632}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:5","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19},"last":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":2,"status":"completed","recordedAt":"2026-10-11T22:49:49.635395Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989642}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:4","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"completed","recordedAt":"2026-10-11T22:49:49.620067Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989632}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:5","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19},"last":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":2,"status":"completed","recordedAt":"2026-10-11T22:49:49.635395Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989642}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:6","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"44afb415-d6e4-4d51-9002-4deb196d463a","sequence":36},"last":{"id":"44afb415-d6e4-4d51-9002-4deb196d463a","sequence":36}},"item":{"itemId":"44afb415-d6e4-4d51-9002-4deb196d463a","kind":"reminderChild","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:49:49.743418Z","fallbackText":"Reminder child session","childSessionId":"4dd3511e-2115-4fbb-a4c4-383cbb383d63","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d28-7997-79d0-8a69-8ac0acf92a56"}},"emittedAtMs":1791758989750}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:7","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52},"last":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52}},"turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32911,"outputTokens":264,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":143},"promptTokens":32911,"totalTokens":33175,"finishReason":"tool_calls","durationMs":3651,"cumulative":{"promptTokens":32911,"outputTokens":264,"totalTokens":33175,"cost":{"usd":0.0033439000000000003,"partial":false}}},"emittedAtMs":1791758993485}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:8","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52},"last":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52}},"windowTokens":1007997,"usedTokens":33175,"pressure":"normal"},"emittedAtMs":1791758993485}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson index b50aa145d67c0..608135031ff09 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson @@ -8,13 +8,13 @@ {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","path":"~/.local/share/muse/sessions/2026/10/11/01a12d5d-405f-7000-89a3-5aa62af6ea23/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T23:47:28.587276Z","updatedAt":"2026-10-11T23:47:28.587281Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:2"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:1","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0c13b14b-877c-4c5b-b05f-8ad250dfd381","sequence":5},"last":{"id":"0c13b14b-877c-4c5b-b05f-8ad250dfd381","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d5d-4060-7000-b6cd-aaabe26f80de","clientName":"t3_code"},"emittedAtMs":1791762448807}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:2","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"28b1f3f4-4448-4a21-a1a0-2df72ad1d326","sequence":6},"last":{"id":"28b1f3f4-4448-4a21-a1a0-2df72ad1d326","sequence":6}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791762448807}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it."}],"displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it."}],"displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:3","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"3f1880e1-81a0-4f21-b536-6b4fa5cf9d12","sequence":16},"last":{"id":"3f1880e1-81a0-4f21-b536-6b4fa5cf9d12","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791762449097}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","status":"accepted","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","status":"running","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:4"},"emittedAtMs":1791762449102}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:4","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"},"emittedAtMs":1791762449103}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:5","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"completed","recordedAt":"2026-10-11T23:47:29.098117Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449103}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:6","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20},"last":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":2,"status":"completed","recordedAt":"2026-10-11T23:47:29.107019Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449131}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:5","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"completed","recordedAt":"2026-10-11T23:47:29.098117Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449103}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:6","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20},"last":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":2,"status":"completed","recordedAt":"2026-10-11T23:47:29.107019Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449131}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"95574bfe-853f-44cc-81f6-88b05d6c8c16","sequence":38},"last":{"id":"95574bfe-853f-44cc-81f6-88b05d6c8c16","sequence":38}},"item":{"itemId":"95574bfe-853f-44cc-81f6-88b05d6c8c16","kind":"reminderChild","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"inProgress","recordedAt":"2026-10-11T23:47:29.328939Z","fallbackText":"Reminder child session","childSessionId":"b16dbb52-7f62-4a5a-84c2-f68d7ea22b18","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d5d-4375-7d72-b5b2-4b096d0df507"}},"emittedAtMs":1791762449336}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7.1","item":{"itemId":"3ce724bc-dacf-400e-af9a-995104717cb1","kind":"reasoning","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"inProgress","summary":[""]}},"emittedAtMs":1791762451554}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7.2","itemId":"3ce724bc-dacf-400e-af9a-995104717cb1","field":"summary.0","delta":"Launching a two-agent ping-pong workflow via the workflow tool while skipping skill reading for a trivial task."},"emittedAtMs":1791762451554}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson index ab6ffd41f65cf..ad68f2bbf7574 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6a-bbb5-7000-ad0b-0c9e728406e5/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:29:25.018803Z","updatedAt":"2026-10-08T07:29:25.018807Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444565334}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6a-bbb5-7000-ad0b-0c9e728406e5/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:29:25.018803Z","updatedAt":"2026-10-08T07:29:25.018807Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"cfd043e8-8728-493d-ba28-252ef91e8a80","sequence":5},"last":{"id":"cfd043e8-8728-493d-ba28-252ef91e8a80","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6a-bbb6-7000-b1a4-a01ea59676ef","clientName":"t3_code"},"emittedAtMs":1791444565340}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with the following text: fixture simple ok"}],"displayText":"Respond with the following text: fixture simple ok","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with the following text: fixture simple ok"}],"displayText":"Respond with the following text: fixture simple ok","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"0f64f4ea-a08c-43e1-af53-cf85cee1a11c","sequence":16},"last":{"id":"0f64f4ea-a08c-43e1-af53-cf85cee1a11c","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444565612}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","status":"accepted","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","status":"running","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:3"},"emittedAtMs":1791444565635}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"},"emittedAtMs":1791444565635}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"completed","recordedAt":"2026-10-08T07:29:25.627042Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565635}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20},"last":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":2,"status":"completed","recordedAt":"2026-10-08T07:29:25.643029Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with the following text: fixture simple ok","displayText":"Respond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565652}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"completed","recordedAt":"2026-10-08T07:29:25.627042Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565635}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20},"last":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":2,"status":"completed","recordedAt":"2026-10-08T07:29:25.643029Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with the following text: fixture simple ok","displayText":"Respond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565652}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","sequence":37},"last":{"id":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","sequence":37}},"item":{"itemId":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","kind":"reminderChild","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:29:25.781511Z","fallbackText":"Reminder child session","childSessionId":"9785bf9d-382d-4781-ab3b-3817c9e6a604","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6a-bef1-7032-974a-059b8765c487"}},"emittedAtMs":1791444565789}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6.1","item":{"itemId":"0a68e576-f554-4700-8853-a68ad2b7179b","kind":"agentMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444567707}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6.2","itemId":"0a68e576-f554-4700-8853-a68ad2b7179b","field":"text","delta":"fixture simple ok"},"emittedAtMs":1791444567707}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson index af6945e0d768a..be30486990816 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","path":"~/.local/share/muse/sessions/2026/10/11/01a12d17-75fa-7000-9157-e8365ce94492/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:31:14.73121Z","updatedAt":"2026-10-11T22:31:14.731214Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791757874894}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","path":"~/.local/share/muse/sessions/2026/10/11/01a12d17-75fa-7000-9157-e8365ce94492/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:31:14.73121Z","updatedAt":"2026-10-11T22:31:14.731214Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:1","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"94d1e1ed-1d3e-4cd5-91bf-a51394fc8a0a","sequence":5},"last":{"id":"94d1e1ed-1d3e-4cd5-91bf-a51394fc8a0a","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d17-75fa-7001-a940-79b5d7c648a4","clientName":"t3_code"},"emittedAtMs":1791757874898}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:2","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"1dc98410-05c3-4618-a431-3638055e20df","sequence":15},"last":{"id":"1dc98410-05c3-4618-a431-3638055e20df","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791757875120}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d17-7790-7000-967d-3f2246f38a60","status":"accepted","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","status":"running","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:3"},"emittedAtMs":1791757875131}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:3","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"turnId":"01a12d17-7790-7000-967d-3f2246f38a60","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"},"emittedAtMs":1791757875132}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:4","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"completed","recordedAt":"2026-10-11T22:31:15.120499Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875132}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:5","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19},"last":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":2,"status":"completed","recordedAt":"2026-10-11T22:31:15.136047Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875144}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:4","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"completed","recordedAt":"2026-10-11T22:31:15.120499Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875132}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:5","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19},"last":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":2,"status":"completed","recordedAt":"2026-10-11T22:31:15.136047Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875144}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:6","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"599a220a-d212-4a81-a60d-7284ff290284","sequence":36},"last":{"id":"599a220a-d212-4a81-a60d-7284ff290284","sequence":36}},"item":{"itemId":"599a220a-d212-4a81-a60d-7284ff290284","kind":"reminderChild","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:31:15.249949Z","fallbackText":"Reminder child session","childSessionId":"0b3f4f01-850a-43bd-af36-9b011699354f","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d17-7819-7212-8057-3558c2bdd46d"}},"emittedAtMs":1791757875258}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:7","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51},"last":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51}},"turnId":"01a12d17-7790-7000-967d-3f2246f38a60","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32900,"outputTokens":129,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":36},"promptTokens":32900,"totalTokens":33029,"finishReason":"tool_calls","durationMs":2354,"cumulative":{"promptTokens":32900,"outputTokens":129,"totalTokens":33029,"cost":{"usd":0.0033158000000000003,"partial":false}}},"emittedAtMs":1791757877764}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:8","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51},"last":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51}},"windowTokens":1007997,"usedTokens":33029,"pressure":"normal"},"emittedAtMs":1791757877764}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson index ffdecab232a55..a024d2db1416e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson @@ -133,7 +133,9 @@ {"type":"emit_inbound","label":"tool_execution_start","frame":{"type":"tool_execution_start","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","args":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}} {"type":"emit_inbound","label":"tool_execution_update","frame":{"type":"tool_execution_update","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","args":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35},"partialResult":{"content":[]}}} {"type":"emit_inbound","label":"tool_execution_update","frame":{"type":"tool_execution_update","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","args":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35},"partialResult":{"content":[{"type":"text","text":"interrupt fixture tool started\n"}],"details":{}}}} -{"type":"expect_outbound","label":"abort","frame":{"type":"abort","id":"t3-9"}} +{"type":"expect_outbound","label":"clear_queue","frame":{"type":"clear_queue","id":"t3-9"}} +{"type":"emit_inbound","label":"response:clear_queue","frame":{"id":"t3-9","type":"response","command":"clear_queue","success":true,"data":{"steering":[],"followUp":[]}}} +{"type":"expect_outbound","label":"abort","frame":{"type":"abort","id":"t3-10"}} {"type":"emit_inbound","label":"tool_execution_end","frame":{"type":"tool_execution_end","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","result":{"content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{}},"isError":true}} {"type":"emit_inbound","label":"message_start","frame":{"type":"message_start","message":{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463}}} {"type":"emit_inbound","label":"message_end","frame":{"type":"message_end","message":{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463}}} @@ -144,9 +146,9 @@ {"type":"emit_inbound","label":"turn_end","frame":{"type":"turn_end","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225606465,"thinkingLevel":"high"},"toolResults":[]}} {"type":"emit_inbound","label":"agent_end","frame":{"type":"agent_end","messages":[{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225601969},{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a command that will wait for 30 seconds, and then after it completes, respond with a specific message. But they also say \"Do not answer until it completes\" - but this command will run for 30 seconds and then naturally finish. However, the response they want is \"interrupt fixture should not finish naturally\" which contradicts the fact that it did finish naturally.\n\nWait, let me re-read. They want me to run the command and not answer until it completes. The command is a node script that logs \"interrupt fixture tool started\" and then waits 30 seconds. If I wait for it to complete, it will finish naturally after 30 seconds. But the response they want is \"interrupt fixture should not finish naturally\" - implying that the fixture should have been interrupted before finishing naturally.\n\nThis is a test scenario. Let me just run it and see what happens. Actually, maybe they want me to just run it and respond accordingly. Let me execute the command.\n\nActually, I think this is part of a test where the system might interrupt/kill the process. Let me just run the command with a sufficient timeout.","thinkingSignature":""},{"type":"toolCall","id":"call_8ac3cb4177994abdb9aa1c57","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":77,"output":317,"cacheRead":1792,"cacheWrite":0,"reasoning":284,"totalTokens":2186,"cost":{"input":0.00000322322,"output":0.000026539239999999998,"cacheRead":0.000015002623999999998,"cacheWrite":0,"total":0.000044765084}},"stopReason":"toolUse","timestamp":1767225601972,"responseId":"gen-1790898593-E5hfYfRp6aKBI57pUKss","rawStopReason":"tool_calls","thinkingLevel":"high"},{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463},{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225606465,"thinkingLevel":"high"}],"willRetry":false}} {"type":"emit_inbound","label":"agent_settled","frame":{"type":"agent_settled"}} -{"type":"emit_inbound","label":"response:abort","frame":{"id":"t3-9","type":"response","command":"abort","success":true}} -{"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","since":"b184b5a8","id":"t3-10"}} -{"type":"emit_inbound","label":"response:get_entries","frame":{"id":"t3-10","type":"response","command":"get_entries","success":true,"data":{"entries":[{"type":"message","id":"ee73daae","parentId":"b184b5a8","timestamp":"2026-01-01T00:00:01.971Z","message":{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225601969}},{"type":"message","id":"c3915df7","parentId":"ee73daae","timestamp":"2026-01-01T00:00:06.406Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a command that will wait for 30 seconds, and then after it completes, respond with a specific message. But they also say \"Do not answer until it completes\" - but this command will run for 30 seconds and then naturally finish. However, the response they want is \"interrupt fixture should not finish naturally\" which contradicts the fact that it did finish naturally.\n\nWait, let me re-read. They want me to run the command and not answer until it completes. The command is a node script that logs \"interrupt fixture tool started\" and then waits 30 seconds. If I wait for it to complete, it will finish naturally after 30 seconds. But the response they want is \"interrupt fixture should not finish naturally\" - implying that the fixture should have been interrupted before finishing naturally.\n\nThis is a test scenario. Let me just run it and see what happens. Actually, maybe they want me to just run it and respond accordingly. Let me execute the command.\n\nActually, I think this is part of a test where the system might interrupt/kill the process. Let me just run the command with a sufficient timeout.","thinkingSignature":""},{"type":"toolCall","id":"call_8ac3cb4177994abdb9aa1c57","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":77,"output":317,"cacheRead":1792,"cacheWrite":0,"reasoning":284,"totalTokens":2186,"cost":{"input":0.00000322322,"output":0.000026539239999999998,"cacheRead":0.000015002623999999998,"cacheWrite":0,"total":0.000044765084}},"stopReason":"toolUse","timestamp":1767225601972,"responseId":"gen-1790898593-E5hfYfRp6aKBI57pUKss","rawStopReason":"tool_calls","thinkingLevel":"high"}},{"type":"message","id":"641f3d3f","parentId":"c3915df7","timestamp":"2026-01-01T00:00:06.463Z","message":{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463}},{"type":"message","id":"e3ab1495","parentId":"641f3d3f","timestamp":"2026-01-01T00:00:06.465Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225606465,"thinkingLevel":"high"}}],"leafId":"e3ab1495"}}} +{"type":"emit_inbound","label":"response:abort","frame":{"id":"t3-10","type":"response","command":"abort","success":true}} +{"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","since":"b184b5a8","id":"t3-11"}} +{"type":"emit_inbound","label":"response:get_entries","frame":{"id":"t3-11","type":"response","command":"get_entries","success":true,"data":{"entries":[{"type":"message","id":"ee73daae","parentId":"b184b5a8","timestamp":"2026-01-01T00:00:01.971Z","message":{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225601969}},{"type":"message","id":"c3915df7","parentId":"ee73daae","timestamp":"2026-01-01T00:00:06.406Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a command that will wait for 30 seconds, and then after it completes, respond with a specific message. But they also say \"Do not answer until it completes\" - but this command will run for 30 seconds and then naturally finish. However, the response they want is \"interrupt fixture should not finish naturally\" which contradicts the fact that it did finish naturally.\n\nWait, let me re-read. They want me to run the command and not answer until it completes. The command is a node script that logs \"interrupt fixture tool started\" and then waits 30 seconds. If I wait for it to complete, it will finish naturally after 30 seconds. But the response they want is \"interrupt fixture should not finish naturally\" - implying that the fixture should have been interrupted before finishing naturally.\n\nThis is a test scenario. Let me just run it and see what happens. Actually, maybe they want me to just run it and respond accordingly. Let me execute the command.\n\nActually, I think this is part of a test where the system might interrupt/kill the process. Let me just run the command with a sufficient timeout.","thinkingSignature":""},{"type":"toolCall","id":"call_8ac3cb4177994abdb9aa1c57","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":77,"output":317,"cacheRead":1792,"cacheWrite":0,"reasoning":284,"totalTokens":2186,"cost":{"input":0.00000322322,"output":0.000026539239999999998,"cacheRead":0.000015002623999999998,"cacheWrite":0,"total":0.000044765084}},"stopReason":"toolUse","timestamp":1767225601972,"responseId":"gen-1790898593-E5hfYfRp6aKBI57pUKss","rawStopReason":"tool_calls","thinkingLevel":"high"}},{"type":"message","id":"641f3d3f","parentId":"c3915df7","timestamp":"2026-01-01T00:00:06.463Z","message":{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463}},{"type":"message","id":"e3ab1495","parentId":"641f3d3f","timestamp":"2026-01-01T00:00:06.465Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225606465,"thinkingLevel":"high"}}],"leafId":"e3ab1495"}}} {"type":"expect_outbound","label":"process_start@p2","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"switch_session@p2","frame":{"type":"switch_session","sessionPath":"/pi-sessions/session-1.jsonl","id":"t3-0"}} {"type":"emit_inbound","label":"extension_ui_request@p2","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000003","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson index 4643928805ff0..0f5ffeaca508c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson @@ -7,14 +7,14 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-2d3d-7000-8178-411fdb90bc34/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:05.094285Z","updatedAt":"2026-10-08T07:32:05.094289Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444725265}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-2d3d-7000-8178-411fdb90bc34/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:05.094285Z","updatedAt":"2026-10-08T07:32:05.094289Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"9865268e-0a39-43a0-89d0-251a820ec8a3","sequence":5},"last":{"id":"9865268e-0a39-43a0-89d0-251a820ec8a3","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6d-2d3e-7000-b0e7-f04c53ee070b","clientName":"t3_code"},"emittedAtMs":1791444725268}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."}],"displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."}],"displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"531b0e9e-c96d-4fa5-a6dd-5cc7d4569543","sequence":16},"last":{"id":"531b0e9e-c96d-4fa5-a6dd-5cc7d4569543","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444725509}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","status":"accepted","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","startedNewTurn":true,"disposition":"started"}}} {"type":"expect_outbound","label":"turn/interrupt","frame":{"jsonrpc":"2.0","id":4,"method":"turn/interrupt","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","commandId":"01a11a6d-2f0b-7000-a1d4-359cf54e471a"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","status":"running","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:3"},"emittedAtMs":1791444725522}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"},"emittedAtMs":1791444725523}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:05.510508Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725523}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20},"last":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:05.530992Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725539}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:05.510508Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725523}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20},"last":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:05.530992Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725539}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37},"last":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37}},"item":{"itemId":"ae604001-22b9-479c-a45b-3dcfed45996c","kind":"reminderChild","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:05.638142Z","fallbackText":"Reminder child session","childSessionId":"1f58196a-6bf6-42da-9499-8f53a439e6e1","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-2f65-70b1-bb8f-69b33bb4fc62"}},"emittedAtMs":1791444725646}} {"type":"emit_inbound","label":"response:turn/interrupt","frame":{"jsonrpc":"2.0","id":4,"result":{"commandId":"01a11a6d-2f0b-7000-a1d4-359cf54e471a","status":"accepted","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} {"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:7","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37},"last":{"id":"3962d8d1-4608-4dda-8fdc-39a1804474aa","sequence":50}},"item":{"itemId":"ae604001-22b9-479c-a45b-3dcfed45996c","kind":"reminderChild","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"cancelled","recordedAt":"2026-10-08T07:32:05.727236Z","fallbackText":"Reminder child session","failureReason":"main run cancelled","childSessionId":"1f58196a-6bf6-42da-9499-8f53a439e6e1","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-2f65-70b1-bb8f-69b33bb4fc62"}},"emittedAtMs":1791444725734}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson index d4db23f4fd8e3..072994e9d82a9 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson @@ -41,7 +41,9 @@ {"type":"emit_inbound","label":"message_end","frame":{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a specific command and wait for it to complete. Let me execute it.","thinkingSignature":""},{"type":"toolCall","id":"call_9726ad99cc114832996228eb","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":1830,"output":102,"cacheRead":0,"cacheWrite":0,"reasoning":23,"totalTokens":1932,"cost":{"input":0.0000766038,"output":0.00000853944,"cacheRead":0,"cacheWrite":0,"total":0.00008514324}},"stopReason":"toolUse","timestamp":1767225600401,"responseId":"gen-1790898592-Mzq78XbaTGiNE3YSafgy","rawStopReason":"tool_calls","thinkingLevel":"high"}}} {"type":"emit_inbound","label":"tool_execution_start","frame":{"type":"tool_execution_start","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","args":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}} {"type":"emit_inbound","label":"tool_execution_update","frame":{"type":"tool_execution_update","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","args":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35},"partialResult":{"content":[]}}} -{"type":"expect_outbound","label":"abort","frame":{"type":"abort","id":"t3-6"}} +{"type":"expect_outbound","label":"clear_queue","frame":{"type":"clear_queue","id":"t3-6"}} +{"type":"emit_inbound","label":"response:clear_queue","frame":{"id":"t3-6","type":"response","command":"clear_queue","success":true,"data":{"steering":[],"followUp":[]}}} +{"type":"expect_outbound","label":"abort","frame":{"type":"abort","id":"t3-7"}} {"type":"emit_inbound","label":"tool_execution_end","frame":{"type":"tool_execution_end","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","result":{"content":[{"type":"text","text":"Command aborted"}],"details":{}},"isError":true}} {"type":"emit_inbound","label":"message_start","frame":{"type":"message_start","message":{"role":"toolResult","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","content":[{"type":"text","text":"Command aborted"}],"details":{},"isError":true,"timestamp":1767225602282}}} {"type":"emit_inbound","label":"message_end","frame":{"type":"message_end","message":{"role":"toolResult","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","content":[{"type":"text","text":"Command aborted"}],"details":{},"isError":true,"timestamp":1767225602282}}} @@ -52,6 +54,6 @@ {"type":"emit_inbound","label":"turn_end","frame":{"type":"turn_end","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225602287,"thinkingLevel":"high"},"toolResults":[]}} {"type":"emit_inbound","label":"agent_end","frame":{"type":"agent_end","messages":[{"role":"system","content":"","sections":{"preamble":"","tools":"","rules":"","docs":"","cwd":""},"timestamp":1790898591817,"toolsAdded":[{"name":"read","description":"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.","parameters":{"type":"object","required":["path"],"properties":{"path":{"type":"string","description":"Path to the file to read (relative or absolute)"},"offset":{"type":"number","description":"Line number to start reading from (1-indexed)"},"limit":{"type":"number","description":"Maximum number of lines to read"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"bash","description":"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.","parameters":{"type":"object","required":["command"],"properties":{"command":{"type":"string","description":"Shell command to execute"},"timeout":{"type":"number","description":"Timeout in seconds (optional, no default timeout)"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"edit","description":"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.","parameters":{"type":"object","required":["path","edits"],"properties":{"path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},"edits":{"type":"array","items":{"type":"object","required":["oldText","newText"],"properties":{"oldText":{"type":"string","description":"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call."},"newText":{"type":"string","description":"Replacement text for this targeted edit."}}},"description":"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead."}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"write","description":"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.","parameters":{"type":"object","required":["path","content"],"properties":{"path":{"type":"string","description":"Path to the file to write (relative or absolute)"},"content":{"type":"string","description":"Content to write to the file"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}}]},{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225600375},{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a specific command and wait for it to complete. Let me execute it.","thinkingSignature":""},{"type":"toolCall","id":"call_9726ad99cc114832996228eb","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":1830,"output":102,"cacheRead":0,"cacheWrite":0,"reasoning":23,"totalTokens":1932,"cost":{"input":0.0000766038,"output":0.00000853944,"cacheRead":0,"cacheWrite":0,"total":0.00008514324}},"stopReason":"toolUse","timestamp":1767225600401,"responseId":"gen-1790898592-Mzq78XbaTGiNE3YSafgy","rawStopReason":"tool_calls","thinkingLevel":"high"},{"role":"toolResult","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","content":[{"type":"text","text":"Command aborted"}],"details":{},"isError":true,"timestamp":1767225602282},{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225602287,"thinkingLevel":"high"}],"willRetry":false}} {"type":"emit_inbound","label":"agent_settled","frame":{"type":"agent_settled"}} -{"type":"emit_inbound","label":"response:abort","frame":{"id":"t3-6","type":"response","command":"abort","success":true}} -{"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","since":"4405fe6a","id":"t3-7"}} -{"type":"emit_inbound","label":"response:get_entries","frame":{"id":"t3-7","type":"response","command":"get_entries","success":true,"data":{"entries":[{"type":"model_change","id":"b78c2012","parentId":"4405fe6a","timestamp":"2026-01-01T00:00:00.366Z","provider":"openrouter","modelId":"deepseek/deepseek-v4-flash"},{"type":"session_info","id":"9dea9c23","parentId":"b78c2012","timestamp":"2026-01-01T00:00:00.370Z","name":"Replay fixture: turn_interrupt_mid_tool"},{"type":"message","id":"8dc98fa4","parentId":"9dea9c23","timestamp":"2026-01-01T00:00:00.379Z","message":{"role":"system","content":"","sections":{"preamble":"","tools":"","rules":"","docs":"","cwd":""},"timestamp":1790898591817,"toolsAdded":[{"name":"read","description":"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.","parameters":{"type":"object","required":["path"],"properties":{"path":{"type":"string","description":"Path to the file to read (relative or absolute)"},"offset":{"type":"number","description":"Line number to start reading from (1-indexed)"},"limit":{"type":"number","description":"Maximum number of lines to read"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"bash","description":"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.","parameters":{"type":"object","required":["command"],"properties":{"command":{"type":"string","description":"Shell command to execute"},"timeout":{"type":"number","description":"Timeout in seconds (optional, no default timeout)"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"edit","description":"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.","parameters":{"type":"object","required":["path","edits"],"properties":{"path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},"edits":{"type":"array","items":{"type":"object","required":["oldText","newText"],"properties":{"oldText":{"type":"string","description":"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call."},"newText":{"type":"string","description":"Replacement text for this targeted edit."}}},"description":"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead."}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"write","description":"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.","parameters":{"type":"object","required":["path","content"],"properties":{"path":{"type":"string","description":"Path to the file to write (relative or absolute)"},"content":{"type":"string","description":"Content to write to the file"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}}]}},{"type":"message","id":"b36f8c4a","parentId":"8dc98fa4","timestamp":"2026-01-01T00:00:00.379Z","message":{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225600375}},{"type":"message","id":"8ea47a23","parentId":"b36f8c4a","timestamp":"2026-01-01T00:00:02.246Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a specific command and wait for it to complete. Let me execute it.","thinkingSignature":""},{"type":"toolCall","id":"call_9726ad99cc114832996228eb","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":1830,"output":102,"cacheRead":0,"cacheWrite":0,"reasoning":23,"totalTokens":1932,"cost":{"input":0.0000766038,"output":0.00000853944,"cacheRead":0,"cacheWrite":0,"total":0.00008514324}},"stopReason":"toolUse","timestamp":1767225600401,"responseId":"gen-1790898592-Mzq78XbaTGiNE3YSafgy","rawStopReason":"tool_calls","thinkingLevel":"high"}},{"type":"message","id":"aa5aec89","parentId":"8ea47a23","timestamp":"2026-01-01T00:00:02.282Z","message":{"role":"toolResult","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","content":[{"type":"text","text":"Command aborted"}],"details":{},"isError":true,"timestamp":1767225602282}},{"type":"message","id":"206b2637","parentId":"aa5aec89","timestamp":"2026-01-01T00:00:02.287Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225602287,"thinkingLevel":"high"}}],"leafId":"206b2637"}}} +{"type":"emit_inbound","label":"response:abort","frame":{"id":"t3-7","type":"response","command":"abort","success":true}} +{"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","since":"4405fe6a","id":"t3-8"}} +{"type":"emit_inbound","label":"response:get_entries","frame":{"id":"t3-8","type":"response","command":"get_entries","success":true,"data":{"entries":[{"type":"model_change","id":"b78c2012","parentId":"4405fe6a","timestamp":"2026-01-01T00:00:00.366Z","provider":"openrouter","modelId":"deepseek/deepseek-v4-flash"},{"type":"session_info","id":"9dea9c23","parentId":"b78c2012","timestamp":"2026-01-01T00:00:00.370Z","name":"Replay fixture: turn_interrupt_mid_tool"},{"type":"message","id":"8dc98fa4","parentId":"9dea9c23","timestamp":"2026-01-01T00:00:00.379Z","message":{"role":"system","content":"","sections":{"preamble":"","tools":"","rules":"","docs":"","cwd":""},"timestamp":1790898591817,"toolsAdded":[{"name":"read","description":"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.","parameters":{"type":"object","required":["path"],"properties":{"path":{"type":"string","description":"Path to the file to read (relative or absolute)"},"offset":{"type":"number","description":"Line number to start reading from (1-indexed)"},"limit":{"type":"number","description":"Maximum number of lines to read"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"bash","description":"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.","parameters":{"type":"object","required":["command"],"properties":{"command":{"type":"string","description":"Shell command to execute"},"timeout":{"type":"number","description":"Timeout in seconds (optional, no default timeout)"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"edit","description":"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.","parameters":{"type":"object","required":["path","edits"],"properties":{"path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},"edits":{"type":"array","items":{"type":"object","required":["oldText","newText"],"properties":{"oldText":{"type":"string","description":"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call."},"newText":{"type":"string","description":"Replacement text for this targeted edit."}}},"description":"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead."}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"write","description":"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.","parameters":{"type":"object","required":["path","content"],"properties":{"path":{"type":"string","description":"Path to the file to write (relative or absolute)"},"content":{"type":"string","description":"Content to write to the file"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}}]}},{"type":"message","id":"b36f8c4a","parentId":"8dc98fa4","timestamp":"2026-01-01T00:00:00.379Z","message":{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225600375}},{"type":"message","id":"8ea47a23","parentId":"b36f8c4a","timestamp":"2026-01-01T00:00:02.246Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a specific command and wait for it to complete. Let me execute it.","thinkingSignature":""},{"type":"toolCall","id":"call_9726ad99cc114832996228eb","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":1830,"output":102,"cacheRead":0,"cacheWrite":0,"reasoning":23,"totalTokens":1932,"cost":{"input":0.0000766038,"output":0.00000853944,"cacheRead":0,"cacheWrite":0,"total":0.00008514324}},"stopReason":"toolUse","timestamp":1767225600401,"responseId":"gen-1790898592-Mzq78XbaTGiNE3YSafgy","rawStopReason":"tool_calls","thinkingLevel":"high"}},{"type":"message","id":"aa5aec89","parentId":"8ea47a23","timestamp":"2026-01-01T00:00:02.282Z","message":{"role":"toolResult","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","content":[{"type":"text","text":"Command aborted"}],"details":{},"isError":true,"timestamp":1767225602282}},{"type":"message","id":"206b2637","parentId":"aa5aec89","timestamp":"2026-01-01T00:00:02.287Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225602287,"thinkingLevel":"high"}}],"leafId":"206b2637"}}} diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 50be40682066b..754170be999c2 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -27,6 +27,7 @@ import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -224,9 +225,12 @@ const serveBox = (seen: Ref.Ref, linkSession: Ref.Ref) => ), ), Layer.provide(NodeServices.layer), - Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + ), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), @@ -307,6 +311,7 @@ const makeLaptop = Effect.gen(function* () { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({}), Layer.mock(SecretRequests.SecretRequests)({}), ServerSettings.layerTest(), Layer.mock(RemoteDelegation.RemoteDelegation)({}), diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index f47ea7d006a36..11d058744084b 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -21,6 +21,7 @@ import * as Base64Url from "effect/encoding/Base64Url"; import * as Hex from "effect/encoding/Hex"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; @@ -263,7 +264,7 @@ const make = Effect.gen(function* () { redirect_uri: LINK_REDIRECT_URI, code_challenge: challenge, code_challenge_method: "S256", - state, + state: Redacted.make(state), resource, }, decision: { _tag: "pairing-code", access: input.access, code: input.pairingCode }, @@ -297,10 +298,10 @@ const make = Effect.gen(function* () { .token({ payload: { grant_type: "authorization_code", - code, + code: Redacted.make(code), redirect_uri: LINK_REDIRECT_URI, client_id: registered.client_id, - code_verifier: verifier, + code_verifier: Redacted.make(verifier), resource, }, }) diff --git a/apps/server/src/peer/PeerMcpClient.test.ts b/apps/server/src/peer/PeerMcpClient.test.ts new file mode 100644 index 0000000000000..7d19f4e856bf7 --- /dev/null +++ b/apps/server/src/peer/PeerMcpClient.test.ts @@ -0,0 +1,177 @@ +import { NodeHttpServer } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +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 Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as McpToolAccess from "../mcp/McpToolAccess.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import * as PeerMcpClient from "./PeerMcpClient.ts"; +import { descriptorOf, layerLinkingEnvironment, linkTo, servePeer } from "./PeerLinks.testkit.ts"; + +const Result = Schema.Struct({ modes: Schema.String }); +const toolkit = Toolkit.make( + Tool.make("probe", { + description: "Returns this request's mode limits.", + success: Result, + dependencies: [McpInvocationContext.McpInvocationContext], + }), + Tool.make("hold", { + description: "Holds a tool request open.", + success: Result, + dependencies: [McpInvocationContext.McpInvocationContext], + }), +); + +it.effect.each([false, true])( + "reuses an initialized session before its first call finishes (cancelled: %s)", + (cancelFirst) => + Effect.scoped( + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const modes = McpInvocationContext.McpInvocationContext.pipe( + Effect.map((scope) => { + const ceiling = McpInvocationContext.clientModeCeiling(scope.client); + return { modes: `${ceiling.runtimeMode}/${ceiling.interactionMode}` }; + }), + ); + const handlers = McpToolAccess.toLayer(toolkit, { + probe: McpToolAccess.reads(() => modes), + hold: McpToolAccess.reads(() => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(modes), + ), + ), + }); + const box = descriptorOf("environment-box", "Box"); + const b = yield* servePeer(box, McpHttpServer.toolkitRegistration(toolkit, handlers)); + const context = yield* Layer.build( + layerLinkingEnvironment(descriptorOf("environment-a", "A")), + ); + const links = Context.get(context, PeerLinks.PeerLinks); + const client = Context.get(context, PeerMcpClient.PeerMcpClient); + yield* linkTo(links, b); + const held = yield* client + .call({ + environmentId: box.environmentId, + tool: "hold", + arguments: {}, + limits: { runtimeMode: "full-access", interactionMode: "default" }, + success: Result, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(entered); + if (cancelFirst) yield* Fiber.interrupt(held); + const before = (yield* Ref.get(b.bearers)).length; + expect( + yield* client.call({ + environmentId: box.environmentId, + tool: "probe", + arguments: {}, + limits: { runtimeMode: "approval-required", interactionMode: "plan" }, + success: Result, + }), + ).toEqual({ modes: "approval-required/plan" }); + // One tool POST; no second initialize or initialized notification. + expect((yield* Ref.get(b.bearers)).length - before).toBe(1); + yield* Deferred.succeed(release, undefined); + if (!cancelFirst) expect(yield* Fiber.join(held)).toEqual({ modes: "auto/default" }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("renews a missing peer session once and reuses its replacement", () => + Effect.gen(function* () { + const sessionHeaders: Array = []; + const statuses = [200, 202, 404, 200, 202, 200, 200]; + const http = HttpClient.make((request) => + Effect.sync(() => { + const index = sessionHeaders.length; + sessionHeaders.push(request.headers["mcp-session-id"]); + const status = statuses[index]; + if (status === undefined) throw new Error("Unexpected retry"); + return HttpClientResponse.fromWeb( + request, + new Response( + index >= 5 + ? JSON.stringify({ + result: { + content: [{ type: "text", text: '{"modes":"approval-required/plan"}' }], + }, + }) + : "", + { + status, + headers: + index === 0 + ? { "mcp-session-id": "old" } + : index === 3 + ? { "mcp-session-id": "renewed" } + : {}, + }, + ), + ); + }), + ); + const box = descriptorOf("environment-box", "Box"); + const context = yield* Layer.build( + PeerMcpClient.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, http), + Layer.mock(PeerLinks.PeerLinks)({ + resolve: () => + Effect.succeed({ + link: { + environmentId: box.environmentId, + label: box.label, + urls: ["https://peer.example"], + access: "auto", + linkedAt: DateTime.makeUnsafe(0), + expiresAt: DateTime.makeUnsafe(1), + lastReachedAt: null, + lastError: null, + }, + url: "https://peer.example", + token: "fixture", + }), + recordOutcome: () => Effect.void, + }), + ), + ), + ), + ); + const client = Context.get(context, PeerMcpClient.PeerMcpClient); + for (let call = 0; call < 2; call++) { + expect( + yield* client.call({ + environmentId: box.environmentId, + tool: "probe", + arguments: {}, + limits: { runtimeMode: "approval-required", interactionMode: "plan" }, + success: Result, + }), + ).toEqual({ modes: "approval-required/plan" }); + } + expect(sessionHeaders).toEqual([ + undefined, + "old", + "old", + undefined, + "renewed", + "renewed", + "renewed", + ]); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index b953d458f8e27..27397d54ba7de 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -175,13 +175,15 @@ const make = Effect.gen(function* () { ); const existing = (yield* Ref.get(sessions)).get(sessionKey); const sessionId = existing ?? (yield* openSession(resolved.url, resolved.token, limitHeader)); + // The initialized session outlives a tool request, including a long wait or cancellation. + if (existing === undefined) { + yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, sessionId)); + } let response = yield* callOnce(sessionId); if (response.status === 404) { const renewed = yield* openSession(resolved.url, resolved.token, limitHeader); yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, renewed)); response = yield* callOnce(renewed); - } else if (existing === undefined) { - yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, sessionId)); } if (response.status === 401) { return yield* new OrchestratorMcpFailure({ diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index a344f63c523ab..d016bb523fdc7 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -49,6 +49,7 @@ import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.t import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; @@ -481,7 +482,9 @@ const boxToolkitLayer = (thread: BoxThread) => { Layer.provide(NodeServices.layer), ), ), - Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + ), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), Layer.provide( @@ -495,6 +498,7 @@ const boxToolkitLayer = (thread: BoxThread) => { }), }), ), + Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(ServerSettings.layerTest()), Layer.provide(NodeServices.layer), @@ -594,6 +598,7 @@ const makeLaptop = ( Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 8143a86b15034..1f3fb4bc6b8bc 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -76,6 +76,8 @@ import Migration0070 from "./Migrations/070_PeerLinks.ts"; import Migration0071 from "./Migrations/071_PeerLinkFence.ts"; import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; +import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; +import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -152,6 +154,8 @@ const migrationEntries = [ [71, "PeerLinkFence", Migration0071], [72, "WaitingLinkRequestIndex", Migration0072], [73, "ActiveTurnItemIndexes", Migration0073], + [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration0074], + [75, "SubagentDeliveryState", Migration0075], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts index 029218bb67b1b..68e6d66ed3f7f 100644 --- a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts +++ b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts @@ -1,8 +1,10 @@ import { assert, it } from "@effect/vitest"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; +import { rebuildMessageSearch } from "../../orchestration-v2/messageSearchIndex.ts"; import { runMigrations } from "../Migrations.ts"; const layer = it.layer(NodeSqliteClient.layer({ filename: ":memory:" })); @@ -53,3 +55,86 @@ layer("069_ThreadMessageSearch", (it) => { }), ); }); + +it.effect.each(["inserted", "updated", "backfilled", "rebuilt"])( + "keeps normal messages indexed and excludes %s placeholders", + (path) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: path === "backfilled" ? 68 : 73 }); + if (path !== "backfilled") { + // Pre-upgrade triggers indexed user text even when it was an attachment placeholder. + yield* sql`DROP TRIGGER orchestration_v2_message_search_insert`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_update`; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_insert + AFTER INSERT ON orchestration_v2_projection_messages + BEGIN + INSERT INTO orchestration_v2_message_search (rowid, text) + VALUES (new.rowid, json_extract(new.payload_json, '$.text')); + END + `; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_update + AFTER UPDATE OF payload_json ON orchestration_v2_projection_messages + BEGIN + DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid; + INSERT INTO orchestration_v2_message_search (rowid, text) + VALUES (new.rowid, json_extract(new.payload_json, '$.text')); + END + `; + if (path !== "rebuilt") yield* runMigrations(); + } + const placeholders = [ + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + ...[ + "", + " ", + "\n", + "\t\r\n", + "\v\f", + "\u00a0", + "\u1680", + "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a", + "\u2028\u2029\u202f\u205f\u3000\ufeff", + ].map((whitespace) => `uLtRaThInK:${whitespace}${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`), + ]; + const normal = [ + "normal context", + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT.toLowerCase(), + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT.toLowerCase()}`, + `quoted ${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT} extra`, + ` ${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + `Ultrathink:\u0085${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + ]; + for (const [index, text] of [...normal, ...placeholders].entries()) { + const payload = JSON.stringify({ text, createdBy: "user" }); + yield* sql` + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, role, streaming, created_at, updated_at, payload_json) + VALUES (${String(index)}, 'thread-1', 'user', 0, + '2026-09-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z', + ${path === "updated" ? JSON.stringify({ text: "normal context" }) : payload}) + `; + if (path === "updated") { + yield* sql` + UPDATE orchestration_v2_projection_messages SET payload_json = ${payload} + WHERE message_id = ${String(index)} + `; + } + } + yield* runMigrations({ toMigrationInclusive: path === "backfilled" ? 69 : undefined }); + if (path === "rebuilt") yield* rebuildMessageSearch(sql); + const indexed = yield* sql<{ readonly messageId: string }>` + SELECT messages.message_id AS "messageId" + FROM orchestration_v2_message_search AS search + INNER JOIN orchestration_v2_projection_messages AS messages ON messages.rowid = search.rowid + ORDER BY messages.rowid + `; + assert.deepEqual( + indexed.map((row) => row.messageId), + normal.map((_, index) => String(index)), + ); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), +); diff --git a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts index 4652debcbfd72..2c527d7e3ea99 100644 --- a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts +++ b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts @@ -3,6 +3,7 @@ import * as Predicate from "effect/Predicate"; import * as SqlClient from "effect/sql/SqlClient"; import { + createMessageSearchTriggers, indexedMessageCondition, indexedMessageText, } from "../../orchestration-v2/messageSearchIndex.ts"; @@ -39,47 +40,7 @@ export default Effect.gen(function* () { ), ); if (!created) return; - yield* sql` - CREATE TRIGGER orchestration_v2_message_search_insert - AFTER INSERT ON orchestration_v2_projection_messages - WHEN ${indexedMessageCondition(sql, "new")} - BEGIN - INSERT INTO orchestration_v2_message_search (rowid, text) - SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} - WHERE json_extract(new.payload_json, '$.text') IS NOT NULL; - END - `; - // Streaming deltas rewrite the row many times a second; skip them until the - // message settles. Upserts rewrite every column, so a finished message - // re-indexes only when what the index reads changed. Deleting a rowid the - // index never held is a no-op. - yield* sql` - CREATE TRIGGER orchestration_v2_message_search_update - AFTER UPDATE OF payload_json, streaming, role ON orchestration_v2_projection_messages - WHEN (old.streaming = 0 OR new.streaming = 0) - AND ( - old.streaming IS NOT new.streaming - OR old.role IS NOT new.role - OR json_extract(old.payload_json, '$.text') IS NOT json_extract(new.payload_json, '$.text') - OR json_extract(old.payload_json, '$.createdBy') - IS NOT json_extract(new.payload_json, '$.createdBy') - ) - BEGIN - DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid AND old.streaming = 0; - INSERT INTO orchestration_v2_message_search (rowid, text) - SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} - WHERE ${indexedMessageCondition(sql, "new")} - AND json_extract(new.payload_json, '$.text') IS NOT NULL; - END - `; - yield* sql` - CREATE TRIGGER orchestration_v2_message_search_delete - AFTER DELETE ON orchestration_v2_projection_messages - WHEN old.streaming = 0 - BEGIN - DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid; - END - `; + yield* createMessageSearchTriggers(sql); yield* sql` INSERT INTO orchestration_v2_message_search (rowid, text) SELECT messages.rowid, ${indexedMessageText(sql, "messages.payload_json")} diff --git a/apps/server/src/persistence/Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts b/apps/server/src/persistence/Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts new file mode 100644 index 0000000000000..6ec95d108f306 --- /dev/null +++ b/apps/server/src/persistence/Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { createMessageSearchTriggers } from "../../orchestration-v2/messageSearchIndex.ts"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const index = yield* sql` + SELECT 1 FROM sqlite_master WHERE name = 'orchestration_v2_message_search' + `; + if (index.length === 0) return; + + // Legacy placeholders stay indexed until rebuild; FTS deletes can overflow last_insert_rowid at commit. + yield* sql`DROP TRIGGER orchestration_v2_message_search_insert`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_update`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_delete`; + yield* createMessageSearchTriggers(sql); +}); diff --git a/apps/server/src/persistence/Migrations/075_SubagentDeliveryState.ts b/apps/server/src/persistence/Migrations/075_SubagentDeliveryState.ts new file mode 100644 index 0000000000000..4406911cc8a8c --- /dev/null +++ b/apps/server/src/persistence/Migrations/075_SubagentDeliveryState.ts @@ -0,0 +1,20 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Command reads skip settled subagents by delivery state. Read from payload_json, that parsed + // every stored prompt; an added column sits after the payload's overflow pages, so the index + // must cover the whole filter. + yield* sql`ALTER TABLE orchestration_v2_projection_subagents ADD COLUMN completion_delivery_state TEXT`; + yield* sql` + UPDATE orchestration_v2_projection_subagents + SET completion_delivery_state = json_extract(payload_json, '$.completionDelivery.state') + WHERE json_type(payload_json, '$.completionDelivery') IS NOT NULL + `; + yield* sql` + CREATE INDEX orchestration_v2_projection_subagents_settled_idx + ON orchestration_v2_projection_subagents(thread_id, status, completion_delivery_state, run_id) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index c08062c09d1ad..4fefa61ec010f 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -53,6 +53,8 @@ describe("V2 preview upgrade", () => { [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], [73, "ActiveTurnItemIndexes"], + [74, "ExcludeAttachmentPlaceholdersFromSearch"], + [75, "SubagentDeliveryState"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -149,6 +151,8 @@ describe("V2 preview upgrade", () => { [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], [73, "ActiveTurnItemIndexes"], + [74, "ExcludeAttachmentPlaceholdersFromSearch"], + [75, "SubagentDeliveryState"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 903e9a7010094..3dee90313a2b3 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -62,6 +62,17 @@ const layerProbeFailure = ( const LSOF_TEST_PORT = 43_123; +const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); + const ownedScanner = (processIds: ReadonlyArray = [1234]) => Effect.tap(PortScanner.PortDiscovery, (scanner) => scanner.registerTerminalProcesses({ @@ -174,10 +185,11 @@ effectIt.effect("preserves explicit loopback hosts and bounds wildcard rewrites" effectIt.effect("projects configured paths independently for simultaneous subscribers", () => { const docsUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; const adminUrl = `http://localhost:${LSOF_TEST_PORT}/admin`; + let ready = true; const fetchFn = ((input: Parameters[0]) => { const url = String(input); return Promise.resolve( - url === docsUrl || url === adminUrl + ready && (url === docsUrl || url === adminUrl) ? new Response("app", { headers: { "content-type": "text/html" } }) : new Response("not found", { status: 404 }), ); @@ -185,7 +197,7 @@ effectIt.effect("projects configured paths independently for simultaneous subscr const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const docsSnapshots: ReadonlyArray[] = []; const adminSnapshots: ReadonlyArray[] = []; yield* scanner.subscribe({ configuredUrls: [docsUrl], initialSnapshot: [] }, (servers) => @@ -198,6 +210,11 @@ effectIt.effect("projects configured paths independently for simultaneous subscr expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); + + ready = false; + yield* TestClock.adjust(Duration.seconds(15)); + expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); + expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); }).pipe(Effect.scoped, Effect.provide(layer)); }); @@ -289,28 +306,45 @@ effectIt.effect("uses the current configured fragment when readiness comes from }).pipe(Effect.provide(layer)); }); -effectIt.effect("falls back to HTTPS and does not follow redirects while probing", () => { - const redirects: Array = []; - const fetchFn = (async ( - input: Parameters[0], - init?: Parameters[1], - ) => { - redirects.push(init?.redirect); - if (String(input).startsWith("http:")) throw new TypeError("TLS listener"); - return new Response(null, { status: 302, headers: { location: "https://example.com" } }); - }) as typeof globalThis.fetch; - const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); +effectIt.effect( + "probes only a configured HTTPS URL, keeps it for an owned listener when the check fails, and does not follow redirects", + () => { + const configuredUrl = `https://localhost:${LSOF_TEST_PORT}/app`; + const requests: Array = []; + let respond = async () => + new Response(null, { status: 302, headers: { location: "https://example.com" } }); + const fetchFn = (async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + requests.push([String(input), init?.redirect]); + return respond(); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); - return Effect.gen(function* () { - const scanner = yield* ownedScanner(); - const servers = yield* scanner.scan(); - expect(servers).toHaveLength(1); - expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); - expect(redirects).toEqual(["manual", "manual"]); - }).pipe(Effect.provide(layer)); -}); + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + + respond = async () => new Response("unavailable", { status: 503 }); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); -effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { + respond = () => Promise.reject(new TypeError("fetch failed")); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + expect(requests).toEqual(Array.from({ length: 3 }, () => [configuredUrl, "manual"])); + }).pipe(Effect.provide(layer)); + }, +); + +effectIt.effect("discovers owned listeners without sending HTTP or TLS probes", () => { const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; let stdout = unowned; @@ -330,19 +364,19 @@ effectIt.effect("probes a port only while T3 terminals own every listener on it" stdout = owned; expect(yield* scanner.scan()).toHaveLength(1); - expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + expect(requests).toEqual([]); yield* scanner.unregisterTerminal({ threadId: "scanner-thread", terminalId: "scanner-terminal", }); expect(yield* scanner.scan()).toHaveLength(0); - expect(requests).toHaveLength(1); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }); effectIt.effect( - "excludes HTTP errors, non-navigation responses, and successful non-documents", + "excludes configured URLs answering with HTTP errors, non-navigation responses, or non-documents", () => { let pid = 1; let makeResponse = () => @@ -350,33 +384,35 @@ effectIt.effect( const fetchFn = ((_input: Parameters[0]) => Promise.resolve(makeResponse())) as typeof globalThis.fetch; const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/`; return Effect.gen(function* () { - const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); - expect(yield* scanner.scan()).toHaveLength(0); + const scanner = yield* PortScanner.PortDiscovery; + const scan = () => scanner.scan([configuredUrl]); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response("ready", { status: 200, headers: { "content-type": "text/plain" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 304, headers: { location: "/cached" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 204, headers: { "content-type": "text/html" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 302 }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => @@ -384,7 +420,7 @@ effectIt.effect( status: 200, headers: { "content-type": "application/xhtml+xml; charset=utf-8" }, }); - expect(yield* scanner.scan()).toHaveLength(1); + expect(yield* scan()).toHaveLength(1); }).pipe(Effect.provide(layer)); }, ); @@ -433,10 +469,11 @@ effectIt.effect( return target === undefined ? missing.readLink(path) : Effect.succeed(target); }, }); - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; const layer = layerProbeFailure( (input) => { lsofSpawns += 1; @@ -449,37 +486,45 @@ effectIt.effect( return Effect.gen(function* () { const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); - expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 8765, pid: 4242, processName: "python3" }, - ]); + expect( + first.map(({ port, pid, processName, url }) => ({ port, pid, processName, url })), + ).toEqual([{ port: 8765, pid: 4242, processName: "python3", url: "http://localhost:8765" }]); yield* scanner.scan(); expect(lsofSpawns).toBe(1); expect(fdWalks).toEqual(["77", "4242", "77", "4242"]); yield* scanner.scan(); yield* scanner.scan(); expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }, ); effectIt.effect("ignores a partial Windows netstat snapshot when one query exits nonzero", () => { - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; + let ipv6Fails = true; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; const layer = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( layerNoProc, Layer.succeed(ProcessRunner.ProcessRunner, { run: (input) => { + if (input.command === "tasklist.exe") { + return Effect.succeed( + processOutput('"node.exe","1234","Console","1","50,000 K"\r\n'), + ); + } const isIpv6 = input.args.includes("tcpv6"); return Effect.succeed({ stdout: isIpv6 ? "" : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n", stderr: "", - code: isIpv6 ? ChildProcessSpawner.ExitCode(1) : null, + code: isIpv6 && ipv6Fails ? ChildProcessSpawner.ExitCode(1) : null, timedOut: false, stdoutTruncated: false, stderrTruncated: false, @@ -502,20 +547,14 @@ effectIt.effect("ignores a partial Windows netstat snapshot when one query exits processIds: [1234], }); expect(yield* scanner.scan()).toEqual([]); + ipv6Fails = false; + expect(yield* scanner.scan()).toMatchObject([ + { port: 5173, pid: 1234, processName: "node", url: "http://localhost:5173" }, + ]); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }); -const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({ - stdout, - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, -}); - effectIt.effect("rechecks the socket owner before stopping a Linux server without lsof", () => { let holder = "4242"; const missing = FileSystem.makeNoop({}); @@ -569,6 +608,63 @@ effectIt.effect("rechecks the socket owner before stopping a Linux server withou }).pipe(Effect.provide(layer)); }); +effectIt.effect.each(["linux", "darwin", "win32"] as const)( + "reads no listeners on %s while T3CODE_PORT_DISCOVERY=0, and still probes configured URLs", + (platform) => { + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; + const commands: string[] = []; + const fileReads: string[] = []; + const requests: string[] = []; + const missing = FileSystem.makeNoop({}); + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + readFileString: (path) => { + fileReads.push(path); + return missing.readFileString(path); + }, + readDirectory: (path) => { + fileReads.push(path); + return missing.readDirectory(path); + }, + }), + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + commands.push(input.command); + return Effect.succeed(processOutput(`p1234\ncnode\nn*:${LSOF_TEST_PORT}\n`)); + }, + }), + Layer.succeed(HostProcess.Platform, platform), + Layer.succeed(HostProcess.Environment, { T3CODE_PORT_DISCOVERY: "0" }), + FetchHttpClient.layer.pipe( + Layer.provide( + Layer.succeed(FetchHttpClient.Fetch, (input) => { + requests.push(String(input)); + return Promise.resolve( + new Response("docs", { headers: { "content-type": "text/html" } }), + ); + }), + ), + ), + ), + ), + ); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + scanner.trackAgent(); + expect(yield* scanner.scan()).toEqual([]); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { port: LSOF_TEST_PORT, pid: null, terminal: null, url: configuredUrl }, + ]); + expect(requests).toEqual([configuredUrl]); + expect(commands).toEqual([]); + expect(fileReads).toEqual([]); + }).pipe(Effect.provide(layer)); + }, +); + effectIt.effect.each(["linux", "darwin"] as const)( "attributes listeners to the agent thread in their environment on %s, after the agent exits", (platform) => { @@ -672,7 +768,7 @@ effectIt.effect.each(["linux", "darwin"] as const)( expect(yield* scanner.scan()).toMatchObject([ { port: agentPort, pid: 4242, terminal: null, agent: { threadId: "thread-agent" } }, ]); - expect(requests).toEqual([`http://localhost:${agentPort}/`]); + expect(requests).toEqual([]); expect(yield* scanner.scan()).toHaveLength(1); expect(environmentReads).toBe(2); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 1080624d7508d..e026b204c03d8 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -68,6 +68,9 @@ export class PortDiscovery extends Context.Service< */ export const AGENT_THREAD_ENV = "T3CODE_THREAD_ID"; +/** `0` turns off listener discovery; configured preview URLs are still probed. */ +const PORT_DISCOVERY_ENV = "T3CODE_PORT_DISCOVERY"; + const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; const PROC_FD_WALK_LIMIT = 50_000; @@ -132,8 +135,8 @@ interface WebProbeCacheEntry { interface WebProbeGroup { readonly server: DiscoveredLocalServer; - readonly urls: ReadonlyArray; - readonly configuredKey: string | null; + readonly url: string; + readonly configuredKey: string; } interface WebProbeSnapshot { @@ -189,17 +192,22 @@ const projectWebProbeSnapshot = ( configuredUrls: ReadonlyArray, ): ReadonlyArray => { const visibleByServer = new Map(); + const firstConfiguredByServer = new Map(); for (const raw of normalizeConfiguredUrls(configuredUrls)) { const url = new URL(raw); const port = urlPort(url); const serverKey = localServerKey(url.hostname, port); + if (!firstConfiguredByServer.has(serverKey)) firstConfiguredByServer.set(serverKey, raw); if (visibleByServer.has(serverKey)) continue; const configured = snapshot.configured.get(webProbeCacheKey(raw)); if (configured) visibleByServer.set(serverKey, { ...configured, url: raw }); } + // An owned listener whose configured check failed still opens at this caller's configured URL. for (const server of snapshot.discovered) { const key = localServerKey(server.host, server.port); - if (!visibleByServer.has(key)) visibleByServer.set(key, server); + if (visibleByServer.has(key)) continue; + const configuredUrl = firstConfiguredByServer.get(key); + visibleByServer.set(key, configuredUrl ? { ...server, url: configuredUrl } : server); } return [...visibleByServer.values()].toSorted((left, right) => left.port - right.port); }; @@ -498,6 +506,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; + const listenerDiscovery = (yield* HostProcess.Environment)[PORT_DISCOVERY_ENV] !== "0"; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make({ listeners: new Map(), @@ -715,24 +724,17 @@ export const make = Effect.gen(function* PortDiscoveryMake() { pid: null, terminal: null, }, - urls: [raw], + url: raw, configuredKey: resourceKey, }); } - for (const server of servers) { - // Never probe an unidentified listener with HTTP or TLS; binary RPC servers can crash on it. - if (!isOwned(server)) continue; - groups.push({ - server, - urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], - configuredKey: null, - }); - } - return groups; }; + // Only configured URLs are requested. Owned listeners are listed as plain HTTP without a + // request: owning a process says nothing about its protocol, and HTTP or TLS bytes break + // binary ones such as Unity's test runner or Steam's IPC. const probeWebServers = Effect.fn("PortDiscovery.probeWebServers")(function* ( servers: ReadonlyArray, configuredUrls: ReadonlyArray, @@ -740,77 +742,37 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const nowMillis = yield* Clock.currentTimeMillis; const cached = yield* Ref.get(webProbeCacheRef); const groups = makeWebProbeGroups(servers, configuredUrls); - const batchProbes = new Map< - string, - Effect.Effect<{ readonly probe: WebProbeCacheEntry; readonly fresh: boolean }> - >(); - const batchProbeSemaphore = yield* Semaphore.make(1); - const getProbe = (url: string, pid: number | null) => { - const key = webProbeCacheKey(url); - const identity = `${key}\u0000${pid ?? ""}`; - return batchProbeSemaphore - .withPermits(1)( - Effect.gen(function* () { - const existing = batchProbes.get(identity); - if (existing) return [existing] as const; - const cachedProbe = cached.get(key); - const cachedIsCurrent = - cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis; - const memoized = yield* Effect.cached( - cachedIsCurrent - ? Effect.succeed({ probe: cachedProbe, fresh: false }) - : probeWebUrl(url).pipe( - Effect.map((result) => ({ - probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, - fresh: true, - })), - ), - ); - batchProbes.set(identity, memoized); - return [memoized] as const; - }), - ) - .pipe(Effect.flatMap(([probe]) => probe)); - }; const probed = yield* Effect.forEach( groups, - (group) => - Effect.gen(function* () { - const probes: Array = []; - let visibleUrl: string | null = null; - for (const url of group.urls) { - const key = webProbeCacheKey(url); - const { probe, fresh } = yield* getProbe(url, group.server.pid); - probes.push([key, probe, fresh]); - if (probe.isWeb) { - visibleUrl = url; - break; - } - } - return { group, probes, visibleUrl }; - }), + (group) => { + const { pid } = group.server; + const cachedProbe = cached.get(group.configuredKey); + return cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis + ? Effect.succeed({ group, probe: cachedProbe, fresh: false }) + : probeWebUrl(group.url).pipe( + Effect.map((result) => ({ + group, + probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, + fresh: true, + })), + ); + }, { concurrency: WEB_PROBE_CONCURRENCY }, ); const completedAtMillis = yield* Clock.currentTimeMillis; const nextCache = new Map( [...cached].filter(([, probe]) => probe.expiresAtMillis > completedAtMillis), ); - const discovered: DiscoveredLocalServer[] = []; const configured = new Map(); - for (const { group, probes, visibleUrl } of probed) { - for (const [key, probe, fresh] of probes) { - nextCache.set( - key, - fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, - ); - } - if (visibleUrl === null) continue; - const server = { ...group.server, url: visibleUrl }; - if (group.configuredKey === null) discovered.push(server); - else configured.set(group.configuredKey, server); + for (const { group, probe, fresh } of probed) { + nextCache.set( + group.configuredKey, + fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, + ); + if (probe.isWeb) configured.set(group.configuredKey, { ...group.server, url: group.url }); } yield* Ref.set(webProbeCacheRef, nextCache); - return { discovered, configured } satisfies WebProbeSnapshot; + return { discovered: servers.filter(isOwned), configured } satisfies WebProbeSnapshot; }); // tasklist costs 200-300ms per PID; query visible servers with bounded concurrency and cache names. @@ -873,6 +835,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* ( configuredUrls: ReadonlyArray, ) { + if (!listenerDiscovery) return yield* probeWebServers([], configuredUrls); const state = yield* Ref.get(stateRef); const terminalOwners = new Map(); for (const registration of state.terminalProcesses.values()) { diff --git a/apps/server/src/preview/PreviewBrowserHost.test.ts b/apps/server/src/preview/PreviewBrowserHost.test.ts index 2a90d000b262b..02ea6bedc4281 100644 --- a/apps/server/src/preview/PreviewBrowserHost.test.ts +++ b/apps/server/src/preview/PreviewBrowserHost.test.ts @@ -72,6 +72,36 @@ const diagnose = (input: { }; describe("diagnoseLaunchFailure", () => { + it.effect("explains how to restart a root server without Chrome's sandbox", () => + Effect.gen(function* () { + const { error, commands } = yield* diagnose({ + platform: "linux", + uid: 0, + output: PreviewBrowserHost.NO_SANDBOX_SIGNATURE, + ldd: "", + }); + expect(error).toBeInstanceOf(PreviewBrowserHost.PreviewBrowserRootError); + expect(error?.message).toContain("T3 server is running as root"); + expect(error?.message).toContain("T3 server's environment and restart the server"); + expect(error?.message).toContain("T3CODE_SERVER_BROWSER_SANDBOX=0 t3 serve"); + expect(error?.message).toContain("Environment=T3CODE_SERVER_BROWSER_SANDBOX=0"); + expect(error?.message).toContain("this disables Chrome's sandbox"); + expect(commands).toEqual([]); + }), + ); + + it.effect("keeps host setup guidance for a nonroot sandbox failure", () => + Effect.gen(function* () { + const { error } = yield* diagnose({ + platform: "linux", + output: PreviewBrowserHost.NO_SANDBOX_SIGNATURE, + ldd: "", + }); + expect(error).toBeInstanceOf(PreviewBrowserHost.PreviewBrowserSandboxError); + expect(error?.message).toContain("Run `sudo t3 browser setup` on the host"); + }), + ); + it.effect("names every library the loader cannot find", () => Effect.gen(function* () { const { error } = yield* diagnose({ diff --git a/apps/server/src/preview/PreviewBrowserHost.ts b/apps/server/src/preview/PreviewBrowserHost.ts index 896f51b308e9b..1847e5f232072 100644 --- a/apps/server/src/preview/PreviewBrowserHost.ts +++ b/apps/server/src/preview/PreviewBrowserHost.ts @@ -73,7 +73,7 @@ export class PreviewBrowserRootError extends Schema.TaggedError ({ } async connectDesktopPage(endpoint: string) { const context = makeContext(); + context.page.viewportSize.mockReturnValue(null); desktopConnections.push({ endpoint, context }); return { browser: { close: async () => {} }, page: context.page as unknown as Page }; } @@ -83,11 +87,16 @@ const PNG_HEADER = (() => { return header.toString("base64"); })(); -function makeSession(frame?: ReturnType) { +function makeSession( + frame: ReturnType | undefined, + viewport: { width: number; height: number }, +) { const events = new NodeEvents.EventEmitter(); return { - on: vi.fn((name: string, listener: () => void) => events.on(name, listener)), - emit: (name: string) => events.emit(name), + on: vi.fn((name: string, callback: (...args: unknown[]) => void) => events.on(name, callback)), + emit: (name: string, ...args: unknown[]) => events.emit(name, ...args), + emitAsync: (name: string, ...args: unknown[]) => + Promise.all(events.listeners(name).map((listener) => listener(...args))), detach: vi.fn(async () => {}), send: vi.fn(async (method: string, input?: unknown): Promise> => { if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] }; @@ -101,7 +110,12 @@ function makeSession(frame?: ReturnType) { if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; if (method === "Page.createIsolatedWorld") { return { - executionContextId: (input as { worldName?: string }).worldName === "t3-preview" ? 1 : 7, + executionContextId: + (input as { worldName?: string }).worldName === "t3-preview-viewport" + ? 9 + : (input as { worldName?: string }).worldName === "t3-preview" + ? 1 + : 7, }; } if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 1) { @@ -118,18 +132,33 @@ function makeSession(frame?: ReturnType) { else pageCopy = ""; return { result: { value } }; } + if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 9) + return desktopSizeAnswer?.() ?? { result: { value: { ...viewport } } }; return { result: { value: "evaluated" } }; }), }; } +function fileChooserElement(accept: string) { + return { + getAttribute: async () => accept, + evaluate: (probe: (element: { ownerDocument: unknown }) => boolean) => + Promise.resolve( + probe({ + ownerDocument: (globalThis as typeof globalThis & { document?: unknown }).document, + }), + ), + }; +} + function makeContext(onClose?: (context: BrowserContext) => void) { const events = new NodeEvents.EventEmitter(); const nativeFileChooser = vi.fn(); const setChooserFiles = vi.fn(async (_files: unknown) => {}); const sessions: ReturnType[] = []; let url = "about:blank"; - let viewport = { width: 1280, height: 800 }; + let viewport: ReturnType = { width: 1280, height: 800 }; + const cssViewport = { width: 1920, height: 1080 }; let closed = false; let contextClosed = false; const page = { @@ -143,7 +172,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { if ( !events.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => ".csv" }), + element: () => fileChooserElement(".csv"), setFiles: setChooserFiles, }) ) { @@ -159,7 +188,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { frames: () => [page], url: () => url, title: vi.fn(async () => "test page"), - viewportSize: () => viewport, + viewportSize: vi.fn(() => viewport), setViewportSize: vi.fn(async (size: typeof viewport) => { viewport = size; }), @@ -197,11 +226,13 @@ function makeContext(onClose?: (context: BrowserContext) => void) { const context = { page, sessions, + cssViewport, newPage: async () => page as unknown as Page, grantPermissions: vi.fn(async () => {}), newCDPSession: async (target: unknown) => { const session = makeSession( target === page ? undefined : (target as ReturnType), + cssViewport, ); sessions.push(session); return session; @@ -268,7 +299,11 @@ let focusedFrame: ReturnType | null = null; let contextFailure: Error | null = null; let initialNavigation: (() => Promise) | null = null; let desktopRendersNext = false; +/** Replaces the answer to the desktop page's size reads. */ +let desktopSizeAnswer: (() => Promise>) | null = null; const desktopDetaches = new NodeEvents.EventEmitter(); +let desktopDetachReady = Promise.withResolvers(); +let desktopReleased = Promise.withResolvers(); const desktopTabs = new Set(); const desktopRenders = (tabId: string) => { if (desktopRendersNext) { @@ -317,6 +352,7 @@ const dependencies = Layer.mergeAll( const onDetach = (key: { threadId: string; tabId: string }) => Queue.offerUnsafe(queue, key); desktopDetaches.on("detach", onDetach); + desktopDetachReady.resolve(); return onDetach; }), (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), @@ -336,7 +372,10 @@ const dependencies = Layer.mergeAll( isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => - Effect.sync(() => releasedDesktopTabs.push(key.tabId)), + Effect.sync(() => { + releasedDesktopTabs.push(key.tabId); + desktopReleased.resolve(); + }), ), pointer: () => Effect.void, turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), @@ -367,6 +406,33 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ maxHeight: 800, quality: 70, }); +/** Fills a viewer's output with frames; resolves once every one of them is acknowledged. */ +const stallViewer = (viewer: ServerBrowser.ServerBrowserViewer) => { + const output = viewer.output as unknown as Queue.Queue; + const acked = Promise.withResolvers(); + let unacked = 0; + const ack = Effect.sync(() => { + if (--unacked === 0) acked.resolve(); + }); + while (Queue.offerUnsafe(output, { _tag: "frame", data: Buffer.alloc(0), ack })) unacked++; + return Effect.promise(() => acked.promise); +}; +const viewportSizes = (items: ReadonlyArray) => + items.flatMap((item) => (item._tag === "viewport" ? [`${item.width}x${item.height}`] : [])); +const openDesktopViewer = Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + desktopRendersNext = true; + const { tabId } = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + const resize = (width: number, height: number) => { + Object.assign(context.cssViewport, { width, height }); + return Effect.promise(() => cdp.emitAsync("Page.frameResized")); + }; + return { tabId, viewer, context, cdp, resize }; +}); beforeEach(() => { contexts.length = 0; @@ -380,6 +446,9 @@ beforeEach(() => { focusedFrame = null; desktopTabs.clear(); desktopRendersNext = false; + desktopSizeAnswer = null; + desktopDetachReady = Promise.withResolvers(); + desktopReleased = Promise.withResolvers(); releasedDesktopTabs.length = 0; desktopConnections.length = 0; }); @@ -559,8 +628,9 @@ it.live.each([ Effect.scoped( Effect.gen(function* () { const { browser, broker, tabId } = yield* ready; + const reportProfiles = yield* browser.profileReporter; for (const recordingFrameRate of reports) { - yield* browser.reportProfiles({ + yield* reportProfiles({ profiles: [], defaultProfileId: "default", ...(recordingFrameRate === undefined ? {} : { recordingFrameRate }), @@ -919,6 +989,133 @@ it.live( ).pipe(Effect.provide(layer)), ); +it.effect.each([ + { + name: "hover burst before a click", + inputs: [ + ...Array.from({ length: 120 }, (_, x) => ({ + type: "mouse", + action: "move", + x, + buttons: 0, + button: "none", + })), + { type: "mouse", action: "down", x: 119, buttons: 1, button: "left" }, + { type: "mouse", action: "up", x: 119, buttons: 0, button: "left" }, + ], + expected: [ + { type: "mouseMoved", x: -1 }, + { type: "mouseMoved", x: 119 }, + { type: "mousePressed", x: 119 }, + { type: "mouseReleased", x: 119 }, + ], + }, + { + name: "modifier changes and drag points", + inputs: [ + { type: "mouse", action: "move", x: 1, buttons: 0, button: "none" }, + { type: "mouse", action: "move", x: 2, buttons: 0, button: "none", modifiers: 2 }, + { type: "mouse", action: "move", x: 3, buttons: 0, button: "none", modifiers: 2 }, + { type: "mouse", action: "down", x: 3, buttons: 1, button: "left" }, + { type: "mouse", action: "move", x: 4, buttons: 1, button: "left" }, + { type: "mouse", action: "move", x: 5, buttons: 1, button: "left" }, + { type: "mouse", action: "up", x: 5, buttons: 0, button: "left" }, + ], + expected: [ + { type: "mouseMoved", x: -1 }, + { type: "mouseMoved", x: 1, modifiers: 0 }, + { type: "mouseMoved", x: 3, modifiers: 2 }, + { type: "mousePressed", x: 3 }, + { type: "mouseMoved", x: 4 }, + { type: "mouseMoved", x: 5 }, + { type: "mouseReleased", x: 5 }, + ], + }, + { + name: "wheel and keyboard boundaries", + inputs: [ + { type: "mouse", action: "move", x: 1, buttons: 0, button: "none" }, + { type: "wheel", x: 1, deltaY: 20 }, + { type: "wheel", x: 1, deltaY: 30 }, + { type: "mouse", action: "move", x: 2, buttons: 0, button: "none" }, + { type: "key", action: "down", key: "a", code: "KeyA", text: "a" }, + { type: "mouse", action: "move", x: 3, buttons: 0, button: "none" }, + ], + expected: [ + { type: "mouseMoved", x: -1 }, + { type: "mouseMoved", x: 1 }, + { type: "mouseWheel", deltaY: 20 }, + { type: "mouseWheel", deltaY: 30 }, + { type: "mouseMoved", x: 2 }, + { type: "keyDown", key: "a" }, + { type: "mouseMoved", x: 3 }, + ], + }, + { + name: "text, navigation and resize boundaries", + inputs: [ + { type: "mouse", action: "move", x: 1, buttons: 0, button: "none" }, + { type: "text", text: "typed" }, + { type: "mouse", action: "move", x: 2, buttons: 0, button: "none" }, + { type: "navigate", url: "http://localhost:5173/next" }, + { type: "mouse", action: "move", x: 3, buttons: 0, button: "none" }, + { type: "resize", width: 640, height: 480 }, + { type: "mouse", action: "move", x: 4, buttons: 0, button: "none" }, + ], + expected: [ + { type: "mouseMoved", x: -1 }, + { type: "mouseMoved", x: 1 }, + { text: "typed" }, + { type: "mouseMoved", x: 2 }, + { type: "mouseMoved", x: 3 }, + { type: "mouseMoved", x: 4 }, + ], + }, + { + name: "ambiguous button fields", + inputs: [ + { type: "mouse", action: "move", x: 1, button: "none" }, + { type: "mouse", action: "move", x: 2, button: "none" }, + { type: "mouse", action: "move", x: 3, buttons: 0 }, + { type: "mouse", action: "move", x: 4, buttons: 0 }, + ], + expected: [-1, 1, 2, 3, 4].map((x) => ({ type: "mouseMoved", x })), + }, +])("orders streamed input correctly for $name", (testCase) => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const session = contexts[0]!.sessions.at(-1)!; + const send = session.send.getMockImplementation()!; + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + yield* Effect.addFinalizer(() => Effect.sync(() => finish.resolve())); + session.send.mockClear(); + session.send.mockImplementationOnce(async (method, input) => { + started.resolve(); + await finish.promise; + return send(method, input); + }); + const running = yield* viewer + .input({ type: "mouse", action: "move", x: -1, button: "none", buttons: 0 }) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Effect.promise(() => started.promise); + const waiting = yield* Effect.forEach(testCase.inputs, (input) => + viewer.input(input).pipe(Effect.forkScoped({ startImmediately: true })), + ); + finish.resolve(); + yield* Effect.forEach([running, ...waiting], Fiber.join); + const dispatched = session.send.mock.calls.flatMap(([method, input]) => + method.startsWith("Input.") ? [input] : [], + ); + expect(dispatched).toHaveLength(testCase.expected.length); + expect(dispatched).toMatchObject(testCase.expected); + }), + ).pipe(Effect.provide(layer)), +); + it.live("only the controlling viewer's copy and cut keys reach its clipboard", () => Effect.scoped( Effect.gen(function* () { @@ -1080,6 +1277,27 @@ it.live.each(["save failed", "oversized"])( ).pipe(Effect.provide(layer)), ); +it.live("a restarted server removes the previous run's downloads and keeps its profiles", () => + Effect.scoped( + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const previousTab = path.join(stateDir, "server-browser", "downloads", "previous-tab"); + const profile = path.join(stateDir, "server-browser", "profiles", "default"); + yield* fs.makeDirectory(previousTab, { recursive: true }); + yield* fs.makeDirectory(profile, { recursive: true }); + yield* fs.writeFileString(path.join(previousTab, "report.csv"), "a,b"); + yield* fs.writeFileString(path.join(profile, "Preferences"), "{}"); + + yield* Layer.build(ServerBrowser.layer); + + expect(yield* fs.exists(previousTab)).toBe(false); + expect(yield* fs.readFileString(path.join(profile, "Preferences"))).toBe("{}"); + }), + ).pipe(Effect.provide(dependencies)), +); + it.live("a page's file picker goes to the controller and takes its uploaded files", () => Effect.scoped( Effect.gen(function* () { @@ -1090,7 +1308,7 @@ it.live("a page's file picker goes to the controller and takes its uploaded file const setFiles = vi.fn(async (_files: unknown) => {}); page.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => ".csv" }), + element: () => fileChooserElement(".csv"), setFiles, }); let offered = yield* Queue.take(viewer.output); @@ -1115,6 +1333,253 @@ it.live("a page's file picker goes to the controller and takes its uploaded file ).pipe(Effect.provide(layer)), ); +it.live("overlapping file pickers keep the newer offer answerable", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const accept = Promise.withResolvers(); + const firstFiles = vi.fn(async (_files: unknown) => {}); + const secondFiles = vi.fn(async (_files: unknown) => {}); + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ ...fileChooserElement(""), getAttribute: () => accept.promise }), + setFiles: firstFiles, + }); + page.emit("filechooser", { + isMultiple: () => false, + element: () => fileChooserElement(".csv"), + setFiles: secondFiles, + }); + accept.resolve(".txt"); + let first = yield* Queue.take(viewer.output); + while (first._tag !== "fileChooser") first = yield* Queue.take(viewer.output); + let second = yield* Queue.take(viewer.output); + while (second._tag !== "fileChooser") second = yield* Queue.take(viewer.output); + expect(second.accept).toBe(".csv"); + expect( + yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: second.id, + files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], + }), + ).toBe(true); + expect(firstFiles).not.toHaveBeenCalled(); + expect(secondFiles).toHaveBeenCalledOnce(); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a detached file picker stays open across same-document navigation", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const pageDocument = {}; + vi.stubGlobal("document", pageDocument); + yield* Effect.addFinalizer(() => Effect.sync(() => vi.unstubAllGlobals())); + const detachedInput = { isConnected: false, ownerDocument: pageDocument }; + const navigationProbe = Promise.withResolvers(); + const navigationProbeStarted = Promise.withResolvers(); + const navigationProbeFinished = Promise.withResolvers(); + let probeCount = 0; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: async () => "", + evaluate: (probe: (element: typeof detachedInput) => boolean) => { + probeCount++; + if (probeCount === 1) return Promise.resolve(probe(detachedInput)); + navigationProbeStarted.resolve(); + return navigationProbe.promise.then((current) => { + const result = probe(detachedInput); + navigationProbeFinished.resolve(); + return current && result; + }); + }, + }), + setFiles: vi.fn(async (_files: unknown) => {}), + }); + let offered = yield* Queue.take(viewer.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); + + page.emit("framenavigated", page); + yield* Effect.promise(() => navigationProbeStarted.promise); + navigationProbe.resolve(true); + yield* Effect.promise(() => navigationProbeFinished.promise); + yield* viewer.input({ type: "releaseControl" }); + let closedAfterNavigation = false; + let control = yield* Queue.take(viewer.output); + while (control._tag !== "control" || control.controller === "you") { + if (control._tag === "fileChooserClosed") closedAfterNavigation = true; + control = yield* Queue.take(viewer.output); + } + expect(closedAfterNavigation).toBe(false); + yield* viewer.input({ type: "takeControl" }); + let restoredControl = yield* Queue.take(viewer.output); + while (restoredControl._tag !== "control" || restoredControl.controller !== "you") { + restoredControl = yield* Queue.take(viewer.output); + } + + expect( + yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: offered.id, + files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], + }), + ).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + +it.live.each(["main", "iframe"])( + "a file picker closes at %s document commit before DOMContentLoaded", + (frame) => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const setFiles = vi.fn(async (_files: unknown) => {}); + const probeStarted = Promise.withResolvers(); + const probeSettled = Promise.withResolvers(); + let probeCount = 0; + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: async () => "", + evaluate: () => { + probeCount++; + if (probeCount === 1) return Promise.resolve(true); + probeStarted.resolve(); + return Promise.reject(new Error("Execution context was destroyed")).finally(() => + probeSettled.resolve(), + ); + }, + }), + setFiles, + }); + let offered = yield* Queue.take(viewer.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); + + page.emit("framenavigated", frame === "main" ? page : makeFrame()); + yield* Effect.promise(() => probeStarted.promise); + yield* Effect.promise(() => probeSettled.promise); + let closed = yield* Queue.take(viewer.output); + while (closed._tag !== "fileChooserClosed") closed = yield* Queue.take(viewer.output); + expect(closed.id).toBe(offered.id); + expect( + yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: offered.id, + files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], + }), + ).toBe(false); + expect(setFiles).not.toHaveBeenCalled(); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a file picker closes when its page is replaced while control changes hands", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const first = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* first.input({ type: "takeControl" }); + const current = Promise.withResolvers(); + const probeStarted = Promise.withResolvers(); + let probeCount = 0; + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: async () => "", + evaluate: () => { + probeCount++; + if (probeCount === 1) return Promise.resolve(true); + probeStarted.resolve(); + return current.promise; + }, + }), + setFiles: vi.fn(async (_files: unknown) => {}), + }); + let offered = yield* Queue.take(first.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(first.output); + + page.emit("framenavigated", page); + yield* Effect.promise(() => probeStarted.promise); + const second = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* first.input({ type: "releaseControl" }); + yield* second.input({ type: "takeControl" }); + let reoffered = yield* Queue.take(second.output); + while (reoffered._tag !== "fileChooser") reoffered = yield* Queue.take(second.output); + current.resolve(false); + let closed = yield* Queue.take(second.output); + while (closed._tag !== "fileChooserClosed") closed = yield* Queue.take(second.output); + expect(closed.id).toBe(reoffered.id); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.fileChooser).toBeNull(); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a file picker is not offered after its document changes while reading accept", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const accept = Promise.withResolvers(); + const acceptRead = Promise.withResolvers(); + const current = Promise.withResolvers(); + const probeStarted = Promise.withResolvers(); + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: () => { + acceptRead.resolve(); + return accept.promise; + }, + evaluate: () => { + probeStarted.resolve(); + return current.promise; + }, + }), + setFiles: vi.fn(async (_files: unknown) => {}), + }); + + yield* Effect.promise(() => acceptRead.promise); + page.emit("framenavigated", page); + accept.resolve(""); + yield* Effect.promise(() => accept.promise); + yield* Effect.promise(() => probeStarted.promise); + current.resolve(false); + yield* Effect.promise(() => current.promise); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.fileChooser).toBeNull(); + }), + ).pipe(Effect.provide(layer)), +); + it.live("only the current controller can answer a file picker", () => Effect.scoped( Effect.gen(function* () { @@ -1124,7 +1589,7 @@ it.live("only the current controller can answer a file picker", () => const setFiles = vi.fn(async (_files: unknown) => {}); contexts[0]!.page.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => "" }), + element: () => fileChooserElement(""), setFiles, }); let offered = yield* Queue.take(first.output); @@ -1144,6 +1609,148 @@ it.live("only the current controller can answer a file picker", () => ).pipe(Effect.provide(layer)), ); +it.effect( + "desktop viewers get the page's CSS size and keep the last one a read cannot measure", + () => + Effect.scoped( + Effect.gen(function* () { + // Each page size read waits here for its answer. + const reads = yield* Queue.unbounded>>(); + const deferReads = () => { + desktopSizeAnswer = () => { + const read = Promise.withResolvers>(); + Queue.offerUnsafe(reads, read); + return read.promise; + }; + }; + deferReads(); + const { viewer, cdp, context } = yield* openDesktopViewer; + const answer = (width: number, height: number) => ({ + result: { value: { width, height } }, + }); + const resized = () => cdp.emitAsync("Page.frameResized"); + // The read on attach never answers and gives up with no size to send. + const blocked = yield* Queue.take(reads); + yield* TestClock.adjust("1 second"); + // The next page load measures again. + desktopSizeAnswer = null; + yield* Effect.promise(() => cdp.emitAsync("Page.loadEventFired")); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual(["1920x1080"]); + // While a dialog is open nothing reads. The host closing it clears it, and since + // closing resizes nothing, the close itself remeasures. + deferReads(); + context.page.emit("dialog", { + type: () => "alert", + message: () => "", + defaultValue: () => "", + }); + yield* Effect.promise(resized); + expect(yield* Queue.size(reads)).toBe(0); + desktopSizeAnswer = null; + yield* Effect.promise(() => cdp.emitAsync("Page.javascriptDialogClosed")); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1920x1080", + "1920x1080", + ]); + // The late answer, a read a navigation breaks and a hidden webview keep the last size. + blocked.resolve(answer(100, 100)); + deferReads(); + const navigated = resized(); + (yield* Queue.take(reads)).reject(new Error("Cannot find context with specified id")); + yield* Effect.promise(() => navigated); + const hidden = resized(); + (yield* Queue.take(reads)).resolve(answer(0, 0)); + yield* Effect.promise(() => hidden); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1920x1080", + "1920x1080", + ]); + // Resizes during a read share one more read, so the newest size arrives last. + const first = resized(); + const firstRead = yield* Queue.take(reads); + const burst = Promise.all([resized(), resized()]); + firstRead.resolve(answer(800, 500)); + (yield* Queue.take(reads)).resolve(answer(1000, 600)); + expect(yield* Queue.size(reads)).toBe(0); + yield* Effect.promise(() => Promise.all([first, burst])); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "800x500", + "1000x600", + "1000x600", + ]); + }), + ).pipe(Effect.provide(layer)), +); + +it.effect("a stalled desktop viewer still gets its size, control and reconnect", () => + Effect.scoped( + Effect.gen(function* () { + const { tabId, viewer, context, resize } = yield* openDesktopViewer; + const output = + viewer.output as unknown as Queue.Queue; + // Ready once the sizes measured on attach and on a first resize both arrived. + yield* resize(1920, 1080); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1920x1080", + "1920x1080", + ]); + yield* Effect.promise(() => desktopDetachReady.promise); + yield* viewer.input({ type: "takeControl" }); + context.page.emit("filechooser", { + isMultiple: () => false, + element: () => fileChooserElement(".csv"), + setFiles: vi.fn(async () => {}), + }); + let chooser = yield* Queue.take(viewer.output); + while (chooser._tag !== "fileChooser") chooser = yield* Queue.take(viewer.output); + yield* Queue.clear(viewer.output); + // A resize compacts a full backlog but keeps control, the open picker and + // one-time notifications in order. + yield* viewer.input({ type: "takeControl" }); + const notifications: Array = [ + { _tag: "fileChooserClosed", id: "chooser-0" }, + { _tag: "popup", tabId: "popup-1" }, + { _tag: "download", id: "download-1", fileName: "report.csv", sizeBytes: 3 }, + { _tag: "clipboard", text: "copied" }, + ]; + for (const item of notifications) Queue.offerUnsafe(output, item); + let acked = stallViewer(viewer); + yield* resize(1000, 600); + expect(yield* Queue.takeAll(viewer.output)).toEqual([ + chooser, + ...notifications, + expect.objectContaining({ _tag: "control", controller: "you" }), + { _tag: "viewport", width: 1000, height: 600 }, + ]); + yield* acked; + // A control change keeps only the newest size. + yield* resize(900, 500); + yield* resize(1200, 700); + acked = stallViewer(viewer); + yield* viewer.input({ type: "releaseControl" }); + expect(yield* Queue.takeAll(viewer.output)).toEqual([ + expect.objectContaining({ _tag: "control", controller: "unclaimed" }), + { _tag: "viewport", width: 1200, height: 700 }, + ]); + yield* acked; + // Notifications that no longer fit go oldest first, never the size. + let copies = 0; + while (Queue.offerUnsafe(output, { _tag: "clipboard", text: `copy ${copies}` })) copies++; + yield* resize(1000, 600); + const compacted = yield* Queue.takeAll(viewer.output); + expect(compacted.at(0)).toEqual({ _tag: "clipboard", text: "copy 1" }); + expect(compacted.at(-1)).toEqual({ _tag: "viewport", width: 1000, height: 600 }); + // The desktop takes the page back; a resize after it cannot bury the reconnect. + acked = stallViewer(viewer); + desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId }); + yield* Effect.promise(() => desktopReleased.promise); + yield* resize(1000, 600); + expect(yield* Queue.takeAll(viewer.output)).toEqual([{ _tag: "reconnect" }]); + yield* acked; + }), + ).pipe(Effect.provide(layer)), +); + it.live("agents read a human's tab with no arguments and act on it only while nobody drives", () => Effect.scoped( Effect.gen(function* () { @@ -1256,7 +1863,8 @@ it.live( const broker = yield* Broker.PreviewAutomationBroker; const manager = yield* Manager.PreviewManager; yield* Effect.yieldNow; - yield* browser.reportProfiles({ + const reportProfiles = yield* browser.profileReporter; + yield* reportProfiles({ profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }], defaultProfileId: "profile-work", }); @@ -1301,3 +1909,207 @@ it.live( }), ).pipe(Effect.provide(layer)), ); + +it.live("profiles reported by one client survive another client's report", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker } = yield* ready; + contextRequests.length = 0; + const work = { id: "profile-work", name: "Work", kind: "persistent" } as const; + const shared = { id: "profile-shared", name: "Shared", kind: "persistent" } as const; + const openWork = () => + broker.invoke({ + scope, + operation: "open", + input: { reuseExistingTab: false, show: false, profileId: work.id }, + }); + const reportFromA = yield* browser.profileReporter; + const clientB = yield* Scope.make(); + const reportFromB = yield* browser.profileReporter.pipe( + Effect.provideService(Scope.Scope, clientB), + ); + yield* reportFromA({ profiles: [work, shared], defaultProfileId: work.id }); + yield* reportFromB({ profiles: [shared], defaultProfileId: shared.id }); + expect(yield* openWork()).toMatchObject({ + defaultProfileId: shared.id, + profiles: [ + { id: "default", name: "Default" }, + { id: "incognito", name: "Incognito", incognito: true }, + { id: work.id, name: work.name }, + { id: shared.id, name: shared.name }, + ], + }); + expect(contextRequests).toEqual([{ profileId: work.id, isolated: false }]); + yield* Scope.close(clientB, Exit.void); + expect(yield* openWork()).toMatchObject({ defaultProfileId: work.id }); + yield* reportFromA({ profiles: [shared], defaultProfileId: shared.id }); + const removed = yield* openWork().pipe(Effect.flip); + expect(removed).toMatchObject({ + _tag: "PreviewAutomationExecutionError", + reason: + 'No browser profile is named "profile-work". Use one of: Default (id default), Incognito (id incognito), Shared (id profile-shared).', + }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live.each([ + { budget: "agent", limit: 8 }, + { budget: "server", limit: 32 }, +])("opens and popups reserve the $budget budget before session creation", ({ budget, limit }) => + Effect.scoped( + Effect.gen(function* () { + const { broker, browser, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const open = (target = scope) => + broker.invoke({ + scope: target, + operation: "open", + input: { reuseExistingTab: false, show: false }, + }); + for (let index = 1; index < limit - 3; index++) { + yield* open(budget === "agent" ? scope : asSession(`other-${Math.floor(index / 8)}`)); + } + + const sessionGate = Promise.withResolvers(); + const launchGate = (contextGate = Promise.withResolvers()); + const sessionStarted = Promise.withResolvers(); + const realOpen = manager.open; + const delayedOpen = vi.spyOn(manager, "open").mockImplementation((input) => + Effect.promise(() => { + sessionStarted.resolve(); + return sessionGate.promise; + }).pipe(Effect.andThen(realOpen(input))), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + sessionGate.resolve(); + launchGate.resolve(); + delayedOpen.mockRestore(); + }), + ); + const events = yield* manager.subscribeEvents; + const first = yield* open().pipe(Effect.result, Effect.forkScoped); + yield* Effect.promise(() => sessionStarted.promise); + const popup = makeContext(); + const resized = Promise.withResolvers(); + const setViewportSize = popup.page.setViewportSize; + popup.page.setViewportSize = vi.fn(async (size) => { + await setViewportSize(size); + if (size?.width === 900) resized.resolve(); + }); + const newCDPSession = popup.newCDPSession; + popup.newCDPSession = async (target) => { + await launchGate.promise; + return newCDPSession(target); + }; + contexts[0]!.page.emit("popup", popup.page); + const last = yield* open().pipe(Effect.result, Effect.forkScoped); + const overflow = yield* open().pipe(Effect.result, Effect.forkScoped); + // The host processes this after the opens, while manager creation is blocked. + const reused = yield* broker.invoke({ + scope, + tabId, + operation: "open", + input: { show: false }, + }); + expect(reused.tabId).toBe(tabId); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength( + limit - 3, + ); + + sessionGate.resolve(); + for (let count = 0; count < 3; count++) { + let event = yield* PubSub.take(events); + while (event.type !== "opened") event = yield* PubSub.take(events); + yield* manager.resize({ + threadId: scope.thread.threadId, + tabId: event.tabId, + viewport: { _tag: "freeform", width: 900, height: 700 }, + }); + } + // The same slots must stay reserved after publication, while pages are still opening. + const duringLaunch = yield* open().pipe(Effect.result, Effect.forkScoped); + const rejectedPopup = makeContext(); + contexts[0]!.page.emit("popup", rejectedPopup.page); + yield* broker.invoke({ scope, tabId, operation: "status", input: {} }); + launchGate.resolve(); + + expect((yield* Fiber.join(first))._tag).toBe("Success"); + expect((yield* Fiber.join(last))._tag).toBe("Success"); + for (const fiber of [overflow, duringLaunch]) { + expect(yield* Fiber.join(fiber)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "PreviewAutomationControlInterruptedError", reason: "tabLimit" }, + }); + } + yield* Effect.promise(() => resized.promise); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + for (const session of sessions) + yield* browser.attachViewer(viewerInput(session.tabId, false)); + expect(sessions).toHaveLength(limit); + expect(popup.page.isClosed()).toBe(false); + expect(rejectedPopup.page.isClosed()).toBe(true); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.tabs).toHaveLength(limit); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("failed session and browser launches release capacity, as does closing a tab", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const open = broker.invoke({ + scope, + operation: "open", + input: { reuseExistingTab: false, show: false }, + }); + for (let count = 1; count < 7; count++) yield* open; + const failSession = vi + .spyOn(manager, "open") + .mockImplementationOnce(() => Effect.die(new Error("session creation failed"))); + yield* Effect.addFinalizer(() => Effect.sync(() => failSession.mockRestore())); + expect(yield* open.pipe(Effect.result)).toMatchObject({ _tag: "Failure" }); + failSession.mockRestore(); + const events = yield* manager.subscribeEvents; + contextFailure = new Error("Chromium launch failed"); + expect(yield* open.pipe(Effect.result)).toMatchObject({ _tag: "Failure" }); + let failedSession = yield* PubSub.take(events); + while (failedSession.type !== "opened") failedSession = yield* PubSub.take(events); + // A failed launch must not leave a session a viewer can relaunch past the budget. + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(7); + contextFailure = null; + expect((yield* open).available).toBe(true); + expect(yield* open.pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect( + yield* broker + .invoke({ + scope, + tabId: PreviewTabId.make(failedSession.tabId), + operation: "open", + input: { show: false }, + }) + .pipe(Effect.flip), + ).toMatchObject({ _tag: "PreviewAutomationTabNotFoundError" }); + const browser = yield* ServerBrowser.ServerBrowser; + expect( + yield* browser.attachViewer(viewerInput(failedSession.tabId, false)).pipe(Effect.flip), + ).toMatchObject({ _tag: "ServerBrowserTabNotFoundError" }); + yield* broker.invoke({ scope, tabId, operation: "close", input: {} }); + expect((yield* open).available).toBe(true); + const status = yield* broker.invoke({ + scope, + operation: "status", + input: {}, + }); + expect(status.tabs).toHaveLength(8); + }), + ).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 6f3e86a7501ea..84299d3e9246a 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -102,6 +102,8 @@ const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } a const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; +/** A dialog blocks page script, so a desktop size read gives up after this. */ +const DESKTOP_VIEWPORT_READ_TIMEOUT = "1 second"; const sleepUntil = (deadline: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); @@ -208,6 +210,8 @@ export interface ServerBrowserViewer { readonly input: (message: unknown) => Effect.Effect; } +export type ProfileReporter = (input: PreviewReportProfilesInput) => Effect.Effect; + export class ServerBrowser extends Context.Service< ServerBrowser, { @@ -242,7 +246,7 @@ export class ServerBrowser extends Context.Service< readonly clearProfile: (profileId: string) => Effect.Effect; - readonly reportProfiles: (input: PreviewReportProfilesInput) => Effect.Effect; + readonly profileReporter: Effect.Effect; } >()("t3/preview/ServerBrowser") {} @@ -309,6 +313,11 @@ interface ServerTab { setting: PreviewViewportSetting; colorScheme: PreviewAppearancePreference; zoomFactor: number; + /** Last usable CSS size measured on the desktop. */ + desktopViewport: { readonly width: number; readonly height: number } | null; + desktopViewportRead: Promise; + /** A read queued behind `desktopViewportRead`, shared by every caller that waits for it. */ + desktopViewportNext: Promise | null; loading: boolean; closing: boolean; recording: Recording | null; @@ -465,7 +474,10 @@ const make = Effect.gen(function* () { const launchersDir = NodePath.join(config.stateDir, "server-browser", "launchers"); const tabs = new Map(); - const pendingTabs = new Map>(); + const pendingTabs = new Map< + string, + { readonly agentId: string | null; promise?: Promise } + >(); const closedPendingTabs = new Set(); const adoptedPages = new Map(); @@ -605,9 +617,48 @@ const make = Effect.gen(function* () { ); }; - const layoutSize = (tab: ServerTab) => { + // Desktop pages have no Playwright viewport; an isolated world keeps page script + // from overriding the CSS size, and failed reads preserve the last usable size. + const readDesktopViewport = async (tab: ServerTab) => { + const readSize = async () => { + const { frameTree } = await tab.cdp.send("Page.getFrameTree"); + const { executionContextId } = await tab.cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "t3-preview-viewport", + }); + const { result } = await tab.cdp.send("Runtime.evaluate", { + expression: "({ width: innerWidth, height: innerHeight })", + contextId: executionContextId, + returnByValue: true, + }); + return asRecord(result.value); + }; + const size = await runPromise( + // A navigation can destroy the isolated world mid-read. + Effect.promise(() => readSize().catch(() => null)).pipe( + Effect.timeoutOption(DESKTOP_VIEWPORT_READ_TIMEOUT), + Effect.map(Option.getOrNull), + ), + ); + const width = size?.width; + const height = size?.height; + if (typeof width === "number" && width > 0 && typeof height === "number" && height > 0) + tab.desktopViewport = { width, height }; + return tab.desktopViewport; + }; + + // Reads run one at a time, and callers that arrive during a read share the next one. + const measureDesktopViewport = (tab: ServerTab) => + (tab.desktopViewportNext ??= tab.desktopViewportRead.then(() => { + tab.desktopViewportNext = null; + return (tab.desktopViewportRead = readDesktopViewport(tab)); + })); + + const layoutSize = async (tab: ServerTab) => { + // A dialog blocks page script; its close measures again. + if (tab.desktop) return tab.dialog ? tab.desktopViewport : measureDesktopViewport(tab); const size = tab.page.viewportSize(); - if (!size || tab.desktop) return size; + if (!size) return size; return { width: Math.max(1, Math.round(size.width / tab.zoomFactor)), height: Math.max(1, Math.round(size.height / tab.zoomFactor)), @@ -622,7 +673,7 @@ const make = Effect.gen(function* () { await tab.page.setViewportSize(size); return; } - const layout = layoutSize(tab)!; + const layout = (await layoutSize(tab))!; await tab.cdp.send("Emulation.setDeviceMetricsOverride", { width: layout.width, height: layout.height, @@ -631,9 +682,9 @@ const make = Effect.gen(function* () { }); }; - const broadcastViewport = (tab: ServerTab) => { - const size = layoutSize(tab); - if (!size) return; + const broadcastViewport = async (tab: ServerTab) => { + const size = await layoutSize(tab); + if (!size || tab.closing) return; for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); }; @@ -650,7 +701,7 @@ const make = Effect.gen(function* () { UNATTACHED_FILL_VIEWPORT; await tab.page.setViewportSize({ width: size.width, height: size.height }); await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); }; const applyRendering = async (tab: ServerTab, snapshot: PreviewSessionSnapshot) => { @@ -663,7 +714,7 @@ const make = Effect.gen(function* () { if (zoomFactor !== tab.zoomFactor && !tab.desktop) { tab.zoomFactor = zoomFactor; await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); } }; @@ -763,6 +814,7 @@ const make = Effect.gen(function* () { const control = new SessionControl(snapshot.automationOwner ?? null, () => ServerBrowserPage.invalidateRefs(page), ); + let pendingFileChooserOffer = Promise.resolve(); const tab: ServerTab = { threadId: ThreadId.make(snapshot.threadId), tabId: snapshot.tabId, @@ -778,7 +830,11 @@ const make = Effect.gen(function* () { control, agentOperating: false, fileChooserIntercepted: false, - onFileChooser: (chooser) => void offerFileChooser(tab, chooser), + onFileChooser: (chooser) => { + pendingFileChooserOffer = pendingFileChooserOffer + .then(() => offerFileChooser(tab, chooser)) + .catch(constVoid); + }, isolatedContext, desktop: desktop === null ? null : { close: desktop.close }, profileId: snapshot.profileId, @@ -789,6 +845,9 @@ const make = Effect.gen(function* () { setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, colorScheme: "system", zoomFactor: 1, + desktopViewport: null, + desktopViewportRead: Promise.resolve(null), + desktopViewportNext: null, loading: false, closing: false, recording: null, @@ -803,6 +862,21 @@ const make = Effect.gen(function* () { await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); } await applyRendering(tab, snapshot); + if (desktop) { + // The desktop resizes and zooms its webview itself; viewers map input with the new size. + const remeasure = () => { + if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); + }; + cdp.on("Page.frameResized", remeasure); + // A read a navigation broke has a fresh page to measure. + cdp.on("Page.loadEventFired", remeasure); + // Closing a dialog resizes nothing, and the host can close it without us. + cdp.on("Page.javascriptDialogClosed", () => { + tab.dialog = null; + return remeasure(); + }); + await cdp.send("Page.enable"); + } const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => request.isNavigationRequest() && request.frame() === page.mainFrame(); const navigationGenerations = new WeakMap(); @@ -822,6 +896,12 @@ const make = Effect.gen(function* () { void reportLoaded(tab); }); page.on("framenavigated", (frame) => { + const open = tab.fileChooser; + if (open) { + void isFileChooserCurrent(open.chooser).then((current) => { + if (!current && tab.fileChooser?.chooser === open.chooser) closeFileChooser(tab); + }); + } // Same-document navigations (SPA routes) fire no load event. if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab); if (!desktop) void recordFrameCopies(tab, frame); @@ -926,6 +1006,12 @@ const make = Effect.gen(function* () { }; const downloadsRoot = NodePath.join(config.stateDir, "server-browser", "downloads"); + // Saved downloads are indexed in memory, so an earlier run's files can never be opened again. + yield* Effect.tryPromise(() => NodeFSP.rm(downloadsRoot, { recursive: true, force: true })).pipe( + Effect.catch((cause) => + Effect.logWarning("server preview could not clear earlier downloads", { cause }), + ), + ); // Tab ids are opaque server strings; hashing keeps them out of the path. const downloadDir = (tab: ServerTab) => NodePath.join( @@ -1025,6 +1111,17 @@ const make = Effect.gen(function* () { } : null; + // Not isConnected: upload helpers often click an input never added to the page. The handle's + // context is destroyed with its document, so a replaced document rejects. + const isFileChooserCurrent = (chooser: FileChooser) => + chooser + .element() + .evaluate( + (element) => + element.ownerDocument === (globalThis as unknown as { document: unknown }).document, + ) + .catch(() => false); + const offerFileChooser = async (tab: ServerTab, chooser: FileChooser) => { const previous = tab.fileChooser; const accept = @@ -1032,6 +1129,7 @@ const make = Effect.gen(function* () { .element() .getAttribute("accept") .catch(() => null)) ?? ""; + if (!(await isFileChooserCurrent(chooser))) return; if (previous) closeFileChooser(tab); tab.fileChooser = { id: NodeCrypto.randomUUID(), @@ -1197,8 +1295,7 @@ const make = Effect.gen(function* () { }; const adoptPopup = async (opener: ServerTab, popup: Page) => { - const agentId = opener.control.agentId; - if (opener.closing || (agentId !== null && atTabLimit(agentId))) { + if (opener.closing) { await popup.close().catch(constVoid); return; } @@ -1208,7 +1305,7 @@ const make = Effect.gen(function* () { (viewer) => viewer.id === opener.control.controller, ); await runPromise( - manager.open({ + openTab({ threadId: opener.threadId, ...(/^https?:/i.test(url) ? { url } : {}), runtime: "server", @@ -1223,9 +1320,8 @@ const make = Effect.gen(function* () { }), }), ).then( - (snapshot) => { - if (opener.control.agentId === null) - controller?.push({ _tag: "popup", tabId: snapshot.tabId }); + (tab) => { + if (opener.control.agentId === null) controller?.push({ _tag: "popup", tabId: tab.tabId }); }, async () => { await popup.close().catch(constVoid); @@ -1236,7 +1332,7 @@ const make = Effect.gen(function* () { const ensureTab = (snapshot: PreviewSessionSnapshot): Promise => { const key = tabKey(snapshot.threadId, snapshot.tabId); const pending = pendingTabs.get(key); - if (pending) return pending; + if (pending?.promise) return pending.promise; const existing = tabs.get(key); if (existing) return Promise.resolve(existing); const opening = createTab(snapshot) @@ -1253,7 +1349,7 @@ const make = Effect.gen(function* () { pendingTabs.delete(key); closedPendingTabs.delete(key); }); - pendingTabs.set(key, opening); + pendingTabs.set(key, { agentId: snapshot.automationOwner ?? null, promise: opening }); return opening; }; @@ -1271,10 +1367,15 @@ const make = Effect.gen(function* () { }); }); - const atTabLimit = (agentSessionId: string) => - tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT || - [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length >= - AGENT_TAB_LIMIT; + const atTabLimit = (agentSessionId: string) => { + const opening = [...pendingTabs.entries()].filter(([key]) => !tabs.has(key)); + return ( + tabs.size + opening.length >= SERVER_TAB_LIMIT || + [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length + + opening.filter(([, pending]) => pending.agentId === agentSessionId).length >= + AGENT_TAB_LIMIT + ); + }; const assertTabCapacity = (agentSessionId: string) => { if (atTabLimit(agentSessionId)) @@ -1284,6 +1385,52 @@ const make = Effect.gen(function* () { ); }; + // A Playwright launch must settle before its capacity can be released. + const openTab = Effect.fn("ServerBrowser.openTab")(function* ( + input: Parameters[0], + ) { + let key: string = NodeCrypto.randomUUID(); + const reservation = { agentId: input.automationOwner ?? null }; + return yield* Effect.acquireUseRelease( + Effect.try({ + try: () => { + if (input.automationOwner !== undefined) assertTabCapacity(input.automationOwner); + pendingTabs.set(key, reservation); + }, + catch: ServerBrowserPage.toOperationError, + }), + () => + manager + .open({ + ...input, + beforePublish: (snapshot) => { + pendingTabs.delete(key); + key = tabKey(snapshot.threadId, snapshot.tabId); + pendingTabs.set(key, reservation); + input.beforePublish?.(snapshot); + }, + }) + .pipe( + Effect.flatMap((snapshot) => + Effect.tryPromise({ + try: () => ensureTab(snapshot), + catch: ServerBrowserPage.toOperationError, + }).pipe( + Effect.onError(() => + manager + .close({ threadId: ThreadId.make(snapshot.threadId), tabId: snapshot.tabId }) + .pipe(Effect.ignore), + ), + ), + ), + ), + () => + Effect.sync(() => { + pendingTabs.delete(key); + }), + ); + }, Effect.uninterruptible); + // Desktop browsing bypasses streamed viewers and server input tracking, // so these idle signals only apply to headless tabs. const closeIdleAgentTabs = () => { @@ -1320,6 +1467,8 @@ const make = Effect.gen(function* () { ? ("agent" as const) : ("unclaimed" as const); + const reportsByClient = new Map(); + let nextClient = 0; let reportedProfiles: { readonly profiles: ReadonlyArray; readonly defaultProfileId: string; @@ -1328,16 +1477,45 @@ const make = Effect.gen(function* () { // last report outlives the client, which lets agents record while no client is connected. let recordingFrameRate = DEFAULT_BROWSER_RECORDING_FRAME_RATE; - const reportProfiles: ServerBrowser["Service"]["reportProfiles"] = (input) => - Effect.sync(() => { - if (input.recordingFrameRate !== undefined) recordingFrameRate = input.recordingFrameRate; - const profiles = resolveBrowserProfiles(input.profiles); - reportedProfiles = { - profiles, - defaultProfileId: - findBrowserProfile(profiles, input.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID, - }; - }); + // Each client reports only the profiles in its own settings, so the catalog is + // their union while they stay connected. The last catalog stays usable after + // every client disconnects, as a single report did before. + const rebuildReportedProfiles = () => { + const byId = new Map(); + let latest: PreviewReportProfilesInput | undefined; + for (const report of reportsByClient.values()) { + for (const profile of report.profiles) byId.set(profile.id, profile); + latest = report; + } + if (latest === undefined) return; + const profiles = resolveBrowserProfiles([...byId.values()]); + reportedProfiles = { + profiles, + defaultProfileId: + findBrowserProfile(profiles, latest.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID, + }; + }; + + const profileReporter: ServerBrowser["Service"]["profileReporter"] = Effect.acquireRelease( + Effect.sync(() => nextClient++), + (client) => + Effect.sync(() => { + reportsByClient.delete(client); + rebuildReportedProfiles(); + }), + ).pipe( + Effect.map( + (client): ProfileReporter => + (input) => + Effect.sync(() => { + if (input.recordingFrameRate !== undefined) + recordingFrameRate = input.recordingFrameRate; + reportsByClient.delete(client); + reportsByClient.set(client, input); + rebuildReportedProfiles(); + }), + ), + ); const profileStatus = () => { const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES; @@ -1383,7 +1561,7 @@ const make = Effect.gen(function* () { }; } const url = tab.page.url(); - const viewport = tab.page.viewportSize(); + const viewport = tab.desktop ? await layoutSize(tab) : tab.page.viewportSize(); const status = { available: true, visible: tab.viewers.size > 0, @@ -1791,23 +1969,18 @@ const make = Effect.gen(function* () { : undefined; const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS); const profileId = existing ? undefined : resolveOpenProfile(open.profileId); - if (!existing) { - closeIdleAgentTabs(); - assertTabCapacity(request.agentSessionId); - } + if (!existing) closeIdleAgentTabs(); const tab = existing ?? - (await ensureTab( - await runPromise( - manager.open({ - threadId: request.threadId, - ...(url ? { url } : {}), - ...(profileId === undefined ? {} : { profileId }), - runtime: "server", - reveal: false, - automationOwner: request.agentSessionId, - }), - ), + (await runPromise( + openTab({ + threadId: request.threadId, + ...(url ? { url } : {}), + ...(profileId === undefined ? {} : { profileId }), + runtime: "server", + reveal: false, + automationOwner: request.agentSessionId, + }), )); if (existing?.dialog) throw new BrowserControlInterrupted( @@ -2069,7 +2242,9 @@ const make = Effect.gen(function* () { const mirrorManagerEvent = (event: PreviewEvent) => Effect.promise(async () => { if (event.type === "opened" && event.snapshot.runtime === "server") { - await ensureTab(event.snapshot).catch(constVoid); + const key = tabKey(event.snapshot.threadId, event.snapshot.tabId); + if (event.snapshot.automationOwner === undefined || pendingTabs.has(key) || tabs.has(key)) + await ensureTab(event.snapshot).catch(constVoid); return; } const key = tabKey(event.threadId, event.tabId); @@ -2213,7 +2388,7 @@ const make = Effect.gen(function* () { const current = tab.page.viewportSize(); if (current?.width === width && current.height === height) return; await tab.page.setViewportSize({ width, height }); - broadcastViewport(tab); + await broadcastViewport(tab); return; } case "viewport": { @@ -2313,15 +2488,22 @@ const make = Effect.gen(function* () { // Dropped frames must still release Chromium. if (Queue.offerUnsafe(output, next)) return; if (next._tag === "frame") runFork(next.ack); - // State a stalled viewer cannot miss replaces its backlog. It runs - // synchronously so an older replacement can never land after a newer one. - else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") { + // State a stalled viewer cannot miss compacts its backlog. It runs + // synchronously so an older compaction can never land after a newer one. + else if (["gone", "reconnect", "control", "viewport", "fileChooser"].includes(next._tag)) { const dropped = Effect.runSyncExit(Queue.clear(output)); if (dropped._tag === "Failure") return; - Queue.offerUnsafe(output, next); - - const chooser = next._tag === "control" ? fileChooserMessage(tab) : null; - if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); + const items = [...dropped.value, next]; + // Without its latest size and control the viewer cannot map input at all. + const state = (["control", "viewport"] as const).flatMap( + (tag) => items.findLast((item) => item._tag === tag) ?? [], + ); + // Everything else is one-time; if it no longer fits, the oldest goes first. + const rest = items.filter( + (item) => item._tag !== "frame" && item._tag !== "control" && item._tag !== "viewport", + ); + for (const item of [...rest.slice(state.length - VIEWER_OUTPUT_LIMIT), ...state]) + Queue.offerUnsafe(output, item); for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); } }, @@ -2410,7 +2592,7 @@ const make = Effect.gen(function* () { ), }); }); - broadcastViewport(tab); + void broadcastViewport(tab).catch(constVoid); yield* Effect.promise(() => startScreencast(1)); // An idle page does not repaint for a new screencast, so the viewer // starts from a still unless a live frame beat it. @@ -2446,9 +2628,17 @@ const make = Effect.gen(function* () { : {}), }); } else { - await tab.control.human(viewer.id, () => - dispatchViewerInput(tab, session, viewer, message), - ); + const dispatch = () => dispatchViewerInput(tab, session, viewer, message); + if ( + message.type === "mouse" && + message.action === "move" && + message.button === "none" && + message.buttons === 0 + ) { + await tab.control.humanLatest(viewer.id, `hover:${modifiersOf(message)}`, dispatch); + } else { + await tab.control.human(viewer.id, dispatch); + } } } catch { broadcastControl(tab); @@ -2547,7 +2737,7 @@ const make = Effect.gen(function* () { return ServerBrowser.of({ attachViewer, clearProfile, - reportProfiles, + profileReporter, openDownload, answerFileChooser, }); diff --git a/apps/server/src/preview/ServerBrowserContexts.test.ts b/apps/server/src/preview/ServerBrowserContexts.test.ts index 3cab864de54f2..522df30fb66c8 100644 --- a/apps/server/src/preview/ServerBrowserContexts.test.ts +++ b/apps/server/src/preview/ServerBrowserContexts.test.ts @@ -183,6 +183,29 @@ describe("ServerBrowserContexts", () => { await pool.close(); }); + it("waits for the context to close when the same profile is cleared concurrently", async () => { + const human = makeContext(); + const closed = Promise.withResolvers(); + human.close.mockImplementation(async () => closed.promise); + launches.persistent.mockResolvedValue(human as unknown as BrowserContext); + const pool = new ServerBrowserContexts(options()); + await pool.contextFor("work/team"); + + const first = pool.clearProfile("work/team"); + const second = pool.clearProfile("work/team"); + const deletionsBeforeClose = launches.rm.mock.calls.length; + closed.resolve(); + await Promise.all([first, second]); + + expect(deletionsBeforeClose).toBe(0); + expect(human.close).toHaveBeenCalledTimes(1); + expect(launches.rm).toHaveBeenCalledExactlyOnceWith("/test/profiles/work%2Fteam", { + recursive: true, + force: true, + }); + await pool.close(); + }); + it("opens a profile requested during its clear only after the storage is deleted", async () => { launches.persistent.mockImplementation(async () => makeContext() as unknown as BrowserContext); const configuration = options(); diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 9f3fd48430752..2a7c342d30d81 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -212,6 +212,8 @@ export class ServerBrowserContexts { async clearProfile(profileId: string) { if (profileId === INCOGNITO_BROWSER_PROFILE_ID) return; const key = JSON.stringify([profileId, null]); + const existing = this.clearing.get(key); + if (existing) return existing; const clearing = (async () => { const pending = this.contexts.get(key); if (pending) { diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 31f180bcc923c..eee077fbf05c3 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -388,3 +388,17 @@ describe("server browser drag", () => { } }); }); + +describe("server browser net errors", () => { + it.each([ + ["net::ERR_CONNECTION_REFUSED", "ERR_CONNECTION_REFUSED", -102], + [ + "page.goto: net::ERR_HTTP2_PROTOCOL_ERROR at https://localhost:5173/", + "ERR_HTTP2_PROTOCOL_ERROR", + -2, + ], + ["net::ERR_TLS13_DOWNGRADE_DETECTED", "ERR_TLS13_DOWNGRADE_DETECTED", -2], + ])("reads the whole error name from %j", (errorText, description, code) => { + expect(ServerBrowserPage.parseNetError(errorText)).toEqual({ description, code }); + }); +}); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 9525ac7c5b579..88308ed691eed 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -894,7 +894,8 @@ const NET_ERROR_CODES: Readonly> = { }; export const parseNetError = (errorText: string) => { - const description = /ERR_[A-Z_]+/.exec(errorText)?.[0] ?? errorText; + // Names can hold digits, as in ERR_HTTP2_PROTOCOL_ERROR. + const description = /ERR_[A-Z\d_]+/.exec(errorText)?.[0] ?? errorText; return { description, code: NET_ERROR_CODES[description] ?? -2 }; }; diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index d3f4781829a85..5f2c685fa685c 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -88,7 +88,7 @@ it.effect.each([ }); const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: (input) => @@ -163,7 +163,7 @@ it.effect("sends replies ahead of stale frames and only the newest frame after a }); const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: () => Effect.succeed({ output, input: () => Effect.void }), @@ -234,7 +234,7 @@ it.effect.each([ let attachments = 0; const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: () => { @@ -275,7 +275,7 @@ it.effect("serves a tab's download only to an authorized session", () => const requests: Array = []; const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: (input) => Effect.sync(() => { requests.push(input); @@ -323,7 +323,7 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco const answers: Array<{ chooserId: string; files: Array<{ name: string; text: string }> }> = []; const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: () => Effect.succeedNone, answerFileChooser: (input) => Effect.sync(() => { @@ -392,7 +392,7 @@ it.effect.each([ Effect.gen(function* () { const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: () => Effect.fail(new ServerBrowser.ServerBrowserLaunchError({ cause: error })), diff --git a/apps/server/src/preview/SessionControl.test.ts b/apps/server/src/preview/SessionControl.test.ts index a5971f010a57e..e086ad854b3fb 100644 --- a/apps/server/src/preview/SessionControl.test.ts +++ b/apps/server/src/preview/SessionControl.test.ts @@ -3,6 +3,193 @@ import { describe, expect, it } from "vite-plus/test"; import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; describe("SessionControl", () => { + it("replaces waiting hover work without changing the running action or delaying the following click", async () => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const events: Array = []; + const running = control.humanLatest("viewer", "hover:0", 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 () => { + events.push(position); + }), + ); + const click = control.human("viewer", async () => { + events.push("click"); + }); + finish.resolve(); + await Promise.all([running, ...moves, click]); + expect(events).toEqual(["running", 119, "click"]); + }); + + it.each(["human", "system", "modifiers"] as const)( + "does not merge waiting hover work across a %s action", + async (barrier) => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const finish = Promise.withResolvers(); + const events: string[] = []; + const running = control.human("viewer", () => finish.promise); + const before = control.humanLatest("viewer", "hover:0", async () => { + events.push("before"); + }); + const recordBarrier = async () => { + events.push("barrier"); + }; + const between = + barrier === "modifiers" + ? control.humanLatest("viewer", "hover:2", recordBarrier) + : barrier === "system" + ? control.system(recordBarrier) + : control.human("viewer", recordBarrier); + const after = control.humanLatest("viewer", "hover:0", async () => { + events.push("after"); + }); + finish.resolve(); + await Promise.all([running, before, between, after]); + expect(events).toEqual(["before", "barrier", "after"]); + }, + ); + + it("does not let another viewer replace or split the owner's waiting hover", async () => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const finish = Promise.withResolvers(); + const events: string[] = []; + const running = control.human("viewer", () => finish.promise); + const stale = control.humanLatest("viewer", "hover", async () => { + events.push("stale"); + }); + await expect( + control.humanLatest("other", "hover", async () => { + events.push("other"); + }), + ).rejects.toThrow(BrowserControlInterrupted); + const latest = control.humanLatest("viewer", "hover", async () => { + events.push("latest"); + }); + finish.resolve(); + await Promise.all([running, stale, latest]); + expect(events).toEqual(["latest"]); + }); + + it.each(["release", "disconnect"] as const)( + "does not revive waiting hover work after %s and retaking control", + async (handoff) => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const events: string[] = []; + const running = control.human("viewer", () => { + started.resolve(); + return finish.promise; + }); + await started.promise; + const stale = expect( + control.humanLatest("viewer", "hover", async () => { + events.push("stale"); + }), + ).rejects.toThrow(BrowserControlInterrupted); + const released = control[handoff]("viewer"); + const retaken = control.take("viewer"); + const fresh = control.humanLatest("viewer", "hover", async () => { + events.push("fresh"); + }); + const latest = control.humanLatest("viewer", "hover", async () => { + events.push("latest"); + }); + finish.resolve(); + await Promise.all([running, stale, released, retaken, fresh, latest]); + expect(events).toEqual(["latest"]); + }, + ); + + it("rejects every caller of a merged hover when the tab closes", async () => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const events: string[] = []; + const running = control.human("viewer", () => { + started.resolve(); + return finish.promise; + }); + await started.promise; + const waiting = [1, 2].map((position) => + expect( + control.humanLatest("viewer", "hover", async () => { + events.push(`hover ${position}`); + }), + ).rejects.toThrow("closed"), + ); + const closed = control.close(); + await expect( + control.humanLatest("viewer", "hover", async () => { + events.push("after close"); + }), + ).rejects.toThrow("closed"); + finish.resolve(); + await Promise.all([running, ...waiting, closed]); + expect(events).toEqual([]); + }); + + it("shares a merged dispatch failure and accepts fresh hover work afterwards", async () => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const finish = Promise.withResolvers(); + const events: string[] = []; + const running = control.human("viewer", () => finish.promise); + const first = expect( + control.humanLatest("viewer", "hover", async () => { + events.push("superseded"); + }), + ).rejects.toThrow("dispatch failed"); + const last = expect( + control.humanLatest("viewer", "hover", async () => { + throw new Error("dispatch failed"); + }), + ).rejects.toThrow("dispatch failed"); + finish.resolve(); + await Promise.all([running, first, last]); + await control.humanLatest("viewer", "hover", async () => { + events.push("recovered"); + }); + expect(events).toEqual(["recovered"]); + }); + + it("waits for tracked navigation before dispatching the latest human hover", async () => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const committed = Promise.withResolvers(); + const events: string[] = []; + const navigation = control.human("viewer", async () => { + control.track( + committed.promise.then(() => { + events.push("navigation committed"); + }), + ); + return "started"; + }); + const first = control.humanLatest("viewer", "hover", async () => { + events.push("superseded"); + }); + const latest = control.humanLatest("viewer", "hover", async () => { + events.push("latest"); + }); + await expect(navigation).resolves.toBe("started"); + expect(events).toEqual([]); + committed.resolve(); + await Promise.all([first, latest]); + expect(events).toEqual(["navigation committed", "latest"]); + }); + it("returns an action result before tracked navigation but drains it before already queued actions", async () => { const control = new SessionControl("agent"); const committed = Promise.withResolvers(); diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index de02f32a1f774..cff65d3379a02 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -22,6 +22,10 @@ export class SessionControl { readonly agentId: string | null; private readonly onGenerationChange: () => void; private tail: Promise = Promise.resolve(); + private latestHuman: { + entry: { viewerId: string; key: string; run: () => Promise }; + result: Promise; + } | null = null; private pending: Promise = Promise.resolve(); private readonly handoff = new Set>(); private owner: string | null = null; @@ -42,6 +46,7 @@ export class SessionControl { } private enqueue(run: () => Promise): Promise { + this.latestHuman = null; const result = this.tail.then(run); this.tail = result.then( () => this.pending, @@ -68,6 +73,7 @@ export class SessionControl { } private changeOwner(owner: string | null) { + this.latestHuman = null; this.owner = owner; this.epoch++; this.onGenerationChange(); @@ -128,6 +134,23 @@ 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); + const latest = this.latestHuman; + if (latest?.entry.viewerId === viewerId && latest.entry.key === key) { + latest.entry.run = run; + return latest.result; + } + const entry = { viewerId, key, run }; + const result = this.human(viewerId, async () => { + if (this.latestHuman?.entry === entry) this.latestHuman = null; + await entry.run(); + }); + this.latestHuman = { entry, result }; + return result; + } + async take(viewerId: string) { this.assertOpen(); if (this.owner !== null && this.owner !== viewerId) { diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 69026f91202af..4248e51b55977 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "@effect/vitest"; import { + type AgentSessionSource, EventId, ProjectId, ProviderDriverKind, @@ -50,7 +51,7 @@ const persistence = Layer.mergeAll( function candidate( modelSelection: typeof CODEX_MODEL_SELECTION, providerSessionId: string, - provider: "codex" | "claudeAgent" = "codex", + provider: AgentSessionSource = "codex", ) { const providerInstanceId = modelSelection.instanceId; return { @@ -82,6 +83,12 @@ function candidate( } satisfies AgentSessionScanner.AgentSessionRecentThread; } +const PI_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("pi"), model: "default" }; + +// Pi threads are owned by their session file path, not the session UUID. +const nativeId = ({ source }: ReturnType) => + source.provider === "pi" ? source.filePath : source.providerSessionId; + function importId(source: ReturnType) { return ThreadId.make( `import:${source.source.providerInstanceId}:${source.source.providerSessionId}`, @@ -95,19 +102,17 @@ const seedOwner = Effect.fn(function* ( const sink = yield* EventSink.EventSinkV2; const ids = yield* IdAllocator.IdAllocatorV2; const { providerInstanceId, providerSessionId, provider } = source.source; + const nativeThreadId = nativeId(source); const threadId = ThreadId.make(`owner:${providerInstanceId}:${providerSessionId}`); const driver = ProviderDriverKind.make(provider); const providerThread: OrchestrationV2ProviderThread = { - id: ids.derive.providerThread({ - driver, - nativeThreadId: providerSessionId, - }), + id: ids.derive.providerThread({ driver, nativeThreadId }), driver, providerInstanceId, providerSessionId: null, appThreadId: threadId, ownerNodeId: null, - nativeThreadRef: { driver, nativeId: providerSessionId, strength: "strong" }, + nativeThreadRef: { driver, nativeId: nativeThreadId, strength: "strong" }, nativeConversationHeadRef: null, status: "idle", firstRunOrdinal: 1, @@ -252,10 +257,12 @@ it.effect( "claudeAgent", ); const deleted = candidate(CODEX_MODEL_SELECTION, "deleted"); + const ownedPi = candidate(PI_MODEL_SELECTION, "owned-pi", "pi"); const owners = [ yield* seedOwner(active), yield* seedOwner(archived, "archived"), yield* seedOwner(deleted, "deleted"), + yield* seedOwner(ownedPi), ]; const before = yield* Effect.forEach(owners, (id) => projections.getThreadRecords(id, ["providerThreads"]), @@ -266,10 +273,19 @@ it.effect( "claudeAgent", ); const unowned = candidate(CODEX_MODEL_SELECTION, "unowned"); - const importer = yield* makeImporter([active, archived, deleted, otherInstance, unowned]); + const unownedPi = candidate(PI_MODEL_SELECTION, "unowned-pi", "pi"); + const importer = yield* makeImporter([ + active, + archived, + deleted, + ownedPi, + otherInstance, + unowned, + unownedPi, + ]); - expect(yield* importer.run).toEqual({ importedCount: 2, skippedCount: 0 }); - for (const source of [active, archived, deleted]) { + expect(yield* importer.run).toEqual({ importedCount: 3, skippedCount: 0 }); + for (const source of [active, archived, deleted, ownedPi]) { const threadId = importId(source); expect(yield* Effect.option(projections.getThread(threadId))).toEqual(Option.none()); expect(yield* events.read({ threadId }).pipe(Stream.runCollect)).toEqual([]); @@ -286,8 +302,8 @@ it.effect( "event" in stored ? [stored.event.threadId] : [], ), ), - ).toEqual(new Set([importId(otherInstance), importId(unowned)])); - for (const source of [otherInstance, unowned]) { + ).toEqual(new Set([importId(otherInstance), importId(unowned), importId(unownedPi)])); + for (const source of [otherInstance, unowned, unownedPi]) { const threadId = importId(source); const records = yield* projections.getThreadRecords(threadId, [ "messages", @@ -296,15 +312,13 @@ it.effect( expect(records.messages.map((message) => message.text)).toEqual(["Fix it", "Fixed"]); expect(records.thread.historyOrigin).toBe("v1_import"); expect(records.thread.activeProviderThreadId).toBe(records.providerThreads[0]?.id); - expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe( - source.source.providerSessionId, - ); + expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe(nativeId(source)); expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId }))).toMatchObject({ providerInstanceId: source.source.providerInstanceId, resumeCursor: - source.source.provider === "codex" - ? { threadId: source.source.providerSessionId } - : { threadId, resume: source.source.providerSessionId }, + source.source.provider === "claudeAgent" + ? { threadId, resume: source.source.providerSessionId } + : { threadId: nativeId(source) }, runtimePayload: { importedTranscripts: [source.source] }, }); } diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 11f168ff1d7be..038b488635053 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -250,6 +250,9 @@ const make = Effect.gen(function* () { }); } const driver = ProviderDriverKind.make(thread.source); + // Pi resumes by session file path; its session UUID only names the thread. + const nativeThreadId = + thread.source === "pi" ? source.filePath : thread.providerSessionId; const persistRuntime = runtimes.upsert( { threadId, @@ -260,9 +263,9 @@ const make = Effect.gen(function* () { status: "stopped", lastSeenAt: thread.updatedAt, resumeCursor: - thread.source === "codex" - ? { threadId: thread.providerSessionId } - : { threadId, resume: thread.providerSessionId }, + thread.source === "claudeAgent" + ? { threadId, resume: thread.providerSessionId } + : { threadId: nativeThreadId }, runtimePayload: { cwd: project.workspaceRoot }, }, { onConflict: "ignore" }, @@ -288,7 +291,7 @@ const make = Effect.gen(function* () { const providerThreadId = idAllocator.derive.providerThread({ driver, providerInstanceId: thread.providerInstanceId, - nativeThreadId: thread.providerSessionId, + nativeThreadId, }); const createdAt = dateTime(thread.createdAt); const updatedAt = dateTime(thread.updatedAt); @@ -337,7 +340,7 @@ const make = Effect.gen(function* () { ownerNodeId: null, nativeThreadRef: { driver, - nativeId: thread.providerSessionId, + nativeId: nativeThreadId, strength: "strong", }, nativeConversationHeadRef: null, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index baf0f27f9edb3..3cbec21d8831d 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -46,6 +46,7 @@ const layerProjectStore = (importedWorkspaceRoots: ReadonlyArray) => interface ScannerTestInput { readonly claudeHomePath: string; readonly codexHomePath: string; + readonly piAgentDir?: string; readonly importedWorkspaceRoots?: ReadonlyArray; readonly configBaseDir?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; @@ -65,6 +66,20 @@ const layerScannerTest = (input: ScannerTestInput) => driver: ProviderDriverKind.make("codex"), config: { homePath: input.codexHomePath }, }, + // Keeps the default Pi instance off the developer's real ~/.pi. + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + enabled: true, + environment: [ + { + name: "PI_CODING_AGENT_DIR", + value: input.piAgentDir ?? `${input.claudeHomePath}/pi-agent`, + sensitive: false, + }, + { name: "PI_CODING_AGENT_SESSION_DIR", value: "", sensitive: false }, + ], + config: {}, + }, ...input.providerInstances, }, }), @@ -561,9 +576,32 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-"); const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); + const piAgentDir = yield* makeTempDir("t3code-pi-agent-"); + const piSessionDir = yield* makeTempDir("t3code-pi-sessions-"); const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); - + const piWorkspace = yield* makeTempDir("t3code-workspace-pi-"); + const piFlatWorkspace = yield* makeTempDir("t3code-workspace-pi-flat-"); + + // Pi nests sessions per cwd by default and keeps a custom session directory flat. + for (const [filePath, cwd, mtime] of [ + [ + path.join(piAgentDir, "sessions", "--slug--", "2026-01-03_pi.jsonl"), + piWorkspace, + "2026-01-03T00:00:00.000Z", + ], + [ + path.join(piSessionDir, "2026-01-04_pi-flat.jsonl"), + piFlatWorkspace, + "2026-01-04T00:00:00.000Z", + ], + ] as const) { + yield* writeTranscript({ + filePath, + contents: `${encodeTranscriptRecord({ type: "session", version: 3, id: "pi", cwd })}\n`, + mtimeMs: Date.parse(mtime), + }); + } yield* writeTranscript({ filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"), contents: claudeSessionLine(claudeWorkspace), @@ -598,12 +636,31 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }], config: {}, }, + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + enabled: true, + environment: [ + { name: "PI_CODING_AGENT_DIR", value: piAgentDir, sensitive: false }, + { name: "PI_CODING_AGENT_SESSION_DIR", value: "", sensitive: false }, + ], + config: {}, + }, + [ProviderInstanceId.make("pi-flat")]: { + driver: ProviderDriverKind.make("pi"), + enabled: true, + environment: [ + { name: "PI_CODING_AGENT_SESSION_DIR", value: piSessionDir, sensitive: false }, + ], + config: {}, + }, }, }); - expect(result.candidates.map((candidate) => candidate.path)).toEqual([ - codexWorkspace, - claudeWorkspace, + expect(result.candidates.map((candidate) => [candidate.path, candidate.sources])).toEqual([ + [piFlatWorkspace, ["pi"]], + [piWorkspace, ["pi"]], + [codexWorkspace, ["codex"]], + [claudeWorkspace, ["claudeAgent"]], ]); }), ); @@ -983,13 +1040,14 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }); describe("recentThreads", () => { - it.effect("imports recent Claude and Codex sessions for the selected project only", () => + it.effect("imports recent Claude, Codex, and Pi sessions for the selected project only", () => 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-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const piAgentDir = yield* makeTempDir("t3code-pi-agent-"); const workspace = yield* makeTempDir("t3code-workspace-"); const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); @@ -1053,21 +1111,96 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ].join("\n"), mtimeMs: nowMs - 60 * 60 * 1000, }); + const piMessage = ( + id: string, + parentId: string | null, + message: { readonly role: string; readonly content: unknown }, + ) => + encodeTranscriptRecord({ + type: "message", + id, + parentId, + timestamp: "2026-08-24T11:00:00.000Z", + message, + }); + // The user rewound to the first prompt; the abandoned branch stays in the file. + yield* writeTranscript({ + filePath: path.join(piAgentDir, "sessions", "--selected--", "2026-08-24_pi.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session", + version: 3, + id: "pi-recent", + cwd: workspace, + }), + piMessage("a1", null, { role: "user", content: "Plan the change" }), + piMessage("a2", "a1", { + role: "assistant", + content: [{ type: "text", text: "Abandoned answer" }], + }), + piMessage("a3", "a2", { role: "user", content: "Abandoned follow-up" }), + piMessage("b1", "a1", { + role: "assistant", + content: [ + { type: "thinking", thinking: "Hidden reasoning" }, + { type: "toolCall", id: "call-1", name: "bash", arguments: {} }, + ], + }), + piMessage("bn", "b1", { role: "toolResult", content: null }), + piMessage("b2", "bn", { + role: "toolResult", + content: [{ type: "text", text: "Tool output" }], + }), + piMessage("b3", "b2", { + role: "assistant", + content: [{ type: "text", text: "Active answer" }], + }), + encodeTranscriptRecord({ + type: "session_info", + id: "b4", + parentId: "b3", + name: "Pi refactor", + }), + ].join("\n"), + mtimeMs: nowMs - 30 * 60 * 1000, + }); + // Version 1 sessions predate the entry tree. + yield* writeTranscript({ + filePath: path.join(piAgentDir, "sessions", "--selected--", "2025-01-01_pi-v1.jsonl"), + contents: [ + encodeTranscriptRecord({ type: "session", id: "pi-v1", cwd: workspace }), + encodeTranscriptRecord({ + type: "message", + message: { role: "user", content: "Legacy prompt" }, + }), + encodeTranscriptRecord({ + type: "message", + message: { role: "assistant", content: [{ type: "text", text: "Legacy answer" }] }, + }), + ].join("\n"), + mtimeMs: nowMs - 2 * 24 * 60 * 60 * 1000, + }); const threads = yield* runRecentThreads({ claudeHomePath, codexHomePath, + piAgentDir, workspaceRoot: workspace, }); expect(threads.map((thread) => thread.providerSessionId)).toEqual([ + "pi-recent", "codex-recent", "claude-recent", + "pi-v1", ]); expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([ + ["Plan the change", "Active answer"], ["Review this code", "Looks good"], ["Fix the project", "Done"], + ["Legacy prompt", "Legacy answer"], ]); + expect(threads[0]).toMatchObject({ source: "pi", title: "Pi refactor" }); }), ); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 7ec79c38a38a9..bf026d9005c1a 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -32,7 +32,9 @@ import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server import * as ServerConfig from "../config.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { resolvePiSessionsDir } from "@t3tools/provider-pi/server/usage"; import * as ServerSettings from "../serverSettings.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import { @@ -78,6 +80,10 @@ const CodexTurnMetadata = Schema.Struct({ const TranscriptRecord = Schema.Struct({ type: Schema.optional(Schema.String), + // Pi entries form a tree through id/parentId; session_info entries carry its name. + id: Schema.optional(Schema.String), + parentId: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.String), timestamp: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), sessionId: Schema.optional(Schema.String), @@ -106,6 +112,13 @@ const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord); +const decodePiEntryLinks = Schema.decodeUnknownOption( + Schema.Struct({ + type: TranscriptRecord.fields.type, + id: TranscriptRecord.fields.id, + parentId: TranscriptRecord.fields.parentId, + }), +); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); @@ -287,6 +300,39 @@ function deriveTitle(source: AgentSessionSource, text: string): string | undefin return request.trim().split("\n")[0]?.slice(0, 100).trim() || undefined; } +// Pi keeps abandoned branches in the file; its active branch runs from the last entry to the root. +function activePiBranch( + records: ReadonlyArray, +): ReadonlyArray { + const entries = records.filter((record) => record.type !== "session"); + const leaf = entries.at(-1); + // Version 1 sessions predate the tree and are linear. + if (leaf?.id === undefined) return entries; + const byId = new Map( + entries.flatMap((entry) => (entry.id === undefined ? [] : [[entry.id, entry] as const])), + ); + const branch: Array = []; + for ( + let entry: DecodedTranscriptRecord | undefined = leaf; + entry !== undefined && branch.length < entries.length; + entry = entry.parentId ? byId.get(entry.parentId) : undefined + ) { + branch.push(entry); + } + return branch.toReversed(); +} + +function isPiConversationRecord( + record: DecodedTranscriptRecord, +): record is DecodedTranscriptRecord & { + readonly message: typeof TranscriptMessage.Type & { readonly role: "user" | "assistant" }; +} { + return ( + record.type === "message" && + (record.message?.role === "user" || record.message?.role === "assistant") + ); +} + export function parseAgentSessionTranscript( input: AgentSessionTranscriptMetadata & { readonly contents: string; @@ -311,8 +357,16 @@ function parseAgentSessionRecords( // Claude filenames are session IDs. Codex rollout filenames include extra // timestamp text, so only transcript metadata can provide a resumable ID. let providerSessionId = - input.source === "codex" ? "" : (claudeFields.sessionId ?? input.fallbackSessionId); - const title = claudeFields.title ?? null; + input.source === "codex" + ? "" + : input.source === "pi" + ? records.find((record) => record.type === "session")?.id?.trim() || input.fallbackSessionId + : (claudeFields.sessionId ?? input.fallbackSessionId); + // Pi titles a session by its latest session_info on any branch; an empty name clears it. + const title = + input.source === "pi" + ? records.findLast((record) => record.type === "session_info")?.name?.trim() || null + : (claudeFields.title ?? null); let model = claudeFields.model ?? null; let derivedTitle: string | undefined; let hasCodexSessionId = false; @@ -403,8 +457,20 @@ function parseAgentSessionRecords( }; let recordIndex = -1; - for (const record of records) { + for (const record of input.source === "pi" ? activePiBranch(records) : records) { recordIndex += 1; + if (input.source === "pi") { + if (!isPiConversationRecord(record)) continue; + const text = extractText(record.message.content); + if (text.length === 0) continue; + retainMessage({ + role: record.message.role, + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } if (input.source === "claudeAgent") { if (isHiddenClaudeRecord(record)) continue; if (record.type !== "user" && record.type !== "assistant") continue; @@ -507,6 +573,8 @@ function shouldRetainDecodedRecord( source: AgentSessionSource, record: DecodedTranscriptRecord, ): boolean { + // Any Pi entry can be a link on the active branch. + if (source === "pi") return true; if (source === "claudeAgent") { if (isHiddenClaudeRecord(record) || (record.type !== "user" && record.type !== "assistant")) { return false; @@ -843,15 +911,29 @@ export const make = Effect.gen(function* () { reader.write(decoder.decode()); recordCount += 1; if (recordCount > recordLimit) return false; - const decoded = decodeTranscriptValue(reader.finish()); - if (Option.isSome(decoded)) { + const value = reader.finish(); + const decoded = decodeTranscriptValue(value); + if (Option.isNone(decoded) && source === "pi") { + // Pi accepts messages this schema rejects, such as null content; keep their tree links. + records.push(...Option.toArray(decodePiEntryLinks(value))); + } else if (Option.isSome(decoded)) { if (Option.isNone(cwd)) cwd = Option.fromNullOr(extractDecodedCwd(decoded.value)); if (source === "claudeAgent") { claudeFields = foldClaudeFields(claudeFields, decoded.value); } if (shouldRetainDecodedRecord(source, decoded.value)) { - records.push(decoded.value); - historyBytes += recordBytes; + if ( + source === "pi" && + decoded.value.type === "message" && + !isPiConversationRecord(decoded.value) + ) { + // Tool output can dwarf the conversation; keep only the tree links. + const { message: _message, ...links } = decoded.value; + records.push(links); + } else { + records.push(decoded.value); + historyBytes += recordBytes; + } } } recordBytes = 0; @@ -1030,6 +1112,59 @@ export const make = Effect.gen(function* () { }, ); + // Pi nests sessions in one directory per cwd, but a custom session directory holds them flat. + const discoverPiTranscripts = Effect.fn("AgentSessionScanner.discoverPiTranscripts")(function* ( + sessionsDir: string, + providerInstanceId: ProviderInstanceId, + operationBudget: number, + ) { + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + const transcripts: Array = []; + for (const entry of yield* readDirectory(sessionsDir)) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const entryPath = path.join(sessionsDir, entry); + const filePaths = entry.endsWith(".jsonl") + ? [entryPath] + : (yield* readDirectory(entryPath)) + .filter((nested) => nested.endsWith(".jsonl")) + .map((nested) => path.join(entryPath, nested)); + + for (const filePath of filePaths) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isSome(stats) && + stats.value.type === "File" && + Option.isSome(stats.value.mtime) + ) { + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + } + return { transcripts, truncated }; + }); + const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( source: AgentSessionSource, transcripts: ReadonlyArray, @@ -1081,7 +1216,7 @@ export const make = Effect.gen(function* () { const raw: Array = []; let truncated = false; - for (const source of ["claudeAgent", "codex"] as const) { + for (const source of ["claudeAgent", "codex", "pi"] as const) { const instances: Array<{ readonly instanceId: ProviderInstanceId; readonly config: ProviderInstanceConfig; @@ -1111,26 +1246,36 @@ export const make = Effect.gen(function* () { const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { - const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; - const configuredHome = instance.environment?.findLast( - (variable) => variable.name === homeVariable, - )?.value; - const environmentHome = - typeof configuredHome === "string" ? configuredHome : hostEnvironment[homeVariable]; + const environmentHome = (name: "CLAUDE_CONFIG_DIR" | "CODEX_HOME") => { + const configured = instance.environment?.findLast( + (variable) => variable.name === name, + )?.value; + return typeof configured === "string" ? configured : hostEnvironment[name]; + }; let homePath: string; - if (source === "claudeAgent") { + if (source === "pi") { + const environment = yield* mergeProviderInstanceEnvironment( + instance.environment, + hostEnvironment, + ).pipe(Effect.provideService(HostProcess.HomeDirectory, homeDir)); + homePath = resolvePiSessionsDir(environment, homeDir, path); + } else if (source === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); if (Option.isNone(config)) continue; - homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + homePath = resolveClaudeConfigDir( + config.value.homePath, + environmentHome("CLAUDE_CONFIG_DIR"), + ); } else { const config = decodeCodexSettings(instance.config ?? {}); if (Option.isNone(config)) continue; + const codexHome = environmentHome("CODEX_HOME"); const codexSettings = config.value.homePath.trim().length === 0 && config.value.shadowHomePath.trim().length === 0 && - environmentHome?.trim() - ? { ...config.value, homePath: environmentHome } + codexHome?.trim() + ? { ...config.value, homePath: codexHome } : config.value; const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( Effect.provideService(Path.Path, path), @@ -1155,9 +1300,13 @@ export const make = Effect.gen(function* () { truncated = true; continue; } - const discovered = yield* source === "claudeAgent" - ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) - : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); + const discover = + source === "claudeAgent" + ? discoverClaudeTranscripts + : source === "codex" + ? discoverCodexTranscripts + : discoverPiTranscripts; + const discovered = yield* discover(home.homePath, home.providerInstanceId, operationBudget); truncated ||= discovered.truncated; transcriptCandidates.push(...discovered.transcripts); } diff --git a/apps/server/src/project/ProjectMutation.test.ts b/apps/server/src/project/ProjectMutation.test.ts new file mode 100644 index 0000000000000..9e4e075f06ea0 --- /dev/null +++ b/apps/server/src/project/ProjectMutation.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "@effect/vitest"; +import { CommandId, type Project, ProjectId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import { ProjectNotEmptyError } from "./ProjectService.ts"; +import { mutateProjectWithClone } from "./ProjectMutation.ts"; + +const projectId = ProjectId.make("project-1"); +const deletedProject: Project = { + id: projectId, + title: "kh-frontend", + workspaceRoot: "/workspace/kh-frontend", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + projectIcon: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("mutateProjectWithClone", () => { + it.effect("keeps the clone when deletion is rejected", () => + Effect.gen(function* () { + const rejected = new ProjectNotEmptyError({ projectId }); + const result = yield* mutateProjectWithClone( + { + create: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.fail(rejected), + }, + { discard: () => Effect.die("must not discard a project that still exists") }, + { type: "project.delete", commandId: CommandId.make("rejected-delete"), projectId }, + ).pipe(Effect.flip); + + expect(result).toBe(rejected); + }), + ); + + it.effect("discards the clone record even when the caller disconnects mid-delete", () => + Effect.gen(function* () { + const committed = yield* Deferred.make(); + const finishDelete = yield* Deferred.make(); + const discarded: Array = []; + const projects = { + create: () => Effect.die("unused"), + update: () => Effect.die("unused"), + // The delete event is already durable while its post-commit cleanup runs. + delete: () => + Deferred.succeed(committed, undefined).pipe( + Effect.andThen(Deferred.await(finishDelete)), + Effect.as(deletedProject), + ), + }; + const clones = { + discard: (id: ProjectId) => Effect.sync(() => void discarded.push(id)), + }; + + const request = yield* mutateProjectWithClone(projects, clones, { + type: "project.delete", + commandId: CommandId.make("command-1"), + projectId, + }).pipe(Effect.forkChild); + yield* Deferred.await(committed); + // Delivered synchronously so it lands before the delete can finish. + request.interruptUnsafe(); + yield* Deferred.succeed(finishDelete, undefined); + yield* Fiber.await(request); + + expect(discarded).toEqual([projectId]); + }), + ); +}); diff --git a/apps/server/src/project/ProjectMutation.ts b/apps/server/src/project/ProjectMutation.ts index 6f5747ee7f5e6..8cf70f302b8f2 100644 --- a/apps/server/src/project/ProjectMutation.ts +++ b/apps/server/src/project/ProjectMutation.ts @@ -1,6 +1,7 @@ import { type ProjectMutation } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import { type ProjectCloneTracker } from "./ProjectCloneTracker.ts"; import { type ProjectService } from "./ProjectService.ts"; type ProjectMutations = Pick; @@ -51,3 +52,17 @@ export const projectMutationOperation = Effect.fn("projectMutationOperation")(fu }); } }); + +// A disconnect after deletion commits must not leave the in-memory clone record behind. +export const mutateProjectWithClone = Effect.fn("mutateProjectWithClone")(function* ( + projects: ProjectMutations, + clones: Pick, + mutation: ProjectMutation, +) { + if (mutation.type !== "project.delete") + return yield* projectMutationOperation(projects, mutation); + return yield* projectMutationOperation(projects, mutation).pipe( + Effect.tap(() => clones.discard(mutation.projectId)), + Effect.uninterruptible, + ); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 29a15a6f0715b..f383436cdb439 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,7 +1,11 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; -import { ProjectId, type TerminalEvent, type TerminalSessionSnapshot } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; +import { + ProjectId, + TerminalNotRunningError, + type TerminalEvent, + type TerminalSessionSnapshot, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -36,6 +40,7 @@ const openSnapshot = (history: string): TerminalSessionSnapshot => ({ const makeHarness = Effect.fn(function* (options: { readonly history?: string; readonly opening?: Deferred.Deferred; + readonly shell?: string | null; }) { const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const writes = yield* Queue.unbounded(); @@ -46,6 +51,10 @@ const makeHarness = Effect.fn(function* (options: { ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void ).pipe(Effect.as(openSnapshot(options.history ?? ""))), + getShell: () => + options.shell === null + ? Effect.fail(new TerminalNotRunningError({ threadId, terminalId })) + : Effect.succeed(options.shell ?? "/bin/zsh"), subscribe: (listener) => Effect.sync(() => { listeners.add(listener); @@ -66,8 +75,6 @@ const makeHarness = Effect.fn(function* (options: { NodeCrypto.layer, ), ), - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }), ); const start = runner.runForThread({ threadId, @@ -92,7 +99,12 @@ const makeHarness = Effect.fn(function* (options: { return { start, listeners, writes, outputLines, emit }; }); -const setup = Effect.fn(function* (options: { readonly history?: string } = {}) { +const setup = Effect.fn(function* ( + options: { + readonly history?: string; + readonly shell?: string | null; + } = {}, +) { const harness = yield* makeHarness(options); const result = yield* harness.start; const completion = result.status === "started" ? result.completion : undefined; @@ -101,6 +113,15 @@ const setup = Effect.fn(function* (options: { readonly history?: string } = {}) }); describe("ProjectSetupScriptRunner", () => { + it.effect("writes immediately when the spawned fallback is PowerShell", () => + Effect.gen(function* () { + const { writes } = yield* setup({ + shell: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }); + expect(yield* Queue.take(writes)).toContain("$global:LASTEXITCODE"); + }), + ); + it.effect("shows a startup prompt and types only once the line editor starts", () => Effect.gen(function* () { const { result, writes, outputLines, emit } = yield* setup(); @@ -160,13 +181,20 @@ describe("ProjectSetupScriptRunner", () => { }), ); - it.effect("does not type into a shell that exits during startup", () => - Effect.gen(function* () { - const { completion, writes, emit } = yield* setup(); - yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); - expect(yield* completion).toMatchObject({ exitCode: null }); - yield* TestClock.adjust("1 minute"); - expect(yield* Queue.size(writes)).toBe(0); - }), + it.effect.each([false, true])( + "does not type into a shell that exits during startup (before shell lookup: %s)", + (beforeShellLookup) => + Effect.gen(function* () { + const { completion, writes, emit, listeners } = yield* setup( + beforeShellLookup ? { shell: null } : {}, + ); + if (!beforeShellLookup) { + yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); + } + expect(yield* completion).toMatchObject({ exitCode: null }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + expect(listeners.size).toBe(0); + }), ); }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 095f20938c8b0..ec11ce9840448 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,4 @@ import { linkOriginIsFenced, ProjectId, type ProjectScript, ThreadId } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { projectScriptRuntimeEnv, projectScriptTerminalInput, @@ -107,9 +106,19 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedError

()( + "ProjectSetupScriptUnsupportedShellError", + { shell: Schema.String }, +) { + override get message(): string { + return "Project script completion is not supported in cmd.exe. Install PowerShell and retry."; + } +} + export const ProjectSetupScriptRunnerError = Schema.Union([ ProjectSetupScriptOperationError, ProjectSetupScriptProjectNotFoundError, + ProjectSetupScriptUnsupportedShellError, ]); export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type; @@ -159,13 +168,9 @@ function lineEditorReading(text: string, previous: boolean): boolean { type CompletionShell = "posix" | "fish" | "powershell"; -function resolveCompletionShell( - platform: NodeJS.Platform, - env: NodeJS.ProcessEnv, -): CompletionShell { - if (platform === "win32") return "powershell"; - const shell = env.SHELL ?? ""; - const name = shell.split("/").at(-1) ?? shell; +function resolveCompletionShell(shell: string): CompletionShell | null { + const name = (shell.split(/[/\\]/).at(-1) ?? shell).toLowerCase().replace(/\.exe$/, ""); + if (name === "cmd") return null; if (name === "fish") return "fish"; if (name === "pwsh" || name === "powershell") return "powershell"; return "posix"; @@ -196,10 +201,6 @@ export const make = Effect.gen(function* () { const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; const serviceScope = yield* Effect.scope; - const completionShell = resolveCompletionShell( - yield* HostProcess.Platform, - yield* HostProcess.Environment, - ); const observeTerminalCompletion = Effect.fn("ProjectSetupScriptRunner.observeTerminalCompletion")( function* (input: { @@ -444,15 +445,7 @@ export const make = Effect.gen(function* () { const completionToken = observe ? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)).replaceAll("-", "") : null; - const commandLine = - observe && completionToken - ? wrapCommandForCompletion( - script.command, - completionShell, - completionSentinel(completionToken), - ) - : script.command; - + const echoedWrapperLines: string[] = []; // Subscribe before opening so neither startup activity nor the sentinel can race the listeners. const observed = observe && completionToken @@ -461,49 +454,76 @@ export const make = Effect.gen(function* () { terminalId, sentinel: completionSentinel(completionToken), sentinelPattern: completionSentinelPattern(completionToken), - echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0), + echoedWrapperLines, onOutputLine: observe.onOutputLine, }) : undefined; - // PowerShell has no line-editor signal; keep its immediate write. - const readiness = - completionShell === "powershell" - ? undefined - : yield* watchShellReadiness({ - threadId: input.threadId, - terminalId, - onStartupPrompt: observed?.flushPartialLine ?? Effect.void, - }); - - yield* terminalManager - .open({ - threadId: input.threadId, - terminalId, - cwd, - worktreePath: input.worktreePath, - env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "openTerminal", - cause, - }), - ), - Effect.onError(() => - Effect.sync(() => { - observed?.unsubscribe(); - readiness?.unsubscribe(); - }), - ), - ); + const readiness = yield* watchShellReadiness({ + threadId: input.threadId, + terminalId, + onStartupPrompt: observed?.flushPartialLine ?? Effect.void, + }); + const { completionShell, commandLine } = yield* Effect.gen(function* () { + const shell = yield* terminalManager + .open({ + threadId: input.threadId, + terminalId, + cwd, + worktreePath: input.worktreePath, + env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, + }) + .pipe( + Effect.andThen(() => + terminalManager + .getShell({ threadId: input.threadId, terminalId }) + .pipe(Effect.catchTags({ TerminalNotRunningError: () => Effect.succeed(null) })), + ), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); + if (shell === null) { + yield* observed?.abandon ?? Effect.void; + return { completionShell: null, commandLine: null }; + } + const completionShell = resolveCompletionShell(shell); + let commandLine = script.command; + if (observe && completionToken) { + if (completionShell === null) { + yield* terminalManager.closeIdle({ threadId: input.threadId, terminalId }); + return yield* new ProjectSetupScriptUnsupportedShellError({ shell }); + } + commandLine = wrapCommandForCompletion( + script.command, + completionShell, + completionSentinel(completionToken), + ); + } + echoedWrapperLines.push(...commandLine.split("\r").filter((line) => line.length > 0)); + return { completionShell, commandLine }; + }).pipe( + Effect.onError(() => + Effect.sync(() => { + observed?.unsubscribe(); + readiness.unsubscribe(); + }), + ), + ); // Return once the terminal exists so callers can show it while a startup prompt waits. - yield* (readiness?.wait ?? Effect.succeed("ready" as const)).pipe( + // PowerShell and cmd have no line-editor signal. + yield* ( + completionShell === "posix" || completionShell === "fish" + ? readiness.wait + : Effect.succeed("ready" as const) + ).pipe( Effect.flatMap((shell) => - shell === "exited" + shell === "exited" || commandLine === null ? Effect.void : terminalManager.write({ threadId: input.threadId, @@ -518,7 +538,7 @@ export const make = Effect.gen(function* () { cause, }).pipe(Effect.andThen(observed?.abandon ?? Effect.void)), ), - Effect.ensuring(Effect.sync(() => readiness?.unsubscribe())), + Effect.ensuring(Effect.sync(() => readiness.unsubscribe())), Effect.forkIn(serviceScope), ); diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index 825e6c1cc8f43..0c4c7a44a5f25 100644 --- a/apps/server/src/project/http.ts +++ b/apps/server/src/project/http.ts @@ -14,8 +14,9 @@ import { } from "../auth/http.ts"; import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; +import * as ProjectCloneTracker from "./ProjectCloneTracker.ts"; import * as ProjectService from "./ProjectService.ts"; -import { projectMutationOperation } from "./ProjectMutation.ts"; +import { mutateProjectWithClone } from "./ProjectMutation.ts"; const failProjectMutation = Effect.fn("environment.projects.failMutation")(function* ( cause: ProjectService.ProjectServiceError | ServerRuntimeStartup.ServerRuntimeStartupError, @@ -36,6 +37,7 @@ export const layer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const projects = yield* ProjectService.ProjectService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; + const projectClones = yield* ProjectCloneTracker.ProjectCloneTracker; return handlers .handle( @@ -56,7 +58,7 @@ export const layer = HttpApiBuilder.group( for (const scope of requiredScopesForProjectMutation(args.payload)) { yield* requireEnvironmentScope(scope); } - const operation = projectMutationOperation(projects, args.payload); + const operation = mutateProjectWithClone(projects, projectClones, args.payload); return yield* startup .enqueueCommand(operation) .pipe(traceLocalHandlerWork, Effect.catch(failProjectMutation)); diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 9a26b53bff4e6..885053c7d0f24 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -29,6 +29,7 @@ import { RemoteDelegation } from "../peer/RemoteDelegation.ts"; import { PreviewManager } from "../preview/Manager.ts"; import { PreviewBrowser } from "../preview/PreviewBrowser.ts"; import { ManagedProjectFolders } from "../project/ManagedProjectFolders.ts"; +import { ProjectCloneTracker } from "../project/ProjectCloneTracker.ts"; import { ProjectService } from "../project/ProjectService.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; import { PullRequestReviewGuideService } from "../pullRequest/PullRequestReviewGuideService.ts"; @@ -58,6 +59,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(PreviewAutomationBroker)({}), Layer.mock(PreviewBrowser)({}), Layer.mock(PreviewManager)({}), + Layer.mock(ProjectCloneTracker)({}), Layer.mock(ProjectService)({}), Layer.mock(ProjectSetupScriptRunner)({}), Layer.mock(ProviderAdapterRegistryV2)({}), diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 902e6508ca25d..84df7d6201901 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -314,7 +314,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma return yield* installationError( "resolve", releaseAsset - ? "Antigravity is not installed. Install it in this environment or set a custom executable path." + ? "Antigravity ACP runtime is not installed. Install it in this environment's provider settings or set a custom ACP executable path. The Antigravity app, IDE, and CLI are separate installations." : `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported environment or a custom executable.`, ); }, diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index db7d22a45e0cd..b5e9f257d17b3 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -225,7 +225,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func : failure?._tag === "ProviderSetupError" ? failure.detail.trim() || "Antigravity could not complete its local health check." : missingInstallation - ? "Antigravity is not installed or its executable could not be found." + ? "Antigravity ACP runtime is not installed or its executable could not be found." : failure ? "Antigravity could not complete its local health check." : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 5bc82a41d4eda..95f5c6709a4ea 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 } from "./Drivers/ClaudeSkills.ts"; +import { discoverClaudeSkills, readClaudeSettingsBaseUrl } from "./Drivers/ClaudeSkills.ts"; import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; import { @@ -78,11 +78,10 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | switch (normalized) { case "claudemaxsubscription": - return "Max"; case "claudemax5xsubscription": - return "Max 5x"; + return "Max"; case "claudemax20xsubscription": - return "Max 20x"; + return "Ultra"; case "claudeenterprisesubscription": return "Enterprise"; case "claudeteamsubscription": @@ -93,11 +92,10 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | return "Free"; case "max": case "maxplan": - return "Max"; case "max5": - return "Max 5x"; + return "Max"; case "max20": - return "Max 20x"; + return "Ultra"; case "enterprise": return "Enterprise"; case "team": @@ -602,7 +600,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( } // First-party account fields cannot verify custom endpoint credentials. - const endpoint = URL.parse(resolvedEnvironment.ANTHROPIC_BASE_URL?.trim() ?? ""); + const baseUrl = + (yield* readClaudeSettingsBaseUrl(claudeSettings, cwd, resolvedEnvironment)) ?? + resolvedEnvironment.ANTHROPIC_BASE_URL; + const endpoint = URL.parse(baseUrl?.trim() ?? ""); const customApiEndpoint = endpoint !== null && (endpoint.protocol === "http:" || endpoint.protocol === "https:") && @@ -614,6 +615,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + updateRequiredModels, slashCommands: dedupedSlashCommands, skills, probe: { @@ -626,13 +628,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + // A proxy's ANTHROPIC_AUTH_TOKEN reads as an API key; the endpoint is what the token is for. const authMetadata = + apiProviderAuthMetadata(capabilities.apiProvider) ?? + (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined) ?? claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, - }) ?? - apiProviderAuthMetadata(capabilities.apiProvider) ?? - (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined); + }); const usageCheckedAt = capabilities.usageCheckedAt ?? checkedAt; const usageLimits = !capabilities.usage ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) diff --git a/apps/server/src/provider/CodexChatGptModels.test.ts b/apps/server/src/provider/CodexChatGptModels.test.ts index 9533a3c64d00a..570cea5f34d90 100644 --- a/apps/server/src/provider/CodexChatGptModels.test.ts +++ b/apps/server/src/provider/CodexChatGptModels.test.ts @@ -19,11 +19,40 @@ it.effect( models: request.headers.authorization === "Bearer account-a" ? [ - { slug: "second", display_name: "Second from OpenAI", visibility: "list" }, - { slug: "hidden", display_name: "Hidden", visibility: "hidden" }, - { slug: "first", display_name: "First from OpenAI", visibility: "list" }, + { + slug: "second", + display_name: "Second from OpenAI", + visibility: "list", + default_reasoning_level: "low", + supported_reasoning_levels: [ + { effort: "low", description: "Fast" }, + { effort: "high", description: "Deep" }, + ], + }, + { + slug: "hidden", + display_name: "Hidden", + visibility: "hidden", + default_reasoning_level: null, + supported_reasoning_levels: null, + }, + { + slug: "first", + display_name: "First from OpenAI", + visibility: "list", + default_reasoning_level: "high", + supported_reasoning_levels: [{ effort: "high", description: "Deep" }], + }, ] - : [{ slug: "account-b-only", display_name: "B", visibility: "list" }], + : [ + { + slug: "account-b-only", + display_name: "B", + visibility: "list", + default_reasoning_level: null, + supported_reasoning_levels: null, + }, + ], }), ), ); @@ -49,7 +78,18 @@ it.effect( ], ); assert.deepEqual(a[1]!.capabilities, native[0]!.capabilities); - assert.isNull(a[0]!.capabilities); + assert.deepEqual(a[0]!.capabilities?.optionDescriptors, [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "low", + }, + ]); const b = yield* chatGptModels("account-b", native).pipe( Effect.provideService(HttpClient.HttpClient, http), ); @@ -57,6 +97,7 @@ it.effect( b.map((model) => model.slug), ["account-b-only"], ); + assert.isNull(b[0]!.capabilities); assert.deepEqual(requests, ["Bearer account-a", "Bearer account-b"]); }), ); diff --git a/apps/server/src/provider/CodexChatGptModels.ts b/apps/server/src/provider/CodexChatGptModels.ts index f3490b6cf737e..ce64f0409d6fd 100644 --- a/apps/server/src/provider/CodexChatGptModels.ts +++ b/apps/server/src/provider/CodexChatGptModels.ts @@ -2,6 +2,7 @@ import type { ServerProviderModel } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest } from "effect/http"; +import { mapCodexModelCapabilities } from "./CodexProvider.ts"; export class ChatGptCatalogError extends Schema.TaggedError()( "ChatGptCatalogError", @@ -14,6 +15,17 @@ const Catalog = Schema.Struct({ slug: Schema.NonEmptyString, display_name: Schema.NonEmptyString, visibility: Schema.String, + default_reasoning_level: Schema.optionalKey(Schema.NullOr(Schema.NonEmptyString)), + supported_reasoning_levels: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + effort: Schema.NonEmptyString, + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + }), + ), + ), + ), }), ), }); @@ -32,15 +44,31 @@ export const chatGptModels = Effect.fn("chatGptModels")(function* ( const catalog = yield* response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Catalog))); return catalog.models .filter((model) => model.visibility === "list") - .map( - (model) => - ({ - ...nativeModels.find((native) => native.slug === model.slug), - capabilities: - nativeModels.find((native) => native.slug === model.slug)?.capabilities ?? null, - slug: model.slug, - name: model.display_name, - isCustom: false, - }) satisfies ServerProviderModel, - ); + .map((model): ServerProviderModel => { + const native = nativeModels.find((candidate) => candidate.slug === model.slug); + // A pinned Codex CLI can predate a model the account already has; the catalog still lists its efforts. + const catalogCapabilities = + model.default_reasoning_level && model.supported_reasoning_levels + ? mapCodexModelCapabilities({ + id: model.slug, + model: model.slug, + displayName: model.display_name, + description: "", + hidden: false, + isDefault: false, + defaultReasoningEffort: model.default_reasoning_level, + supportedReasoningEfforts: model.supported_reasoning_levels.map((level) => ({ + reasoningEffort: level.effort, + description: level.description ?? "", + })), + }) + : null; + return { + ...native, + capabilities: native?.capabilities ?? catalogCapabilities, + slug: model.slug, + name: model.display_name, + isCustom: false, + }; + }); }, Effect.timeout("15 seconds")); diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index 849b3c928af8b..e0683199ffd5f 100644 --- a/apps/server/src/provider/CodexInstallation.test.ts +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -13,7 +13,8 @@ import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as CodexInstallation from "./CodexInstallation.ts"; @@ -31,7 +32,8 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( input: { options?: Partial; manifestCurrent?: Effect.Effect; - body?: Stream.Stream; + body?: Stream.Stream; + downloadStatus?: number; baseDir?: string; local?: { version: string; appServerFails?: boolean; versionFails?: boolean }; platform?: NodeJS.Platform; @@ -106,7 +108,10 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( Effect.sync(() => { downloads++; return Object.defineProperty( - HttpClientResponse.fromWeb(request, new Response(null)), + HttpClientResponse.fromWeb( + request, + new Response(null, { status: input.downloadStatus ?? 200 }), + ), "stream", { value: input.body ?? Stream.succeed(archive), @@ -366,6 +371,81 @@ it.effect("rejects corrupted downloads without publishing a runtime", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect.each(["interrupted body", "HTTP 404"] as const)( + "reports a download failure for %s without blaming disk access", + (failure) => + Effect.gen(function* () { + const request = HttpClientRequest.get(asset.url); + const response = HttpClientResponse.fromWeb(request, new Response(null)); + const h = yield* makeHarness( + failure === "HTTP 404" + ? { downloadStatus: 404 } + : { + body: Stream.concat( + Stream.succeed(archive.subarray(0, 10)), + Stream.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.DecodeError({ + request, + response, + cause: new TypeError("terminated"), + }), + }), + ), + ), + }, + ); + yield* h.installation.start; + expect(yield* terminalState(h.installation)).toMatchObject({ + phase: "failed", + message: + failure === "HTTP 404" + ? "The Codex download server returned HTTP 404. Try again later." + : "The Codex download was interrupted. Check your internet connection, then retry installation.", + }); + expect(h.downloads()).toBe(1); + expect(yield* h.fs.readDirectory(h.installation.managedDirectory)).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "preserves the filesystem failure message when installation cannot create its directory", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-install-test-" }); + const baseDir = `${directory}/not-a-directory`; + yield* fs.writeFileString(baseDir, "existing file"); + const h = yield* makeHarness({ baseDir }); + yield* h.installation.start; + expect(yield* terminalState(h.installation)).toMatchObject({ + phase: "failed", + message: "Could not install Codex. Check disk space and directory access, then try again.", + }); + expect(h.downloads()).toBe(0); + expect(yield* fs.readFileString(baseDir)).toBe("existing file"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reports a download timeout and cleans the staging directory", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); + const h = yield* makeHarness({ + body: Stream.concat(Stream.succeed(archive.subarray(0, 10)), Stream.fromEffect(stall)), + }); + yield* h.installation.start; + yield* Deferred.await(started); + yield* TestClock.adjust("45 minutes"); + expect(yield* terminalState(h.installation)).toMatchObject({ + phase: "failed", + message: "The Codex download timed out. Retry installation.", + }); + expect(h.downloads()).toBe(1); + expect(yield* h.fs.readDirectory(h.installation.managedDirectory)).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("cancels an in-flight download and cleans the staging directory", () => Effect.gen(function* () { const downloading = yield* Deferred.make(); diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index 758eb406f1b54..7cd973b73fa76 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -1,5 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest. -import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; +import { + ProviderDriverKind, + type ProviderInstallState, + type ServerProviderCompatibilityAdvisory, +} from "@t3tools/contracts"; import { cliArchiveTarCommand } from "@t3tools/shared/cliRelease"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -125,6 +129,9 @@ interface CodexInstallationService { readonly managedDirectory: string; readonly resolve: () => Effect.Effect; readonly acquire: () => Effect.Effect; + readonly compatibility: ( + version: string, + ) => Effect.Effect; readonly start: Effect.Effect; readonly cancel: ( operationId: string, @@ -539,6 +546,14 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function ), Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })), Effect.timeout("45 minutes"), + Effect.catchTags({ + TimeoutError: (cause) => + installationError( + "download", + "The Codex download timed out. Retry installation.", + cause, + ), + }), ); if (downloadedBytes !== release.archiveBytes || hash.digest("hex") !== release.sha256) return yield* installationError( @@ -605,6 +620,20 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function yield* activate(); }, Effect.scoped, + Effect.catchTags({ + HttpClientError: (cause) => { + const httpStatus = + cause.reason._tag === "StatusCodeError" ? cause.reason.response.status : undefined; + return new CodexInstallationError({ + operation: "download", + detail: + httpStatus === undefined + ? "The Codex download was interrupted. Check your internet connection, then retry installation." + : `The Codex download server returned HTTP ${httpStatus}. Try again later.`, + cause, + }); + }, + }), Effect.mapError( wrapFailure( "install", @@ -796,6 +825,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function managedDirectory, resolve, acquire, + compatibility, start, cancel, state: SubscriptionRef.get(state), diff --git a/apps/server/src/provider/CodexMcpElicitation.ts b/apps/server/src/provider/CodexMcpElicitation.ts index 7703b02f03b94..eafe09e07e706 100644 --- a/apps/server/src/provider/CodexMcpElicitation.ts +++ b/apps/server/src/provider/CodexMcpElicitation.ts @@ -76,7 +76,14 @@ function mcpElicitationPersistenceDecision( return null; } -function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) { +// Claude hands over the same MCP request with its own envelope; these are the +// only fields the response is built from. +type McpElicitationResponseInput = { + readonly mode: string; + readonly requestedSchema?: unknown; +}; + +function mcpElicitationFormFields(payload: McpElicitationResponseInput) { if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) { return undefined; } @@ -171,7 +178,7 @@ export function describeMcpElicitation( } export function toMcpElicitationResponse( - payload: EffectCodexSchema.McpServerElicitationRequestParams, + payload: McpElicitationResponseInput, decision: ProviderApprovalDecision, ): EffectCodexSchema.McpServerElicitationRequestResponse { if (decision === "decline" || decision === "cancel") { @@ -196,8 +203,9 @@ export function toMcpElicitationResponse( const chosenOption = options.find((option) => persist ? mcpElicitationPersistenceDecision(option.value) === decision - : /once|accept|approve|allow/i.test(option.value) && - mcpElicitationPersistenceDecision(option.value) === null, + : /^(?:once|accept|approve|allow)(?:[_\s-]*(?:once(?:[_\s-]*only)?|this[_\s-]*time))?$/i.test( + option.value.trim(), + ) && mcpElicitationPersistenceDecision(option.value) === null, ); if (chosenOption) { content[key] = chosenOption.value; diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 3346b3e9b5e96..9c577d30ae149 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -39,7 +39,9 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts import { buildServerProvider, COMPACT_SLASH_COMMAND, + DEFAULT_TIMEOUT_MS, providerModelsFromSettings, + spawnAndCollect, type ServerProviderDraft, } from "@t3tools/provider-core/server/snapshotProbe"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; @@ -75,6 +77,9 @@ const CODEX_CLIENT_NAME = "T3 Code"; // Handshake plus account/models/skills requests can exceed the shared 10s probe budget. const CODEX_AUTH_PROBE_TIMEOUT_MS = 20_000; const CODEX_DISCOVERY_MARGIN_MS = 250; +// `codex-cli 0.159.0`, `codex-cli 0.160.0-alpha.1` or the npm CLI's bare `0.1.4161006`. +// Keep a prerelease suffix so compatibility stays unknown for it, as it does for `initialize`. +const CODEX_CLI_VERSION_PATTERN = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$)/u; const CODEX_PRESENTATION = { displayName: "Codex", @@ -459,22 +464,29 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: { - readonly binaryPath: string; +const codexProcessEnvironment = Effect.fn("codexProcessEnvironment")(function* (input: { readonly homePath?: string | undefined; - readonly launchArgs?: string | undefined; - readonly cwd: string; readonly environment?: NodeJS.ProcessEnv | undefined; }) { // Expand CODEX_HOME explicitly: spawn passes a literal ~ that Codex rejects. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath, yield* HostProcess.HomeDirectory) : undefined; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const environment = { + return { ...codexAppServerEnvironment(input.environment), ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; +}); + +export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string | undefined; + readonly launchArgs?: string | undefined; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv | undefined; +}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = yield* codexProcessEnvironment(input); const spawnCommand = yield* resolveSpawnCommand( input.binaryPath, codexAppServerArgs(input.launchArgs), @@ -594,6 +606,39 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; }); +// Old CLIs can exit before initialize reports the version needed for compatibility advice. +const probeCodexCliVersion = Effect.fn("probeCodexCliVersion")( + function* (input: { + readonly binaryPath: string; + readonly homePath?: string | undefined; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv | undefined; + }) { + const environment = yield* codexProcessEnvironment(input); + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["--version"], { + env: environment, + extendEnv: true, + }); + const result = yield* spawnAndCollect( + input.binaryPath, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ); + if (result.code !== 0) return null; + return CODEX_CLI_VERSION_PATTERN.exec(result.stdout)?.[1] ?? null; + }, + Effect.timeoutOption(Duration.millis(DEFAULT_TIMEOUT_MS)), + Effect.map(Option.getOrNull), + Effect.catch((cause) => + Effect.logDebug("Codex --version fallback failed.", { cause }).pipe(Effect.as(null)), + ), +); + export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { readonly binaryPath: string; readonly homePath?: string; @@ -739,6 +784,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (Result.isFailure(probeResult)) { const error = probeResult.failure; const installed = !isCodexAppServerSpawnError(error); + const version = installed + ? yield* probeCodexCliVersion({ + binaryPath: codexSettings.binaryPath, + homePath: codexSettings.homePath, + cwd: process.cwd(), + environment: resolvedEnvironment, + }) + : null; return buildServerProvider({ presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, @@ -747,7 +800,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu skills: [], probe: { installed, - version: null, + version, status: "error", auth: { status: "unknown" }, message: installed diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index bc68e42b76a2a..6aff76d250a76 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -119,7 +119,8 @@ function claudeManagedSettingsPath( return "/etc/claude-code/managed-settings.json"; } -function skillOverrideSettingsPaths( +/** Claude Code settings files, lowest precedence first. */ +function claudeSettingsPaths( path: Path.Path, configDirPath: string, cwd: string | undefined, @@ -247,7 +248,7 @@ const readSkillSettings = Effect.fn("readSkillSettings")(function* ( const enabledPlugins = new Map(); const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd); - for (const settingsPath of skillOverrideSettingsPaths( + for (const settingsPath of claudeSettingsPaths( path, configDirPath, cwd, @@ -427,6 +428,43 @@ 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 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* ( + config: Pick, + cwd: string | undefined, + environment: NodeJS.ProcessEnv, +): 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; + for (const settingsPath of claudeSettingsPaths( + path, + configDirPath, + undefined, + platform, + environment, + )) { + const contents = yield* fileSystem + .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; + } + return baseUrl; +}); + // Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray { const directories: string[] = []; diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 0aacca303e6a6..38de3c92a9570 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -667,7 +667,9 @@ it.layer(layerTest)("CodexDriver", (it) => { homePath: NodePath.join(tempDir, "codex-home"), }, }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); - const capabilities = yield* instance.snapshot.resolveMaintenance(); + const capabilities = yield* instance.snapshot + .resolveMaintenance() + .pipe(Effect.provideService(HostProcess.Environment, { PATH: "" })); const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( Effect.provideServiceEffect( ProviderLatestVersions.ProviderLatestVersions, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index bbd910cf5d4a9..97ec4954ed840 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -147,6 +147,7 @@ export const CodexDriver: ProviderDriver< const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const installation = yield* CodexInstallation.CodexInstallation; const modelCatalog = yield* ModelCatalog.ModelCatalog; const currentCatalog = modelCatalog.current(DRIVER_KIND); const processEnv = yield* mergeProviderInstanceEnvironment(environment); @@ -199,7 +200,10 @@ export const CodexDriver: ProviderDriver< enabled, config, }, - { onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + { + onUsageLimits: (update) => snapshot.applyUsageLimits(update), + compatibility: installation.compatibility, + }, ).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts index d9ee4f4a04ff5..3820ce0cf5234 100644 --- a/apps/server/src/provider/Drivers/codexUsage.test.ts +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { totalTokens } from "@t3tools/provider-core/server/usage"; import { codexUsageFormat, initialCodexScanState, parseCodexLine } from "./codexUsage.ts"; @@ -373,6 +374,106 @@ describe("parseCodexLine", () => { expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); }); + it("ignores a replay of the current sequence's first request without rewinding the baseline", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const request = counters(100, 20); + const processed = [ + counted(request, request), + counted(request, counters(200, 40)), + counted(request, request), + counted(request, counters(300, 60)), + ].map((line) => { + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }); + + expect({ + processed, + skipped: codexUsageFormat.readMalformedRecords?.(state), + }).toEqual({ processed: [120, 120, 0, 120], skipped: 0 }); + }); + + it("bills a genuine restart with the same counters at a new timestamp", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const request = counters(100, 20); + const lines = [ + counted(request, request), + counted(request, counters(200, 40)), + counted(request, request).replace("05:17:49.919Z", "05:18:49.919Z"), + counted(counters(30, 5), counters(130, 25)), + ]; + const processed = lines.map((line) => { + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }); + expect({ processed, skipped: codexUsageFormat.readMalformedRecords?.(state) }).toEqual({ + processed: [120, 120, 120, 35], + skipped: 0, + }); + }); + + it.each([ + { + reset: true, + markerTotal: counters(0, 0), + nextTotal: counters(130, 25), + processed: [120, 120, 0, 120, 35], + }, + { + reset: false, + markerTotal: counters(200, 40), + nextTotal: counters(230, 45), + processed: [120, 120, 0, 0, 35], + }, + ])( + "bills a request equal to the old sequence start only after a context marker reset the counters (reset: $reset)", + ({ markerTotal, nextTotal, processed }) => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const request = counters(100, 20); + const marker = counted({ ...counters(0, 0), total_tokens: 26387 }, markerTotal, { + model_context_window: 475000, + }); + const billed = [ + counted(request, request), + counted(request, counters(200, 40)), + marker, + counted(request, request), + counted(counters(30, 5), nextTotal), + ].map((line) => { + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }); + + expect({ + processed: billed, + skipped: codexUsageFormat.readMalformedRecords?.(state), + }).toEqual({ processed, skipped: 0 }); + }, + ); + + it("forgets the old sequence start when a malformed pair zeroes the cumulative baseline", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const request = counters(100, 20); + const processed = [ + counted(request, request), + counted(counters(30, 5), counters(0, 0)), + counted(request, request), + counted(counters(30, 5), counters(130, 25)), + ].map((line) => { + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }); + + expect({ + processed, + skipped: codexUsageFormat.readMalformedRecords?.(state), + }).toEqual({ processed: [120, 0, 120, 35], skipped: 1 }); + }); + it("retains initial tier evidence only for the own OpenAI session and exact context until native settings arrive", () => { const meta = (id: string, model_provider?: string) => JSON.stringify({ type: "session_meta", payload: { id, model_provider } }); @@ -535,5 +636,33 @@ describe("parseCodexLine", () => { ); expect(record).not.toBeNull(); }); + + it("forgets the old sequence start when a copied zero baseline rebases the counters", () => { + const state = initialCodexScanState(); + const forkInstant = "2026-08-01T05:00:00.000Z"; + const request = counters(100, 20); + const billed = (line: string) => { + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }; + parseCodexLine(stamped("2026-08-01T04:59:00.000Z", turnContext), state); + const first = billed(stamped("2026-08-01T04:59:01.000Z", counted(request, request))); + parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state); + const copied = billed( + stamped( + "2026-08-01T05:00:00.001Z", + counted({ ...counters(0, 0), total_tokens: 26387 }, counters(0, 0)), + ), + ); + const afterBurst = billed(stamped("2026-08-01T05:00:06.000Z", counted(request, request))); + const next = billed( + stamped("2026-08-01T05:00:06.100Z", counted(counters(30, 5), counters(130, 25))), + ); + + expect({ + processed: [first, copied, afterBurst, next], + skipped: codexUsageFormat.readMalformedRecords?.(state), + }).toEqual({ processed: [120, 0, 120, 35], skipped: 0 }); + }); }); }); diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index 38615eb2cb0cb..20939e54db799 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -55,6 +55,12 @@ function readCodexTokenUsage(value: unknown): CodexTokenUsage | null { return isCodexTokenUsage(usage) ? usage : 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; + if (cumulative.input_tokens + cumulative.output_tokens === 0) delete state.sequenceStartSignature; +} + function advanceCodexUsage(previous: CodexTokenUsage | null, last: CodexTokenUsage) { return readCodexTokenUsage( Object.fromEntries(CODEX_USAGE_FIELDS.map((key) => [key, (previous?.[key] ?? 0) + last[key]])), @@ -71,6 +77,8 @@ export const CodexScanState = Schema.Struct({ 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)), malformedRecords: Schema.mutableKey(TokenCount), sawSessionMeta: Schema.mutableKey(Schema.Boolean), modelProvider: Schema.mutableKey(Schema.String), @@ -193,7 +201,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | 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) state.lastCumulativeUsage = cumulative; + if (cumulative !== null) rebaseCodexUsage(state, cumulative); else if (!hasCumulative && last !== null && signature !== state.lastUsageSignature) { state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, last); } @@ -206,7 +214,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | const previous = state.lastCumulativeUsage; // A valid snapshot remains the next baseline even when its paired request is malformed. - if (cumulative !== null) state.lastCumulativeUsage = cumulative; + if (cumulative !== null) rebaseCodexUsage(state, cumulative); if (last === null || (hasCumulative && cumulative === null)) { state.malformedRecords += 1; return null; @@ -236,6 +244,12 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | 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]); + const sequenceStart = JSON.stringify([timestampMs, last]); + // 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; + } // 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. const reconciled = @@ -248,6 +262,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | state.malformedRecords += 1; return null; } + if (restarted) state.sequenceStartSignature = sequenceStart; } else { if (signature === state.lastUsageSignature) return null; // Advance expected totals for billed legacy requests so a cumulative re-emission is stale. diff --git a/apps/server/src/provider/EventNdjsonLogger.test.ts b/apps/server/src/provider/EventNdjsonLogger.test.ts index af94ede2e5894..c04150dce8df6 100644 --- a/apps/server/src/provider/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/EventNdjsonLogger.test.ts @@ -420,6 +420,45 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); + it.effect("retires a thread log once a retention interval passes without writes", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const finishedPath = ownedLogPath(basePath, "finished"); + const activePath = ownedLogPath(basePath, "active"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { + batchWindowMs: 0, + }); + const logger = store.logger("native"); + + yield* logger.write({ id: "finished-first" }, ThreadId.make("finished")); + yield* TestClock.adjust("5 minutes"); + yield* logger.write({ id: "active-first" }, ThreadId.make("active")); + assert.equal(NodeFS.existsSync(finishedPath), true); + NodeFS.truncateSync(finishedPath, 512 * 1024 * 1024 + 1); + + yield* TestClock.adjust("5 minutes"); + yield* logger.write({ id: "active-second" }, ThreadId.make("active")); + assert.equal(NodeFS.existsSync(finishedPath), false); + assert.equal(NodeFS.existsSync(activePath), true); + + yield* logger.write({ id: "finished-again" }, ThreadId.make("finished")); + yield* store.close(); + assert.deepEqual( + NodeFS.readFileSync(finishedPath, "utf8") + .trim() + .split("\n") + .map((line) => parseLogLine(line).payload), + ['{"id":"finished-again"}'], + ); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + it.effect("attributes batches that were written before a later chunk fails", () => Effect.gen(function* () { const records: ReadonlyArray = [ diff --git a/apps/server/src/provider/EventNdjsonLogger.ts b/apps/server/src/provider/EventNdjsonLogger.ts index 00956a1bcffd4..0e8c9043dcf0f 100644 --- a/apps/server/src/provider/EventNdjsonLogger.ts +++ b/apps/server/src/provider/EventNdjsonLogger.ts @@ -114,6 +114,7 @@ interface StoreState { readonly pending: Array; readonly pendingBytes: number; readonly sinks: ReadonlyMap; + readonly writtenSinceRetention: ReadonlySet; readonly flushScheduled: boolean; readonly closed: boolean; readonly lastRetentionAt: number; @@ -483,6 +484,7 @@ const drainPending = Effect.fnUntraced(function* (input: { } const sinks = new Map(input.state.sinks); + const writtenSegments = new Set(input.state.writtenSinceRetention); const failures: Array = []; const attributionByStream = new Map< EventNdjsonStream, @@ -508,6 +510,7 @@ const drainPending = Effect.fnUntraced(function* (input: { sink = created.success; sinks.set(threadSegment, sink); } + writtenSegments.add(threadSegment); const written = yield* writeBatchedMessages( sink, @@ -534,6 +537,13 @@ const drainPending = Effect.fnUntraced(function* (input: { const retentionDue = input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs; + if (retentionDue) { + // A sink idle for a whole retention interval reopens from disk on its next + // write, so retention may remove its files without leaving a stale size. + for (const threadSegment of sinks.keys()) { + if (!writtenSegments.has(threadSegment)) sinks.delete(threadSegment); + } + } const retention = retentionDue ? yield* Effect.promise(() => enforceRetention({ @@ -563,6 +573,7 @@ const drainPending = Effect.fnUntraced(function* (input: { pending: [], pendingBytes: 0, sinks, + writtenSinceRetention: retentionDue ? new Set() : writtenSegments, flushScheduled: input.timerFired ? false : input.state.flushScheduled, closed: input.close, lastRetentionAt: retentionDue ? input.now : input.state.lastRetentionAt, @@ -629,6 +640,7 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( pending: [], pendingBytes: 0, sinks: new Map(), + writtenSinceRetention: new Set(), flushScheduled: false, closed: false, lastRetentionAt: initializedAt, diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index dc9c94e854f68..af3359ea7f27d 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -15,6 +15,7 @@ import { type ResolvedProviderInstanceEnvironment, ProviderInstanceId, SecretProviderId, + SettingsSecretValue, type SecretReference, } from "@t3tools/contracts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; @@ -23,6 +24,9 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -40,6 +44,7 @@ import * as ModelManifest from "./ModelManifest.ts"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; +import { ProviderDriverError } from "./Errors.ts"; import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; @@ -201,6 +206,136 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { Layer.provideMerge(layerBase), ); + it.effect.each(["replacement", "removal", "creation failure"] as const)( + "detaches retired provider scopes after repeated %s", + (scenario) => + Effect.gen(function* () { + const parent = yield* Scope.make(); + const fileSystem = yield* FileSystem.FileSystem; + const homePath = yield* fileSystem.makeTempDirectoryScoped(); + let creates = 0; + let closes = 0; + const driver: typeof CodexDriver = { + ...CodexDriver, + create: Effect.fn("ProviderInstanceRegistry.test.create")(function* ( + input: Parameters[0], + ) { + creates++; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closes++; + }), + ); + if (input.config.binaryPath === "creation-failure") { + return yield* new ProviderDriverError({ + driver: CodexDriver.driverKind, + instanceId: input.instanceId, + detail: "fixture creation failed", + }); + } + return yield* CodexDriver.create(input); + }), + }; + const siblingId = ProviderInstanceId.make("sibling"); + const retiredId = ProviderInstanceId.make("retired"); + const entry = { + driver: CodexDriver.driverKind, + enabled: false, + config: makeCodexConfig({ homePath }), + }; + yield* Effect.gen(function* () { + const { registry, mutator } = yield* makeProviderInstanceRegistry({ + drivers: [driver], + configMap: { [siblingId]: entry }, + }); + const sibling = yield* registry.getInstance(siblingId); + const finalizerCount = () => { + const state = parent.state; + return state._tag === "Open" ? (state.finalizers?.size ?? 1) : 0; + }; + const baseline = finalizerCount(); + for (let index = 0; index < 20; index++) { + yield* mutator.reconcile({ + [siblingId]: entry, + [retiredId]: { + ...entry, + config: makeCodexConfig({ + homePath, + binaryPath: scenario === "creation failure" ? "creation-failure" : "unused-codex", + customModels: [`revision-${index}`], + }), + }, + }); + if (scenario === "removal") yield* mutator.reconcile({ [siblingId]: entry }); + } + yield* mutator.reconcile({ [siblingId]: entry }); + expect(yield* registry.getInstance(siblingId)).toBe(sibling); + expect(closes).toBe(creates - 1); + expect(parent.state._tag).toBe("Open"); + expect(finalizerCount()).toBe(baseline); + }).pipe( + Effect.provideService(Scope.Scope, parent), + Effect.ensuring(Scope.close(parent, Exit.void)), + ); + expect(closes).toBe(creates); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect.each(["replacement", "removal"] as const)( + "hides retired instances during reconcile (%s)", + (scenario) => + Effect.gen(function* () { + let closed = 0; + const replacementStarted = yield* Deferred.make(); + const releaseReplacement = yield* Deferred.make(); + const driver: typeof CodexDriver = { + ...CodexDriver, + create: (input) => + Effect.gen(function* () { + if (input.displayName === "Second") { + yield* Deferred.succeed(replacementStarted, undefined); + yield* Deferred.await(releaseReplacement); + } + const instance = yield* CodexDriver.create(input); + yield* Effect.addFinalizer(() => Effect.sync(() => closed++)); + return instance; + }), + }; + const retiredId = ProviderInstanceId.make("retired"); + const siblingId = ProviderInstanceId.make("sibling"); + const nextId = scenario === "replacement" ? retiredId : ProviderInstanceId.make("new"); + const entry = { + driver: CodexDriver.driverKind, + enabled: false, + config: makeCodexConfig({}), + }; + const { registry, mutator } = yield* makeProviderInstanceRegistry({ + drivers: [driver], + configMap: { [retiredId]: entry, [siblingId]: entry }, + }); + const sibling = yield* registry.getInstance(siblingId); + const reconcile = yield* mutator + .reconcile({ + [siblingId]: entry, + [nextId]: { + ...entry, + displayName: "Second", + config: makeCodexConfig({ customModels: ["replacement-model"] }), + }, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(replacementStarted); + expect(closed).toBe(1); + expect(yield* registry.getInstance(retiredId)).toBeUndefined(); + expect(yield* registry.listInstances).toEqual([sibling]); + yield* Deferred.succeed(releaseReplacement, undefined); + yield* Fiber.join(reconcile); + expect((yield* registry.getInstance(nextId))?.displayName).toBe("Second"); + expect(yield* registry.getInstance(siblingId)).toBe(sibling); + expect(closed).toBe(1); + }).pipe(Effect.provide(layerTest)), + ); + it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () => Effect.gen(function* () { const staleId = ProviderInstanceId.make("codex_stale"); @@ -668,11 +803,13 @@ describe("ProviderInstanceRegistry secret environment", () => { function setup( initial: Record, configSchema: AnyProviderDriver["configSchema"] = Schema.Unknown, + config: unknown = {}, ) { const vault = new Map(Object.entries(initial)); const reads: Array> = []; const invalidated: Array> = []; const created: Array = []; + const configs: Array = []; const driver: AnyProviderDriver = { driverKind, metadata: { displayName: "Fake" }, @@ -681,6 +818,7 @@ describe("ProviderInstanceRegistry secret environment", () => { create: (input) => Effect.sync(() => { created.push(input.environment); + configs.push(input.config); return { instanceId: input.instanceId, enabled: input.enabled } as ProviderInstance; }), }; @@ -703,7 +841,7 @@ describe("ProviderInstanceRegistry secret environment", () => { const configMap: ProviderInstanceConfigMap = { [instanceId]: { driver: driverKind, - config: {}, + config, environment: [ { name: "OPENAI_API_KEY", value: secret, sensitive: true }, { name: "BASE_URL", value: "https://example.test", sensitive: false }, @@ -721,7 +859,7 @@ describe("ProviderInstanceRegistry secret environment", () => { yield* PubSub.take(changes); return built; }); - return { vault, reads, invalidated, created, make, makeGated, gate }; + return { vault, reads, invalidated, created, configs, make, makeGated, gate }; } it.effect("starts an instance with the secret its environment points at", () => { @@ -787,6 +925,26 @@ describe("ProviderInstanceRegistry secret environment", () => { }).pipe(Effect.scoped); }); + it.effect("reads a config field typed SettingsSecretValue and rebuilds when it rotates", () => { + const apiKey: SecretReference = { ...secret, reference: "op://Private/Gateway/key" }; + const fixture = setup( + { [secret.reference]: "sk-live", [apiKey.reference]: "gw-live" }, + Schema.Struct({ apiKey: SettingsSecretValue, model: Schema.String }), + { apiKey, model: "m" }, + ); + return Effect.gen(function* () { + const { mutator } = yield* fixture.make; + expect(fixture.reads.at(-1)).toEqual([secret.reference, apiKey.reference]); + + fixture.vault.set(apiKey.reference, "gw-rotated"); + yield* mutator.reloadSecrets(); + expect(fixture.configs).toEqual([ + { apiKey: "gw-live", model: "m" }, + { apiKey: "gw-rotated", model: "m" }, + ]); + }).pipe(Effect.scoped); + }); + it.effect("keeps a working instance live when a reload cannot read its secret", () => { const fixture = setup({ [secret.reference]: "sk-live" }); return Effect.gen(function* () { diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 70352c5ef8593..a944f751a1d34 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -7,6 +7,7 @@ import { type ProviderDriverKind, type ResolvedProviderInstanceEnvironment, type SecretReference, + secretReferencesInSettings, type ServerProvider, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -42,11 +43,16 @@ export class ProviderInstanceRegistry extends Context.Service< } >()("t3/provider/ProviderInstanceRegistry") {} +interface InstanceSecrets { + readonly environment: ResolvedProviderInstanceEnvironment; + readonly config: Readonly>; +} + interface LiveEntry { readonly instance: ProviderInstance; readonly scope: Scope.Closeable; readonly entry: ProviderInstanceConfig; - readonly environment: ResolvedProviderInstanceEnvironment; + readonly secrets: InstanceSecrets; } interface RegistryState { @@ -61,8 +67,18 @@ const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boole const secretReferencesIn = (environment: ProviderInstanceEnvironment): Array => environment.flatMap((variable) => (typeof variable.value === "string" ? [] : [variable.value])); -const secretReferencesOf = (entry: ProviderInstanceConfig): ReadonlyArray => - secretReferencesIn(entry.environment ?? []); +const configSecretReferences = ( + driver: AnyProviderDriver | undefined, + entry: ProviderInstanceConfig, +) => (driver === undefined ? [] : secretReferencesInSettings(driver.configSchema, entry.config)); + +const secretReferencesOf = ( + driversById: ReadonlyMap>, + entry: ProviderInstanceConfig, +): ReadonlyArray => [ + ...secretReferencesIn(entry.environment ?? []), + ...configSecretReferences(driversById.get(entry.driver), entry).map(({ reference }) => reference), +]; const withoutSecretReferences = ( environment: ProviderInstanceEnvironment, @@ -71,25 +87,38 @@ const withoutSecretReferences = ( typeof variable.value === "string" ? [{ ...variable, value: variable.value }] : [], ); -const readSecretEnvironment = Effect.fn("ProviderInstanceRegistry.readSecretEnvironment")( - function* (environment: ProviderInstanceEnvironment) { - const references = secretReferencesIn(environment); - const values = - references.length === 0 ? [] : yield* (yield* SecretResolver.SecretResolver).read(references); - const unreadable: Array = []; - let index = 0; - const resolved: ResolvedProviderInstanceEnvironment = environment.flatMap((variable) => { - if (typeof variable.value === "string") return [{ ...variable, value: variable.value }]; - const secret = values[index++]; - if (secret === undefined) { - unreadable.push(variable.name); - return []; - } - return [{ ...variable, value: secret, sensitive: true }]; - }); - return { environment: resolved, unreadable }; - }, -); +const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecrets")(function* ( + driver: AnyProviderDriver | undefined, + entry: ProviderInstanceConfig, +) { + const variables = entry.environment ?? []; + const configFields = configSecretReferences(driver, entry); + const references = [ + ...secretReferencesIn(variables), + ...configFields.map(({ reference }) => reference), + ]; + const values = + references.length === 0 ? [] : yield* (yield* SecretResolver.SecretResolver).read(references); + const unreadable: Array = []; + let index = 0; + const environment: ResolvedProviderInstanceEnvironment = variables.flatMap((variable) => { + if (typeof variable.value === "string") return [{ ...variable, value: variable.value }]; + const secret = values[index++]; + if (secret === undefined) { + unreadable.push(variable.name); + return []; + } + return [{ ...variable, value: secret, sensitive: true }]; + }); + const config: Record = {}; + for (const { field } of configFields) { + const secret = values[index++]; + if (secret === undefined) unreadable.push(field); + else config[field] = secret; + } + const secrets: InstanceSecrets = { environment, config }; + return { secrets, unreadable }; +}); /** Conflicting saved flags must preserve any explicit disable. */ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { @@ -164,7 +193,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* 0) { + if (deferSecrets && enabled && secretReferencesOf(driversById, entry).length > 0) { return { kind: "unavailable" as const, snapshot: yield* buildUnavailableProviderSnapshot({ @@ -176,9 +205,12 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* 0) { @@ -194,17 +226,19 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { replacedIds.add(instanceId); } } - for (const id of [...removedIds, ...replacedIds]) { + const retiredIds = [...removedIds, ...replacedIds]; + if (retiredIds.length > 0) { + const remaining = new Map(previousEntries); + for (const id of retiredIds) remaining.delete(id); + yield* Ref.set(state.entries, remaining); + } + for (const id of retiredIds) { const live = previousEntries.get(id); if (live) { yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore); @@ -286,7 +326,7 @@ const makeReconcile = (input: { const kept = previousEntries.has(instanceId) && !replacedIds.has(instanceId); return kept || !(yield* isEntryEnabled(driversById, entry)) ? [] - : secretReferencesOf(entry); + : secretReferencesOf(driversById, entry); }), ).pipe(Effect.map((perEntry) => perEntry.flat())); if (references.length > 0 && !deferSecrets) { @@ -408,13 +448,13 @@ export const makeProviderInstanceRegistry = Effect.fn( const configMap = yield* Ref.get(configMapRef); const targets = yield* Effect.filter(Object.entries(configMap), ([rawInstanceId, entry]) => (instanceId === undefined || rawInstanceId === instanceId) && - secretReferencesOf(entry).length > 0 + secretReferencesOf(driversById, entry).length > 0 ? isEntryEnabled(driversById, entry) : Effect.succeed(false), ); if (targets.length === 0) return; const resolver = yield* SecretResolver.SecretResolver; - const references = targets.flatMap(([, entry]) => secretReferencesOf(entry)); + const references = targets.flatMap(([, entry]) => secretReferencesOf(driversById, entry)); yield* resolver.invalidate(references); yield* resolver.read(references); const live = yield* Ref.get(entries); @@ -422,9 +462,12 @@ export const makeProviderInstanceRegistry = Effect.fn( for (const [rawInstanceId, entry] of targets) { const current = live.get(ProviderInstanceId.make(rawInstanceId)); if (current === undefined) continue; - const { environment, unreadable } = yield* readSecretEnvironment(entry.environment ?? []); + const { secrets, unreadable } = yield* readInstanceSecrets( + driversById.get(entry.driver), + entry, + ); // A failed re-read keeps the working instance and the value it already holds. - if (unreadable.length === 0 && !Equal.equals(environment, current.environment)) { + if (unreadable.length === 0 && !Equal.equals(secrets, current.secrets)) { rebuild.add(current.instance.instanceId); } } @@ -442,10 +485,12 @@ export const makeProviderInstanceRegistry = Effect.fn( ), ); const startupReferences = yield* Effect.filter(Object.entries(input.configMap), ([, entry]) => - secretReferencesOf(entry).length > 0 + secretReferencesOf(driversById, entry).length > 0 ? isEntryEnabled(driversById, entry) : Effect.succeed(false), - ).pipe(Effect.map((targets) => targets.flatMap(([, entry]) => secretReferencesOf(entry)))); + ).pipe( + Effect.map((targets) => targets.flatMap(([, entry]) => secretReferencesOf(driversById, entry))), + ); if (startupReferences.length > 0) { // The read waits outside the lock so other mutations are not held for a vault prompt; a // configuration saved meanwhile has already reconciled itself. diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 5e356c84516a8..60aad9c59977a 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1,3 +1,4 @@ +import * as CodexErrors from "effect-codex-app-server/errors"; import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; @@ -10,7 +11,9 @@ 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 FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -34,6 +37,7 @@ import { } from "@t3tools/contracts"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { deepMerge } from "@t3tools/shared/Struct"; import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; @@ -88,6 +92,7 @@ const disabledDefaultSlots = ( ); const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); +const decodeCodexSettings = Schema.decodeSync(CodexSettings); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); process.env.T3CODE_CURSOR_ENABLED = "1"; @@ -216,6 +221,20 @@ function layerMockSpawner( ); } +// A Codex CLI on disk whose `app-server` and `--version` behave as `script` says. +const writeFakeCodexCli = Effect.fn("writeFakeCodexCli")(function* (script: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fake-codex-" }); + const binaryPath = path.join(directory, "codex"); + yield* fs.writeFileString(binaryPath, `#!/bin/sh\n${script}\n`); + yield* fs.chmod(binaryPath, 0o755); + return { directory, binaryPath }; +}); + +// The npm `@openai/codex` 0.1 CLI treats `app-server` as a prompt and exits 1. +const OBSOLETE_CODEX_APP_SERVER = `echo "Missing OpenAI API key." >&2; exit 1`; + const codexModelCapabilities = createModelCapabilities({ optionDescriptors: [ selectDescriptor("reasoningEffort", "Reasoning", [ @@ -321,6 +340,13 @@ const layerTestNodeServices = Layer.mergeAll( Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), }), + // An empty home, so the developer's own Claude settings never reach provider probes. + Layer.effect( + HostProcess.HomeDirectory, + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); + }), + ).pipe(Layer.provide(NodeServices.layer)), ); it.layer( @@ -427,6 +453,137 @@ it.layer( assert.strictEqual(status.auth.status, "unknown"); }), ); + + it.effect("reads the version of a Codex CLI too old to run app-server (#20351)", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const codexHome = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-codex-home-" })), + ); + const { binaryPath } = yield* writeFakeCodexCli( + [ + `if [ "$1" = "--version" ]; then`, + // The fallback runs with the provider's environment and CODEX_HOME. + ` [ "$CODEX_HOME" = "${codexHome}" ] && [ "$FAKE_CODEX_ENV" = "kept" ] || exit 2`, + ` echo 0.1.4161006; exit 0`, + `fi`, + OBSOLETE_CODEX_APP_SERVER, + ].join("\n"), + ); + const settings = decodeCodexSettings({ binaryPath, homePath: codexHome }); + + const status = yield* checkCodexProviderStatus(settings, undefined, { + ...process.env, + FAKE_CODEX_ENV: "kept", + }); + + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, "0.1.4161006"); + assert.match(status.message ?? "", /^Codex app-server provider probe failed: /); + const classified = withBundledCompatibility({ + ...status, + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + }); + assert.strictEqual(classified.compatibilityAdvisory?.status, "broken"); + }), + ); + + it.effect("keeps a prerelease --version unclassified instead of reading it as stable", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const { binaryPath } = yield* writeFakeCodexCli( + [ + `[ "$1" = "--version" ] && { echo "codex-cli 0.159.0-alpha.1"; exit 0; }`, + OBSOLETE_CODEX_APP_SERVER, + ].join("\n"), + ); + + const status = yield* checkCodexProviderStatus(decodeCodexSettings({ binaryPath })); + + assert.strictEqual(status.version, "0.159.0-alpha.1"); + const classified = withBundledCompatibility({ + ...status, + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + }); + assert.strictEqual(classified.compatibilityAdvisory?.status, "unknown"); + }), + ); + + it.effect("keeps the probe failure without a version when --version also fails", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const { binaryPath } = yield* writeFakeCodexCli( + [ + `[ "$1" = "--version" ] && { echo "codex 0.1.0" >&2; exit 3; }`, + OBSOLETE_CODEX_APP_SERVER, + ].join("\n"), + ); + + const status = yield* checkCodexProviderStatus(decodeCodexSettings({ binaryPath })); + + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, null); + assert.match(status.message ?? "", /^Codex app-server provider probe failed: /); + }), + ); + + it.effect("bounds a hanging Codex --version fallback and releases its process scope", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const released = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Deferred.succeed(released, undefined)); + return ChildProcessSpawner.makeHandle({ + ...mockHandle({ stdout: "", stderr: "", code: 0 }), + exitCode: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + }); + }), + ); + const fiber = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.fail(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })), + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* Deferred.await(started); + yield* TestClock.adjust("4 seconds"); + const status = yield* Fiber.join(fiber); + assert.strictEqual(status.version, null); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.match(status.message ?? "", /^Codex app-server provider probe failed: /); + assert.isTrue(yield* Deferred.isDone(released)); + }), + ); + + it.effect("does not run --version when the Codex executable cannot start", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, binaryPath } = yield* writeFakeCodexCli( + `touch "$(dirname "$0")/ran"; echo 0.1.4161006`, + ); + + const status = yield* checkCodexProviderStatus(decodeCodexSettings({ binaryPath }), () => + Effect.fail( + new CodexErrors.CodexAppServerSpawnError({ + command: `${binaryPath} app-server`, + cause: new Error("spawn ENOENT"), + }), + ), + ); + + assert.strictEqual(status.installed, false); + assert.strictEqual(status.version, null); + assert.isFalse(yield* fs.exists(path.join(directory, "ran"))); + }), + ); }); describe("ProviderRegistry.layer", () => { @@ -2315,6 +2472,42 @@ it.layer( ), ); + it.effect("tells a logged-out CLI which models need a Claude Code update", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "none", + apiKeySource: "none", + apiProvider: "firstParty", + }), + { ANTHROPIC_BASE_URL: "" }, + undefined, + { + models: [ + { + model: { slug: "claude-next", name: "Next", isCustom: false, capabilities: null }, + runtime: {}, + compatibility: { minVersion: "1.1.0" }, + }, + ], + }, + ); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.deepStrictEqual(status.updateRequiredModels, [ + { slug: "claude-next", name: "Next", minVersion: "1.1.0" }, + ]); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect.each([ { source: "inherited HTTPS endpoint", baseUrl: "https://proxy.example/api", overrides: [] }, { @@ -2353,6 +2546,68 @@ it.layer( ), ); + it.effect.each([ + { + settings: { env: { ANTHROPIC_BASE_URL: "https://hub.example.ts.net:8317" } }, + baseUrl: "https://api.anthropic.com", + type: "customEndpoint", + label: "Custom API endpoint", + }, + { + settings: { env: { ANTHROPIC_BASE_URL: "https://api.anthropic.com" } }, + baseUrl: "https://proxy.example", + type: "apiKey", + label: "Claude API Key", + }, + { + settings: {}, + baseUrl: "https://proxy.example", + type: "customEndpoint", + label: "Custom API endpoint", + }, + ])( + "uses the settings endpoint before the process endpoint: $type / $baseUrl", + ({ settings, baseUrl, type, label }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-settings-", + }); + yield* fileSystem.writeFileString( + path.join(configDir, "settings.json"), + JSON.stringify(settings), + ); + const stateDir = yield* fileSystem.makeTempDirectoryScoped(); + yield* fileSystem.makeDirectory(path.join(stateDir, ".claude")); + yield* fileSystem.writeFileString( + path.join(stateDir, ".claude", "settings.json"), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: "https://state-dir.example" } }), + ); + const status = yield* checkClaudeProviderStatus( + { ...defaultClaudeSettings, homePath: configDir }, + claudeCapabilities({ + tokenSource: "ANTHROPIC_AUTH_TOKEN", + apiKeySource: "none", + apiProvider: "firstParty", + }), + { ANTHROPIC_BASE_URL: baseUrl }, + stateDir, + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.type, type); + assert.strictEqual(status.auth.label, label); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect.each([ "https://api.anthropic.com", "https://api.anthropic.com:443", diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index bdd8670762639..64e937afdf30f 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -44,6 +44,7 @@ import type { import { makeManualOnlyProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilities, + latestProviderVersionCacheKeys, } from "@t3tools/provider-core/server/maintenanceResolver"; import type { ProviderSnapshotSource } from "./builtInProviderCatalog.ts"; @@ -732,9 +733,11 @@ export const layer = Layer.effect( Effect.gen(function* () { yield* instance.invalidateCaches ?? Effect.void; const maintenance = yield* instance.snapshot.resolveMaintenance({ fresh: true }); - if (maintenance.packageName) { - yield* latestVersions.invalidate(maintenance.packageName); - } + yield* Effect.forEach( + latestProviderVersionCacheKeys(maintenance), + latestVersions.invalidate, + { discard: true }, + ); }).pipe(Effect.catchCause((cause) => recoverInstanceFailure(instance.instanceId, cause))), { concurrency: "unbounded", discard: true }, ); diff --git a/apps/server/src/provider/acp/AntigravityClientFiles.ts b/apps/server/src/provider/acp/AntigravityClientFiles.ts index c6079234d916c..225c2684d02b9 100644 --- a/apps/server/src/provider/acp/AntigravityClientFiles.ts +++ b/apps/server/src/provider/acp/AntigravityClientFiles.ts @@ -78,13 +78,25 @@ export const readAntigravityClientTextFile = Effect.fn("AntigravityClientFiles.r `File '${input.request.path}' is not a readable text file under ${CLIENT_FILE_MAX_BYTES} bytes.`, ); } - const text = yield* input.fileSystem - .readFileString(filePath) + const bytes = yield* input.fileSystem + .readFile(filePath) .pipe( Effect.mapError(() => EffectAcpErrors.AcpRequestError.internalError(`Could not read '${input.request.path}'.`), ), ); + const text = yield* Effect.try({ + try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), + catch: () => + EffectAcpErrors.AcpRequestError.invalidParams( + `File '${input.request.path}' is not a UTF-8 text file.`, + ), + }); + if (text.includes("\0")) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `File '${input.request.path}' contains binary data.`, + ); + } const line = input.request.line ?? undefined; const limit = input.request.limit ?? undefined; if (line === undefined && limit === undefined) { diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1711e148d4ca4..c8085be773a63 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 5135, - "codexAdditionalContextBytes": 6865, + "claudeSystemAppendBytes": 5129, + "codexAdditionalContextBytes": 6859, "mcpToolListJsonBytes": 46007 } diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 7c0eeabb0a755..16e3c87ce1ec1 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -1,9 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; import { makeAntigravityUsageProbe } from "./antigravityUsageLimits.ts"; @@ -59,6 +61,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( tokenStatus?: number; refreshToken?: string; userId?: string; + userInfoAt?: number; missingToken?: boolean; authMethod?: "oauth-personal" | "gemini-api-key"; } = {}, @@ -115,7 +118,13 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( }) : Response.json(options.models ?? { models: {} }), ); - }), + }).pipe( + Effect.tap(() => + request.url.endsWith("/userinfo") && options.userInfoAt !== undefined + ? TestClock.setTime(options.userInfoAt) + : Effect.void, + ), + ), ); const usage = yield* makeAntigravityUsageProbe({ profileDirectory, @@ -125,6 +134,288 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( }); describe("Antigravity subscription limits", () => { + // Oracle: the 10:16 UTC payload and its narrow Workaround rule. + it.effect("omits rolling resets for untouched summary buckets and keeps used Gemini resets", () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-21T10:16:04Z"))); + const { probe } = yield* fixture({ + summary: { + groups: [ + { + buckets: [ + { + bucketId: "3p-5h", + window: "5h", + remainingFraction: 1, + resetTime: "2026-10-21T15:16:05Z", + }, + { + bucketId: "3p-weekly", + window: "weekly", + remainingFraction: 1, + resetTime: "2026-10-28T10:16:05Z", + }, + { + bucketId: "gemini-5h", + window: "5h", + remainingFraction: 0.848104, + resetTime: "2026-10-21T13:29:53Z", + description: + "You have used some of your 5-hour limit, it will fully refresh in 3 hours, 13 minutes.", + }, + { + bucketId: "gemini-weekly", + window: "weekly", + remainingFraction: 0.9620177, + resetTime: "2026-10-28T08:29:53Z", + description: + "You have used some of your weekly limit, it will fully refresh in 6 days, 22 hours.", + }, + ], + }, + ], + }, + }); + const limits = yield* probe; + const byId = new Map(limits.windows.map((window) => [window.id, window])); + expect(byId.get("3p-5h")).toEqual({ + id: "3p-5h", + label: "Claude & GPT (shared) · 5-hour", + kind: "session", + usedPercent: 0, + windowDurationMins: 300, + }); + expect(byId.get("3p-weekly")).toEqual({ + id: "3p-weekly", + label: "Claude & GPT (shared) · Weekly", + kind: "weekly", + usedPercent: 0, + windowDurationMins: 10080, + }); + 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)), + ); + + // Oracle: measure the tolerance from the summary request, after auth. + it.effect("uses summary request time after authentication for the reset tolerance", () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-21T10:16:02Z"))); + const { probe } = yield* fixture({ + userInfoAt: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-21T10:16:04Z")), + summary: { + buckets: [ + { + bucketId: "3p-5h", + window: "5h", + remainingFraction: 1, + resetTime: "2026-10-21T15:17:03Z", + }, + ], + }, + }); + expect((yield* probe).windows[0]?.resetsAt).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + // Oracle: keep descriptions, measured resets, and spent buckets. + it.effect("keeps summary resets with a description, a distant reset, or no quota left", () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-21T10:16:04Z"))); + const { probe } = yield* fixture({ + summary: { + buckets: [ + { + bucketId: "described", + window: "5h", + remainingFraction: 1, + description: "Reported reset", + resetTime: "2026-10-21T15:16:04Z", + }, + { + bucketId: "distant", + window: "weekly", + remainingFraction: 1, + resetTime: "2026-10-28T12:16:04Z", + }, + { + bucketId: "spent", + window: "5h", + remainingFraction: 0, + resetTime: "2026-10-21T15:16:04Z", + }, + { + bucketId: "overfull", + window: "5h", + remainingFraction: 1.01, + resetTime: "2026-10-21T15:16:04Z", + }, + { + bucketId: "empty-description", + window: "5h", + remainingFraction: 1, + description: "", + resetTime: "2026-10-21T15:16:04Z", + }, + ], + }, + }); + expect((yield* probe).windows.map(({ id, resetsAt }) => ({ id, resetsAt }))).toEqual([ + { id: "described", resetsAt: "2026-10-21T15:16:04.000Z" }, + { id: "empty-description", resetsAt: "2026-10-21T15:16:04.000Z" }, + { id: "overfull", resetsAt: "2026-10-21T15:16:04.000Z" }, + { 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)), + ); + + // Oracle: the 60-second tolerance applies to both known window lengths. + it.effect("omits summary resets 59 seconds away but keeps resets 61 seconds away", () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-21T10:16:04Z"))); + const { probe } = yield* fixture({ + summary: { + buckets: [ + { + bucketId: "5h-before", + window: "5h", + remainingFraction: 1, + resetTime: "2026-10-21T15:15:05Z", + }, + { + bucketId: "5h-after", + window: "5h", + remainingFraction: 1, + resetTime: "2026-10-21T15:17:05Z", + }, + { + bucketId: "5h-edge", + window: "5h", + remainingFraction: 1, + resetTime: "2026-10-21T15:17:04Z", + }, + { + bucketId: "weekly-after", + window: "weekly", + remainingFraction: 1, + resetTime: "2026-10-28T10:17:03Z", + }, + { + bucketId: "weekly-before", + window: "weekly", + remainingFraction: 1, + resetTime: "2026-10-28T10:15:03Z", + }, + { + bucketId: "weekly-edge", + window: "weekly", + remainingFraction: 1, + resetTime: "2026-10-28T10:15:04Z", + }, + ], + }, + }); + expect((yield* probe).windows.map(({ id, resetsAt }) => ({ id, resetsAt }))).toEqual([ + { id: "5h-after", resetsAt: "2026-10-21T15:17:05.000Z" }, + { id: "5h-before", resetsAt: undefined }, + { id: "5h-edge", resetsAt: undefined }, + { id: "weekly-after", resetsAt: undefined }, + { id: "weekly-before", resetsAt: "2026-10-28T10:15:03.000Z" }, + { id: "weekly-edge", resetsAt: undefined }, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + // Oracle: the existing endpoint merge keeps the most-used reading. + it.effect("keeps the used endpoint reading and its reset over an untouched reading", () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-21T10:16:04Z"))); + const { probe } = yield* fixture({ + summaryByHost: { + "daily-cloudcode-pa.googleapis.com": { + buckets: [ + { + bucketId: "3p-5h", + window: "5h", + remainingFraction: 1, + resetTime: "2026-10-21T15:16:04Z", + }, + { + bucketId: "3p-weekly", + window: "weekly", + remainingFraction: 0.5, + resetTime: "2026-10-27T10:16:04Z", + }, + ], + }, + "cloudcode-pa.googleapis.com": { + buckets: [ + { + bucketId: "3p-5h", + window: "5h", + remainingFraction: 0.5, + resetTime: "2026-10-21T13:16:04Z", + }, + { + bucketId: "3p-weekly", + window: "weekly", + remainingFraction: 1, + resetTime: "2026-10-28T10:16:04Z", + }, + ], + }, + }, + }); + expect((yield* probe).windows).toEqual([ + { + id: "3p-5h", + label: "Claude & GPT (shared) · 5-hour", + kind: "session", + usedPercent: 50, + windowDurationMins: 300, + resetsAt: "2026-10-21T13:16:04.000Z", + }, + { + id: "3p-weekly", + label: "Claude & GPT (shared) · Weekly", + kind: "weekly", + usedPercent: 50, + windowDurationMins: 10080, + resetsAt: "2026-10-27T10:16:04.000Z", + }, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + // Oracle: model quotas have no evidence for the summary-only rule. + it.effect("keeps full model fallback resets even at a full window from the request", () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-21T10:16:04Z"))); + const { probe } = yield* fixture({ + summaryStatus: 404, + models: { + models: { + "gemini-a": { + quotaInfo: { remainingFraction: 1, resetTime: "2026-10-21T15:16:04Z" }, + weeklyQuotaInfo: { remainingFraction: 1, resetTime: "2026-10-28T10:16:04Z" }, + }, + }, + }, + }); + expect( + (yield* probe).windows.map(({ id, resetsAt, windowDurationMins }) => ({ + id, + resetsAt, + windowDurationMins, + })), + ).toEqual([ + { 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)), + ); + it.effect("reports separate Gemini and shared Claude/GPT five-hour and weekly limits", () => Effect.gen(function* () { const { probe, requests } = yield* fixture(); diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index 75333ae8262e7..beecf7b53db13 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -19,6 +19,10 @@ const ENDPOINTS = [ "https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com", ]; +const SUMMARY_WINDOW_MINS = new Map([ + ["5h", 5 * 60], + ["weekly", 7 * 24 * 60], +]); const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; const TokenFile = Schema.Struct({ client_id: Schema.NonEmptyString, @@ -40,6 +44,7 @@ const Bucket = Schema.Struct({ bucketId: Schema.NonEmptyString, window: Schema.optional(Schema.String), displayName: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), disabled: Schema.optional(Schema.Boolean), }); const Summary = Schema.Struct({ @@ -96,7 +101,22 @@ function quotaWindow( }; } -function summaryWindows(summary: typeof Summary.Type) { +function summaryResetTime( + bucket: typeof Bucket.Type, + windowDurationMins: number | undefined, + requestedAt: number, +): string | undefined { + // Untouched summary buckets report a rolling full-window reset. + const untouched = + bucket.remainingFraction === 1 && + bucket.description === undefined && + windowDurationMins !== undefined && + Math.abs(Date.parse(bucket.resetTime ?? "") - requestedAt - windowDurationMins * 60_000) <= + 60_000; + return untouched ? undefined : bucket.resetTime; +} + +function summaryWindows(summary: typeof Summary.Type, requestedAt: number) { const windows = new Map(); const groups = summary.groups?.some((group) => group.buckets?.length) ? summary.groups @@ -111,12 +131,13 @@ function summaryWindows(summary: typeof Summary.Type) { : group.displayName?.trim() || bucket.bucketId; const weekly = bucket.window === "weekly"; const session = bucket.window === "5h"; + const windowDurationMins = SUMMARY_WINDOW_MINS.get(bucket.window ?? ""); const window = quotaWindow( bucket.bucketId, `${family} · ${weekly ? "Weekly" : session ? "5-hour" : bucket.displayName?.trim() || "Quota"}`, - bucket, + { ...bucket, resetTime: summaryResetTime(bucket, windowDurationMins, requestedAt) }, weekly ? "weekly" : session ? "session" : "other", - weekly ? 7 * 24 * 60 : session ? 5 * 60 : undefined, + windowDurationMins, ); if (window) windows.set(window.id, window); } @@ -248,25 +269,32 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( // Merge endpoints because quota groups differ; keep each bucket's most-used reading. const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* ( operation: string, - parse: (response: unknown) => Effect.Effect, + parse: ( + response: unknown, + requestedAt: number, + ) => Effect.Effect, ) { const responses = yield* Effect.forEach( ENDPOINTS, (endpoint) => - request( - HttpClientRequest.post(`${endpoint}/v1internal:${operation}`).pipe( - HttpClientRequest.setHeaders({ - Authorization: `Bearer ${accessToken}`, - "User-Agent": `antigravity/hub/1.0.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`, - }), - HttpClientRequest.bodyJsonUnsafe( - token.project_id ? { project: token.project_id } : {}, + Effect.gen(function* () { + // Capture each request after auth, so refresh latency does not widen the tolerance. + const requestedAt = yield* Clock.currentTimeMillis; + return yield* request( + HttpClientRequest.post(`${endpoint}/v1internal:${operation}`).pipe( + HttpClientRequest.setHeaders({ + Authorization: `Bearer ${accessToken}`, + "User-Agent": `antigravity/hub/1.0.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`, + }), + HttpClientRequest.bodyJsonUnsafe( + token.project_id ? { project: token.project_id } : {}, + ), ), - ), - ).pipe( - Effect.flatMap(parse), - Effect.orElseSucceed(() => []), - ), + ).pipe( + Effect.flatMap((response) => parse(response, requestedAt)), + Effect.orElseSucceed(() => []), + ); + }), { concurrency: "unbounded" }, ); const windows = new Map(); @@ -276,8 +304,8 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( } return [...windows.values()]; }); - const summary = yield* fetchWindows("retrieveUserQuotaSummary", (response) => - decodeSummary(response).pipe(Effect.map(summaryWindows)), + const summary = yield* fetchWindows("retrieveUserQuotaSummary", (response, requestedAt) => + decodeSummary(response).pipe(Effect.map((summary) => summaryWindows(summary, requestedAt))), ); const windows = summary.length ? summary diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 943928c4e18cf..9b7d1ffb35d41 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -153,6 +153,9 @@ function layerMockSpawner( function makeRegistry( initialProviders: ServerProvider | ReadonlyArray = baseProvider, + options?: { + readonly installedVersionAfterUpdate?: string; + }, ) { return Effect.gen(function* () { const providersRef = yield* Ref.make>( @@ -193,7 +196,18 @@ function makeRegistry( refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), reloadSecretsAndRefresh: () => Ref.get(providersRef), - refreshAfterUpdate: () => Ref.get(providersRef), + refreshAfterUpdate: () => { + const version = options?.installedVersionAfterUpdate; + if (version === undefined) { + return Ref.get(providersRef); + } + return Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ + ...provider, + version, + })), + ); + }, refreshWorkspaceSnapshot: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), @@ -220,6 +234,7 @@ const makeTestRunner = ( ranges: [], })), }, + latestVersions: Layer.Layer = ProviderLatestVersions.layer, ) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( @@ -233,13 +248,28 @@ const makeTestRunner = ( forceRefresh: Effect.succeed(manifest), refreshInBackground: Effect.void, }), - ProviderLatestVersions.layer, + latestVersions, ), ), ), ), ); +function layerFailingLatestLookupAfterFirst() { + let lookups = 0; + return Layer.succeed(ProviderLatestVersions.ProviderLatestVersions, { + cached: (_key, lookup) => + Effect.gen(function* () { + lookups += 1; + if (lookups > 1) { + return yield* Effect.die("latest lookup failed"); + } + return yield* lookup; + }), + invalidate: () => Effect.void, + }); +} + describe("providerMaintenanceRunner", () => { it.effect("runs the allowlisted provider update command and records success", () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; @@ -269,7 +299,190 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), + layerMockSpawner((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect.each([ + { name: "current", probe: {}, skip: true }, + { name: "failed", probe: { status: "error" }, skip: false }, + { name: "behind", probe: { version: "2.1.309" }, skip: false }, + { name: "unknown", probe: { version: null }, skip: false }, + { + name: "installed current", + probe: { version: "2.1.309", installedVersion: "2.1.310" }, + skip: true, + }, + { name: "installed behind", probe: { installedVersion: "2.1.309" }, skip: false }, + { name: "installed unknown", probe: { installedVersion: null }, skip: false }, + ] as const)("only skips a successful fresh current probe: $name", ({ probe, skip }) => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + const refreshedInstanceIds: Array = []; + return Effect.gen(function* () { + const { registry, updateStatesRef, providersRef } = yield* makeRegistry({ + ...baseProvider, + version: "2.1.309", + versionAdvisory: { + status: "behind_latest", + currentVersion: "2.1.309", + latestVersion: "2.1.310", + updateCommand: "claude update", + canUpdate: true, + checkedAt: "2026-04-10T00:00:00.000Z", + message: "Update available.", + }, + }); + const updater = yield* makeTestRunner({ + ...registry, + refreshInstance: (instanceId) => { + refreshedInstanceIds.push(instanceId); + return Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => + provider.instanceId === instanceId + ? { + ...provider, + version: "2.1.310", + ...probe, + checkedAt: "2026-04-10T00:00:01.000Z", + } + : provider, + ), + ); + }, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@anthropic-ai/claude-code", + updateExecutable: "claude", + updateArgs: ["update"], + updateLockKey: "claudeAgent-native", + }), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(refreshedInstanceIds, [CODEX_INSTANCE_ID]); + assert.deepStrictEqual(calls, skip ? [] : [{ command: "claude", args: ["update"] }]); + if (!skip) return; + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + assert.strictEqual(result.providers[0]?.updateState?.message, "Already up to date."); + assert.deepStrictEqual( + (yield* Ref.get(updateStatesRef)).map((state) => [state.status, state.message]), + [ + ["queued", "Waiting for another provider update to finish."], + ["running", "Checking for the latest version"], + ["succeeded", "Already up to date."], + ], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("2.1.310"), + layerMockSpawner((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("still runs the installer when refreshInstance returns the cached snapshot", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry({ + ...baseProvider, + version: "2.1.310", + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@anthropic-ai/claude-code", + updateExecutable: "claude", + updateArgs: ["update"], + updateLockKey: "claudeAgent-native", + }), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, [{ command: "claude", args: ["update"] }]); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("2.1.310"), + layerMockSpawner((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("still runs the installer when precheck advisory enrichment fails", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry({ + ...baseProvider, + version: "2.1.310", + versionAdvisory: { + status: "current", + currentVersion: "2.1.310", + latestVersion: "2.1.310", + updateCommand: "claude update", + canUpdate: true, + checkedAt: "2026-04-10T00:00:00.000Z", + message: null, + }, + }); + const updater = yield* makeTestRunner( + { + ...registry, + refreshInstance: (instanceId) => + Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => + provider.instanceId === instanceId + ? { ...provider, checkedAt: "2026-04-10T00:00:01.000Z" } + : provider, + ), + ), + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@anthropic-ai/claude-code", + updateExecutable: "claude", + updateArgs: ["update"], + updateLockKey: "claudeAgent-native", + }), + ), + }, + undefined, + layerFailingLatestLookupAfterFirst(), + ); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, [{ command: "claude", args: ["update"] }]); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("2.1.310"), layerMockSpawner((command, args) => { calls.push({ command, args }); return { stdout: "updated" }; @@ -298,7 +511,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner(() => ({ stdout: "updated" })), ), ), @@ -331,7 +544,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner((_command, _args, options) => { seen.push(options.env); return { stdout: "updated" }; @@ -371,7 +584,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner((command, args) => { calls.push({ command, args }); return { stdout: "updated" }; @@ -396,6 +609,28 @@ describe("providerMaintenanceRunner", () => { 4_096, ); assert.strictEqual(ProviderMaintenanceRunner.toProgressLine("x".repeat(300))?.length, 200); + + for (const redraw of [ + "\r", + "\u001b[G", + "\u001b[0G", + "\u001b[1G", + "\u001b[01G", + "\u001b[999D", + "\u001b[999D\u001b[J", + ]) { + const output = `first${redraw}second${redraw}last`; + for (let boundary = 0; boundary <= output.length; boundary++) { + let partialLine = ""; + for (const chunk of [output.slice(0, boundary), output.slice(boundary)]) { + partialLine = ProviderMaintenanceRunner.splitOutputLines(partialLine, chunk).partialLine; + } + assert.strictEqual(ProviderMaintenanceRunner.toProgressLine(partialLine), "last"); + } + } + for (const incomplete of ["\u001b", "\u001b[", "\u001b[0", "\u001b[01"]) { + assert.strictEqual(ProviderMaintenanceRunner.toProgressLine(`progress${incomplete}`), null); + } }); it.effect("aborts without running when the installation changed since the advisory", () => { @@ -433,7 +668,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner((command) => { calls.push(command); return { stdout: "updated" }; @@ -461,7 +696,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner(() => ({ stdout: "checking install\n", stderr: "Error: Could not detect the Codex installation method.\n\n", @@ -487,7 +722,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner(() => ({ code: 1 })), ), ), @@ -552,7 +787,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner(() => { startedLatch.resolve(); return { @@ -571,7 +806,9 @@ describe("providerMaintenanceRunner", () => { "releases the running-provider marker when interrupted after queuing but before the lock run starts", () => Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); + const { registry } = yield* makeRegistry(baseProvider, { + installedVersionAfterUpdate: "1.0.0", + }); let blockQueuedState = true; const queuedStateWrittenLatch: { resolve: () => void } = { resolve: () => {} }; const releaseQueuedStateLatch: { resolve: () => void } = { resolve: () => {} }; @@ -612,7 +849,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner(() => ({ stdout: "updated" })), ), ), @@ -626,7 +863,9 @@ describe("providerMaintenanceRunner", () => { readonly shell: boolean | string | undefined; }> = []; return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); + const { registry } = yield* makeRegistry(baseProvider, { + installedVersionAfterUpdate: "1.0.0", + }); const runner = yield* makeTestRunner(registry); const result = yield* runner.updateProvider(CODEX_DRIVER); @@ -652,7 +891,7 @@ describe("providerMaintenanceRunner", () => { Layer.succeed(SpawnExecutableResolution, (command) => command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined, ), - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { @@ -689,8 +928,14 @@ it.effect.each([ status: "succeeded", }, { - mode: "unknown", + mode: "targeted-current", version: "1.14.20", + installation: { installedVersion: "1.14.20" }, + status: "succeeded", + }, + { + mode: "unknown", + version: "1.14.19", installation: { installedVersion: null }, status: "unchanged", }, @@ -723,6 +968,10 @@ it.effect.each([ const updater = yield* makeTestRunner( { ...registry, + refreshInstance: () => + Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, checkedAt: "2026-04-10T00:00:01.000Z" })), + ), getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), refreshAfterUpdate: () => Effect.gen(function* () { @@ -786,7 +1035,7 @@ it.effect( "uses fresh installer versions for post-update verification and compatibility checks", () => { const calls: string[] = []; - let latest = "1.0.0"; + let latest = "2.0.0"; const manifest: ModelManifest.ModelManifestData = { version: 1, currentModels: {}, diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 9dddf23579d8a..c2347da003300 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -83,6 +83,7 @@ class ProviderMaintenanceCommandError extends Data.TaggedError("ProviderMaintena interface VerifiedProviderRefresh { readonly providers: ReadonlyArray; readonly verifiedProviders: ReadonlyArray; + readonly advisoryEnriched: boolean; } const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -203,11 +204,14 @@ export function splitOutputLines( partialLine: string, text: string, ): { readonly lines: ReadonlyArray; readonly partialLine: string } { - const parts = (partialLine + text).split(/\r\n|\r|\n/); + const parts = (partialLine + text).replace(/\x1b\[(?:0*1?G|999D)/g, "\r").split(/\r\n|\r|\n/); return { partialLine: (parts.pop() ?? "").slice(-UPDATE_PARTIAL_LINE_MAX_LENGTH), lines: parts }; } export function toProgressLine(line: string): string | null { + if (/\x1b(?:\[[0-9]*)?$/.test(line)) { + return null; + } const text = NodeUtil.stripVTControlCharacters(line).replace(/\s+/g, " ").trim(); if (text.length === 0) { return null; @@ -315,12 +319,14 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), }); - const verifyRefreshedProvider = ( + const verifyFromRefresh = ( + refresh: Effect.Effect>, provider: ProviderDriverKind, maintenanceCapabilities: ProviderMaintenanceCapabilities, instanceId: ProviderInstanceId, + phase: "pre-update" | "post-update", ): Effect.Effect => - providerRegistry.refreshAfterUpdate(provider, instanceId).pipe( + refresh.pipe( Effect.flatMap((providers) => { const refreshedProviders = providers.filter( (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, @@ -329,6 +335,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { return Effect.succeed({ providers, verifiedProviders: [], + advisoryEnriched: true, }); } return Effect.forEach( @@ -348,15 +355,18 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { Effect.map((verifiedProviders): VerifiedProviderRefresh => ({ providers, verifiedProviders, + advisoryEnriched: true, })), Effect.catchCause((cause) => - Effect.logWarning("Provider post-update version verification failed", { + Effect.logWarning("Provider version verification failed", { provider, + phase, cause: Cause.pretty(cause), }).pipe( Effect.as({ providers, verifiedProviders: refreshedProviders, + advisoryEnriched: false, }), ), ), @@ -481,6 +491,41 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), ); } + + if (targetVersion === undefined) { + const prior = (yield* providerRegistry.getProviders).find( + (candidate) => candidate.instanceId === instanceId, + ); + const precheck = yield* verifyFromRefresh( + providerRegistry.refreshInstance(instanceId), + provider, + fresh, + instanceId, + "pre-update", + ); + const alreadyCurrent = + precheck.advisoryEnriched && + prior !== undefined && + precheck.verifiedProviders.length > 0 && + precheck.verifiedProviders.every( + (verified) => + verified.checkedAt !== prior.checkedAt && + verified.installed && + verified.status !== "error" && + verified.versionAdvisory?.status === "current", + ); + if (alreadyCurrent) { + return yield* finish( + makeUpdateState({ + status: "succeeded", + startedAt, + finishedAt: yield* nowIso, + message: "Already up to date.", + }), + ); + } + } + yield* setRunningMessage(`Running ${describeCommand(command)}`); const result = yield* runMaintenanceCommand(command, setRunningMessage); const finishedAt = yield* nowIso; @@ -512,10 +557,12 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { yield* invalidateLatestProviderVersion(verified).pipe( Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), ); - const { verifiedProviders } = yield* verifyRefreshedProvider( + const { verifiedProviders } = yield* verifyFromRefresh( + providerRegistry.refreshAfterUpdate(provider, instanceId), provider, verified, instanceId, + "post-update", ); // Cursor about probes can fail transiently; missing versions alone do not imply failure. const couldNotVerify = diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts index 2485be7a97a30..bb6eb38f16146 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.test.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -123,6 +123,27 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => { }), ); + it.effect.each(["", "{", '{"pr":{"revision":42}}'])( + "recovers from malformed scope revisions without reusing stale entries: %j", + (corrupt) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const original = yield* cacheLayer(directory); + yield* original.get("summary", Effect.succeed("stale"), ["pr"]); + yield* fs.writeFileString(directory + "/revisions", corrupt); + const recovered = yield* cacheLayer(directory); + let reads = 0; + const lookup = Effect.sync(() => String(++reads)); + assert.strictEqual(yield* recovered.get("summary", lookup, ["pr"]), "1"); + assert.strictEqual(yield* recovered.get("summary", lookup, ["pr"]), "1"); + yield* recovered.invalidate("pr"); + assert.strictEqual(yield* recovered.get("summary", lookup, ["pr"]), "2"); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get("summary", lookup, ["pr"]), "2"); + assert.strictEqual(reads, 2); + }), + ); it.effect("resumes caching after a failed scope read", () => Effect.gen(function* () { const backing = yield* KeyValueStore.KeyValueStore; diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 62ac7da3789d6..6ea0aea8f76b6 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -88,9 +88,17 @@ export const make = Effect.gen(function* () { crypto.digest("SHA-256", new TextEncoder().encode(key)).pipe(Effect.map(Hex.encode)); const revisions = yield* Cache.makeWith( () => - backing - .get("revisions") - .pipe(Effect.flatMap((raw) => Schema.decodeEffect(revisionCodec)(raw ?? "{}"))), + backing.get("revisions").pipe( + Effect.flatMap((raw) => Schema.decodeEffect(revisionCodec)(raw ?? "{}")), + Effect.catchTags({ + SchemaError: Effect.fn("PullRequestReadCache.recoverRevisions")(function* () { + // Without scope revisions, persisted PR entries cannot be trusted. + yield* backing.clear; + yield* Effect.logWarning("PR cache reset after invalid scope revisions"); + return yield* Schema.decodeEffect(revisionCodec)("{}"); + }), + }), + ), { capacity: 1, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index b4dd965570a9f..a4d944a457286 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -466,6 +466,51 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic ), ); + it.effect("keeps excerpts of source lines a configured diff driver would convert away", () => + Effect.scoped( + Effect.gen(function* () { + const repositories = yield* makeRepositories; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + `${repositories.workspace}/.git/info/attributes`, + "feature.txt diff=blank\n", + ); + yield* git(repositories.workspace, ["config", "diff.blank.textconv", "true"]); + const stored = yield* Deferred.make(); + const context = yield* Layer.build( + makeLayer({ + ...repositories, + stored, + generate: () => + Effect.succeed({ + stops: [ + { + title: "The feature", + summary: "The change.", + attention: null, + excerpts: [{ path: "feature.txt", side: "new", start: 1, end: 3, caption: "" }], + files: [], + notes: [], + questions: [], + }, + ], + }), + }), + ); + const service = Context.get( + context, + PullRequestReviewGuideService.PullRequestReviewGuideService, + ); + yield* service.generate(reference); + yield* Deferred.await(stored); + const result = yield* service.get(reference); + assert.deepStrictEqual(result.guide?.stops[0]?.excerpts, [ + { path: "feature.txt", side: "new", start: 1, end: 3, caption: "" }, + ]); + }), + ), + ); + it.effect("refuses generation when the verified remote disappeared despite a usable origin", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index e0dc7309289b6..a7618a23d27ae 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -344,7 +344,10 @@ const make = Effect.gen(function* () { }) .pipe(Effect.map((result) => result.stdout)); const [nameOnly, patch] = yield* Effect.all( - [diff(["--name-only", "-z"]), diff(["--unified=0", "--no-color", "--no-ext-diff"])], + [ + diff(["--name-only", "-z"]), + diff(["--unified=0", "--no-color", "--no-ext-diff", "--no-textconv"]), + ], { concurrency: "unbounded" }, ); return parseReviewGuideChange(nameOnly, patch); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index cc6220db84907..fd332cad0d4e7 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -257,6 +257,7 @@ function makeService(input: { readonly providers: ReadonlyArray; readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; readonly resolveRepositoryIdentity?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"]["resolve"]; + readonly onProjectRead?: () => void; }) { // Build in the test scope; per-call Effect.provide would close the marks database on service acquisition. return Effect.flatMap( @@ -280,11 +281,17 @@ function makeService(input: { }), Layer.mock(ProjectService.ProjectService)({ listShells: (options) => - Effect.succeed( - input.projects.filter((project) => options?.projectIds?.includes(project.id) ?? true), - ), + Effect.sync(() => { + input.onProjectRead?.(); + return input.projects.filter( + (project) => options?.projectIds?.includes(project.id) ?? true, + ); + }), getShell: (projectId) => - Effect.succeed(Option.fromNullishOr(input.projects.find((p) => p.id === projectId))), + Effect.sync(() => { + input.onProjectRead?.(); + return Option.fromNullishOr(input.projects.find((p) => p.id === projectId)); + }), }), Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ resolve: input.resolveRepositoryIdentity ?? (() => Effect.succeed(null)), @@ -475,6 +482,52 @@ it.effect("reads nothing from a host with no implementation, but reports it", () }), ); +it.effect("reads nothing from a host its provider does not serve, but reports it", () => + Effect.gen(function* () { + const reached = new Set(); + const bitbucket = (id: string, host: string) => + project({ + id, + title: host, + workspaceRoot: `/${id}`, + repository: "proj/repo", + provider: SourceControlProviderKind.make("bitbucket"), + host, + }); + const service = yield* makeService({ + projects: [bitbucket("p1", "bitbucket.org"), bitbucket("p2", "bitbucket.example.com")], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("bitbucket"), { + servesHost: (host) => host === "bitbucket.org", + getViewer: ({ host }) => { + reached.add(host ?? ""); + return Effect.succeed("bilal"); + }, + listChangeRequests: ({ host }) => { + reached.add(host); + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual([...reached], ["bitbucket.org"]); + assert.deepStrictEqual( + result.providers.map(({ host, configured, detail }) => ({ host, configured, detail })), + [ + { host: "bitbucket.org", configured: true, detail: null }, + { + host: "bitbucket.example.com", + configured: false, + detail: "This host cannot be browsed here yet.", + }, + ], + ); + }), +); + it.effect("says where each repository carries on, and from nothing it has run out of", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1710,7 +1763,11 @@ it.effect("resolves a project's repository identity when its shell has none cach it.effect("routes a hosted reference to another repository through a project on that host", () => Effect.gen(function* () { const seen: Array<{ cwd: string; repository: string; host: string }> = []; + let projectReads = 0; const service = yield* makeService({ + onProjectRead: () => { + projectReads += 1; + }, projects: [ project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), ], @@ -1732,6 +1789,20 @@ it.effect("routes a hosted reference to another repository through a project on assert.strictEqual(summary.number, 7); assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); + + // A sync sweep resolves the project once for all of its references, then reads fresh again. + const readsPerReference = projectReads; + const read = (number: number) => + service.summary( + { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number }, + { recoverTransientFailure: false }, + ); + assert.isAbove(readsPerReference, 2); + yield* service.withSharedProjectReads(Effect.all([read(8), read(9), read(10)])); + // One read of the project and one of the host's projects, for all three references. + assert.strictEqual(projectReads, readsPerReference + 2); + yield* read(11); + assert.strictEqual(projectReads, readsPerReference * 2 + 2); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 14ddb1de995cb..725ac068dad5d 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -250,6 +250,10 @@ export class PullRequestService extends Context.Service< input: PullRequestReportStateInput, ) => Effect.Effect; readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect; + /** Project and remote lookups inside `effect` read once per filter, e.g. across one sync sweep. */ + readonly withSharedProjectReads: ( + effect: Effect.Effect, + ) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; readonly ciRuns: (input: PullRequestRef) => Effect.Effect; readonly ciJobs: ( @@ -385,6 +389,12 @@ interface WorkspaceProjects { readonly viewerRoots: ReadonlyMap>; } +// Inherited by fibers forked inside the scope, so it is closed, not just dropped, when the scope ends. +const sharedProjectReads = Context.Reference<{ + open: boolean; + readonly reads: Map>; +} | null>("t3/PullRequestService/sharedProjectReads", { defaultValue: () => null }); + interface RepositoryBatch { readonly key: string; readonly entries: ReadonlyArray; @@ -565,6 +575,7 @@ function withRateLimitBackoff( ? {} : { unavailableRequirement: api.unavailableRequirement }), ...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }), + ...(api.servesHost === undefined ? {} : { servesHost: api.servesHost }), ...(api.readsPublicHostWithoutCheckout === undefined ? {} : { readsPublicHostWithoutCheckout: api.readsPublicHostWithoutCheckout }), @@ -900,6 +911,34 @@ export const make = Effect.gen(function* () { const listWorkspaceProjects = ( filter: Pick, + ): Effect.Effect => + Effect.flatMap(sharedProjectReads, (shared) => { + if (shared === null || !shared.open) return readWorkspaceProjects(filter); + const key = JSON.stringify([filter.projectId, filter.projectIds, filter.host]); + const existing = shared.reads.get(key); + if (existing !== undefined) return existing; + return Effect.cached(readWorkspaceProjects(filter)).pipe( + Effect.tap((read) => Effect.sync(() => shared.reads.set(key, read))), + Effect.flatten, + ); + }); + + const withSharedProjectReads = (effect: Effect.Effect) => + Effect.suspend(() => { + const shared = { open: true, reads: new Map() }; + return effect.pipe( + Effect.provideService(sharedProjectReads, shared), + Effect.ensuring( + Effect.sync(() => { + shared.open = false; + shared.reads.clear(); + }), + ), + ); + }); + + const readWorkspaceProjects = ( + filter: Pick, ): Effect.Effect => (filter.projectId === undefined ? projects.listShells( @@ -959,7 +998,8 @@ export const make = Effect.gen(function* () { if (filter.host !== undefined && host !== filter.host.toLowerCase()) { continue; } - const api = registry.get(kind); + const registered = registry.get(kind); + const api = registered?.servesHost?.(host) === false ? null : registered; if (api !== null) { const roots = viewerRoots.get(host); if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); @@ -1467,19 +1507,24 @@ export const make = Effect.gen(function* () { : null, }; }), - Effect.orElseSucceed((): RepositoryBatch => ({ - key, - entries: [], - errors: [ - { - projectId: project.project.id, - projectTitle: project.project.title, - message: `${project.repository} could not be read.`, - }, - ], - truncated: false, - nextCursor: null, - })), + Effect.catch((error): Effect.Effect => + Effect.succeed({ + key, + entries: [], + errors: [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: + error.accessHint === undefined + ? `${project.repository} could not be read.` + : `${project.repository} could not be read. ${error.accessHint}`, + }, + ], + truncated: false, + nextCursor: null, + }), + ), ); } }; @@ -4217,6 +4262,7 @@ export const make = Effect.gen(function* () { ); return PullRequestService.of({ + withSharedProjectReads, routing, routingIdentity, withRoutingCredential, diff --git a/apps/server/src/pullRequest/linkedThreads.test.ts b/apps/server/src/pullRequest/linkedThreads.test.ts index 230758714b381..161cb05f8e9ea 100644 --- a/apps/server/src/pullRequest/linkedThreads.test.ts +++ b/apps/server/src/pullRequest/linkedThreads.test.ts @@ -9,7 +9,7 @@ import { listLinkedPullRequestThreads } from "./linkedThreads.ts"; const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.effect( - "finds active and archived threads for exactly one pull request, excluding deleted and dismissed links", + "finds active and archived threads for exactly one pull request, excluding deleted, dismissed, and side chat links", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -55,6 +55,13 @@ it.effect( source: "created", }, { id: "deleted", host: "github.com", repository: "acme/web", number: 7, source: "manual" }, + { + id: "side-chat", + host: "github.com", + repository: "acme/web", + number: 7, + source: "manual", + }, { id: "dismissed", host: "github.com", @@ -87,6 +94,7 @@ it.effect( for (const fixture of fixtures) { const payload = yield* encodePayload({ linkedPullRequest: null, + ...(fixture.id === "side-chat" ? { lineage: { sideChat: true } } : {}), pullRequests: [{ ...fixture, linkedAt: createdAt, snapshot: null, stack: null }], }); yield* sql` diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index c7a2b01946a2b..b140714531ad9 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -39,6 +39,7 @@ export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThre WHERE json_extract(link.value, '$.number') = ${key.number} AND json_extract(link.value, '$.source') != 'stack-dismissed' AND t.deleted_at IS NULL + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1 ORDER BY "updatedAt" DESC, id ASC `.pipe(NodeSqliteClient.readOnly); const threads = rows diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts index 69479b1ccfeed..a47743b34263a 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts @@ -91,6 +91,37 @@ describe("parseReviewGuideChange", () => { expect(change.hunks.get("src/gone.ts")).toEqual({ new: [], old: [{ start: 1, end: 20 }] }); expect(change.hunks.get("docs/readme.md")).toEqual({ new: [{ start: 3, end: 3 }], old: [] }); }); + + it("reads file names git quotes or ends with a tab", () => { + const unusual = [ + "diff --git a/with space.txt b/with space.txt", + "--- a/with space.txt\t", + "+++ b/with space.txt\t", + "@@ -1,0 +2 @@", + 'diff --git "a/tab\\tname.txt" "b/tab\\tname.txt"', + '--- "a/tab\\tname.txt"', + '+++ "b/tab\\tname.txt"', + "@@ -1,0 +2 @@", + 'diff --git "a/back\\\\slash.txt" "b/back\\\\slash.txt"', + '--- "a/back\\\\slash.txt"', + '+++ "b/back\\\\slash.txt"', + "@@ -1,0 +2 @@", + 'diff --git "a/gone\\tfile.txt" "b/gone\\tfile.txt"', + '--- "a/gone\\tfile.txt"', + "+++ /dev/null", + "@@ -1 +0,0 @@", + "", + ].join("\n"); + const names = ["with space.txt", "tab\tname.txt", "back\\slash.txt", "gone\tfile.txt"]; + const parsed = parseReviewGuideChange(names.join("\0"), unusual); + expect([...parsed.hunks.keys()]).toEqual(names); + expect(parsed.hunks.get("gone\tfile.txt")).toEqual({ new: [], old: [{ start: 1, end: 1 }] }); + const result = normalizeReviewGuide( + guide({ stops: [stop({ excerpts: names.slice(0, 3).map((path) => excerpt(path, 1, 5)) })] }), + parsed, + ); + expect(result.stops[0]?.excerpts).toEqual(names.slice(0, 3).map((path) => excerpt(path, 2, 2))); + }); }); describe("normalizeReviewGuide", () => { diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts index 4075f19ba9ba9..184908e4bd7f2 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts @@ -1,5 +1,6 @@ import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; import type { ReviewGuideGenerationResult } from "@t3tools/provider-core/server/textGeneration"; +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; const MAX_EXCERPT_LINES = 25; @@ -25,6 +26,18 @@ const hunkRange = (start: string, count: string | undefined): LineRange | null = return lines === 0 ? null : { start: Number(start), end: Number(start) + lines - 1 }; }; +/** + * Git quotes a name holding a tab, quote or backslash, and ends an unquoted name holding a space + * with a tab, so the first literal tab is never part of what the file is called. + */ +function headerPath(line: string, prefix: string): string | null { + const rest = line.slice(4); + const tab = rest.indexOf("\t"); + const token = tab === -1 ? rest : rest.slice(0, tab); + const path = token.startsWith('"') ? unquoteGitPatchPath(token) : token; + return path.startsWith(prefix) ? path.slice(prefix.length) : null; +} + export function parseReviewGuideChange(nameOnly: string, patch: string): ReviewGuideChange { const hunks = new Map(); let oldPath: string | null = null; @@ -37,9 +50,9 @@ export function parseReviewGuideChange(nameOnly: string, patch: string): ReviewG current = null; inHunks = false; } else if (!inHunks && line.startsWith("--- ")) { - oldPath = line.startsWith("--- a/") ? line.slice(6) : null; + oldPath = headerPath(line, "a/"); } else if (!inHunks && line.startsWith("+++ ")) { - const path = line.startsWith("+++ b/") ? line.slice(6) : oldPath; + const path = headerPath(line, "b/") ?? oldPath; if (path === null) continue; current = hunks.get(path) ?? { new: [], old: [] }; hunks.set(path, current); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 54742a0af7d66..bad5b0d20d2ac 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -1,10 +1,11 @@ -import type { - EnvironmentId, - OrchestrationV2DomainEvent, - OrchestrationV2ThreadShell, - OrchestrationV2TurnItem, - Project, - ThreadId, +import { + isListedThreadLineage, + type EnvironmentId, + type OrchestrationV2DomainEvent, + type OrchestrationV2ThreadShell, + type OrchestrationV2TurnItem, + type Project, + type ThreadId, } from "@t3tools/contracts"; import { RelayApi, @@ -514,10 +515,11 @@ export const make = Effect.gen(function* () { // Read only this thread; full shell reads would make each activity event scale with all threads. const threadShell = yield* threads.getThreadShell(threadId); if ( - threadShell?.lineage.relationshipToParent === "subagent" && + threadShell !== null && + !isListedThreadLineage(threadShell.lineage) && !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) ) { - // Subagents have no relay rows to clear; per-event tombstones would needlessly redeliver user aggregates. + // Subagents and side chats have no relay rows to clear; per-event tombstones would needlessly redeliver user aggregates. return; } const thread = @@ -727,9 +729,9 @@ export const make = Effect.gen(function* () { } const environmentId = yield* serverEnvironment.getEnvironmentId; const projectSnapshot = yield* projects.snapshot; - // Subagents never publish awareness, and they are most threads. + // Subagents and side chats never publish awareness, and subagents are most threads. const topLevelThreadIds = (yield* threads.listActiveShellIndex()).flatMap((thread) => - thread.lineage.relationshipToParent === "subagent" ? [] : [thread.id], + isListedThreadLineage(thread.lineage) ? [thread.id] : [], ); const shellSnapshot = yield* threads.getShellSnapshot({ location: "active", diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 9faf81c99195f..4d04f16a4feb3 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -49,6 +49,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 DueHint from "../scheduling/DueHint.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; import { @@ -406,35 +407,6 @@ export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(funct return tasks; }); -// Backstop for a write path that forgets to invalidate; the tick still runs every few seconds. -const DUE_HINT_MAX_AGE_MS = 10 * 60 * 1_000; - -/** - * Remembers when a poller next has work so idle scheduler ticks skip SQL. - * `invalidate` after every write that can move work earlier; a tick records - * only if no write happened since it read `version`. - */ -const makeDueHint = () => { - let version = 0; - let hint: { readonly dueAtMs: number; readonly checkedAtMs: number } | null = null; - return { - invalidate: Effect.sync(() => { - version++; - hint = null; - }), - version: Effect.sync(() => version), - isIdle: (nowMs: number) => - Effect.sync( - () => - hint !== null && hint.dueAtMs > nowMs && nowMs - hint.checkedAtMs < DUE_HINT_MAX_AGE_MS, - ), - record: (readVersion: number, dueAtMs: number, nowMs: number) => - Effect.sync(() => { - if (readVersion === version) hint = { dueAtMs, checkedAtMs: nowMs }; - }), - }; -}; - export const layer = Layer.effect( ScheduledTaskService, Effect.gen(function* () { @@ -465,8 +437,8 @@ export const layer = Layer.effect( const activeRuns = yield* Ref.make>(new Set()); // Notifications re-emit the full list; slow subscribers need only the newest dirty signal. const changesPubSub = yield* PubSub.sliding(1); - const dueTasks = makeDueHint(); - const pendingWebhooks = makeDueHint(); + const dueTasks = DueHint.make(); + const pendingWebhooks = DueHint.make(); // Every scheduled_tasks write that can change next_run_at, enabled or run state notifies. const notifyChanged = dueTasks.invalidate.pipe( Effect.andThen(PubSub.publish(changesPubSub, undefined)), @@ -1517,8 +1489,11 @@ export const layer = Layer.effect( const truncated = request.body.byteLength > WEBHOOK_DELIVERY_LOG_BODY_LIMIT; return { truncated, + // Streaming decode drops a character the byte limit split, instead of ending in U+FFFD. body: truncated - ? new TextDecoder().decode(request.body.subarray(0, WEBHOOK_DELIVERY_LOG_BODY_LIMIT)) + ? new TextDecoder().decode(request.body.subarray(0, WEBHOOK_DELIVERY_LOG_BODY_LIMIT), { + stream: true, + }) : request.bodyText, }; }; @@ -1531,6 +1506,35 @@ export const layer = Layer.effect( WHERE delivery_id = ${deliveryId}`.pipe(Effect.asVoid, Effect.mapError(dedupeError)); }; + // Each suppression bucket retains its own rows, never real run history. + const deliveryBucket = (outcome: string, error: string | undefined) => + outcome === "filtered" + ? "filtered" + : outcome === "duplicate" || + outcome === "coalesced" || + outcome === "rate_limited" || + error === PROMPT_TOO_LONG + ? "dedupe" + : "main"; + const pruneDeliveries = ( + taskId: string, + retentionBucket: ReturnType, + ) => { + const rowBucket = sql.literal(`CASE WHEN outcome = 'filtered' THEN 'filtered' + WHEN outcome IN ('duplicate', 'coalesced', 'rate_limited') OR error = 'The filled-in prompt is too long.' THEN 'dedupe' + ELSE 'main' END`); + return sql` + DELETE FROM scheduled_task_webhook_deliveries + WHERE task_id = ${taskId} AND ${rowBucket} = ${retentionBucket} + AND delivery_id NOT IN ( + SELECT delivery_id FROM scheduled_task_webhook_deliveries + WHERE task_id = ${taskId} AND ${rowBucket} = ${retentionBucket} + ORDER BY received_at DESC, rowid DESC + LIMIT ${retentionBucket === "main" ? WEBHOOK_DELIVERY_RETENTION : retentionBucket === "filtered" ? WEBHOOK_FILTERED_DELIVERY_RETENTION : WEBHOOK_DEDUPE_DELIVERY_RETENTION} + ) + `; + }; + const recordDelivery = (input: { readonly id: string; readonly taskId: ScheduledTaskId; @@ -1572,30 +1576,7 @@ export const layer = Layer.effect( rendered_prompt = excluded.rendered_prompt, error = excluded.error `; - // Each suppression bucket retains its own rows, never real run history. - const bucket = (outcome: string, error: string | undefined) => - outcome === "filtered" - ? "filtered" - : outcome === "duplicate" || - outcome === "coalesced" || - outcome === "rate_limited" || - error === PROMPT_TOO_LONG - ? "dedupe" - : "main"; - const retentionBucket = bucket(input.outcome, input.error); - const rowBucket = sql.literal(`CASE WHEN outcome = 'filtered' THEN 'filtered' - WHEN outcome IN ('duplicate', 'coalesced', 'rate_limited') OR error = 'The filled-in prompt is too long.' THEN 'dedupe' - ELSE 'main' END`); - yield* sql` - DELETE FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.taskId} AND ${rowBucket} = ${retentionBucket} - AND delivery_id NOT IN ( - SELECT delivery_id FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.taskId} AND ${rowBucket} = ${retentionBucket} - ORDER BY received_at DESC, rowid DESC - LIMIT ${retentionBucket === "main" ? WEBHOOK_DELIVERY_RETENTION : retentionBucket === "filtered" ? WEBHOOK_FILTERED_DELIVERY_RETENTION : WEBHOOK_DEDUPE_DELIVERY_RETENTION} - ) - `; + yield* pruneDeliveries(input.taskId, deliveryBucket(input.outcome, input.error)); }), ) .pipe( @@ -1606,11 +1587,21 @@ export const layer = Layer.effect( }; const markDeliveryFailed = (deliveryId: string, message: string) => - sql` - UPDATE scheduled_task_webhook_deliveries - SET outcome = 'dispatch_failed', error = ${message} - WHERE delivery_id = ${deliveryId} - `.pipe(Effect.ignore); + sql + .withTransaction( + Effect.gen(function* () { + const [updated] = yield* sql<{ task_id: string }>` + UPDATE scheduled_task_webhook_deliveries + SET outcome = 'dispatch_failed', error = ${message} + WHERE delivery_id = ${deliveryId} + RETURNING task_id + `; + // An oversized prompt moves the row into the suppression bucket. + if (updated !== undefined) + yield* pruneDeliveries(updated.task_id, deliveryBucket("dispatch_failed", message)); + }), + ) + .pipe(Effect.ignore); /** Sliding one-minute window for deliveries that can start work. */ const takeRateSlot = ( diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index 48d3024d4f938..cdd4e60e62d76 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -481,6 +481,33 @@ it.effect("keeps credential headers and query values out of the delivery log", ( ), ); +it.effect("drops a character split by the delivery log's body limit", () => + withService(({ service }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); + const limit = 64 * 1024; + const euro = new TextEncoder().encode("€"); + const body = new Uint8Array(limit - 1 + euro.length).fill(0x61); + body.set(euro, limit - 1); + yield* service.triggerWebhook( + requestFor(task, { body, bodyText: new TextDecoder().decode(body) }), + ); + const [summary] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; + const { delivery } = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: summary!.id, + }); + assert.isTrue(delivery.bodyTruncated); + assert.equal(delivery.bodyBytes, body.byteLength); + assert.equal(delivery.body.slice(-3), "aaa"); + assert.equal(delivery.body.length, limit - 1); + }), + ), +); + const queuedDeliveryCases = [ { change: "paused", @@ -1320,6 +1347,40 @@ it.effect.each([119_000, 120_000])( }, ); +it.effect("oversized coalesced windows stay within the suppression bucket's limit", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: `{{body.absent}}${" ".repeat(10_000)}{{body.text}}`, + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const bodyText = JSON.stringify({ group: "B", text: "x".repeat(120_000) }); + for (let i = 0; i < 21; i++) { + const accepted = yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + assert.equal(accepted._tag === "accepted" && accepted.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + } + assert.equal(yield* Queue.size(launches), 0); + const counts = yield* sql<{ + outcome: string; + count: number; + }>`SELECT outcome, COUNT(*) AS count FROM scheduled_task_webhook_deliveries GROUP BY outcome`; + assert.deepEqual(counts, [{ outcome: "dispatch_failed", count: 20 }]); + }), + { workers }, + ); +}); + it.effect.each([1, 11])( "keeps relay claims for %i coalesced updates after their window closes", (count) => { diff --git a/apps/server/src/scheduledTasks/webhookRoute.test.ts b/apps/server/src/scheduledTasks/webhookRoute.test.ts index 5ef605be0ab42..95ff46c2a75ff 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.test.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.test.ts @@ -249,6 +249,32 @@ describe("webhook route", () => { } }); + it("refuses HEAD probes without triggering the hook", async () => { + let calls = 0; + const { handler, dispose } = handlerFor(() => { + calls += 1; + return Effect.succeed({ + _tag: "accepted", + deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:1"), + outcome: "accepted", + }); + }); + try { + const response = await handler( + new Request("http://env.local/api/hooks/id/tok", { method: "HEAD" }), + ); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET, POST, PUT, PATCH"); + expect(response.headers.get("x-t3-hook-outcome")).toBe("method_not_allowed"); + expect(calls).toBe(0); + const get = await handler(new Request("http://env.local/api/hooks/id/tok")); + expect(get.status).toBe(202); + expect(calls).toBe(1); + } finally { + await dispose(); + } + }); + it("hides service failures and defects behind a fixed 500", async () => { const failures = [ Effect.fail(new ScheduledTaskError({ message: "database locked" })), diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index e43d9d81c930d..dc6ce74bfa3f8 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -20,8 +20,16 @@ export const WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; // The relay records this outcome header. const WEBHOOK_OUTCOME_HEADER = "x-t3-hook-outcome"; -const json = (status: number, body: Record, outcome: string) => - HttpServerResponse.jsonUnsafe(body, { status, headers: { [WEBHOOK_OUTCOME_HEADER]: outcome } }); +const json = ( + status: number, + body: Record, + outcome: string, + headers?: Record, +) => + HttpServerResponse.jsonUnsafe(body, { + status, + headers: { ...headers, [WEBHOOK_OUTCOME_HEADER]: outcome }, + }); export const layer = HttpApiBuilder.group( EnvironmentHttpApi, @@ -41,6 +49,12 @@ export const layer = HttpApiBuilder.group( readonly params: { readonly hookId: string; readonly token: string }; readonly request: HttpServerRequest.HttpServerRequest; }) { + // The router answers HEAD with the GET route; link checkers probe with HEAD. + if (request.method === "HEAD") { + return json(405, { error: "method_not_allowed" }, "method_not_allowed", { + allow: "GET, POST, PUT, PATCH", + }); + } const contentLength = Number(request.headers["content-length"] ?? "0"); if (!Number.isFinite(contentLength) || contentLength > WEBHOOK_MAX_BODY_BYTES) { return json(413, { error: "body_too_large" }, "body_too_large"); diff --git a/apps/server/src/scheduling/DueHint.ts b/apps/server/src/scheduling/DueHint.ts new file mode 100644 index 0000000000000..2df8af5f0c536 --- /dev/null +++ b/apps/server/src/scheduling/DueHint.ts @@ -0,0 +1,30 @@ +import * as Effect from "effect/Effect"; + +// Backstop for a write path that forgets to invalidate; the tick still runs every few seconds. +const DUE_HINT_MAX_AGE_MS = 10 * 60 * 1_000; + +/** + * Remembers when a poller next has work so idle scheduler ticks skip SQL. + * `invalidate` after every write that can move work earlier; a tick records + * only if no write happened since it read `version`. + */ +export const make = () => { + let version = 0; + let hint: { readonly dueAtMs: number; readonly checkedAtMs: number } | null = null; + return { + invalidate: Effect.sync(() => { + version++; + hint = null; + }), + version: Effect.sync(() => version), + isIdle: (nowMs: number) => + Effect.sync( + () => + hint !== null && hint.dueAtMs > nowMs && nowMs - hint.checkedAtMs < DUE_HINT_MAX_AGE_MS, + ), + record: (readVersion: number, dueAtMs: number, nowMs: number) => + Effect.sync(() => { + if (readVersion === version) hint = { dueAtMs, checkedAtMs: nowMs }; + }), + }; +}; diff --git a/apps/server/src/secrets/settingsSecretValue.ts b/apps/server/src/secrets/settingsSecretValue.ts new file mode 100644 index 0000000000000..b17458e2d8f13 --- /dev/null +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -0,0 +1,20 @@ +import { secretReferencesInSettings } 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 + * 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* < + Settings extends Readonly>, +>(resolver: SecretResolver.SecretResolver["Service"], schema: Schema.Top, settings: Settings) { + 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 { ...settings, ...resolved }; +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ceab3f17b18f1..9c978f2e91019 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -64,6 +64,7 @@ import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts" import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts"; import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControl/sourceControlDefinitions.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; +import * as SecretResolver from "./secrets/SecretResolver.ts"; import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -634,6 +635,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerOrchestrationApplication), Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), + Layer.provideMerge(SecretResolver.layer), Layer.provideMerge(layerServerSettings), Layer.provideMerge(layerSourceControlProviderRegistry), Layer.provideMerge(layerGit), diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 9a4e71149a496..f6b3114c630dc 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -179,6 +179,24 @@ describe("ServerLoggerLive", () => { }), ); + it.effect("exports logged request failures without their connection tickets", () => + Effect.gen(function* () { + const { requests } = yield* logInSpanThrough( + { otlpLogsUrl: "https://collector.example.com/v1/logs" }, + Effect.logError( + Cause.die( + new Error("RequestParseError (GET /ws?wsTicket=ticket-secret&dpop=proof-secret)"), + ), + ), + ); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, "wsTicket=&dpop="); + assert.notInclude(body, "ticket-secret"); + assert.notInclude(body, "proof-secret"); + }), + ); + it.effect("prints a cause passed as a log field instead of collapsing it", () => Effect.gen(function* () { const output = yield* printThroughConsole({ diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index d138175029799..79eded855f6de 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -11,9 +11,15 @@ import * as OtlpLogger from "effect/observability/OtlpLogger"; import * as ServerConfig from "./config.ts"; +// HTTP errors logged with --log-ws-events carry the request URL and its connection ticket. +const redactingCredentials = (logger: Logger.Logger) => + Logger.make((options) => + logger.log({ ...options, cause: SharedObservability.redactCauseCredentials(options.cause) }), + ); + // consolePretty collapses Cause fields via util.inspect; render them with Cause.pretty like standalone arguments. const consolePretty = (): Logger.Logger => { - const pretty = Logger.consolePretty(); + const pretty = redactingCredentials(Logger.consolePretty()); return Logger.make((options) => pretty.log({ ...options, @@ -56,7 +62,7 @@ export const layer = Effect.gen(function* () { exportInterval: `${logs.exportIntervalMs} millis`, headers: logs.headers, resource: ServerConfig.otlpResource(config, hostName), - }); + }).pipe(Effect.map(redactingCredentials)); // Logger.layer replaces the whole set; name every logger here. // OTLP includes traced messages as log records, so also using tracerLogger would duplicate in-span exports. diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 6d89f45cb792e..a211ae7e48a9a 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -5,6 +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 { T3_NPX_COMMAND } from "@t3tools/shared/releasePackage"; import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -55,7 +56,7 @@ export class OlderBootServiceError extends Schema.TaggedError - thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent", + (thread) => thread.projectId === project.id && isListedThreadLineage(thread.lineage), ); if (existingThread === undefined) { const serverSettings = yield* ServerSettings.ServerSettingsService; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 0ae14fae5da7a..ef8b4d6afae79 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1533,6 +1533,25 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); + + const reference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Bitbucket/token", + }; + yield* serverSettings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "bb-access" } }, + }); + assert.isTrue(Option.isSome(yield* stored("bitbucket", "accessToken", null))); + const referenced = yield* serverSettings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: reference } }, + }); + 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, + ); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 3697c877edc22..3d4daa659e58a 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -6,6 +6,7 @@ import { type ServerSettings, SimlockDeviceHostConfig, UsageLimitSourceConfig, + isSecretReference, secretSettingsFields, } from "@t3tools/contracts"; import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; @@ -60,7 +61,7 @@ export function mapSettingsSecrets( ) => { const next = { ...fields }; for (const field of secretFields) { - if (!(field in next)) continue; + if (!(field in next) || isSecretReference(next[field])) continue; next[field] = writeSecretFieldValues( next[field], readSecretFieldValues(next[field]).map(([serverHost, value]) => { diff --git a/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts b/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts index a774954aa43bc..da89f39c573a8 100644 --- a/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GiteaSourceControlProvider.test.ts @@ -76,6 +76,7 @@ function setup( Layer.mergeAll( Layer.mock(GiteaCli.GiteaCli)({ baseUrl: Option.some("https://forge.example.test"), + sshHosts: ["forge-alias"], request, }), FileSystem.layerNoop({ readFileString: () => Effect.succeed("PR body") }), @@ -95,6 +96,40 @@ function setup( return { provider, request, fetchBranch, fetchTracking, ensureRemote, switchRef, localBranches }; } +it.effect("refines SSH aliases to the configured Gitea web repository", () => { + const { provider } = setup(() => ({ body: {} })); + const identity = { + canonicalKey: "forge-alias:team/repo", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@forge-alias:team/repo.git", + }, + rootPath: "/repo", + provider: SourceControlProviderKind.make("unknown"), + }; + return Effect.gen(function* () { + const refined = yield* (yield* provider).refineRepositoryIdentity!({ + identity, + resolveContext: () => Effect.succeed(context), + }); + assert.strictEqual(refined.provider, "gitea"); + assert.strictEqual(refined.webUrl, "https://forge.example.test/team/repo"); + }); +}); + +it.effect("resolves the web host for SSH aliases", () => { + const { provider } = setup(() => ({ body: {} })); + return Effect.gen(function* () { + const host = yield* (yield* provider).resolveRemoteWebHost!({ + cwd: "/repo", + context, + resolveContext: () => Effect.succeed(context), + }); + assert.strictEqual(host, "forge.example.test"); + }); +}); + it.effect("maps merged state, draft and deleted source repository accurately", () => { const { provider } = setup(() => ({ body: pull(42, { diff --git a/apps/server/src/sourceControl/GiteaSourceControlProvider.ts b/apps/server/src/sourceControl/GiteaSourceControlProvider.ts index acb2a1bd7cce6..d17641398232b 100644 --- a/apps/server/src/sourceControl/GiteaSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GiteaSourceControlProvider.ts @@ -141,6 +141,67 @@ export const make = Effect.gen(function* () { ? Effect.succeed(api.baseUrl.value) : Effect.fail(failure("resolveRepository", cwd, GiteaCli.GITEA_SETUP_HINT)); + // SSH remotes can name a tea alias rather than the web server, so resolve the + // configured login before persisting the browser identity and PR host. + const refineRepositoryIdentity: NonNullable< + SourceControlProvider.SourceControlProvider["Service"]["refineRepositoryIdentity"] + > = Effect.fn("GiteaSourceControlProvider.refineRepositoryIdentity")(function* ({ + identity, + resolveContext, + }) { + if ( + !identity.rootPath || + (identity.provider !== undefined && + identity.provider !== "unknown" && + identity.provider !== "gitea") + ) + return identity; + const baseUrl = Option.getOrUndefined(api.baseUrl); + if ( + baseUrl === undefined || + giteaRepositoryFromRemote(identity.locator.remoteUrl, baseUrl, api.sshHosts) === null + ) + return identity; + const context = yield* resolveContext({ + cwd: identity.rootPath, + context: { + provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" }, + remoteName: identity.locator.remoteName, + remoteUrl: identity.locator.remoteUrl, + }, + }); + if (context?.provider.kind !== "gitea") return identity; + const repository = giteaRepositoryFromRemote( + identity.locator.remoteUrl, + context.provider.baseUrl, + api.sshHosts, + ); + if (repository === null) return identity; + return { + ...identity, + provider: SourceControlProviderKind.make("gitea"), + webUrl: `${context.provider.baseUrl.replace(/\/+$/u, "")}/${repository}`, + }; + }); + + const resolveRemoteWebHost: NonNullable< + SourceControlProvider.SourceControlProvider["Service"]["resolveRemoteWebHost"] + > = Effect.fn("GiteaSourceControlProvider.resolveRemoteWebHost")(function* ({ + cwd, + context, + resolveContext, + }) { + if (!isSshRemoteUrl(context.remoteUrl)) return null; + const resolved = yield* resolveContext({ + cwd, + context: { + ...context, + provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" }, + }, + }); + return resolved?.provider.kind === "gitea" ? new URL(resolved.provider.baseUrl).host : null; + }); + const resolveRepository = Effect.fn("GiteaSourceControlProvider.resolveRepository")( function* (input: { readonly cwd: string; @@ -241,6 +302,8 @@ export const make = Effect.gen(function* () { return SourceControlProvider.SourceControlProvider.of({ kind: SourceControlProviderKind.make("gitea"), + refineRepositoryIdentity, + resolveRemoteWebHost, listChangeRequests: Effect.fn("GiteaSourceControlProvider.listChangeRequests")( function* (input) { const { repository } = yield* resolveRepository(input); diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts index 73e79a8cc49f3..00b648d0f0364 100644 --- a/apps/server/src/sourceControl/ServerSourceControlHost.ts +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -1,11 +1,14 @@ +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { resolveSourceControlHostSecrets } from "./sourceControlHostSecrets.ts"; export const layer = Layer.effect( SourceControlHost.SourceControlHost, @@ -14,8 +17,24 @@ export const layer = Layer.effect( const process = yield* VcsProcess.VcsProcess; const git = yield* GitVcsDriver.GitVcsDriver; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + const secrets = yield* SecretResolver.SecretResolver; return SourceControlHost.SourceControlHost.of({ - settings: { get: serverSettings.getSettings, update: serverSettings.updateSettings }, + settings: { + get: serverSettings.getSettings, + getWithSecrets: (kind) => + serverSettings.getSettings.pipe( + Effect.flatMap((settings) => + resolveSourceControlHostSecrets(secrets, settings.sourceControlHosts, kind).pipe( + Effect.map((sourceControlHosts) => + sourceControlHosts === settings.sourceControlHosts + ? settings + : { ...settings, sourceControlHosts }, + ), + ), + ), + ), + update: serverSettings.updateSettings, + }, process: { run: process.run }, git: { execute: git.execute, @@ -35,4 +54,4 @@ export const layer = Layer.effect( }, }); }), -); +).pipe(Layer.provide(SecretResolverLayer.layer)); diff --git a/apps/server/src/sourceControl/SourceControlSignIn.ts b/apps/server/src/sourceControl/SourceControlSignIn.ts index d6c2ab1a69a33..3dd9ca03e2062 100644 --- a/apps/server/src/sourceControl/SourceControlSignIn.ts +++ b/apps/server/src/sourceControl/SourceControlSignIn.ts @@ -5,6 +5,7 @@ import { type SourceControlSignInInput, type SourceControlSignInPrompt, type SourceControlSignInResult, + type SourceControlStarredInput, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -12,7 +13,7 @@ import * as Layer from "effect/Layer"; import * as SourceControlBuiltInDrivers from "./builtInDrivers.ts"; -/** Signs the server in to a forge through the forge's own app, for hosts whose driver offers it. */ +/** Signs the server in to a forge through the forge's own app, and reads what its account did. */ export class SourceControlSignIn extends Context.Service< SourceControlSignIn, { @@ -26,6 +27,7 @@ export class SourceControlSignIn extends Context.Service< input: SourceControlSignInInput, ) => Effect.Effect; readonly access: (input: SourceControlAppAccessInput) => Effect.Effect; + readonly starred: (input: SourceControlStarredInput) => Effect.Effect; } >()("t3/sourceControl/SourceControlSignIn") {} @@ -63,6 +65,11 @@ const make = Effect.gen(function* () { signIn === undefined ? "signed-out" : yield* signIn.access(input.host, input.repository), } satisfies SourceControlAppAccess; }), + starred: Effect.fn("SourceControlSignIn.starred")(function* (input) { + const isStarred = drivers.find(({ driver }) => driver.kind === input.provider)?.instance + .isRepositoryStarred; + return isStarred === undefined ? false : yield* isStarred(input.host, input.repository); + }), }); }); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts new file mode 100644 index 0000000000000..bfbe4a1c8ac94 --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -0,0 +1,124 @@ +import { assert, it } from "@effect/vitest"; +import { SecretProviderId } from "@t3tools/contracts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as Effect from "effect/Effect"; + +import { + collectSourceControlHostSecretReferences, + invalidateSettingsSecrets, + resolveSourceControlHostSecrets, +} from "./sourceControlHostSecrets.ts"; + +const source = (reference: string) => ({ + provider: SecretProviderId.make("homelab"), + reference, +}); + +const secretsByReference = new Map([["bitbucket#token", "bb_from_provider"]]); + +const hosts = { + bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, + gitcafe: { token: source("missing#token") }, + github: { tokens: { "github.com": "ghp_literal" } }, +}; + +function countingResolver() { + const reads: Array = []; + const resolver = SecretResolver.SecretResolver.of({ + read: (references) => + Effect.sync(() => + references.map((reference) => { + reads.push(reference.reference); + return secretsByReference.get(reference.reference); + }), + ), + invalidate: () => Effect.void, + }); + return { reads, resolver }; +} + +it.effect( + "replaces only the requested kind's credential references with the secrets they name", + () => + Effect.gen(function* () { + const { reads, resolver } = countingResolver(); + const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "bitbucket"); + assert.deepEqual(resolved, { + ...hosts, + bitbucket: { email: "me@example.test", accessToken: "bb_from_provider" }, + }); + assert.deepEqual(reads, ["bitbucket#token"]); + }), +); + +it.effect("treats a reference the provider cannot read as an unset credential", () => + Effect.gen(function* () { + const { reads, resolver } = countingResolver(); + const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "gitcafe"); + assert.deepEqual(resolved.gitcafe, { token: "" }); + assert.deepEqual(reads, ["missing#token"]); + }), +); + +it.effect("leaves the resolver unused for a kind that holds no reference", () => + Effect.gen(function* () { + const { reads, resolver } = countingResolver(); + const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "github"); + assert.strictEqual(resolved, hosts); + assert.deepEqual(reads, []); + }), +); + +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({ + bitbucket: { accessToken: "literal" }, + github: { tokens: { "github.com": "ghp_literal" } }, + }), + [], + ); +}); + +it.effect("reads a collected reference again once it is invalidated", () => + Effect.gen(function* () { + let current = "stale"; + const cache = new Map(); + const resolver = SecretResolver.SecretResolver.of({ + read: (references) => + Effect.sync(() => + references.map((reference) => { + const hit = cache.get(reference.reference); + if (hit !== undefined) return hit; + cache.set(reference.reference, current); + return current; + }), + ), + invalidate: (references) => + Effect.sync(() => { + if (references === undefined) cache.clear(); + else for (const reference of references) cache.delete(reference.reference); + }), + }); + const read = () => resolveSourceControlHostSecrets(resolver, hosts, "bitbucket"); + assert.deepEqual((yield* read()).bitbucket, { + email: "me@example.test", + accessToken: "stale", + }); + current = "rotated"; + yield* invalidateSettingsSecrets(resolver, Effect.succeed({ sourceControlHosts: hosts })); + assert.deepEqual((yield* read()).bitbucket, { + email: "me@example.test", + accessToken: "rotated", + }); + current = "rotated again"; + yield* invalidateSettingsSecrets(resolver, Effect.fail("settings unreadable")); + assert.deepEqual((yield* read()).bitbucket, { + email: "me@example.test", + accessToken: "rotated again", + }); + }), +); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 46c96ca5b7f05..cf86ade6b0eee 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -1,4 +1,14 @@ -import { secretSettingsFields } from "@t3tools/contracts"; +import { + isSecretReference, + secretReferencesInSettings, + secretSettingsFields, + type SecretReference, + type ServerSettings, +} from "@t3tools/contracts"; +import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as Effect from "effect/Effect"; + +import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; @@ -30,6 +40,7 @@ export function readSecretFieldValues( value: unknown, ): ReadonlyArray { if (typeof value === "string") return [[null, value]]; + if (isSecretReference(value)) return []; if (value === null || typeof value !== "object" || Array.isArray(value)) return []; return Object.entries(value).flatMap(([serverHost, entry]) => typeof entry === "string" ? [[serverHost.trim().toLowerCase(), entry] as const] : [], @@ -41,5 +52,50 @@ export function writeSecretFieldValues( values: ReadonlyArray, ): unknown { if (typeof original === "string") return values[0]?.[1] ?? ""; + if (isSecretReference(original)) return original; return Object.fromEntries(values.map(([serverHost, value]) => [serverHost ?? "", value])); } + +type SourceControlHostsSettings = Readonly>>>; + +const hostSettingsSchemas = new Map( + sourceControlDefinitions.definitions.flatMap((definition) => + definition.settings ? [[definition.kind as string, definition.settings] as const] : [], + ), +); + +/** Every secret provider reference held in any host kind's credential fields. */ +export function collectSourceControlHostSecretReferences( + hosts: SourceControlHostsSettings, +): ReadonlyArray { + return [...hostSettingsSchemas].flatMap(([kind, schema]) => + secretReferencesInSettings(schema, hosts[kind]).map(({ reference }) => reference), + ); +} + +/** Replaces each secret provider reference in one host kind's credential fields with the secret it names. */ +export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.resolveSecrets")( + function* ( + resolver: SecretResolver.SecretResolver["Service"], + hosts: SourceControlHostsSettings, + kind: string, + ) { + const schema = hostSettingsSchemas.get(kind); + const fields = hosts[kind]; + if (schema === undefined || fields === undefined) return hosts; + const resolved = yield* resolveSettingsSecrets(resolver, schema, fields); + return resolved === fields ? hosts : { ...hosts, [kind]: resolved }; + }, +); + +/** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ +export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* ( + resolver: SecretResolver.SecretResolver["Service"], + settings: Effect.Effect, E>, +) { + const references = yield* settings.pipe( + Effect.map((current) => collectSourceControlHostSecretReferences(current.sourceControlHosts)), + Effect.orElseSucceed(() => undefined), + ); + yield* resolver.invalidate(references); +}); diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 5f945abd1858c..e18cdc4320c75 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -461,6 +461,22 @@ const cleanupFixture = ( setOpenSessionCwd: (cwd: string | null) => { openSessionCwd = cwd; }, + openTerminal: (paths: { cwd: string; worktreePath: string | null }) => + terminalListener?.({ + type: "upsert", + terminal: { + threadId: "thread-1", + terminalId: "terminal-1", + status: "running", + pid: 1, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Terminal", + updatedAt: "2026-06-10T12:00:00.000Z", + ...paths, + }, + }) ?? Effect.void, fullStatusCalls: () => fullStatusCalls, snapshotReads: () => snapshotReads, activate: (domainEvents: Stream.Stream) => { @@ -705,6 +721,7 @@ describe("subagent worktree ownership", () => { "pending", "unrelated", "fork", + "side-chat", "recent-activity", "recent-inactive", "recent-grace", @@ -758,7 +775,12 @@ describe("subagent worktree ownership", () => { rootThreadId: scenario === "wrong-root" ? ThreadId.make("other-root") : root.lineage.rootThreadId, relationshipToParent: - scenario === "fork" ? "fork" : scenario === "unrelated" ? null : "subagent", + scenario === "fork" || scenario === "side-chat" + ? "fork" + : scenario === "unrelated" + ? null + : "subagent", + ...(scenario === "side-chat" ? { sideChat: true } : {}), }, status: scenario === "running" ? "running" : "completed", pendingBackgroundTasks: scenario === "pending" ? [{ taskId: "task", kind: "command" }] : [], @@ -792,7 +814,9 @@ describe("subagent worktree ownership", () => { root, ]); const report = yield* fixture.service.runNow; - const removed = ["idle", "grandchild", "isolated-owner", "fork-owner"].includes(scenario); + const removed = ["idle", "grandchild", "isolated-owner", "fork-owner", "side-chat"].includes( + scenario, + ); const shared = [ "unrelated", "fork", @@ -1594,6 +1618,54 @@ describe("orphan worktree reports", () => { }), ), ); + + it.live.each([ + ["win32", "cwd"], + ["win32", "worktreePath"], + ["linux", "cwd"], + ["linux", "worktreePath"], + ] as const)( + "matches a terminal at the worktree root with host casing rules (%s, %s)", + ([platform, field]) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture().pipe( + Effect.provideService(HostProcess.Platform, platform), + ); + const upper = fixture.worktree.toUpperCase(); + yield* fixture.openTerminal( + field === "cwd" + ? { cwd: upper, worktreePath: null } + : { cwd: fixture.repo, worktreePath: upper }, + ); + const report = yield* fixture.service.runNow; + expect(report.entries[0]).toMatchObject( + platform === "win32" + ? { outcome: "kept", reason: "Open terminal" } + : { outcome: "removed" }, + ); + }), + ), + ); + + it.live.each(["win32", "linux"] as const)( + "matches a provider session at the worktree root with host casing rules (%s)", + (platform) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture().pipe( + Effect.provideService(HostProcess.Platform, platform), + ); + fixture.setOpenSessionCwd(fixture.worktree.toUpperCase()); + const report = yield* fixture.service.runNow; + expect(report.entries[0]).toMatchObject( + platform === "win32" + ? { outcome: "kept", reason: "Provider session is still open" } + : { outcome: "removed" }, + ); + }), + ), + ); }); describe("storage cleanup reports and local file policies", () => { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 58d29a5d05cf6..5f2b291a60e76 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -144,7 +144,7 @@ export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): nu ); } -/** Only a complete subagent chain within this worktree shares ownership. */ +/** Only a complete subagent or side chat chain within this worktree shares ownership. */ function worktreeOwner>( threads: ReadonlyArray, ) { @@ -159,7 +159,8 @@ function worktreeOwner + Effect.gen(function* () { + // Observe retained payloads, since delivery and bounded history alone cannot detect a leak. + let readState: Effect.Effect = Effect.void; + const makeRef = vi.mocked(SynchronizedRef.make).getMockImplementation()!; + using _capture = vi.spyOn(SynchronizedRef, "make").mockImplementation((value: A) => + makeRef(value).pipe( + Effect.tap((ref) => + Effect.sync(() => { + if (typeof value === "object" && value !== null && "sessions" in value) { + readState = SynchronizedRef.get(ref); + } + }), + ), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { historyByteLimit: 1024 }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const measured = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const drained = yield* Deferred.make(); + yield* Effect.addFinalizer(() => Deferred.succeed(finish, undefined)); + yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined)); + const chunkBytes = 64 * 1024; + const chunkCount = 256; + let delivered = 0; + const unsubscribe = yield* manager.subscribe((event) => + Effect.gen(function* () { + if (event.type !== "output") return; + delivered += 1; + expect(event.data).toBe( + delivered === 1 + ? "start" + : (delivered === 2 + ? "queued-a" + : delivered === 3 + ? "queued-b" + : `${delivered - 3}` + ).padEnd(chunkBytes, "x"), + ); + if (delivered === 1) { + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + } + if (delivered === chunkCount) { + yield* Deferred.succeed(measured, undefined); + yield* Deferred.await(finish); + } else if (delivered < chunkCount) { + process.emitData(`${delivered}`.padEnd(chunkBytes, "x")); + } else if (delivered === chunkCount + 2) { + yield* Deferred.succeed(drained, undefined); + } + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + process.emitData("start"); + yield* Deferred.await(entered); + process.emitData("queued-a".padEnd(chunkBytes, "x")); + process.emitData("queued-b".padEnd(chunkBytes, "x")); + yield* Deferred.succeed(release, undefined); + yield* Deferred.await(measured); + + const state = (yield* readState) as { + sessions: ReadonlyMap< + string, + { pendingProcessEvents: ReadonlyArray<{ data?: string } | undefined> } + >; + }; + const retained = [...state.sessions.values()].flatMap( + (session) => session.pendingProcessEvents, + ); + const retainedBytes = retained.reduce( + (bytes, event) => bytes + (event?.data?.length ?? 0), + 0, + ); + expect(delivered).toBe(chunkCount); + expect(retainedBytes).toBeLessThanOrEqual(2 * chunkBytes); + expect(retained.length).toBeLessThanOrEqual(4); + yield* Deferred.succeed(finish, undefined); + yield* Deferred.await(drained); + }), + ); + it.effect("retains plain Unicode and control characters around a split query", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -1195,7 +1285,7 @@ it.layer( it.effect("keeps reading and deleting history at an encoded path that already fit", () => Effect.gen(function* () { - const threadId = "x".repeat(120); + const threadId = "x".repeat(48); const { manager, logsDir } = yield* createManager(); const fs = yield* FileSystem.FileSystem; const logPath = yield* historyLogPath(logsDir, threadId); @@ -1213,6 +1303,180 @@ it.layer( }), ); + it.effect.each([ + { + label: "delegated setup", + threadId: + "thread:delegated-task:command%3Amcp%3A00000000-0000-4000-8000-000000000000%3Adelegate-task%3Aexample-r4-workspace-prepare-engine3", + terminalId: "setup-setup-worktree-2", + }, + { label: "long thread", threadId: "t".repeat(400), terminalId: DEFAULT_TERMINAL_ID }, + { label: "Unicode thread", threadId: "项目😀".repeat(60), terminalId: "setup" }, + { label: "long terminal", threadId: "short", terminalId: "t".repeat(128) }, + { label: "142-byte boundary", threadId: "t".repeat(48), terminalId: "s".repeat(48) }, + { label: "144-byte boundary", threadId: "t".repeat(49), terminalId: "s".repeat(48) }, + ])("persists $label history with a 143-byte filename limit", ({ threadId, terminalId }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const check = (filePath: string) => + Buffer.byteLength(path.basename(filePath)) <= 143 + ? Effect.void + : Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "access", + pathOrDescriptor: filePath, + cause: { code: "ENAMETOOLONG" }, + }), + ); + const limited = FileSystem.FileSystem.of({ + ...fs, + exists: (filePath) => Effect.andThen(check(filePath), fs.exists(filePath)), + open: (filePath, options) => Effect.andThen(check(filePath), fs.open(filePath, options)), + writeFileString: (filePath, data, options) => + Effect.andThen(check(filePath), fs.writeFileString(filePath, data, options)), + remove: (filePath, options) => + Effect.andThen(check(filePath), fs.remove(filePath, options)), + }); + const { manager, ptyAdapter, logsDir } = yield* createManager().pipe( + Effect.provideService(FileSystem.FileSystem, limited), + ); + yield* manager.open(openInput({ threadId, terminalId })); + ptyAdapter.processes[0]!.emitData("setup output\n"); + yield* manager.close({ threadId, terminalId }); + const names = yield* fs.readDirectory(logsDir); + expect(names).toHaveLength(1); + expect(Buffer.byteLength(names[0]!)).toBeLessThanOrEqual(143); + expect((yield* manager.open(openInput({ threadId, terminalId }))).history).toBe( + "setup output\n", + ); + const otherThread = `${threadId}-other`; + expect((yield* manager.open(openInput({ threadId: otherThread, terminalId }))).history).toBe( + "", + ); + ptyAdapter.processes[2]!.emitData("other output\n"); + yield* manager.close({ threadId: otherThread, terminalId }); + expect(yield* fs.readDirectory(logsDir)).toHaveLength(2); + yield* manager.close({ threadId, terminalId, deleteHistory: true }); + expect(yield* fs.readDirectory(logsDir)).toHaveLength(1); + yield* manager.close({ threadId: otherThread, deleteHistory: true }); + expect(yield* fs.readDirectory(logsDir)).toEqual([]); + }), + ); + + it.effect.each([ + { source: "encoded", terminalId: DEFAULT_TERMINAL_ID }, + { source: "encoded", terminalId: "setup-setup-worktree-2" }, + { source: "sanitized", terminalId: DEFAULT_TERMINAL_ID }, + ])("migrates oversized $source history for $terminalId", ({ source, terminalId }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { manager, logsDir } = yield* createManager(); + const threadId = "m".repeat(100); + const oldPath = + source === "encoded" + ? path.join( + logsDir, + `terminal_${Base64Url.encode(threadId)}${terminalId === DEFAULT_TERMINAL_ID ? "" : `_${Base64Url.encode(terminalId)}`}.log`, + ) + : path.join(logsDir, `${threadId}.log`); + yield* fs.writeFileString(oldPath, "old output\n"); + expect((yield* manager.open(openInput({ threadId, terminalId }))).history).toBe( + "old output\n", + ); + const names = yield* fs.readDirectory(logsDir); + expect(names).toHaveLength(1); + expect(Buffer.byteLength(names[0]!)).toBeLessThanOrEqual(143); + expect(yield* fs.exists(oldPath)).toBe(false); + expect(yield* fs.readFileString(path.join(logsDir, names[0]!))).toBe("old output\n"); + yield* manager.close({ threadId, terminalId, deleteHistory: true }); + expect(yield* fs.readDirectory(logsDir)).toEqual([]); + }), + ); + + it.effect.each(["terminal", "thread"] as const)( + "deletes compact and older encoded history when closing a %s", + (scope) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { manager, logsDir } = yield* createManager(); + const threadId = "d".repeat(100); + const terminalId = "setup-setup-worktree-2"; + yield* manager.open(openInput({ threadId, terminalId })); + yield* manager.close({ threadId, terminalId }); + const oldPath = path.join( + logsDir, + `terminal_${Base64Url.encode(threadId)}_${Base64Url.encode(terminalId)}.log`, + ); + yield* fs.writeFileString(oldPath, "stale copy\n"); + yield* fs.writeFileString(path.join(logsDir, "unrelated.log"), "keep\n"); + if (scope === "terminal") { + yield* manager.close({ threadId, terminalId, deleteHistory: true }); + } else { + yield* manager.close({ threadId, deleteHistory: true }); + } + expect(yield* fs.readDirectory(logsDir)).toEqual(["unrelated.log"]); + }), + ); + + it.effect("prefers bounded history over a stale encoded copy", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir } = yield* createManager(); + const fs = yield* FileSystem.FileSystem; + const threadId = "p".repeat(100); + yield* manager.open(openInput({ threadId })); + ptyAdapter.processes[0]!.emitData("current\n"); + yield* manager.close({ threadId }); + const oldPath = yield* historyLogPath(logsDir, threadId); + yield* fs.writeFileString(oldPath, "stale\n"); + + expect((yield* manager.open(openInput({ threadId }))).history).toBe("current\n"); + yield* manager.close({ threadId, terminalId: DEFAULT_TERMINAL_ID, deleteHistory: true }); + expect(yield* fs.readDirectory(logsDir)).toEqual([]); + }), + ); + + it.effect("retains encoded history if migration cannot write the bounded file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + let failNextWrite = true; + const limited = FileSystem.FileSystem.of({ + ...fs, + writeFileString: (filePath, data, options) => { + if (!failNextWrite) return fs.writeFileString(filePath, data, options); + failNextWrite = false; + return fs.writeFileString(filePath, "partial").pipe( + Effect.andThen( + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "writeFileString", + cause: { code: "ENOSPC" }, + }), + ), + ), + ); + }, + }); + const { manager, logsDir } = yield* createManager().pipe( + Effect.provideService(FileSystem.FileSystem, limited), + ); + const threadId = "f".repeat(100); + const oldPath = yield* historyLogPath(logsDir, threadId); + yield* fs.writeFileString(oldPath, "preserve me\n"); + + expect(Exit.isFailure(yield* Effect.exit(manager.open(openInput({ threadId }))))).toBe(true); + expect(yield* fs.readFileString(oldPath)).toBe("preserve me\n"); + expect(yield* fs.readDirectory(logsDir)).toHaveLength(1); + expect((yield* manager.open(openInput({ threadId }))).history).toBe("preserve me\n"); + }), + ); + it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => Effect.gen(function* () { const { manager, logsDir } = yield* createManager(); @@ -1267,6 +1531,61 @@ it.layer( }), ); + it.effect( + "writes completion syntax for the fallback shell instead of the configured fish shell", + () => + Effect.gen(function* () { + const env = { SHELL: "/missing/fish" }; + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { env }).pipe( + Effect.provide(layerWithHostPlatform("linux")), + ); + ptyAdapter.spawnFailures.push(new Error("spawn fish ENOENT")); + const written = yield* Deferred.make(); + const terminals = TerminalManager.TerminalManager.of({ + ...manager, + write: (input) => + manager.write(input).pipe(Effect.tap(() => Deferred.succeed(written, undefined))), + }); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, terminals), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => Effect.succeed(null), + }), + ServerSettings.layerTest(), + Layer.succeed(HostProcess.Platform, "linux"), + Layer.succeed(HostProcess.Environment, env), + ), + ), + ); + yield* runner.runForThread({ + threadId: "thread-setup-fallback", + worktreePath: baseDir, + project: { + id: ProjectId.make("project-setup-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }, + observeCompletion: {}, + }); + const process = ptyAdapter.processes[0]!; + process.emitData("\x1b[?2004h"); + yield* Deferred.await(written); + const command = process.writes[0]!; + expect(command).toContain('"$?"'); + }), + ); + it.effect("preserves Windows Path casing when appending managed ACP binaries", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1360,6 +1679,62 @@ it.layer( }), ); + it.effect("closes an idle cmd fallback before rejecting its completion wrapper", () => + Effect.gen(function* () { + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { + env: { SystemRoot: "C:\\Windows" }, + subprocessInspector: () => + Effect.succeed({ + hasRunningSubprocess: false, + childCommand: null, + processIds: [], + }), + }).pipe(Effect.provide(layerWithHostPlatform("win32"))); + ptyAdapter.spawnFailures.push( + new Error("spawn pwsh ENOENT"), + new Error("spawn WindowsPowerShell ENOENT"), + new Error("spawn powershell ENOENT"), + ); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, manager), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => Effect.succeed(null), + }), + ServerSettings.layerTest(), + ), + ), + ); + const error = yield* runner + .runForThread({ + threadId: "thread-cmd-fallback", + worktreePath: baseDir, + project: { + id: ProjectId.make("project-cmd-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }, + observeCompletion: {}, + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("ProjectSetupScriptUnsupportedShellError"); + expect(ptyAdapter.spawnInputs.at(-1)?.shell).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(ptyAdapter.processes[0]?.writes).toEqual([]); + expect(ptyAdapter.processes[0]?.killed).toBe(true); + }), + ); + it.effect("turns off fish terminal queries unless the user chose query-term", () => Effect.gen(function* () { for (const [platform, env, runtimeEnv, expected] of [ diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index bd7ca2c92e3c2..9d3942c6cb62b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -93,6 +93,8 @@ export { const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024; +// eCryptfs permits 143-byte names. +const MAX_HISTORY_FILENAME_BYTES = 143; const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024; const DEFAULT_PERSIST_DEBOUNCE_MS = 250; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; @@ -170,6 +172,8 @@ export class TerminalManager extends Context.Service< input: TerminalOpenInput, ) => Effect.Effect; + readonly getShell: (input: TerminalObserveInput) => Effect.Effect; + /** * Attached viewers share one PTY sized to the smallest of them, so a narrow client * still sees the whole TUI. Streams and resizes with the same `viewerId` (one client @@ -283,7 +287,7 @@ interface TerminalSessionState { pid: number | null; history: BoundedTerminalHistory; pendingHistoryControlSequence: string; - pendingProcessEvents: Array; + pendingProcessEvents: Array; pendingProcessEventIndex: number; processEventDrainRunning: boolean; exitCode: number | null; @@ -294,6 +298,7 @@ interface TerminalSessionState { cols: number; rows: number; process: PtyAdapter.PtyProcess | null; + shell: string | null; unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; @@ -1304,8 +1309,6 @@ function legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); } -const MAX_FILE_NAME_BYTES = 255; - function encodedHistoryThreadPart(threadId: string): string { return `terminal_${Base64Url.encode(threadId)}`; } @@ -1672,28 +1675,46 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } }); - // Nested subagent thread ids can encode past the filename limit. Only names that would not fit - // switch to hashed ids, so every history file that already exists keeps its path. - const historyPath = (threadId: string, terminalId: string) => { - const isDefault = terminalId === DEFAULT_TERMINAL_ID; - const encodedName = joinHistoryFileName( - encodedHistoryThreadPart(threadId), - isDefault ? null : Base64Url.encode(terminalId), + const encodedHistoryPath = (threadId: string, terminalId: string) => + path.join( + logsDir, + joinHistoryFileName( + encodedHistoryThreadPart(threadId), + terminalId === DEFAULT_TERMINAL_ID ? null : Base64Url.encode(terminalId), + ), ); - if (encodedName.length <= MAX_FILE_NAME_BYTES) { - return path.join(logsDir, encodedName); - } + + const historyPath = (threadId: string, terminalId: string) => { + const encoded = encodedHistoryPath(threadId, terminalId); + if (path.basename(encoded).length <= MAX_HISTORY_FILENAME_BYTES) return encoded; return path.join( logsDir, - joinHistoryFileName(hashedHistoryThreadPart(threadId), isDefault ? null : hashId(terminalId)), + joinHistoryFileName( + hashedHistoryThreadPart(threadId), + terminalId === DEFAULT_TERMINAL_ID ? null : hashId(terminalId), + ), ); }; - const legacyHistoryName = (threadId: string) => `${legacySafeThreadId(threadId)}.log`; - const legacyHistoryPath = (threadId: string) => path.join(logsDir, legacyHistoryName(threadId)); - // Ids this long could never have been written under the legacy name. - const hasLegacyHistoryPath = (threadId: string) => - Buffer.byteLength(legacyHistoryName(threadId)) <= MAX_FILE_NAME_BYTES; + const legacyHistoryPath = (threadId: string) => + path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); + + const historyPaths = (threadId: string, terminalId: string) => [ + ...new Set([ + historyPath(threadId, terminalId), + encodedHistoryPath(threadId, terminalId), + ...(terminalId === DEFAULT_TERMINAL_ID ? [legacyHistoryPath(threadId)] : []), + ]), + ]; + + const historyExists = (filePath: string) => { + const name = path.basename(filePath); + // An oversized old path can itself fail on access. Directory entries also + // preserve migration of such histories on filesystems with a larger limit. + return name.length <= MAX_HISTORY_FILENAME_BYTES + ? fileSystem.exists(filePath) + : fileSystem.readDirectory(logsDir).pipe(Effect.map((entries) => entries.includes(name))); + }; const readManagerState = SynchronizedRef.get(managerStateRef); @@ -2022,105 +2043,86 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId: string, ) { const nextPath = historyPath(threadId, terminalId); - if ( - yield* fileSystem - .exists(nextPath) - .pipe( + for (const sourcePath of historyPaths(threadId, terminalId)) { + const migrating = sourcePath !== nextPath; + const operation = migrating ? "migrate" : "read"; + if ( + !(yield* historyExists(sourcePath).pipe( Effect.mapError( - (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + (cause) => new TerminalHistoryError({ operation, threadId, terminalId, cause }), ), - ) - ) { - const { history: raw, truncated } = yield* readHistoryTail(nextPath).pipe( + )) + ) + continue; + const { history: raw, truncated } = yield* readHistoryTail(sourcePath).pipe( Effect.scoped, Effect.mapError( - (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + (cause) => new TerminalHistoryError({ operation, threadId, terminalId, cause }), ), ); const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); const capped = history.value(); - if (truncated || capped !== raw) { - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe( - Effect.mapError( - (cause) => - new TerminalHistoryError({ operation: "truncate", threadId, terminalId, cause }), - ), - ); - } - return history; - } - - if (terminalId !== DEFAULT_TERMINAL_ID || !hasLegacyHistoryPath(threadId)) { - return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); - } - - const legacyPath = legacyHistoryPath(threadId); - if ( - !(yield* fileSystem - .exists(legacyPath) - .pipe( + if (migrating || truncated || capped !== raw) { + const write = migrating + ? Effect.gen(function* () { + const temporaryPath = yield* fileSystem.makeTempFileScoped({ + directory: logsDir, + prefix: ".history-", + }); + yield* fileSystem.writeFileString(temporaryPath, capped); + yield* fileSystem.rename(temporaryPath, nextPath); + }).pipe(Effect.scoped) + : fileSystem.writeFileString(nextPath, capped); + yield* write.pipe( Effect.mapError( (cause) => - new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + new TerminalHistoryError({ + operation: migrating ? "migrate" : "truncate", + threadId, + terminalId, + cause, + }), ), - )) - ) { - return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); + ); + } + if (migrating) { + yield* fileSystem.remove(sourcePath, { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to remove legacy terminal history", { + threadId, + terminalId, + error, + }), + ), + ); + } + return history; } - - const { history: raw } = yield* readHistoryTail(legacyPath).pipe( - Effect.scoped, - Effect.mapError( - (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), - ), - ); - const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); - const capped = history.value(); - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe( - Effect.mapError( - (cause) => - new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), - ), - ); - yield* fileSystem.remove(legacyPath, { force: true }).pipe( - Effect.catch((cleanupError) => - Effect.logWarning("failed to remove legacy terminal history", { - threadId, - error: cleanupError, - }), - ), - ); - return history; + return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); }); const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( threadId: string, terminalId: string, ) { - yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - if (terminalId === DEFAULT_TERMINAL_ID && hasLegacyHistoryPath(threadId)) { - yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal history", { - threadId, - terminalId, - error, - }), + yield* Effect.forEach( + historyPaths(threadId, terminalId), + (filePath) => + Effect.gen(function* () { + if (yield* historyExists(filePath)) { + yield* fileSystem.remove(filePath, { force: true }); + } + }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to delete terminal history", { + threadId, + terminalId, + error, + }), + ), ), - ); - } + { discard: true }, + ); }); const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( @@ -2241,9 +2243,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return { type: "idle" } as const; } + session.pendingProcessEvents[session.pendingProcessEventIndex] = undefined; session.pendingProcessEventIndex += 1; - if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) { - session.pendingProcessEvents = []; + if (session.pendingProcessEventIndex * 2 >= session.pendingProcessEvents.length) { + session.pendingProcessEvents = session.pendingProcessEvents.slice( + session.pendingProcessEventIndex, + ); session.pendingProcessEventIndex = 0; } @@ -2371,7 +2376,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func index = 0, lastError: PtyAdapter.PtySpawnError | null = null, ): Effect.fn.Return< - { process: PtyAdapter.PtyProcess; shellLabel: string }, + { process: PtyAdapter.PtyProcess; shell: string; shellLabel: string }, PtyAdapter.PtySpawnError > { if (index >= shellCandidates.length) { @@ -2414,6 +2419,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (attempt._tag === "Success") { return { process: attempt.success, + shell: candidate.shell, shellLabel: formatShellCandidate(candidate), }; } @@ -2442,6 +2448,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const startingAt = yield* nowIso; yield* modifyManagerState((state) => { session.status = "starting"; + session.shell = null; session.cwd = input.cwd; session.worktreePath = input.worktreePath ?? null; session.cols = input.cols; @@ -2516,6 +2523,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }; yield* modifyManagerState((state) => { session.process = ptyProcess; + session.shell = spawnResult.shell; session.pid = processPid; session.status = "running"; // onExit may replay an exit immediately; accept it before subscribing. @@ -2892,6 +2900,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols, rows, process: null, + shell: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, @@ -2989,6 +2998,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); + const getShell = Effect.fn("terminal.getShell")(function* (input: TerminalObserveInput) { + const session = yield* requireSession(input.threadId, input.terminalId); + if (session.status !== "running" || session.shell === null) { + return yield* new TerminalNotRunningError(input); + } + return session.shell; + }); + const openOrAttachForStream = Effect.fnUntraced(function* (requestedInput: TerminalAttachInput) { const terminalId = requestedInput.terminalId; const input = withSharedGrid(toSessionKey(requestedInput.threadId, terminalId), requestedInput); @@ -3473,6 +3490,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, + getShell, attachStream, attachOutputStream, observeStream, diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts index 775d2542d67ec..2cfe556ebf58f 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -1,8 +1,11 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import { TestClock } from "effect/testing"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as OpenCode2AdapterV2Testkit from "../orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts"; @@ -119,4 +122,70 @@ it.layer(layer)("OpenCode2TextGeneration", (it) => { assert.deepEqual(failure.cause, refusal); }).pipe(Effect.scoped), ); + + it.effect("times out when the prompt request and its interrupt never answer", () => + Effect.gen(function* () { + const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; + const sessionId = "ses_f0ec85cf6ffebfalpvV2E9nocu"; + const prompted = yield* Deferred.make(); + const server = yield* OpenCode2AdapterV2Testkit.replayServer( + { + provider: "opencode", + protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, + version: "2.0.18", + scenario: "opencode2_title_generation_stalled", + entries: [ + subscribe!, + connected!, + create!, + created!, + { + type: "expect_outbound", + frame: { type: "session.prompt", input: { sessionID: sessionId, text: "" } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.prompt", data: "" }, + }, + { + type: "expect_outbound", + frame: { type: "session.interrupt", input: { sessionID: sessionId } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.interrupt", data: "" }, + }, + { + type: "expect_outbound", + frame: { type: "session.remove", input: { sessionID: sessionId } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.remove", data: null }, + }, + { type: "runtime_exit", status: "success" }, + ], + }, + { onPrompt: () => Deferred.doneUnsafe(prompted, Effect.void) }, + ); + const textGeneration = yield* OpenCode2TextGeneration.make().pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, server), + ); + const generation = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "fix the login redirect loop after oauth", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/big-pickle", + }, + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Deferred.await(prompted); + yield* TestClock.adjust("3 minutes"); + yield* TestClock.adjust("5 seconds"); + const failure = yield* Fiber.join(generation); + assert.equal(failure.detail, "OpenCode did not finish generating in time."); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/triage/TriageService.test.ts b/apps/server/src/triage/TriageService.test.ts index d3ffc8c91d03e..8e95b7ae58bde 100644 --- a/apps/server/src/triage/TriageService.test.ts +++ b/apps/server/src/triage/TriageService.test.ts @@ -15,22 +15,25 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as TriageService from "./TriageService.ts"; import { pickSourceRef } from "./TriageService.ts"; -const MAIN = "a1dc856ee61278d0199c36d4bcdef5fdea4d21fe\trefs/heads/main"; +const RTVISION = "a1dc856ee61278d0199c36d4bcdef5fdea4d21fe\trefs/heads/rtvision"; it("clones the server's release tag, peeling annotated tags to their commit", () => { - assert.deepEqual(pickSourceRef(`${MAIN}\n7027b1f\trefs/tags/v0.1.1\n`, "0.1.1"), { + assert.deepEqual(pickSourceRef(`${RTVISION}\n7027b1f\trefs/tags/v0.1.1\n`, "0.1.1"), { ref: "v0.1.1", commit: "7027b1f", }); assert.deepEqual( - pickSourceRef(`${MAIN}\n1111111\trefs/tags/v0.1.1\n2222222\trefs/tags/v0.1.1^{}\n`, "0.1.1"), + pickSourceRef( + `${RTVISION}\n1111111\trefs/tags/v0.1.1\n2222222\trefs/tags/v0.1.1^{}\n`, + "0.1.1", + ), { ref: "v0.1.1", commit: "2222222" }, ); }); -it("falls back to main when the server's version has no tag", () => { - assert.deepEqual(pickSourceRef(`${MAIN}\n`, "0.1.2-dev"), { - ref: "main", +it("falls back to rtvision when the server's version has no tag", () => { + assert.deepEqual(pickSourceRef(`${RTVISION}\n`, "0.1.2-dev"), { + ref: "rtvision", commit: "a1dc856ee61278d0199c36d4bcdef5fdea4d21fe", }); assert.isUndefined(pickSourceRef("", "0.1.1")); diff --git a/apps/server/src/triage/TriageService.ts b/apps/server/src/triage/TriageService.ts index f7a93f24a6641..14dfa76a226fc 100644 --- a/apps/server/src/triage/TriageService.ts +++ b/apps/server/src/triage/TriageService.ts @@ -16,7 +16,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { buildTriageAppPrompt, type TriageSourceClone } from "./triagePrompt.ts"; import { triageSourceCacheDir, writeTriageSession } from "./triageSession.ts"; -const T3CODE_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; +const T3CODE_REPOSITORY_URL = "https://github.com/RTVision/t3code"; // No tty: a credential prompt would hang until the timeout, so git must fail // instead. An empty GIT_ASKPASS also skips core.askPass and SSH_ASKPASS helpers. @@ -47,7 +47,7 @@ const describeClient = (client: AuthClientPresentationMetadata) => .filter((part) => part !== undefined) .join(" "); -/** Picks the release tag for `version`, or `main` when the build has no tag. */ +/** Picks the release tag for `version`, or the fork's integration branch when untagged. */ export const pickSourceRef = (lsRemoteOutput: string, version: string) => { const commits = new Map( lsRemoteOutput @@ -60,8 +60,8 @@ export const pickSourceRef = (lsRemoteOutput: string, version: string) => { // Annotated tags list the tag object first and the commit as `^{}`. const tagCommit = commits.get(`refs/tags/${tag}^{}`) ?? commits.get(`refs/tags/${tag}`); if (tagCommit !== undefined) return { ref: tag, commit: tagCommit }; - const mainCommit = commits.get("refs/heads/main"); - return mainCommit === undefined ? undefined : { ref: "main", commit: mainCommit }; + const branchCommit = commits.get("refs/heads/rtvision"); + return branchCommit === undefined ? undefined : { ref: "rtvision", commit: branchCommit }; }; const make = Effect.gen(function* () { @@ -121,7 +121,7 @@ const make = Effect.gen(function* () { }); // Release tags never move, so one lookup serves every later session. Builds - // with no tag track main, which does move, so they look it up each time. + // with no tag track rtvision, which does move, so they look it up each time. let releaseSource: { readonly ref: string; readonly commit: string } | undefined; const resolveSource = Effect.fn("TriageService.resolveSource")(function* () { if (releaseSource !== undefined) return releaseSource; @@ -129,12 +129,12 @@ const make = Effect.gen(function* () { const listed = yield* runGit( "resolveSourceRef", sourceDir, - ["ls-remote", T3CODE_REPOSITORY_URL, tag, `${tag}^{}`, "refs/heads/main"], + ["ls-remote", T3CODE_REPOSITORY_URL, tag, `${tag}^{}`, "refs/heads/rtvision"], // Triage still starts without source; a dead network should not stall it. 5_000, ); const source = pickSourceRef(listed.stdout, serverVersion); - if (source?.ref !== "main") releaseSource = source; + if (source?.ref !== "rtvision") releaseSource = source; return source; }); diff --git a/apps/server/src/triage/triagePrompt.test.ts b/apps/server/src/triage/triagePrompt.test.ts index e034ad107fecd..b63e5ead250f9 100644 --- a/apps/server/src/triage/triagePrompt.test.ts +++ b/apps/server/src/triage/triagePrompt.test.ts @@ -31,7 +31,11 @@ it("launch prompt stays a single argv-safe line naming the prompt file", () => { it("context file labels the CLI version as the CLI's, not the user's build", () => { const context = buildTriageContext({ ...contextInput, - entry: { kind: "cli", cliVersion: "0.0.42", launchedAs: "npx t3 triage" }, + entry: { + kind: "cli", + cliVersion: "0.0.42", + launchedAs: "npx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 triage", + }, server: "running (pid 42, http://127.0.0.1:3773, version 0.0.43-nightly.20260923.2173)", }); assert.notInclude(context, "Installed version"); diff --git a/apps/server/src/triage/triagePrompt.ts b/apps/server/src/triage/triagePrompt.ts index 764022a5b396b..176a32502c847 100644 --- a/apps/server/src/triage/triagePrompt.ts +++ b/apps/server/src/triage/triagePrompt.ts @@ -4,7 +4,7 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; export const TRIAGE_PLAYBOOK = `# T3 Code triage playbook -You are a support engineer for T3 Code (https://github.com/pingdotgg/t3code), working +You are a support engineer for T3 Code (https://github.com/RTVision/t3code), working inside a coding-agent session on the machine of a user whose install is misbehaving: crashes, auth failures, broken setups, slow launches, or anything else. Your job is to find out what went wrong, unblock the user if you can, and turn what you learned into @@ -28,13 +28,13 @@ the database. Its triage CLI version (called "Installed version" in older context files) is only the version of the \`t3\` that ran \`t3 triage\`. It is often not the build the bug -happened on: \`npx t3 triage\` runs whatever \`t3\` npm resolves, while the user may +happened on: \`npx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 triage\` runs whatever \`t3\` npm resolves, while the user may run the desktop app, the background service, or a nightly. The server process line gives the local server's version when the server recorded one. ## 3. Check for a newer playbook -Fetch https://raw.githubusercontent.com/pingdotgg/t3code/main/.github/triage/PLAYBOOK.md. +Fetch https://raw.githubusercontent.com/RTVision/t3code/rtvision/.github/triage/PLAYBOOK.md. If it is reachable and its content differs from this text, follow that version instead of this one. The user may be on an old release with an old copy. @@ -52,9 +52,9 @@ such as \`v0.0.42\`), into the source cache directory named in the context file, one subdirectory per commit hash: git clone --depth 1 --filter=blob:none --branch \\ - https://github.com/pingdotgg/t3code / + https://github.com/RTVision/t3code / -If the tag does not exist (nightly builds), clone \`main\` instead, and treat file +If the tag does not exist (nightly builds), clone \`rtvision\` instead, and treat file and line references as approximate: the user's build may not match \`main\` exactly. If the target directory already exists from an earlier triage run, reuse it instead of cloning again. Before cloning, delete other entries in the @@ -98,7 +98,7 @@ comes from this repo's \`main\` branch. ## 6. Check upstream -Search existing issues in pingdotgg/t3code (use \`gh\`, or the public GitHub search +Search existing issues in RTVision/t3code (use \`gh\`, or the public GitHub search API if \`gh\` is missing or not logged in). Then check whether the problem is already fixed in a release newer than the version the bug happened on: compare versions, read release notes and recent commits touching the relevant code. @@ -130,7 +130,7 @@ of \`main\` for that work, never the tag-pinned diagnosis clone. posting. Never post without it. - Note at the end of the issue which model and agent produced it. - If \`gh\` is not authenticated, offer \`gh auth login\`, or build a prefilled - https://github.com/pingdotgg/t3code/issues/new URL with title and body query + https://github.com/RTVision/t3code/issues/new URL with title and body query parameters; print the URL, and open it in their browser only after they approve. - If the user pasted screenshots, remind them to drag the images into the issue @@ -244,8 +244,8 @@ Clone it as step 4 describes. } const verb = clone._tag === "cloned" ? "The server cloned" : "The server is cloning"; const what = - clone.ref === "main" - ? `main, since its version (${clone.serverVersion}) has no release tag` + clone.ref === "rtvision" + ? `rtvision, since its version (${clone.serverVersion}) has no release tag` : `the source for its own version (${clone.serverVersion})`; const wait = clone._tag === "cloned" @@ -286,7 +286,7 @@ ${entry.before} - OS: ${input.os} - Node: ${input.nodeVersion} ${entry.after === undefined ? "" : `${entry.after}\n`}- Server process: ${input.server} -- Repo: https://github.com/pingdotgg/t3code +- Repo: https://github.com/RTVision/t3code ${input.entry.kind === "app" ? IN_APP_SECTION : ""}${input.sourceClone === undefined ? "" : sourceSection(input.sourceClone)} ## Paths diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 33f2332dbd4a5..9600f0a45adfb 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -5,6 +5,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeSqlite from "node:sqlite"; +import * as NodeZlib from "node:zlib"; import { assert, describe, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; @@ -467,6 +468,73 @@ describe("Codex usage history", () => { }); describe("UsageService", () => { + it.live("counts compressed Codex rollouts across refresh, rewrites, and cache restarts", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const sessions = NodePath.join(home, "codex", "sessions"); + const compressedPath = NodePath.join(sessions, "compressed.jsonl.zst"); + const usage = (output_tokens: number, timestamp = "2026-08-01T10:00:00Z") => + encodeUnknownJsonString({ + type: "event_msg", + timestamp, + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 10, output_tokens } }, + }, + }) + "\n"; + const rollout = (id: string, output: number) => + [ + { type: "session_meta", payload: { id } }, + { type: "turn_context", payload: { model: "gpt-6-astra" } }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + + "\n" + + usage(output); + const compressed = rollout("compressed-session", 23); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sessions, "plain.jsonl"), + rollout("plain-session", 17), + ); + await NodeFSP.writeFile(compressedPath, NodeZlib.zstdCompressSync(compressed)); + }); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 40); + assert.strictEqual( + first.sources.find((source) => source.fingerprint.provider === "codex")?.distinctSessions, + 2, + ); + assert.deepStrictEqual((yield* service.readSummary(WINDOW)).buckets, first.buckets); + yield* service.awaitPersisted; + // The same rollout may remain in a plain file while compression finishes. + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(sessions, "copy.jsonl"), compressed), + ); + assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 40); + yield* Effect.promise(() => + NodeFSP.writeFile( + compressedPath, + NodeZlib.zstdCompressSync(compressed + usage(31, "2026-08-01T10:00:05Z")), + ), + ); + const rewritten = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(rewritten), 71); + assert.deepStrictEqual((yield* service.readSummary(WINDOW)).buckets, rewritten.buckets); + yield* service.awaitPersisted; + const restarted = yield* UsageService.make; + assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, rewritten.buckets); + yield* restarted.awaitPersisted; + }).pipe( + Effect.scoped, + Effect.provide(layerService({ prefix: "usage-compressed-codex", home, settings })), + ); + }).pipe(Effect.scoped), + ); + it.live("keeps the first host identity across recreation and explicit override removal", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index e7fa7067916c9..f2b4fb43b48b4 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -4,13 +4,18 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; 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 * as Schema from "effect/Schema"; import { TEST_FORMATS } from "./usageTestFormats.ts"; -import { readTranscriptRecords, transcriptLineGate } from "./usageTranscriptReader.ts"; +import { + listTranscriptFiles, + readTranscriptRecords, + transcriptLineGate, +} from "./usageTranscriptReader.ts"; let dir: string; @@ -71,6 +76,103 @@ function codexUsageLine(outputTokens: number, secondsOffset: number): string { })}\n`; } +describe("compressed transcripts", () => { + it("discovers nested plain and zstd JSONL files within the requested window", async () => { + const nested = NodePath.join(dir, "nested"); + await NodeFSP.mkdir(nested); + const plain = NodePath.join(dir, "active.jsonl"); + const compressed = NodePath.join(nested, "archived.jsonl.zst"); + const old = NodePath.join(nested, "old.jsonl.zst"); + await NodeFSP.writeFile(plain, claudeLine(1, 5)); + await NodeFSP.writeFile(compressed, NodeZlib.zstdCompressSync(claudeLine(2, 7))); + await NodeFSP.writeFile(old, NodeZlib.zstdCompressSync(claudeLine(3, 11))); + await NodeFSP.writeFile(NodePath.join(nested, "unrelated.zst"), "ignored"); + await NodeFSP.writeFile(NodePath.join(nested, "partial.jsonl.zst.tmp"), "ignored"); + await NodeFSP.utimes(old, 1, 1); + + const listed = await listTranscriptFiles(dir, Date.parse("2026-01-01T00:00:00Z")); + assert.deepStrictEqual( + listed.files.map((file) => file.path).sort(), + [plain, compressed].sort(), + ); + assert.strictEqual(listed.failedPaths, 0); + assert.isAbove(listed.files.find((file) => file.path === compressed)?.size ?? 0, 0); + }); + + it.each([ + { parser: "native", streamingThresholdBytes: undefined }, + { parser: "projected", streamingThresholdBytes: 1024 }, + ])( + "matches plain Codex records and unfinished tails through long lines ($parser)", + async ({ streamingThresholdBytes }) => { + const plain = NodePath.join(dir, "rollout.jsonl"); + const compressed = plain + ".zst"; + const large = codexUsageLine(9, 5).replace( + '"payload":', + '"padding":' + JSON.stringify("日本語 é".repeat(128 * 1024)) + ',"payload":', + ); + const content = + codexMetaLine() + + codexModelLine("gpt-6-astra") + + codexTierLine("ultrafast") + + large + + codexUsageLine(13, 6).replace('"output_tokens":13', '"output_tokens":"invalid"') + + codexUsageLine(21, 8).trimEnd(); + await NodeFSP.writeFile(plain, content); + await NodeFSP.writeFile(compressed, NodeZlib.zstdCompressSync(content)); + + const options = + streamingThresholdBytes === undefined ? undefined : { streamingThresholdBytes }; + const expected = await readTranscriptRecords(plain, TEST_FORMATS.codex, undefined, options); + const actual = await readTranscriptRecords( + compressed, + TEST_FORMATS.codex, + undefined, + options, + ); + assert.isNotNull(expected); + assert.isNotNull(actual); + assert.strictEqual(expected.records.length, 1); + assert.strictEqual(expected.tailRecords.length, 1); + assert.deepStrictEqual(actual.records, expected.records); + assert.deepStrictEqual(actual.tailRecords, expected.tailRecords); + assert.strictEqual(expected.malformedRecords, 1); + assert.strictEqual(actual.malformedRecords, expected.malformedRecords); + assert.strictEqual(actual.records[0]?.speed, "ultrafast"); + assert.isFalse(actual.resumed); + assert.strictEqual(actual.position.resumeOffset, 0); + }, + ); + + it("ignores a prior resume position and rereads compressed rollouts from their start", async () => { + const plain = NodePath.join(dir, "rollout.jsonl"); + const content = codexMetaLine() + codexModelLine("gpt-6-astra") + codexUsageLine(9, 5); + await NodeFSP.writeFile(plain, content); + const first = await readTranscriptRecords(plain, TEST_FORMATS.codex); + assert.isNotNull(first); + + const compressed = plain + ".zst"; + await NodeFSP.writeFile(compressed, NodeZlib.zstdCompressSync(content + codexUsageLine(21, 8))); + const actual = await readTranscriptRecords(compressed, TEST_FORMATS.codex, first.position); + assert.isNotNull(actual); + assert.isFalse(actual.resumed); + assert.deepStrictEqual( + actual.records.map((record) => record.totals.outputTokens), + [9, 21], + ); + const repeated = await readTranscriptRecords(compressed, TEST_FORMATS.codex, actual.position); + assert.isNotNull(repeated); + assert.isFalse(repeated.resumed); + assert.deepStrictEqual(repeated.records, actual.records); + }); + + it("returns null for zstd decoder errors rather than caching an empty parse", async () => { + const path = NodePath.join(dir, "corrupt.jsonl.zst"); + await NodeFSP.writeFile(path, "not a zstd frame"); + assert.isNull(await readTranscriptRecords(path, TEST_FORMATS.claude)); + }); +}); + describe("readTranscriptRecords resume", () => { it.each([ { mode: "buffered", streamingThresholdBytes: Infinity }, diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 4f131f310f418..38b80d36c8665 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -3,6 +3,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeStringDecoder from "node:string_decoder"; +import * as NodeZlib from "node:zlib"; import type { SelectedFields, @@ -85,7 +86,11 @@ export async function listTranscriptFiles( for (const entry of entries) { const child = NodePath.join(dir, entry.name); if (entry.isDirectory()) await walk(child); - else if (fileName !== undefined ? entry.name === fileName : entry.name.endsWith(".jsonl")) { + else if ( + fileName !== undefined + ? entry.name === fileName + : entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.zst") + ) { candidates.push(child); } } @@ -162,6 +167,7 @@ export async function readTranscriptRecords( options?: { readonly streamingThresholdBytes?: number }, ): Promise { const streamingThresholdBytes = options?.streamingThresholdBytes ?? STREAMING_THRESHOLD_BYTES; + const compressed = filePath.endsWith(".jsonl.zst"); let handle: NodeFSP.FileHandle; try { handle = await NodeFSP.open(filePath, "r"); @@ -181,6 +187,7 @@ export async function readTranscriptRecords( ? Option.none() : decodeState(resumeFrom.state); if ( + !compressed && resumeFrom !== undefined && resumeFrom.resumeOffset > 0 && (codec === undefined || Option.isSome(savedState)) && @@ -247,11 +254,14 @@ export async function readTranscriptRecords( streaming = undefined; decoder = undefined; }; - const stream = handle.createReadStream({ + const input = handle.createReadStream({ start, - autoClose: false, + autoClose: compressed, highWaterMark: 256 * 1024, - }) as AsyncIterable; + }); + // Compressed snapshots cache a full parse; decoded byte offsets cannot + // resume the compressed input. + const stream = compressed ? input.compose(NodeZlib.createZstdDecompress()) : input; for await (const chunk of stream) { let lineStart = 0; while (lineStart < chunk.length) { @@ -279,7 +289,7 @@ export async function readTranscriptRecords( decodeState === undefined ? state : Option.getOrThrow(decodeState(encodedState)); finish(tailState, tailRecords); - const guardLength = Math.min(GUARD_LENGTH, resumeOffset); + const guardLength = compressed ? 0 : Math.min(GUARD_LENGTH, resumeOffset); let guardHash = 0; if (guardLength > 0) { const window = Buffer.alloc(guardLength); @@ -292,7 +302,7 @@ export async function readTranscriptRecords( tailRecords, malformedRecords: format.readMalformedRecords?.(tailState) ?? 0, position: { - resumeOffset, + resumeOffset: compressed ? 0 : resumeOffset, guardLength, guardHash, state: encodedState, diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 88ca1eed8f5ea..ba2b4abe50af7 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -918,6 +918,126 @@ it.effect.each(["missing", "invalid"] as const)( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +const captureRecordingGitCommands = Effect.fn("test.captureRecordingGitCommands")(function* ( + cwd: string, + checkpointRef: CheckpointRef, +) { + const liveProcess = yield* VcsProcess.VcsProcess; + const commands: Array = []; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + let index = 0; + while (input.args[index] === "-C" || input.args[index] === "-c") index += 2; + commands.push(input.args[index]); + return liveProcess.run(input); + }, + }), + ); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + return commands; +}); + +it.effect.each(["committed", "unborn"] as const)( + "checkpoint capture reads the repository with one rev-parse (HEAD=%s)", + (head) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-processes-" }); + const git = (args: ReadonlyArray) => + driver.execute({ operation: "checkpoint-test", cwd, args }); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/test"); + if (head === "committed") { + yield* makeCheckpointFixture(driver, cwd); + } else { + yield* git(["init"]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "unstaged\n"); + } + + const commands = yield* captureRecordingGitCommands(cwd, checkpointRef); + + assert.deepStrictEqual(commands, [ + "rev-parse", + "config", + ...(head === "committed" ? ["read-tree", "ls-files"] : []), + "check-ignore", + "add", + "write-tree", + "commit-tree", + "update-ref", + ]); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + const [, ...parents] = (yield* git([ + "rev-list", + "--parents", + "-n", + "1", + checkpointRef, + ])).stdout + .trim() + .split(" "); + assert.deepStrictEqual( + parents, + head === "committed" ? [(yield* git(["rev-parse", "HEAD"])).stdout.trim()] : [], + ); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect("checkpoint capture outside a repository fails with the common dir lookup error", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-no-repo-" }); + + const error = yield* captureRecordingGitCommands( + cwd, + CheckpointRef.make("refs/t3/checkpoints/test"), + ).pipe(Effect.flip); + + assert.instanceOf(error, VcsProcessExitError); + assert.strictEqual(error.operation, "GitVcsDriver.checkpoints.resolveGitCommonDir"); + assert.strictEqual(error.exitCode, 128); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "checkpoint capture handles a repository path containing a newline", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-new\nline-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + + const commands = yield* captureRecordingGitCommands(cwd, checkpointRef); + + assert.deepStrictEqual(commands, [ + "rev-parse", + "rev-parse", + "config", + "rev-parse", + "read-tree", + "ls-files", + "check-ignore", + "add", + "write-tree", + "commit-tree", + "update-ref", + ]); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.deepStrictEqual( + (yield* fileSystem.readDirectory(path.join(cwd, ".git"))).filter((name) => + name.startsWith("t3-checkpoint-index-"), + ), + [], + ); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("restores empty checkpoints without changing paths outside the workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 74e409a8bc184..fe49a6b1391af 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -830,6 +830,45 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return path.resolve(physicalCwd, gitCommonDir); }); + // --verify prints HEAD after the paths; --quiet leaves only paths for an unborn HEAD. + const resolveCaptureRepository = Effect.fn("GitVcsDriver.resolveCaptureRepository")(function* ( + cwd: string, + ) { + const result = yield* execute({ + operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, + cwd, + args: [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + "--git-path", + "index", + "--verify", + "--quiet", + "HEAD", + ], + allowNonZeroExit: true, + }); + const headExists = result.exitCode === 0; + const headChecked = headExists || result.exitCode === 1; + const lines = result.stdout.split("\n"); + const [gitCommonDir = "", indexPath = ""] = lines; + if (headChecked && lines.length === (headExists ? 4 : 3) && lines.at(-1) === "") { + return { + gitCommonDir: gitCommonDir.trim(), + indexPath: indexPath.trim(), + headExists, + }; + } + // Git before 2.31 echoes --path-format, and a path containing a newline spans lines. + // Separate commands read the paths as before and report a failure the same way. + return { + gitCommonDir: yield* resolveGitCommonDir(cwd), + indexPath: undefined, + headExists: headChecked ? headExists : yield* hasHeadCommit(cwd), + }; + }); + // Git defaults and macOS writeout-only can leave empty refs after crashes; flush before publication. // Set fsync first: Git 2.36 ignores unknown batch without resetting the method. // Batch fully flushes refs and equals fsync on macOS HFS+/APFS and Windows NTFS/ReFS. @@ -862,7 +901,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "-c", "splitIndex.sharedIndexExpire=never", ]; - const gitCommonDir = yield* resolveGitCommonDir(input.cwd); + const { gitCommonDir, indexPath, headExists } = yield* resolveCaptureRepository(input.cwd); const indexId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); const commitEnv: NodeJS.ProcessEnv = { @@ -881,7 +920,6 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ); yield* Effect.gen(function* () { - const headExists = yield* hasHeadCommit(input.cwd); const sparseConfig = yield* execute({ operation, cwd: input.cwd, @@ -900,17 +938,19 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } if (headExists) { const reusedIndex = yield* Effect.gen(function* () { - const indexPath = yield* execute({ - operation, - cwd: input.cwd, - args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], - }); - const { mtime } = yield* fileSystem.stat(indexPath.stdout.trim()); + const userIndexPath = + indexPath ?? + (yield* execute({ + operation, + cwd: input.cwd, + args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], + })).stdout.trim(); + const { mtime } = yield* fileSystem.stat(userIndexPath); if (Option.isNone(mtime)) return false; // Stay below the source timestamp even if Date rounded up, preserving Git's racy check. const indexTime = Math.floor((mtime.value.getTime() - 1) / 1000); if (indexTime <= 0) return false; - yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath); + yield* fileSystem.copyFile(userIndexPath, tempIndexPath); yield* execute({ operation, cwd: input.cwd, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index cd4b6949fad86..c0f0967725e58 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5,8 +5,10 @@ import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it, describe } from "@effect/vitest"; +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 FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -49,7 +51,7 @@ const layerServerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-vcs-driver-test-", }); const layerTest = GitVcsDriver.layer.pipe( - Layer.provide(layerServerConfig), + Layer.provideMerge(layerServerConfig), Layer.provideMerge(NodeServices.layer), ); @@ -330,6 +332,415 @@ it.effect("invalidates origin remote cache when a driver mutation adds origin", }).pipe(Effect.provide(layerTest)), ); +it.effect.each([ + { hookExitCode: 0, commonDirFailure: "none" }, + { hookExitCode: 1, commonDirFailure: "none" }, + { hookExitCode: 0, commonDirFailure: "error" }, + { hookExitCode: 1, commonDirFailure: "error" }, + { hookExitCode: 0, commonDirFailure: "non-repository" }, + { hookExitCode: 1, commonDirFailure: "non-repository" }, +])( + "invalidates sibling worktree refs and status after checkout hook exit $hookExitCode with common-dir failure $commonDirFailure", + ({ hookExitCode, commonDirFailure }) => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs: Array> = []; + let invalidationFailure = "none"; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + spawnedArgs.push(command.args); + if (invalidationFailure !== "none" && command.args.includes("--git-common-dir")) { + if (invalidationFailure === "non-repository") return makeNonRepositoryHandle(); + return ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + stderr: Stream.encodeText(Stream.make("fatal: could not read repository metadata")), + }); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const siblingPath = path.join(yield* makeTmpDir("git-worktrees-"), "sibling"); + yield* git(cwd, ["worktree", "add", "-b", "cache-sibling", siblingPath, "HEAD"]); + + const before = yield* driver.listRefs({ cwd: siblingPath, refresh: true }); + assert.isTrue(before.refs.find((ref) => ref.name === "cache-sibling")?.current); + const statusBefore = yield* driver.statusDetailsLocal(siblingPath); + assert.isFalse(statusBefore.hasOriginRemote); + assert.isFalse(statusBefore.isDefaultBranch); + yield* driver.listRefs({ cwd, refresh: true }); + yield* git(cwd, ["checkout", "-b", "creator-current"]); + yield* writeTextFile( + cwd, + ".git/hooks/post-checkout", + "#!/bin/sh\n" + + "git remote add origin https://example.invalid/repo.git || exit 2\n" + + "git update-ref refs/remotes/origin/cache-sibling HEAD || exit 2\n" + + "git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/cache-sibling || exit 2\n" + + `exit ${hookExitCode}\n`, + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "created"); + spawnedArgs.length = 0; + invalidationFailure = commonDirFailure; + + const created = yield* driver + .createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/cache-created", + }) + .pipe(Effect.result); + + assert.equal(Result.isSuccess(created), hookExitCode === 0); + if (Result.isFailure(created)) { + assert.equal(created.failure.operation, "GitVcsDriver.createWorktree"); + assert.equal(created.failure.detail, "git worktree add failed"); + assert.equal(created.failure.exitCode, hookExitCode); + } + const metadataQueries = spawnedArgs.filter((args) => args.includes("--git-common-dir")); + assert.deepStrictEqual(metadataQueries, [["rev-parse", "--git-common-dir"]]); + assert.isFalse(spawnedArgs.some((args) => args.includes("symbolic-ref"))); + invalidationFailure = "none"; + + spawnedArgs.length = 0; + const creatorStatus = yield* driver.statusDetailsLocal(cwd); + assert.equal(creatorStatus.branch, "creator-current"); + assert.isTrue(spawnedArgs.some((args) => args.includes("--git-common-dir"))); + const siblingRefs = yield* driver.listRefs({ cwd: siblingPath }); + const createdRef = siblingRefs.refs.find((ref) => ref.name === "feature/cache-created"); + assert.isDefined(createdRef?.worktreePath); + assert.equal( + NodeFS.realpathSync.native(createdRef!.worktreePath!), + NodeFS.realpathSync.native(worktreePath), + ); + assert.isTrue(siblingRefs.hasPrimaryRemote); + assert.isTrue(siblingRefs.refs.find((ref) => ref.name === "cache-sibling")?.current); + const statusAfter = yield* driver.statusDetailsLocal(siblingPath); + assert.isTrue(statusAfter.hasOriginRemote); + assert.isTrue(statusAfter.isDefaultBranch); + const creatorRefs = yield* driver.listRefs({ cwd }); + assert.isTrue(creatorRefs.refs.find((ref) => ref.name === "creator-current")?.current); + assert.isFalse(creatorRefs.refs.find((ref) => ref.name === initialBranch)?.current); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect.each(["branch", "detached", "nested", "aliased-root", "aliased-nested"] as const)( + "discovers only the common directory during worktree creation from %s", + (location) => + 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); + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const path = yield* Path.Path; + if (location === "detached") yield* git(cwd, ["checkout", "--detach"]); + const isNested = location === "nested" || location === "aliased-nested"; + const physicalCwd = isNested ? path.join(cwd, "nested") : cwd; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(physicalCwd, { recursive: true }); + const isAliased = location === "aliased-root" || location === "aliased-nested"; + const creationCwd = isAliased + ? path.join(yield* makeTmpDir("git-alias-"), "link") + : physicalCwd; + if (isAliased) NodeFS.symlinkSync(physicalCwd, creationCwd, "junction"); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "cold"); + + yield* driver.createWorktree({ + cwd: creationCwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/cold", + }); + + assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 1); + assert.isFalse(spawnedArgs.some((args) => args.includes("--show-toplevel"))); + assert.isFalse(spawnedArgs.some((args) => args.includes("symbolic-ref"))); + spawnedArgs.length = 0; + const originalStatus = yield* driver.statusDetailsLocal(creationCwd); + assert.equal(originalStatus.branch, location === "detached" ? null : initialBranch); + const originalRefs = yield* driver.listRefs({ cwd: creationCwd }); + assert.equal(originalRefs.isAtRepositoryRoot, !isNested); + assert.deepStrictEqual( + originalRefs.refs.filter((ref) => ref.current).map((ref) => ref.name), + location === "detached" ? [] : [initialBranch], + ); + assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 1); + const refs = yield* driver.listRefs({ cwd: worktreePath }); + assert.isTrue(refs.refs.find((ref) => ref.name === "feature/cold")?.current); + assert.equal( + yield* git(worktreePath, ["rev-parse", "HEAD"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect.each(["bare", "unborn"] as const)( + "invalidates a %s repository after worktree creation", + (state) => + Effect.scoped( + Effect.gen(function* () { + const source = yield* makeTmpDir(); + const cwd = yield* makeTmpDir("git-discovery-fallback-"); + if (state === "bare") { + yield* initRepoWithCommit(source); + yield* git(source, ["clone", "--bare", source, cwd]); + } else { + yield* git(cwd, ["init"]); + } + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args); + return delegate.spawn( + state === "bare" ? ChildProcess.setEnv(command, { GIT_DIR: cwd }) : command, + ); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const path = yield* Path.Path; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "fallback"); + const result = yield* driver + .createWorktree({ + cwd, + path: worktreePath, + refName: "HEAD", + newRefName: "feature/fallback", + }) + .pipe(Effect.result); + assert.equal(Result.isSuccess(result), state === "bare"); + if (Result.isFailure(result)) { + assert.equal(result.failure.operation, "GitVcsDriver.createWorktree.createBranch"); + assert.equal(result.failure.detail, "git worktree add failed"); + } + assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 1); + assert.isFalse(spawnedArgs.some((args) => args[0] === "symbolic-ref")); + spawnedArgs.length = 0; + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.isRepo); + if (state === "bare") { + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/fallback")); + } else { + assert.isEmpty(refs.refs); + } + assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 1); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "preserves embedded newlines in repository paths after worktree creation", + () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const cwd = path.join(yield* makeTmpDir(), "repo\nwith-newline"); + yield* fs.makeDirectory(cwd); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "newline"); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/newline", + }); + const status = yield* driver.statusDetailsLocal(cwd); + assert.equal(status.branch, initialBranch); + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/newline")); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "preserves a trailing carriage return in the repository root after creation through an alias", + () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const sandbox = yield* makeTmpDir(); + const repository = path.join(sandbox, "repo\r"); + const cwd = path.join(sandbox, "alias"); + yield* fs.makeDirectory(repository); + const { initialBranch } = yield* initRepoWithCommit(repository); + NodeFS.symlinkSync(repository, cwd, "junction"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: path.join(yield* makeTmpDir("git-worktrees-"), "created"), + refName: initialBranch, + newRefName: "feature/carriage-return", + }); + const status = yield* driver.statusDetailsLocal(cwd); + assert.equal(status.workingTree.rootPath, NodeFS.realpathSync.native(repository)); + assert.equal(status.branch, initialBranch); + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.isAtRepositoryRoot); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/carriage-return")); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect( + "reads fresh metadata when another mutation completes during common-directory discovery", + () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const commit = yield* git(cwd, ["rev-parse", "HEAD"]); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const discoveryComplete = yield* Deferred.make(); + const releaseDiscovery = yield* Deferred.make(); + let holdDiscovery = false; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const handle = yield* delegate.spawn(command); + if ( + !holdDiscovery || + !ChildProcess.isStandardCommand(command) || + !command.args.includes("--git-common-dir") + ) + return handle; + holdDiscovery = false; + const output = yield* Stream.runCollect(handle.stdout); + yield* handle.exitCode; + yield* Deferred.succeed(discoveryComplete, undefined); + yield* Deferred.await(releaseDiscovery); + return ChildProcessSpawner.makeHandle({ + ...handle, + stdout: Stream.fromIterable(output), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + yield* driver.listRefs({ cwd, refresh: true }); + const path = yield* Path.Path; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "concurrent"); + holdDiscovery = true; + const creation = yield* driver + .createWorktree({ + cwd, + path: worktreePath, + refName: "HEAD", + newRefName: "feature/concurrent", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.gen(function* () { + yield* Deferred.await(discoveryComplete); + yield* driver.switchRef({ cwd, refName: commit }); + }).pipe(Effect.ensuring(Deferred.succeed(releaseDiscovery, undefined))); + const created = yield* Fiber.join(creation); + assert.equal(created.worktree.path, worktreePath); + const refs = yield* driver.listRefs({ cwd }); + assert.isEmpty(refs.refs.filter((ref) => ref.current)); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/concurrent")); + assert.equal((yield* driver.statusDetailsLocal(cwd)).branch, null); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect("invalidates cached metadata after interrupted worktree creation", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const discoveryComplete = yield* Deferred.make(); + const releaseDiscovery = yield* Deferred.make(); + let holdDiscovery = false; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const handle = yield* delegate.spawn(command); + if ( + !holdDiscovery || + !ChildProcess.isStandardCommand(command) || + !command.args.includes("--git-common-dir") + ) + return handle; + holdDiscovery = false; + const output = yield* Stream.runCollect(handle.stdout); + yield* handle.exitCode; + yield* Deferred.succeed(discoveryComplete, undefined); + yield* Deferred.await(releaseDiscovery); + return ChildProcessSpawner.makeHandle({ ...handle, stdout: Stream.fromIterable(output) }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + yield* driver.listRefs({ cwd, refresh: true }); + yield* driver.statusDetailsLocal(cwd); + yield* git(cwd, ["checkout", "-b", "creator-current"]); + const path = yield* Path.Path; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "interrupted"); + holdDiscovery = true; + const creation = yield* driver + .createWorktree({ + cwd, + path: worktreePath, + refName: "HEAD", + newRefName: "feature/interrupted", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + const cancellation = yield* Effect.gen(function* () { + yield* Deferred.await(discoveryComplete); + return yield* Fiber.interrupt(creation).pipe(Effect.forkChild({ startImmediately: true })); + }).pipe(Effect.ensuring(Deferred.succeed(releaseDiscovery, undefined))); + yield* Fiber.join(cancellation); + const exit = yield* Fiber.await(creation); + assert.isTrue(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)); + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.refs.find((ref) => ref.name === "creator-current")?.current); + const interruptedRef = refs.refs.find((ref) => ref.name === "feature/interrupted"); + assert.isDefined(interruptedRef?.worktreePath); + assert.equal( + NodeFS.realpathSync.native(interruptedRef!.worktreePath!), + NodeFS.realpathSync.native(worktreePath), + ); + assert.equal((yield* driver.statusDetailsLocal(cwd)).branch, "creator-current"); + const nextPath = path.join(yield* makeTmpDir("git-worktrees-"), "after-interruption"); + yield* driver.createWorktree({ + cwd, + path: nextPath, + refName: "HEAD", + newRefName: "feature/after", + }); + assert.isTrue( + (yield* driver.listRefs({ cwd })).refs.some((ref) => ref.name === "feature/after"), + ); + }), + ).pipe(Effect.provide(layerTest)), +); + describe("pullCurrentBranch", () => { // A fast-forward that prints over 1 MB takes thousands of files, so the real pull's output is // padded past the cap instead. @@ -2125,6 +2536,27 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.each([ + { scenario: "kept", before: "\uFEFFold\n", after: "\uFEFFnew\n" }, + { scenario: "added", before: "same\n", after: "\uFEFFsame\n" }, + { scenario: "removed", before: "\uFEFFsame\n", after: "same\n" }, + ])("preserves byte-order marks in diff expansion when $scenario", ({ before, after }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "README.md", before); + yield* git(cwd, ["commit", "-am", "write original contents"]); + yield* writeTextFile(cwd, "README.md", after); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd), + ); + + assert.deepStrictEqual(contents, { oldContents: before, newContents: after }); + }), + ); + it.effect("reads a base ref that looks like an option as a revision", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2624,10 +3056,23 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(repo, ["checkout", "-b", "real-only"]); // The decoy must exist before the first lookup, which caches repository paths. - const refs = yield* (yield* GitVcsDriver.GitVcsDriver).listRefs({ cwd: link }); + const driver = yield* GitVcsDriver.GitVcsDriver; + const refs = yield* driver.listRefs({ cwd: link }); assert.isTrue(refs.isRepo); assert.isFalse(refs.refs.some((ref) => ref.name === "decoy-only")); assert.isTrue(refs.refs.some((ref) => ref.name === "real-only")); + + yield* driver.createWorktree({ + cwd: link, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "real"), + refName: "real-only", + newRefName: "feature/real-created", + }); + const createdRefs = yield* driver.listRefs({ cwd: link }); + assert.isFalse(createdRefs.isAtRepositoryRoot); + assert.isFalse(createdRefs.refs.some((ref) => ref.name === "decoy-only")); + assert.isTrue(createdRefs.refs.some((ref) => ref.name === "real-only" && ref.current)); + assert.isTrue(createdRefs.refs.some((ref) => ref.name === "feature/real-created")); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index c5bdf3d068ef9..421c240514e42 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -844,7 +844,7 @@ const collectOutput = Effect.fnUntraced(function* ( onLine: ((line: string) => Effect.Effect) | undefined, keepLineCallbacksAfterTruncation = false, ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { - const decoder = new TextDecoder(); + const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); const lineDecoder = keepLineCallbacksAfterTruncation && onLine ? new TextDecoder() : null; let bytes = 0; let text = ""; @@ -1371,9 +1371,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } }); - const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( - cwd: string, - ) { + const readGitCommonDir = Effect.fn("readGitCommonDir")(function* (cwd: string) { // Git reports relative paths from the physical cwd, which can differ from a symlink. const commonDirResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.resolveRepositoryPaths.commonDir", @@ -1407,9 +1405,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolvedGitCommonDir = path.isAbsolute(commonDirOutput) ? path.normalize(commonDirOutput) : path.resolve(physicalCwd, commonDirOutput); - const gitCommonDir = yield* fileSystem + return yield* fileSystem .realPath(resolvedGitCommonDir) .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir)); + }); + + const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( + cwd: string, + ) { + const gitCommonDir = yield* readGitCommonDir(cwd); + if (gitCommonDir === null) return null; const [worktreeRootResult, currentBranchResult] = yield* Effect.all( [ executeGit( @@ -1547,11 +1552,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); const invalidateStatusStaticCaches = Effect.fn("GitVcsDriverCore.invalidateStatusStaticCaches")( - function* (cwd: string) { - const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( - Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), - ); - const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); + function* (cwd: string, gitCommonDir?: string | null) { + const commonDir = + gitCommonDir === undefined + ? (yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ))?.gitCommonDir + : gitCommonDir; + const cacheKey = commonDir ?? normalizeRepositoryPathsCacheKey(cwd); yield* Cache.invalidate(defaultBranchCache, cacheKey); yield* Cache.invalidate(originExistsCache, cacheKey); }, @@ -3554,7 +3562,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (bytes.includes(0)) { return yield* fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`); } - return new TextDecoder("utf-8").decode(bytes); + return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); }); const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( @@ -3943,17 +3951,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const invalidateListRefsSnapshot = Effect.fn("invalidateListRefsSnapshot")(function* ( cwd: string, + gitCommonDir?: string | null, ) { const repositoryPathsCacheKey = normalizeRepositoryPathsCacheKey(cwd); - const repositoryPaths = yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey); - if (repositoryPaths === null) return; - const previousGeneration = currentListRefsGeneration(repositoryPaths.gitCommonDir); - bumpListRefsGeneration(repositoryPaths.gitCommonDir); - bumpListRefsEpoch(repositoryPaths.gitCommonDir); + const commonDir = + gitCommonDir === undefined + ? (yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey))?.gitCommonDir + : gitCommonDir; + if (commonDir == null) return; + const previousGeneration = currentListRefsGeneration(commonDir); + bumpListRefsGeneration(commonDir); + bumpListRefsEpoch(commonDir); yield* Cache.invalidate( listRefsRefreshSnapshotCache, new GitRefsRefreshCacheKey({ - gitCommonDir: repositoryPaths.gitCommonDir, + gitCommonDir: commonDir, generation: previousGeneration, }), ); @@ -4824,6 +4836,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]), ), ); + const invalidateWorktreeCreationCaches = Effect.fn( + "GitVcsDriverCore.invalidateWorktreeCreationCaches", + )(function* (cwd: string) { + let gitCommonDir = yield* readGitCommonDir(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + if (gitCommonDir === null) { + gitCommonDir = yield* Cache.getOption( + repositoryPathsCache, + normalizeRepositoryPathsCacheKey(cwd), + ).pipe( + Effect.map((cached) => Option.getOrNull(cached)?.gitCommonDir ?? null), + Effect.orElseSucceed(() => null), + ); + } + yield* Effect.all([ + invalidateListRefsSnapshot(cwd, gitCommonDir).pipe(Effect.ignore), + invalidateStatusStaticCaches(cwd, gitCommonDir).pipe(Effect.ignore), + ]); + }); const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( input, ) => @@ -4864,7 +4896,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* resolveBranchName, listRefs, createWorktree: Effect.fnUntraced(function* (input, options) { - return yield* withListRefsInvalidation(input.cwd, createWorktree(input, options)); + return yield* createWorktree(input, options).pipe( + Effect.ensuring(invalidateWorktreeCreationCaches(input.cwd)), + ); }), fetchRefIntoBranch: Effect.fnUntraced(function* (input) { return yield* withListRefsInvalidation(input.cwd, fetchRefIntoBranch(input)); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 78d572456d787..dcb45ac3d93fb 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -132,6 +133,113 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("lists symlinks as what they point to, hiding ones that cannot be opened", () => + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + if (platform === "win32") return; + const cwd = yield* makeTempDir({ git: true }); + const outside = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "real.txt"); + yield* writeTextFile(cwd, "realdir/nested.txt"); + yield* writeTextFile(outside, "outside.txt"); + yield* fileSystem.symlink("real.txt", path.join(cwd, "link.txt")); + yield* fileSystem.symlink("realdir", path.join(cwd, "linkdir")); + yield* fileSystem.symlink(outside, path.join(cwd, "outside-link")); + yield* fileSystem.symlink(".git", path.join(cwd, "git-link")); + yield* fileSystem.symlink("missing", path.join(cwd, "dangling")); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); + expect(root.entries.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([ + { path: "link.txt", kind: "file" }, + { path: "linkdir", kind: "directory" }, + { path: "real.txt", kind: "file" }, + { path: "realdir", kind: "directory" }, + ]); + expect(yield* workspaceEntries.list({ cwd, directoryPath: "linkdir" })).toEqual({ + entries: [{ path: "linkdir/nested.txt", kind: "file" }], + truncated: false, + }); + }), + ); + + it.effect("hides folder links that lead back to a folder on the listed path", () => + // "Expand all folders" loads every folder it expands, so a link that loops would + // keep nesting the same entries without end. + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + if (platform === "win32") return; + const cwd = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "a/file.txt"); + yield* writeTextFile(cwd, "b/file.txt"); + yield* fileSystem.symlink(".", path.join(cwd, "self")); + yield* fileSystem.symlink("..", path.join(cwd, "a/up")); + yield* fileSystem.symlink("../b", path.join(cwd, "a/to-b")); + yield* fileSystem.symlink("../a", path.join(cwd, "b/to-a")); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const paths = (directoryPath: string) => + workspaceEntries + .list({ cwd, directoryPath }) + .pipe(Effect.map(({ entries }) => entries.map((entry) => entry.path).toSorted())); + expect(yield* paths("")).toEqual(["a", "b"]); + // A link into a sibling folder is fine on its own... + expect(yield* paths("a")).toEqual(["a/file.txt", "a/to-b"]); + // ...but once followed, the sibling's link back is hidden. + expect(yield* paths("a/to-b")).toEqual(["a/to-b/file.txt"]); + }), + ); + + it.effect("resolves a folder of many symlinks in bounded batches", () => + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + if (platform === "win32") return; + const cwd = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Shaped like a pnpm `node_modules`: one link per package into `.pnpm`. + const directoryPath = [...Array.from({ length: 80 }, () => "nested"), "node_modules"].join( + "/", + ); + const packages = Array.from({ length: 200 }, (_, index) => `pkg-${index}`); + yield* Effect.forEach( + packages, + (name) => + Effect.gen(function* () { + yield* writeTextFile(cwd, `${directoryPath}/.pnpm/${name}/index.js`); + yield* fileSystem.symlink(`.pnpm/${name}`, path.join(cwd, directoryPath, name)); + }), + { concurrency: 32, discard: true }, + ); + const realpath = NodeFSP.realpath; + let inFlight = 0; + let maxInFlight = 0; + vi.spyOn(NodeFSP, "realpath").mockImplementation((async (target: string) => { + maxInFlight = Math.max(maxInFlight, ++inFlight); + try { + return await realpath(target); + } finally { + inFlight--; + } + }) as typeof NodeFSP.realpath); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const { entries } = yield* workspaceEntries.list({ cwd, directoryPath }); + expect(entries.map((entry) => entry.path).toSorted()).toEqual( + [ + `${directoryPath}/.pnpm`, + ...packages.map((name) => `${directoryPath}/${name}`), + ].toSorted(), + ); + expect(entries.every((entry) => entry.kind === "directory")).toBe(true); + expect(maxInFlight).toBeLessThanOrEqual(64); + }), + ); + it.effect("labels the children of wide folders exactly as git does for each child", () => Effect.gen(function* () { // Above 1,000 children, listing first asks whether the whole folder is ignored. diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 5f1c5f2660a68..0b853ccdaa437 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -117,6 +117,9 @@ const IGNORED_FILES_LIMIT = 200; /** Paths sent to one `git check-ignore` call when labelling a folder's children. */ const IGNORE_CHECK_CHUNK_SIZE = 1000; +/** Children classified at once when listing a folder; each symlink costs a realpath and a stat. */ +const SYMLINK_RESOLVE_BATCH_SIZE = 64; + const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( input: FilesystemBrowseInput, path: Path.Path, @@ -318,13 +321,71 @@ export const make = Effect.gen(function* () { throw new Error("Directory must be inside the workspace and outside .git."); } const children = await NodeFSP.readdir(directory, { withFileTypes: true }); + const isSameOrInside = (parent: string, child: string) => { + const relativeChild = path.relative(parent, child); + return ( + relativeChild !== ".." && + !relativeChild.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativeChild) + ); + }; + // Reject canonical ancestors so "Expand all folders" cannot follow a cycle. + let visitedFolders: Promise> | undefined; + const loadVisitedFolders = () => + (visitedFolders ??= (async () => { + const folders = [root]; + let current = normalizedCwd; + for (const segment of target.relativePath.split("/").filter(Boolean)) { + current = path.join(current, segment); + folders.push(await NodeFSP.realpath(current)); + } + return folders; + })()); + const resolveSymlinkKind = async ( + name: string, + ): Promise => { + const resolved = await NodeFSP.realpath(path.join(directory, name)).catch( + () => undefined, + ); + if ( + resolved === undefined || + !isSameOrInside(root, resolved) || + path.relative(root, resolved).split(path.sep).includes(".git") + ) { + return undefined; + } + const stat = await NodeFSP.stat(resolved).catch(() => undefined); + if (stat?.isFile()) return "file"; + if (!stat?.isDirectory()) return undefined; + const folders = await loadVisitedFolders(); + return folders.some((folder) => isSameOrInside(resolved, folder)) + ? undefined + : "directory"; + }; + // Bound filesystem work for directories such as pnpm node_modules. + const kinds: Array = []; + for (let offset = 0; offset < children.length; offset += SYMLINK_RESOLVE_BATCH_SIZE) { + kinds.push( + ...(await Promise.all( + children + .slice(offset, offset + SYMLINK_RESOLVE_BATCH_SIZE) + .map(async (child): Promise => { + if (child.name === ".git") return undefined; + if (child.isDirectory()) return "directory"; + if (child.isFile()) return "file"; + return child.isSymbolicLink() ? resolveSymlinkKind(child.name) : undefined; + }), + )), + ); + } return { - entries: children.flatMap((child): ProjectEntry[] => { - if (child.name === ".git" || (!child.isDirectory() && !child.isFile())) return []; + entries: children.flatMap((child, index): ProjectEntry[] => { + const kind = kinds[index]; + if (kind === undefined) return []; return [ { path: target.relativePath ? `${target.relativePath}/${child.name}` : child.name, - kind: child.isDirectory() ? "directory" : "file", + kind, }, ]; }), diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 443e97a083d8c..5dd5764c10b02 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -428,6 +428,38 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("saves over an existing file without rescanning the workspace", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/main.ts", "export {};\n"); + + yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "src/main.ts", + contents: "export const edited = true;\n", + }); + + expect(yield* fileSystem.readFileString(path.join(cwd, "src/main.ts"))).toBe( + "export const edited = true;\n", + ); + }).pipe( + Effect.provide( + Layer.fresh(WorkspaceFileSystem.layer).pipe( + Layer.provide(layerKnownWorkspacesTest), + Layer.provide(WorkspacePaths.layer), + Layer.provide( + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ + refresh: () => Effect.die("An overwrite rescanned the workspace."), + }), + ), + ), + ), + ), + ); + it.effect("rejects writes outside the workspace root", () => 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 7107cd99631a8..053360572b312 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -531,6 +531,9 @@ export const make = Effect.gen(function* () { yield* knownWorkspaces.assertContains(input.cwd); const target = yield* resolveWriteTarget(input); const realTargetPath = target.realTargetPath; + const replacesFile = yield* fileSystem + .exists(realTargetPath) + .pipe(Effect.orElseSucceed(() => false)); yield* fileSystem.makeDirectory(path.dirname(realTargetPath), { recursive: true }).pipe( Effect.mapError( @@ -565,7 +568,12 @@ export const make = Effect.gen(function* () { cause, }), }); - yield* workspaceEntries.refresh(input.cwd); + // Editor autosave overwrites existing files, which leaves the indexed paths unchanged, and + // fff's watcher applies the new contents on its own. A forced rescan re-walks the whole + // workspace, and the save would wait for it. + if (!replacesFile) { + yield* workspaceEntries.refresh(input.cwd); + } return { relativePath: target.relativePath }; }); diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index 574904b343563..b178c621453b8 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -5,6 +5,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as WorkspacePaths from "./WorkspacePaths.ts"; const layerTest = Layer.empty.pipe( @@ -58,5 +60,25 @@ it.layer(layerTest)("WorkspacePathsLive", (it) => { ); }), ); + + // On Windows `\` is a separator, so these names cannot exist there. + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "keeps a backslash in a name as part of the name", + () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + + for (const relativePath of ["weird\\name.txt", "..\\..\\etc"]) { + expect( + yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: cwd, + relativePath, + }), + ).toEqual({ absolutePath: path.join(cwd, relativePath), relativePath }); + } + }), + ); }); }); diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 6b748e19396ec..ea183ed43e1ab 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -103,10 +103,6 @@ export class WorkspacePaths extends Context.Service< } >()("t3/workspace/WorkspacePaths") {} -function toPosixRelativePath(input: string): string { - return input.replaceAll("\\", "/"); -} - const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -188,7 +184,10 @@ const make = Effect.gen(function* () { } const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); - const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); + // Only the host separator becomes `/`: on POSIX `\` is a file name character. + const relativeToRoot = path + .relative(input.workspaceRoot, absolutePath) + .replaceAll(path.sep, "/"); if ( relativeToRoot.length === 0 || relativeToRoot === "." || diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 280d66ffa9aa2..84c794de87572 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,5 +1,5 @@ +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as DiskSpace from "./diskSpace.ts"; -import * as DocumentText from "./provider/DocumentText.ts"; import * as GiteaCli from "./sourceControl/GiteaCli.ts"; import * as Option from "effect/Option"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -46,6 +46,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CommandOutputHub from "./orchestration-v2/CommandOutputHub.ts"; import * as CodexInstallation from "./provider/CodexInstallation.ts"; import * as DeviceService from "./device/DeviceService.ts"; +import * as DocumentText from "./provider/DocumentText.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; @@ -87,6 +88,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as NpmRelease from "./cloud/npmRelease.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -535,10 +537,12 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(SecretResolver.SecretResolver)({}), Layer.mock(GuestShareService.GuestShareService)({}), Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({}), Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({}), Layer.mock(ServerSelfUpdate.ServerSelfUpdate)({}), + Layer.mock(NpmRelease.NpmRelease)({}), Layer.mock(SessionStore.SessionStore)({ cookieName: "config-test", legacyCookieName: undefined }), Layer.mock(SourceControlDiscovery.SourceControlDiscovery)({}), Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), @@ -666,6 +670,7 @@ it.effect( }, {}, yield* ServerBrowser.ServerBrowser, + () => Effect.void, ).pipe(Layer.provide(configRpcServices)), ), ); @@ -833,6 +838,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( }, {}, yield* ServerBrowser.ServerBrowser, + () => Effect.void, ).pipe(Layer.provide(configRpcServices)), ), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 60c5467abd649..c08e029f6538a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -180,9 +180,12 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as NpmRelease from "./cloud/npmRelease.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as ServerSettings from "./serverSettings.ts"; +import { invalidateSettingsSecrets } from "./sourceControl/sourceControlHostSecrets.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -216,7 +219,7 @@ import * as ProjectService from "./project/ProjectService.ts"; import { threadWorkspaceRoot } from "./project/threadWorkspaceRoot.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts"; -import { projectMutationOperation } from "./project/ProjectMutation.ts"; +import { mutateProjectWithClone } from "./project/ProjectMutation.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; @@ -1265,6 +1268,7 @@ export const layerWsRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientAnalyticsProps: Readonly>, serverBrowser: ServerBrowser.ServerBrowser["Service"], + reportProfiles: ServerBrowser.ProfileReporter, ) => Layer.effectContext( Effect.gen(function* () { @@ -1374,11 +1378,13 @@ export const layerWsRpc = ( const providerAuth = yield* ProviderAuthService.ProviderAuthService; const providerInstallation = yield* makeProviderInstallation(); const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; + const npmRelease = yield* NpmRelease.NpmRelease; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const storageCleanup = yield* StorageCleanup.StorageCleanup; const databaseStorage = yield* DatabaseStorage.DatabaseStorage; const serverSettings = yield* ServerSettings.ServerSettingsService; + const secretResolver = yield* SecretResolver.SecretResolver; const peerLinks = yield* PeerLinks.PeerLinks; const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; @@ -1904,10 +1910,7 @@ export const layerWsRpc = ( ); const mutateProject = Effect.fn("ws.projects.mutate")(function* (mutation: ProjectMutation) { - const result = yield* projectMutationOperation(projectService, mutation); - if (mutation.type === "project.delete") - yield* projectCloneTracker.discard(mutation.projectId); - return result; + return yield* mutateProjectWithClone(projectService, projectCloneTracker, mutation); }); const handlers = ServerWsRpcGroup.of({ @@ -2358,6 +2361,9 @@ export const layerWsRpc = ( if (input.instanceId === undefined) { yield* usageLimitSources.refresh; } + if (input.reloadSecrets === true && input.instanceId === undefined) { + yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); + } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, @@ -2526,6 +2532,7 @@ export const layerWsRpc = ( [WS_METHODS.providerInstallSubscribe]: (input) => providerInstallation.subscribe(input), [WS_METHODS.providerInstallRemove]: (input) => providerInstallation.remove(input), [WS_METHODS.serverUpdateServer]: (input) => serverSelfUpdate.update(input), + [WS_METHODS.serverGetNpmRelease]: (input) => npmRelease.get(input), [WS_METHODS.serverUpdateServerWithProgress]: (input) => ServerSelfUpdate.updateProgressStream(serverSelfUpdate, input), [WS_METHODS.serverCommitDesktopUpdate]: (input) => @@ -2738,6 +2745,7 @@ export const layerWsRpc = ( [WS_METHODS.sourceControlSignInWait]: (input) => sourceControlSignIn.wait(input), [WS_METHODS.sourceControlSignInCancel]: (input) => sourceControlSignIn.cancel(input), [WS_METHODS.sourceControlAppAccess]: (input) => sourceControlSignIn.access(input), + [WS_METHODS.sourceControlStarred]: (input) => sourceControlSignIn.starred(input), [WS_METHODS.projectCloneStart]: (input) => projectCloneTracker.start(input, { createProject: (project) => @@ -3098,7 +3106,7 @@ export const layerWsRpc = ( [WS_METHODS.previewClose]: (input) => previewManager.close(input), [WS_METHODS.previewList]: (input) => previewManager.list(input), [WS_METHODS.previewClearProfile]: (input) => serverBrowser.clearProfile(input.profileId), - [WS_METHODS.previewReportProfiles]: (input) => serverBrowser.reportProfiles(input), + [WS_METHODS.previewReportProfiles]: (input) => reportProfiles(input), [WS_METHODS.previewReportStatus]: (input) => previewManager.reportStatus(input), [WS_METHODS.subscribePreviewEvents]: (_input) => previewManager.events, [WS_METHODS.deviceConfigure]: (input) => deviceService.configure(input), @@ -3353,6 +3361,7 @@ export const layer = Layer.unwrap( Effect.gen(function* () { const serverBrowser = yield* ServerBrowser.ServerBrowser; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; + const npmRelease = yield* NpmRelease.NpmRelease; const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const peerLinks = yield* PeerLinks.PeerLinks; @@ -3390,6 +3399,7 @@ export const layer = Layer.unwrap( ); const clientAnalyticsProps = readClientAnalyticsProps(request); yield* analytics.recordOwners(readClientOwners(request), "client"); + const reportProfiles = yield* serverBrowser.profileReporter; const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe( @@ -3419,7 +3429,7 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - layerWsRpc(session, clientAnalyticsProps, serverBrowser).pipe( + layerWsRpc(session, clientAnalyticsProps, serverBrowser, reportProfiles).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Request fibers run in the handlers' context, so this reporter sees // their defects, not the rest of the server's. @@ -3427,6 +3437,7 @@ export const layer = Layer.unwrap( Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide(Layer.succeed(NpmRelease.NpmRelease, npmRelease)), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), // One server-lifetime service means clients share the same PR caches, and a WS // mutation invalidates the HTTP diff cache that every client reads from. @@ -3461,7 +3472,7 @@ export const layer = Layer.unwrap( () => Effect.raceFirst( rpcWebSocketHttpEffect, - sessions.awaitInvalidation(session.sessionId).pipe( + sessions.awaitInvalidation(session.sessionId, session.proofKeyThumbprint).pipe( Effect.as(HttpServerResponse.empty()), Effect.catchTags({ SessionCredentialVerificationError: (error) => @@ -3479,4 +3490,4 @@ export const layer = Layer.unwrap( ), ); }), -); +).pipe(Layer.provide(NpmRelease.layer)); diff --git a/apps/web/package.json b/apps/web/package.json index c4b3977d97028..67f4005f4cd68 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,7 @@ "test": "vp test run --passWithNoTests --project unit --project browser" }, "dependencies": { - "@base-ui/react": "^1.4.1", + "@base-ui/react": "^1.9.0", "@betteroffice/docx": "0.4.3", "@betteroffice/fonts": "0.4.0", "@betteroffice/pptx": "0.2.0", @@ -61,12 +61,15 @@ "hast-util-to-html": "^9.0.5", "hast-util-to-jsx-runtime": "^2.3.6", "heic-to": "^1.5.2", + "html-url-attributes": "^3.0.1", "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", "katex": "^0.16.47", "lucide": "^0.564.0", "lucide-react": "^0.564.0", + "mdast-util-from-markdown": "^2.0.3", + "micromark-util-combine-extensions": "^2.0.1", "morphicons": "^1.7.1", "pdfjs-dist": "6.4.299", "pele": "^1.8.0", @@ -76,6 +79,7 @@ "remark-gfm": "catalog:", "remark-math": "6.0.0", "remark-parse": "catalog:", + "remark-rehype": "catalog:", "saxes": "6.0.0", "sql.js": "1.15.0", "tailwind-merge": "^3.4.0", @@ -83,6 +87,7 @@ "unbash": "^5.0.0", "unified": "catalog:", "use-sync-external-store": "^1.6.0", + "vfile": "^6.0.3", "zustand": "^5.0.11" }, "devDependencies": { @@ -92,6 +97,7 @@ "@tanstack/router-plugin": "^1.161.0", "@types/compression": "^1.8.1", "@types/culori": "^4.0.1", + "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "@types/react": "catalog:", "@types/react-dom": "catalog:", @@ -102,6 +108,7 @@ "@vitejs/plugin-react": "^6.1.2", "@vitest/browser-playwright": "5.0.5", "compression": "^1.8.2", + "micromark-util-types": "catalog:", "oxc-transform-react": "~0.152.0", "playwright": "1.60.0", "react-test-renderer": "19.3.0", diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index b9312858e334b..9d77c475155cb 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -1,9 +1,10 @@ import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { ClientSettings } from "@t3tools/contracts/settings"; import { createBrowserHistory, createHashHistory } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; import { applyAppearanceContrast } from "./appearanceContrast"; -import { applyAppearanceFontVariables } from "./appearanceFonts"; +import { applyAppearanceFontVariables, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "./appearanceFonts"; import { startElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { startBrowserProfileReporting } from "./browser/BrowserProfileReporter"; import { startPreviewTitleRecording } from "./browserHistoryStore"; @@ -30,6 +31,7 @@ import { } from "./composerDraftStore"; import { startDefaultThemeAdoption } from "./hooks/useDefaultTheme"; import { startEnvironmentThemeRepaint } from "./hooks/useEnvironmentTheme"; +import { readLocalStorageValue, subscribeLocalStorageKey } from "./hooks/useLocalStorage"; import { getClientSettings, startModelPreferencesMigration, @@ -58,10 +60,18 @@ import { resolveThreadRouteTarget } from "./threadRoutes"; function startClientAppearance(): void { const root = document.documentElement; let applied: ClientSettings | null = null; + let appliedSeparateFonts: boolean | null = null; const apply = () => { const settings = getClientSettings(); const previous = applied; applied = settings; + const separateFonts = readLocalStorageValue( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); + const previousSeparateFonts = appliedSeparateFonts; + appliedSeparateFonts = separateFonts; if (previous?.diffColorScheme !== settings.diffColorScheme) { root.dataset.diffColorScheme = settings.diffColorScheme; } @@ -86,7 +96,8 @@ function startClientAppearance(): void { previous.fontSizeInterface !== settings.fontSizeInterface || previous.fontSizePrompt !== settings.fontSizePrompt || previous.fontSizeCode !== settings.fontSizeCode || - previous.fontSmoothing !== settings.fontSmoothing + previous.fontSmoothing !== settings.fontSmoothing || + previousSeparateFonts !== separateFonts ) { applyAppearanceFontVariables(root, { sans: settings.fontFamilySans, @@ -96,10 +107,12 @@ function startClientAppearance(): void { sizePrompt: settings.fontSizePrompt, sizeCode: settings.fontSizeCode, smoothing: settings.fontSmoothing, + separate: separateFonts, }); } }; subscribeClientSettings(apply); + subscribeLocalStorageKey(TYPOGRAPHY_ADVANCED_STORAGE_KEY, apply); apply(); } diff --git a/apps/web/src/appearanceFonts.browser.test.ts b/apps/web/src/appearanceFonts.browser.test.ts new file mode 100644 index 0000000000000..9bdc5c2ef9864 --- /dev/null +++ b/apps/web/src/appearanceFonts.browser.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; + +import "~/index.css"; +import { applyAppearanceFontVariables } from "./appearanceFonts"; + +function renderedFontSize(style: (probe: HTMLElement) => void): string { + const probe = document.createElement("div"); + style(probe); + document.body.append(probe); + try { + return getComputedStyle(probe).fontSize; + } finally { + probe.remove(); + } +} + +const composerFontSize = () => + renderedFontSize((probe) => { + // The composer editor's own size class. + probe.className = "text-(length:--font-size-prompt,var(--text-sm))"; + }); + +const touchComposerFontSize = () => + renderedFontSize((probe) => { + probe.style.fontSize = "var(--font-size-prompt-touch)"; + }); + +describe("applyAppearanceFontVariables", () => { + it("sizes the composer from the interface size until fonts are set separately", () => { + const root = document.documentElement; + const style = root.getAttribute("style"); + const preferences = { + sans: "", + code: "", + composer: "", + sizeInterface: 20, + sizePrompt: 14, + sizeCode: 13, + smoothing: true, + }; + try { + applyAppearanceFontVariables(root, { ...preferences, separate: false }); + expect(composerFontSize()).toBe("17.5px"); + expect(touchComposerFontSize()).toBe("17.5px"); + + applyAppearanceFontVariables(root, { ...preferences, separate: true }); + expect(composerFontSize()).toBe("14px"); + // iOS zooms into inputs under 16px. + expect(touchComposerFontSize()).toBe("16px"); + } finally { + if (style === null) root.removeAttribute("style"); + else root.setAttribute("style", style); + } + }); +}); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 48f8e5955e7e1..e7c2867cb279f 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -66,9 +66,10 @@ export interface AppearanceFontPreferences { readonly sizePrompt: number; readonly sizeCode: number; readonly smoothing: boolean; + readonly separate: boolean; } -/** Always writes sizes: interface size sets the root; prompt and code use pixels to avoid double scaling. */ +/** Interface size sets the root; prompt and code use pixels to avoid double scaling. */ export function applyAppearanceFontVariables( root: HTMLElement, preferences: AppearanceFontPreferences, @@ -76,7 +77,7 @@ export function applyAppearanceFontVariables( const families: ReadonlyArray = [ ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], - ["--font-composer", preferences.composer, "var(--font-sans)"], + ["--font-composer", preferences.separate ? preferences.composer : "", "var(--font-sans)"], ]; for (const [variable, custom, fallback] of families) { const list = cssFontFamilies(custom); @@ -88,7 +89,10 @@ export function applyAppearanceFontVariables( } root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; - root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`); + root.style.setProperty( + "--font-size-prompt", + preferences.separate ? `${clampPromptFontSize(preferences.sizePrompt)}px` : "var(--text-sm)", + ); const code = clampCodeFontSize(preferences.sizeCode); root.style.setProperty("--font-size-code", `${code}px`); root.style.setProperty("--diffs-font-size", `${code}px`); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.browser.test.tsx b/apps/web/src/browser/BrowserDeviceToolbar.browser.test.tsx new file mode 100644 index 0000000000000..65b58b6c4f683 --- /dev/null +++ b/apps/web/src/browser/BrowserDeviceToolbar.browser.test.tsx @@ -0,0 +1,71 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; +import { useState } from "react"; +import { expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; + +function Viewport({ width = 1920, height = 1080 }: { width?: number; height?: number }) { + const [setting, setSetting] = useState({ + _tag: "freeform", + width, + height, + }); + const [aspectRatio, setAspectRatio] = useState(width / height); + if (setting._tag === "fill") throw new Error("Expected fixed viewport"); + return ( + <> + setSetting(next)} + /> + + {setting.width} × {setting.height} + + + + ); +} + +it.each([ + { width: 1920, height: 1080, axis: "width", typed: "2560", expected: "2560 × 1440" }, + { width: 390, height: 844, axis: "height", typed: "2532", expected: "1170 × 2532" }, +])("keeps each typed $axis digit until the locked size is committed", async (testCase) => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: `Viewport ${testCase.axis}` }); + await userEvent.clear(input); + await userEvent.type(input, testCase.typed); + await expect.element(input).toHaveValue(Number(testCase.typed)); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent(testCase.expected); +}); + +it("clamps a completed width below the locked minimum when leaving the toolbar", async () => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: "Viewport width" }); + await userEvent.clear(input); + await userEvent.type(input, "256"); + await expect.element(input).toHaveValue(256); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent("427 × 240"); +}); + +it("still limits a completed locked size to the viewport area cap", async () => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: "Viewport width" }); + await userEvent.clear(input); + await userEvent.type(input, "3840"); + await expect.element(input).toHaveValue(3840); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent("2880 × 2880"); +}); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index dd5b707cfebdb..977431aa6ff4d 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -51,6 +51,7 @@ export function BrowserDeviceToolbar({ const [customSize, setCustomSize] = useState<{ readonly width: string; readonly height: string; + readonly axis?: "width" | "height"; } | null>(null); const presentedSize = customSize ?? { width: String(setting.width), @@ -61,15 +62,25 @@ export function BrowserDeviceToolbar({ PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === setting.presetId) ? setting.presetId : RESPONSIVE_VALUE; - const customWidth = Number(presentedSize.width); - const customHeight = Number(presentedSize.height); + const typedWidth = Number(presentedSize.width); + const typedHeight = Number(presentedSize.height); + const { width: customWidth, height: customHeight } = + aspectRatio === null || customSize?.axis === undefined + ? { width: typedWidth, height: typedHeight } + : resizeFreeformViewport( + { width: typedWidth, height: typedHeight }, + { x: 0, y: 0 }, + 1, + customSize?.axis === "height" ? "south" : "east", + aspectRatio, + ); const customValid = - Number.isInteger(customWidth) && - Number.isInteger(customHeight) && - customWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && - customWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && - customHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && - customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && + Number.isInteger(typedWidth) && + Number.isInteger(typedHeight) && + typedWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && + typedWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && + typedHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && + typedHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA; const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => { @@ -96,6 +107,7 @@ export function BrowserDeviceToolbar({ const next = { width: axis === "width" ? value : (current?.width ?? String(setting.width)), height: axis === "height" ? value : (current?.height ?? String(setting.height)), + axis, }; const numeric = Number(value); if ( @@ -115,7 +127,11 @@ export function BrowserDeviceToolbar({ axis === "width" ? "east" : "south", aspectRatio, ); - return { width: String(resized.width), height: String(resized.height) }; + return { + ...next, + width: axis === "width" ? value : String(resized.width), + height: axis === "height" ? value : String(resized.height), + }; }); }; diff --git a/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx new file mode 100644 index 0000000000000..39f151674b648 --- /dev/null +++ b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx @@ -0,0 +1,123 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { + PreviewStreamEvents, + PreviewStreamInput, +} from "@t3tools/client-runtime/preview/server-browser-stream"; + +import "~/index.css"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; + +const mocks = vi.hoisted(() => ({ + send: vi.fn<(input: PreviewStreamInput) => void>(), + connect: vi.fn(), + events: null as PreviewStreamEvents | null, + painted: null as (() => void) | null, +})); + +vi.mock(import("@t3tools/client-runtime/preview/server-browser-stream"), async (original) => ({ + ...(await original()), + createPreviewFramePainter: (_canvas, onPainted) => { + mocks.painted = onPainted ?? null; + return { paint: () => undefined, stop: () => undefined }; + }, + createPreviewStreamClient: (_options, events) => { + mocks.events = events; + mocks.connect(); + return { send: mocks.send, stop: () => undefined } as never; + }, +})); + +vi.mock(import("~/state/previewStream"), async (original) => ({ + ...(await original()), + usePreviewStreamAccess: () => ({}) as never, +})); + +import { ServerBrowserSurface } from "./ServerBrowserSurface"; + +async function renderSurface() { + mocks.send.mockReset(); + mocks.connect.mockReset(); + const container = document.createElement("div"); + container.style.cssText = "width: 400px; height: 300px;"; + await using stack = new AsyncDisposableStack(); + stack.use( + await render( + , + { container }, + ), + ); + const canvas = container.querySelector("canvas"); + if (!canvas) throw new Error("The surface did not render its canvas."); + // The stream connects once the surface has measured its size. + await vi.waitFor(() => expect(mocks.connect).toHaveBeenCalled()); + return Object.assign(stack.move(), { canvas }); +} + +const mouseUp = (canvas: HTMLCanvasElement, button: number) => { + const event = new MouseEvent("mouseup", { button, bubbles: true, cancelable: true }); + canvas.dispatchEvent(event); + return event; +}; + +describe("ServerBrowserSurface", () => { + it("routes mouse thumb buttons to the preview's history instead of the app", async () => { + await using surface = await renderSurface(); + const { canvas } = surface; + + expect(mouseUp(canvas, 3).defaultPrevented).toBe(true); + expect(mouseUp(canvas, 4).defaultPrevented).toBe(true); + expect(mocks.send.mock.calls).toEqual([ + [{ type: "history", delta: -1 }], + [{ type: "history", delta: 1 }], + ]); + }); + + it("leaves other mouse buttons alone", async () => { + await using surface = await renderSurface(); + const { canvas } = surface; + + expect(mouseUp(canvas, 0).defaultPrevented).toBe(false); + expect(mocks.send).not.toHaveBeenCalled(); + }); + + it("does not forward thumb-button presses to the page", async () => { + await using surface = await renderSurface(); + const { canvas } = surface; + canvas.width = 400; + canvas.height = 300; + // Synthetic pointers have no active pointer id to capture. + canvas.setPointerCapture = () => undefined; + mocks.painted?.(); + mocks.events?.onViewport({ width: 400, height: 300 }); + mocks.events?.onControl?.({ canOperate: true, controller: "you", generation: 1, dialog: null }); + const { left, top } = canvas.getBoundingClientRect(); + const press = (type: "pointerdown" | "pointerup", button: number) => + canvas.dispatchEvent( + new PointerEvent(type, { + button, + buttons: type === "pointerdown" ? 1 << button : 0, + clientX: left + 10, + clientY: top + 10, + pointerType: "mouse", + bubbles: true, + }), + ); + const mouseInputs = () => mocks.send.mock.calls.filter(([input]) => input.type === "mouse"); + + for (const button of [3, 4]) { + press("pointerdown", button); + press("pointerup", button); + } + expect(mouseInputs()).toEqual([]); + + press("pointerdown", 0); + expect(mouseInputs()).not.toEqual([]); + }); +}); diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index c6ad2f51c3f31..3b14dcb5f3c78 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -22,6 +22,7 @@ import type { import { type FormEvent, type KeyboardEvent, + type MouseEvent, type PointerEvent, type Ref, useEffect, @@ -94,6 +95,10 @@ const buttonOf = (button: number): PreviewStreamMouseButton => const pressedButtonOf = (buttons: number): PreviewStreamMouseButton => buttons & 1 ? "left" : buttons & 2 ? "right" : buttons & 4 ? "middle" : "none"; +// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. +const historyDeltaOf = (button: number): -1 | 1 | null => + button === 3 ? -1 : button === 4 ? 1 : null; + /** Browsers may refuse a write that arrives after the key press; a toast button is a fresh gesture. */ async function copyPageText(text: string) { try { @@ -490,6 +495,8 @@ export function ServerBrowserSurface(props: { const handlePointer = (event: PointerEvent) => { if (controlRef.current?.controller !== "you") return; + // Thumb buttons drive this tab's history in handleMouseUp, never the page. + if (historyDeltaOf(event.button) !== null) return; const { type, pointerId, clientX, clientY } = event; const cancelled = type === "pointercancel"; const touch = touchRef.current; @@ -626,6 +633,16 @@ export function ServerBrowserSurface(props: { } }; + // Chromium runs a thumb button's back/forward on mouseup in this document, which + // would move the app's router. Cancel it and send the press to the preview, as + // the toolbar does. + const handleMouseUp = (event: MouseEvent) => { + const delta = historyDeltaOf(event.button); + if (delta === null) return; + event.preventDefault(); + send({ type: "history", delta }); + }; + const resetInput = (textarea: HTMLTextAreaElement) => { keySentRef.current = false; textarea.value = INPUT_SENTINEL; @@ -703,6 +720,7 @@ export function ServerBrowserSurface(props: {

(input: { readonly profileId?: PreviewOpenInput["profileId"]; readonly background?: boolean; }): Promise> { + // Read before any await: a side chat pane that unmounts meanwhile drops its host mapping. + const threadRef = rightPanelHostRef(input.threadRef); const defaults = await resolveBrowserDefaults().catch( (cause: unknown) => new BrowserSettingsReadError({ cause }), ); if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } - const runtime = previewRuntimeFor(input.threadRef.environmentId); - const previousActiveTabId = readThreadPreviewState(input.threadRef).activeTabId; + const runtime = previewRuntimeFor(threadRef.environmentId); + const previousActiveTabId = readThreadPreviewState(threadRef).activeTabId; // The server changes the active tab, not panel selection; selection changes come from the user. const selectedSurface = () => - selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, input.threadRef) - ?.id ?? null; + selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, threadRef)?.id ?? + null; const surfaceBeforeOpen = selectedSurface(); const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, + environmentId: threadRef.environmentId, input: { - threadId: input.threadRef.threadId, + threadId: threadRef.threadId, url: input.url, viewport: defaults.viewport, profileId: input.profileId ?? defaults.profileId, @@ -81,21 +87,21 @@ export async function openUrlInPreview(input: { }, }); return mapAtomCommandResult(result, (snapshot) => { - rememberPreviewUrl(input.threadRef, input.url); + rememberPreviewUrl(threadRef, input.url); if (input.background) { - updatePreviewServerSnapshot(input.threadRef, snapshot); + updatePreviewServerSnapshot(threadRef, snapshot); // Restore focus unless the user picked a tab while opening this one. if ( previousActiveTabId && - readThreadPreviewState(input.threadRef).activeTabId === snapshot.tabId && + readThreadPreviewState(threadRef).activeTabId === snapshot.tabId && selectedSurface() === surfaceBeforeOpen ) { - setActivePreviewTab(input.threadRef, previousActiveTabId); + setActivePreviewTab(threadRef, previousActiveTabId); } return; } - applyPreviewServerSnapshot(input.threadRef, snapshot); - useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + applyPreviewServerSnapshot(threadRef, snapshot); + useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); }); } @@ -124,6 +130,7 @@ export async function openFileInPreview(input: { ), ); } + const hostRef = rightPanelHostRef(input.threadRef); const insideWorkspace = mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined; const assetResult = await input.createAssetUrl({ @@ -146,7 +153,7 @@ export async function openFileInPreview(input: { ); } return openUrlInPreview({ - threadRef: input.threadRef, + threadRef: hostRef, url: assetUrl, openPreview: input.openPreview, }); diff --git a/apps/web/src/closedViewStore.test.ts b/apps/web/src/closedViewStore.test.ts index 0223dfb9d5bd5..3e2cacc57e736 100644 --- a/apps/web/src/closedViewStore.test.ts +++ b/apps/web/src/closedViewStore.test.ts @@ -54,6 +54,12 @@ describe("closedViewStore", () => { threadRef: refA, surface: { kind: "pull-request", id: "pull-request:x", repository: "owner/repo", number: 1 }, }, + { + id: "bad", + kind: "panel-tab", + threadRef: refA, + surface: { kind: "side-chat", id: "side-chat:x" }, + }, ])("discards incomplete saved views %j while retaining valid history", async (entry) => { const id = useClosedViewStore.getState().remember(diff(refA)); const { storage, name } = useClosedViewStore.persist.getOptions(); @@ -85,6 +91,11 @@ describe("closedViewStore", () => { it("removes private metadata and malformed entries from history saved by earlier versions", async () => { const store = useClosedViewStore.getState(); + const sideChatId = store.remember({ + kind: "panel-tab", + threadRef: refA, + surface: { id: "side-chat:side", kind: "side-chat", threadId: ThreadId.make("side") }, + }); const publicId = store.remember(diff(refA)); const browserId = store.remember({ kind: "browser", @@ -115,6 +126,7 @@ describe("closedViewStore", () => { expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([ browserId, publicId, + sideChatId, ]); const saved = JSON.stringify(await storage!.getItem(name!)); expect(saved).not.toContain("private.example"); diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index 76fee83a83d08..b5db7bd741bef 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -83,6 +83,8 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { typeof surface.target.name === "string" && (surface.target.platform === "ios" || surface.target.platform === "android"))) ); + case "side-chat": + return typeof surface.threadId === "string" && surface.id === `side-chat:${surface.threadId}`; case "pull-request": return ( typeof surface.projectId === "string" && diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index c5f7536305337..679f362fdf5a0 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -150,11 +150,14 @@ export function useManagedRelayDevices() { /** How this account can raise its tunnel cap: link T3 Chat, or upgrade the linked plan. */ export type ManagedTunnelUpgrade = "link" | "pro" | "premier" | null; -// Mirrors the relay's cap without manual overrides, which the relay does not expose. +// Mirrors the relay's cap: a manual override is exact, otherwise the T3 Chat plan raises the default. function managedTunnelCap(t3Chat: RelayT3ChatLinkResponse): { readonly limit: number; readonly upgrade: ManagedTunnelUpgrade; } { + if (t3Chat.maxTunnelsOverride !== undefined) { + return { limit: t3Chat.maxTunnelsOverride, upgrade: null }; + } if (!t3Chat.available) return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: null }; switch (t3Chat.link?.plan) { case "premier": diff --git a/apps/web/src/components/AppRouteModal.browser.test.tsx b/apps/web/src/components/AppRouteModal.browser.test.tsx new file mode 100644 index 0000000000000..cb31eeca45ab0 --- /dev/null +++ b/apps/web/src/components/AppRouteModal.browser.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { useCommitOnBlur } from "../hooks/useCommitOnBlur"; +import { AppRouteModal } from "./AppRouteModal"; + +function CommitOnBlurField({ onCommit }: { onCommit: (next: string) => void }) { + return ; +} + +describe("AppRouteModal", () => { + it("commits a focused field when Esc closes it", async () => { + const onCommit = vi.fn(); + await using _screen = await render( + + + , + { router: "/settings/general" }, + ); + + await page.getByRole("textbox", { name: "Instructions" }).fill("Keep titles short"); + await userEvent.keyboard("{Escape}"); + + expect(onCommit).toHaveBeenCalledWith("Keep titles short"); + }); +}); diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx index 89e676da2e3c3..55d7c4112f968 100644 --- a/apps/web/src/components/AppRouteModal.tsx +++ b/apps/web/src/components/AppRouteModal.tsx @@ -27,7 +27,12 @@ export function AppRouteModal({ label, children }: { label: string; children: Re // Portaled layers outside this tree (toasts, Clerk, confirm dialogs) are not backdrop presses. disablePointerDismissal onOpenChange={(nextOpen) => { - if (!nextOpen) setOpen(false); + if (nextOpen) return; + // Closing unmounts a focused field without a blur, so commit-on-blur rows would + // drop what was typed. Blur first so they commit. + const focused = document.activeElement; + if (focused instanceof HTMLElement && popupRef.current?.contains(focused)) focused.blur(); + setOpen(false); }} onOpenChangeComplete={(nextOpen) => { if (!nextOpen) void closeAppModal(); diff --git a/apps/web/src/components/BackgroundThreadAnnouncer.tsx b/apps/web/src/components/BackgroundThreadAnnouncer.tsx index 82c280e6e56fe..3176c8132eed9 100644 --- a/apps/web/src/components/BackgroundThreadAnnouncer.tsx +++ b/apps/web/src/components/BackgroundThreadAnnouncer.tsx @@ -1,5 +1,6 @@ import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { isListedThreadLineage } from "@t3tools/contracts"; import type { AnyRouter } from "@tanstack/react-router"; import type { AtomRegistry } from "effect/reactivity"; @@ -42,9 +43,8 @@ export function startBackgroundThreadAnnouncements( const now = new Date().toISOString(); const snapshots: BackgroundThreadSnapshot[] = []; for (const thread of threads) { - // Subagents have no row of their own; their requests surface on the parent. - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") - continue; + // Subagents and side chats have no row of their own; their requests surface on the parent. + if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) continue; if (!liveEnvironmentIds.has(thread.environmentId)) continue; snapshots.push({ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index f5df83675554c..478045c956b19 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -16,6 +16,18 @@ const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({ workspaceSearch: vi.fn(), filesystemMetadata: vi.fn(), })); +const parsedSources = vi.hoisted(() => [] as string[]); +vi.mock(import("mdast-util-from-markdown"), async (original) => { + const actual = await original(); + return { + ...actual, + fromMarkdown: ((source: string, ...rest: never[]) => { + parsedSources.push(String(source)); + return (actual.fromMarkdown as (...args: unknown[]) => unknown)(source, ...rest); + }) as typeof actual.fromMarkdown, + }; +}); + vi.mock(import("../state/use-atom-query-runner"), async (original) => ({ ...(await original()), useAtomQueryRunner: ((family: unknown) => @@ -99,6 +111,7 @@ it("keeps compact audio playback loaded across signed URL renewals", async () => }); function setup() { + parsedSources.length = 0; settingsOverrides.mathRenderingEnabled = false; renderDiagram.mockReset(); workspaceSearch.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); @@ -139,6 +152,34 @@ it("loads a host image through the page's own route when there is no environment ); }); +it("reuses the parse of a streaming reply's finished blocks when it remounts", async () => { + setup(); + const message = ( + + ); + { + await using _first = await render(message); + } + await using screen = await render(message); + await expect.element(screen.getByText("Still growing")).toBeVisible(); + expect(parsedSources.filter((source) => source.includes("Finished block"))).toHaveLength(1); +}); + +it("reuses the parse of a remounted message that has stable extra plugins", async () => { + setup(); + const parsed = vi.fn(); + const countingPlugins = [() => () => void parsed()]; + const message = ( + + ); + { + await using _first = await render(message); + } + await using screen = await render(message); + await expect.element(screen.getByText("Cached text")).toBeVisible(); + expect(parsed).toHaveBeenCalledTimes(1); +}); + describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index 2fe2b091c49b7..eb1e54bd77b01 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -18,6 +18,7 @@ import { useRightPanelStore } from "../rightPanelStore"; const state = vi.hoisted(() => ({ allowed: true, + terminalAllowed: false, choice: { kind: "gui", editor: "vscode" } as EditorChoice, listeners: new Set<() => void>(), openEditor: vi.fn(), @@ -80,8 +81,9 @@ vi.mock(import("../state/session"), async (original) => { const { useSyncExternalStore } = await import("react"); const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) => id !== null && - scope !== AuthTerminalOperateScope && - (scope !== AuthOrchestrationOperateScope || id !== threadRef.environmentId || state.allowed); + (scope !== AuthTerminalOperateScope + ? scope !== AuthOrchestrationOperateScope || id !== threadRef.environmentId || state.allowed + : state.terminalAllowed); return { ...(await original()), readEnvironmentScope, @@ -220,6 +222,7 @@ const serverConfig = { function setup() { using stack = new DisposableStack(); state.allowed = true; + state.terminalAllowed = false; state.choice = { kind: "gui", editor: "vscode" }; state.listeners.clear(); state.pullRequests = []; @@ -384,3 +387,12 @@ it("keeps denied terminal-editor routes out of the context menu even when local expect(state.openEditor).not.toHaveBeenCalled(); expect(state.toast).not.toHaveBeenCalled(); }); + +it("offers terminal-editor routes when terminal authorization is granted", async () => { + using _setup = setup(); + state.choice = { kind: "terminal", editor: "neovim" }; + state.terminalAllowed = true; + await using screen = await renderMarkdown("[Readme](docs/readme.md)"); + await screen.openContextMenu(); + expect(offeredActions()).toContain("open"); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d0b817fac9607..0c96bf42c23e2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -87,8 +87,8 @@ import type { ExtraProps as ReactMarkdownExtraProps, Options as ReactMarkdownOptions, } from "react-markdown"; -import ReactMarkdown from "react-markdown"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; +import type { Pluggable, PluggableList } from "unified"; import { defaultUrlTransform } from "react-markdown"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; @@ -123,7 +123,11 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; -import { PreferredEditorEnvironmentRequiredError, useEditorDispatch } from "../editorPreferences"; +import { + PreferredEditorEnvironmentRequiredError, + useEditorDispatch, + usePreferredEditor, +} from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; @@ -185,6 +189,8 @@ import { import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBlock"; +import { createMarkdownRenderer } from "./chat/markdownRenderer"; +import { splitMarkdownBlocks } from "./chat/markdownStreamBlocks"; import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock"; import { MarkdownTable } from "./chat/MarkdownTable"; import { MarkdownDetails } from "./chat/MarkdownDetails"; @@ -200,6 +206,7 @@ import { import { MarkdownExternalLinkContent } from "./chat/MarkdownExternalLink"; import { MarkdownFileLink } from "./chat/MarkdownFileLink"; import { canUseMarkdownFileShellActions } from "./chat/MarkdownFileLink.logic"; +import { rehypeChipPunctuation } from "./chat/markdownChipPunctuation"; import { handleMarkdownFragmentClick, rehypeHeadingIds } from "./chat/markdownFragments"; import { rehypeTextDirection } from "./chat/markdownTextDirection"; import { hastHasText, hastPlainTextDeep, plainHastText } from "./chat/markdownHast.logic"; @@ -215,8 +222,6 @@ import { reportMarkdownActionFailure } from "./chat/markdownActionFailure"; const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError); -type MarkdownImageAssetResource = ComponentProps["resource"]; - interface ChatMarkdownProps { /** Compact markdown transforms remove blocks before rendering inside a label. */ inline?: boolean; @@ -234,10 +239,9 @@ interface ChatMarkdownProps { onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; onRunShellCommand?: ((command: string) => void) | undefined; imageBaseDir?: string | undefined; - /** Host-backed images, such as private uploads in a pull request description. */ - resolveImageAsset?: ((source: string) => MarkdownImageAssetResource | null) | undefined; workspaceMutationId?: string | null | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + /** Keep one list per configuration (a module constant): it keys the shared parse cache. */ extraRemarkPlugins?: NonNullable; renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; /** Loads media a source control host keeps behind a credential through the server, which holds @@ -245,6 +249,7 @@ interface ChatMarkdownProps { sourceControlMedia?: boolean | undefined; headingLevelOffset?: number | undefined; } + export interface ChatMarkdownContextReference { kind: string; contextId: string; @@ -253,6 +258,7 @@ export interface ChatMarkdownContextReference { const EMPTY_MARKDOWN_SKILLS: ReadonlyArray = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; + const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, presentation: PresentationIcon, @@ -265,12 +271,14 @@ const ARTIFACT_TEMPLATE_ICON_BY_KIND = { email: MailIcon, slack: MessageSquareIcon, } satisfies Record; + function CodexArtifactTemplateCard(props: { readonly template: CodexArtifactTemplate; readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; }) { const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind]; const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind); + return (
= { note: { label: "Note", @@ -392,18 +393,27 @@ const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ ...CHAT_MARKDOWN_REHYPE_PLUGINS, rehypeHeadingIds, rehypeTextDirection, + rehypeChipPunctuation, ] satisfies NonNullable; const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [ rehypeHeadingIds, rehypeTextDirection, + rehypeChipPunctuation, ] satisfies NonNullable; +function markdownUrlTransform(href: string) { + if (parseAssistantCitationHref(href)) return href; + if (parseComposerContextHref(href)) return href; + if (parseThreadLinkHref(href)) return href; + if (isWindowsDrivePathHref(href)) return href; + return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); +} + const NO_MARKDOWN_EDITORS: ReadonlyArray = []; type MarkdownServerConfigFields = ReturnType; -/** The server config fields markdown reads. */ function markdownServerConfigFields(config: ServerConfig | null) { return { pullRequestsEnabled: config !== null, @@ -429,9 +439,7 @@ function markdownServerConfigFieldsEqual( ); } -// The config object changes with every provider status push and snapshot. The -// atom keeps its previous value while the fields markdown reads are equal, so -// those pushes don't re-render every visible reply. +// Compare only markdown config fields to avoid rerendering replies on every provider status push. const markdownServerConfigFieldsAtom = Atom.family((environmentId: EnvironmentId | null) => Atom.make((get) => { const next = markdownServerConfigFields(get(serverEnvironment.configValueAtom(environmentId))); @@ -440,6 +448,44 @@ const markdownServerConfigFieldsAtom = Atom.family((environmentId: EnvironmentId }).pipe(Atom.withLabel("markdown-server-config-fields")), ); +function fileLinkSourcesKey( + text: string, + cwd: string | undefined, + imageBaseDir: string | undefined, +): string { + const hrefs = extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text)) + .filter((href) => !parseComposerContextHref(href)) + .map(normalizeMarkdownLinkHrefKey) + .filter((href) => resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd)); + const spans = extractInlineCodeSpans(text).filter((span) => + resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd), + ); + return JSON.stringify([[...new Set(hrefs)], [...new Set(spans)]]); +} + +function resolveFileLinkSources( + key: string, + cwd: string | undefined, + imageBaseDir: string | undefined, +) { + const [hrefs, spans] = JSON.parse(key) as [string[], string[]]; + const markdownFileLinkMetaByHref = new Map(); + for (const href of hrefs) { + const meta = resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd); + if (meta) markdownFileLinkMetaByHref.set(href, meta); + } + const inlineCodeFileLinkMetaByText = new Map(); + for (const span of spans) { + const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); + if (meta) inlineCodeFileLinkMetaByText.set(span, meta); + } + const fileLinkParentSuffixByPath = buildFileLinkParentSuffixByPath([ + ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), + ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), + ]); + return { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath }; +} + function useChatMarkdownState({ text, inline = false, @@ -453,7 +499,6 @@ function useChatMarkdownState({ onUseArtifactTemplate, onRunShellCommand, imageBaseDir, - resolveImageAsset, workspaceMutationId, onImageExpand, renderContextReference, @@ -463,7 +508,6 @@ function useChatMarkdownState({ const resolvedTheme = useRenderedAppearance(); const markdownRef = useRef(null); const mediaRequestId = useRef(0); - // A preview opened from another thread or workspace never carries over. const mediaScopeKey = [ threadRef?.environmentId, threadRef?.threadId, @@ -471,8 +515,7 @@ function useChatMarkdownState({ cwd, imageBaseDir, ].join("\u0000"); - // Tagged with the scope it was opened in: a request from the previous scope can - // resolve before the effect cleanup below invalidates it, and must stay hidden. + // Hide requests from an old scope even if they resolve before effect cleanup. const [localMedia, setLocalMedia] = useState<{ scope: string; preview: ExpandedImagePreview; @@ -529,8 +572,8 @@ function useChatMarkdownState({ resolvedFilePath, cwd, threadRef, - // Read at click time: a reconnect replaces the prepared connection, and - // subscribing would hand every rendered markdown element a new context. + // Read the connection on click; reconnect replaces it, and subscriptions would refresh every markdown + // context. httpBaseUrl: environmentId === null ? undefined : readPreparedConnection(environmentId)?.httpBaseUrl, createAssetUrl, @@ -587,10 +630,13 @@ function useChatMarkdownState({ markdownServerConfigFieldsAtom(environmentId), ); const projects = useProjects(); - const editorDispatch = useEditorDispatch(environmentId, availableEditors, cwd); - const preferredEditorMenuLabel = openInEditorMenuLabel(editorDispatch.choice?.editor ?? null); - const openInPreferredEditor = editorDispatch.open; - const canOpenPreferredEditor = editorDispatch.canOpen; + const [preferredEditor] = usePreferredEditor(availableEditors); + const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); + const { open: openInPreferredEditor, canOpen: canOpenPreferredEditor } = useEditorDispatch( + environmentId, + availableEditors, + ); + const canOpenInPreferredEditor = canOpenPreferredEditor; const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false, }); @@ -615,47 +661,6 @@ function useChatMarkdownState({ }); }; const diffThemeName = resolveDiffThemeName(resolvedTheme); - const markdownFileLinkMetaByHref = (() => { - const metaByHref = new Map< - string, - NonNullable> - >(); - for (const href of extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text))) { - if (parseComposerContextHref(href)) continue; - const normalizedHref = normalizeMarkdownLinkHrefKey(href); - if (metaByHref.has(normalizedHref)) continue; - const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd); - if (meta) { - metaByHref.set(normalizedHref, meta); - } - } - return metaByHref; - })(); - const inlineCodeFileLinkMetaByText = (() => { - const metaByText = new Map(); - for (const span of extractInlineCodeSpans(text)) { - if (metaByText.has(span)) continue; - const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); - if (meta) { - metaByText.set(span, meta); - } - } - return metaByText; - })(); - const fileLinkParentSuffixByPath = (() => { - const filePaths = [ - ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), - ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), - ]; - return buildFileLinkParentSuffixByPath(filePaths); - })(); - const markdownUrlTransform = (href: string) => { - if (parseAssistantCitationHref(href)) return href; - if (parseComposerContextHref(href)) return href; - if (parseThreadLinkHref(href)) return href; - if (isWindowsDrivePathHref(href)) return href; - return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); - }; const handleCopy = (event: ReactClipboardEvent) => { const selection = window.getSelection(); if (!selection || selection.isCollapsed || !event.clipboardData) return; @@ -673,18 +678,18 @@ function useChatMarkdownState({ }; const openChangeRequestLink = useOpenChangeRequestLink(threadRef, pullRequestPanelRef); const openDeferredMarkdownLink = useOpenLink(threadRef); - // Subscribed rather than read at click time: the anchor has to decide - // synchronously whether to intercept its `_blank`, and a subscription is what - // makes a persisted "app" apply once settings hydrate after launch. + // Subscribe so hydrated settings apply before the anchor synchronously decides whether to intercept _blank. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); const mathEnabled = useClientSettings((settings) => settings.mathRenderingEnabled); + // Keyed by the file links found, not the text, so streamed text that adds none keeps the + // renderer context, and every block reading it, unchanged. + // A template literal marks the key as a string, so the compiler memoizes the maps on its value. + const fileLinksKey = `${fileLinkSourcesKey(text, cwd, imageBaseDir)}`; + const { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath } = + resolveFileLinkSources(fileLinksKey, cwd, imageBaseDir); const resolveThreadPullRequest = ( href: string, - ): - | (ThreadPullRequestKey & { - readonly url: string; - }) - | null => { + ): (ThreadPullRequestKey & { readonly url: string }) | null => { if ( threadRef === undefined || readThreadShell(threadRef) === null || @@ -796,8 +801,7 @@ function useChatMarkdownState({ }; const openFileInPanel = (fileLinkMeta: MarkdownFileLinkMeta, panelPath: string) => { if (!threadRef) return; - // Claimed on every open so a synchronous one supersedes a lookup already - // in flight. + // Every open supersedes pending lookups, including synchronous opens. const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, fileLinkMeta.line); @@ -857,7 +861,7 @@ function useChatMarkdownState({ theme={resolvedTheme} threadRef={threadRef} onOpen={ - canOpenPreferredEditor + canOpenInPreferredEditor ? async () => openInPreferredEditor( markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()), @@ -891,6 +895,7 @@ function useChatMarkdownState({ /> ); }; + const componentState = { canOperateHost, canOperatePreview, @@ -903,7 +908,6 @@ function useChatMarkdownState({ renderContextReference, headingLevelOffset, imageBaseDir, - resolveImageAsset, workspaceMutationId, inlineCodeFileLinkMetaByText, inline, @@ -931,17 +935,18 @@ function useChatMarkdownState({ componentState, handleCopy, markdownRef, - markdownUrlTransform, localMediaPreview, closeLocalMediaPreview, }; } + const ChatMarkdownRendererContext = React.createContext< ReturnType["componentState"] >(null!); -// The markdown source changes on every streamed delta. Only the renderers that -// slice it by node offset read it, so it sits apart from the renderer context. + +// Isolate streamed source text from the renderer context; only offset-based renderers need it. const ChatMarkdownTextContext = React.createContext(""); + function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) { const Tag = `h${level}` as const; return function MarkdownHeading({ @@ -1190,9 +1195,7 @@ const CHAT_MARKDOWN_COMPONENTS = { canOpenInApp: canOpenInPreview, }) !== "app" ) { - // Chromium turns an Option/Alt-click into a download that never reaches the - // shell. On desktop, open the link here while the key is still down, so link - // routers such as Finicky can still read it. + // Open desktop Alt-clicks before Chromium downloads them so link routers can read the held key. if (isElectron && event.altKey) { event.preventDefault(); event.stopPropagation(); @@ -1314,6 +1317,7 @@ const CHAT_MARKDOWN_COMPONENTS = { ); } + const label = nodeToPlainText(children); const start = node?.position?.start.offset; const end = node?.position?.end.offset; @@ -1327,7 +1331,8 @@ const CHAT_MARKDOWN_COMPONENTS = { chip ) : ( - {children} {chip} + {children} + {chip} ); }, @@ -1376,7 +1381,6 @@ const CHAT_MARKDOWN_COMPONENTS = { sourceControlMedia, imageBaseDir, threadRef, - resolveImageAsset, workspaceMutationId, renderContextReference, fileLinkChip, @@ -1404,20 +1408,6 @@ const CHAT_MARKDOWN_COMPONENTS = { const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); const { className, style: _style, width, height, ...imageProps } = props; const authoredSizeStyle = authoredImageSizeStyle(width, height); - const imageAsset = resolveImageAsset?.(classifiedSrc); - if (imageAsset && environmentId) { - return ( - - ); - } const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); const mediaKind = mediaKindFromPath(classifiedSrc); if (mediaKind === "audio") { @@ -1588,6 +1578,7 @@ const CHAT_MARKDOWN_COMPONENTS = { if (!codeBlock) { return
{children}
; } + const language = extractFenceLanguage(codeBlock.className); if ( mathEnabled && @@ -1651,6 +1642,78 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, } satisfies Components; + +const REMARK_MATH: Pluggable = [remarkMath, { singleDollarTextMath: true }]; +// Shared lists, so every message with the same options shares a processor and its cache. +const CHAT_MARKDOWN_REMARK_PLUGIN_SETS = { + plain: CHAT_MARKDOWN_REMARK_PLUGINS, + breaks: CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, + math: [REMARK_MATH, ...CHAT_MARKDOWN_REMARK_PLUGINS], + mathBreaks: [REMARK_MATH, ...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS], +} satisfies Record; + +const combinedPlugins = new WeakMap>(); + +// One combined list per pair, so a stable extra list shares a renderer and its parse cache. +function withExtraPlugins(shared: PluggableList, extra: PluggableList): PluggableList { + if (extra.length === 0) return shared; + let byShared = combinedPlugins.get(extra); + if (!byShared) combinedPlugins.set(extra, (byShared = new WeakMap())); + let combined = byShared.get(shared); + if (!combined) byShared.set(shared, (combined = [...shared, ...extra])); + return combined; +} + +const markdownRenderers = new WeakMap< + PluggableList, + WeakMap> +>(); + +function markdownRenderer(remarkPlugins: PluggableList, rehypePlugins: PluggableList) { + let byRehype = markdownRenderers.get(remarkPlugins); + if (!byRehype) markdownRenderers.set(remarkPlugins, (byRehype = new WeakMap())); + let renderer = byRehype.get(rehypePlugins); + if (!renderer) { + renderer = createMarkdownRenderer({ + remarkPlugins, + rehypePlugins, + components: CHAT_MARKDOWN_COMPONENTS, + urlTransform: markdownUrlTransform, + }); + byRehype.set(rehypePlugins, renderer); + } + return renderer; +} + +const MarkdownSource = memo(function MarkdownSource({ + text, + remarkPlugins, + rehypePlugins, + shared, + cache, +}: { + text: string; + remarkPlugins: PluggableList; + rehypePlugins: PluggableList; + /** The plugins are one of the shared sets. */ + shared: boolean; + /** The text will not change again; streamed text would only fill the cache with prefixes. */ + cache: boolean; +}) { + // Per-instance plugins, such as the incremental parser, set up their state on every render. + const render = shared + ? markdownRenderer(remarkPlugins, rehypePlugins) + : createMarkdownRenderer({ + remarkPlugins, + rehypePlugins, + components: CHAT_MARKDOWN_COMPONENTS, + urlTransform: markdownUrlTransform, + }); + return ( + {render(text, shared && cache)} + ); +}); + function ChatMarkdown({ text, className, @@ -1660,29 +1723,41 @@ function ChatMarkdown({ extraRemarkPlugins = EMPTY_REMARK_PLUGINS, ...props }: ChatMarkdownProps) { - const { - componentState, - handleCopy, - markdownRef, - markdownUrlTransform, - localMediaPreview, - closeLocalMediaPreview, - } = useChatMarkdownState({ text, inline, ...props }); + const { componentState, handleCopy, markdownRef, localMediaPreview, closeLocalMediaPreview } = + useChatMarkdownState({ text, inline, ...props }); + // Streaming re-renders only the last block; offsets for task edits need the whole text. + const streamingBlocks = + props.isStreaming === true && + !inline && + extraRemarkPlugins.length === 0 && + !props.onTaskListChange + ? splitMarkdownBlocks(text) + : null; const incrementalParsing = props.isStreaming === true && + streamingBlocks === null && extraRemarkPlugins.length === 0 && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); const Root = inline ? "span" : "div"; const bindMarkdownRef = (node: HTMLElement | null) => { markdownRef.current = node; }; - const remarkPlugins = [ - ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), - ...extraRemarkPlugins, - ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), - ]; - if (componentState.mathEnabled) - remarkPlugins.unshift([remarkMath, { singleDollarTextMath: true }]); + const sharedRemarkPlugins = + CHAT_MARKDOWN_REMARK_PLUGIN_SETS[ + componentState.mathEnabled + ? lineBreaks + ? "mathBreaks" + : "math" + : lineBreaks + ? "breaks" + : "plain" + ]; + // The incremental parser keeps per-instance state, so its output is not shared. + const uncachedPlugins = incrementalParsing; + const remarkPlugins = incrementalParsing + ? [...sharedRemarkPlugins, ...extraRemarkPlugins, createIncrementalMarkdownPlugin()] + : withExtraPlugins(sharedRemarkPlugins, extraRemarkPlugins); + const blocks = streamingBlocks ?? [text]; return ( - - ( + - {text} - - + shared={!uncachedPlugins} + // Every block before the last is finished, so its parse can be reused. + cache={!props.isStreaming || index < blocks.length - 1} + /> + ))} {localMediaPreview ? ( @@ -1719,4 +1795,5 @@ function ChatMarkdown({ ); } + export default memo(ChatMarkdown); diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx new file mode 100644 index 0000000000000..8fe98b78da113 --- /dev/null +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -0,0 +1,64 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { ThreadId } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import type * as Context from "effect/Context"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { AppRoot } from "~/AppRoot"; +import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { getRouter } from "~/router"; +import { + ENVIRONMENT_ID, + PROJECT_ID, + installFakeEnvironment, + type FakeDriver, +} from "../../test/fakeEnvironment"; +import { render } from "../../test/render"; + +// The socket is the only fake layer; see test/appScenarios.renders.tsx. +vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { + const [actual, { fakeDriver }, Layer] = await Promise.all([ + importOriginal<{ readonly ConnectionDriver: Context.Key }>(), + import("../../test/fakeEnvironment"), + import("effect/Layer"), + ]); + return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; +}); + +vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ + ...(await original()), + trackRpcRequestSent: () => {}, +})); + +it("a new draft does not load its reserved thread before the server lists it", async () => { + using _environment = installFakeEnvironment([]); + using fetchSpy = vi.spyOn(globalThis, "fetch"); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const draftId = DraftId.make("draft-27443"); + const threadId = ThreadId.make("thread-27443"); + useComposerDraftStore + .getState() + .setProjectDraftThreadId(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), draftId, { threadId }); + + const router = getRouter(createMemoryHistory({ initialEntries: [`/draft/${draftId}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByTestId("composer-editor")).toBeVisible(); + // The shell snapshot has arrived once the sidebar lists the project, so the connection is up. + await expect.element(page.getByText("render", { exact: true }).first()).toBeVisible(); + + const threadRequests = fetchSpy.mock.calls + .map(([input]) => String(input instanceof Request ? input.url : input)) + .filter((url) => url.includes(`/api/orchestration/threads/${threadId}/`)); + // A 404 here marks the thread deleted, and the view never loads it once the send creates it. + expect(threadRequests).toEqual([]); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bef8f7ab405f1..2ba094c23e1ac 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -531,6 +531,7 @@ export function resolveComposerProviderSelection(input: { : requestedInstanceId; return { selectedProviderEntry, + requestedInstanceId, requestedDriverKind, lockedContinuationGroupKey, unavailableProviderInstanceId, @@ -748,6 +749,7 @@ export function cloneComposerImageForRetry( export function deriveComposerSendState(options: { prompt: string; imageCount: number; + existingAttachmentCount?: number; terminalContexts: ReadonlyArray; elementContextCount?: number; }): { @@ -768,6 +770,7 @@ export function deriveComposerSendState(options: { hasSendableContent: trimmedPrompt.length > 0 || options.imageCount > 0 || + (options.existingAttachmentCount ?? 0) > 0 || sendableTerminalContexts.length > 0 || elementContextCount > 0, }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 183980cc26f34..dcbea4281fa83 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,4 +1,3 @@ -import { focusVimNormal, getVimMode, setVimMode, useVimAction, vimEnabled } from "../vim/runtime"; import { workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; @@ -62,6 +61,7 @@ import { type AssistantCitation, type ChatFileAttachment, isProviderNativeSubagentThread, + orchestrationV2WorktreePending, type EnvironmentId, type MessageId, type ModelSelection, @@ -188,6 +188,8 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; import { newMessageId } from "~/lib/utils"; +import { isInSideChatPane, type SideChatParentPanel } from "~/lib/sideChatPane"; +import { focusVimNormal, getVimMode, vimEnabled } from "../vim/runtime"; import { NO_PROVIDER_MODEL_SELECTION, providerInstanceEntriesFor, @@ -274,6 +276,8 @@ import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; +import { useSideChat } from "./chat/useSideChat"; +import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; import { @@ -390,6 +394,8 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + /** A side chat in its parent's right panel; the parent keeps the keyboard and workspace UI. */ + embedded?: SideChatParentPanel; routeKind: "server"; draftId?: never; } @@ -399,6 +405,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + embedded?: never; routeKind: "draft"; draftId: DraftId; }; @@ -413,7 +420,9 @@ export default function ChatView(props: ChatViewProps) { onDiffPanelOpen, reserveTitleBarControlInset = true, forceExpandedMobileComposer = false, + embedded: parentPanel, } = props; + const embedded = parentPanel !== undefined; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); const draftId = routeKind === "draft" ? props.draftId : null; @@ -534,7 +543,6 @@ export default function ChatView(props: ChatViewProps) { } return { hasMoreHistory: serverThreadHistory.hasMoreHistory, - cursor: serverThreadHistory.historyCursor, loading: serverThreadHistory.loading, error: serverThreadHistory.error, onLoadEarlier: (throughEntryId) => { @@ -867,7 +875,29 @@ export default function ChatView(props: ChatViewProps) { : scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId); // The title it had at delegation, until this client sees the parent itself. const delegatedFromThread = useQuietThreadShell(delegatedFromRef); + const sideChatParentRef = + activeThread?.lineage.sideChat === true && activeThread.lineage.parentThreadId !== null + ? scopeThreadRef(activeThread.environmentId, activeThread.lineage.parentThreadId) + : null; + const sideChatParent = useQuietThreadShell(sideChatParentRef); const parentThreadLink = (() => { + if (sideChatParentRef !== null) { + return { + title: sideChatParent?.title ?? "Parent thread", + sideChat: true, + // Embedded, the parent is the view around it. + ...(embedded + ? {} + : { + open: (): void => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(sideChatParentRef), + }); + }, + }), + }; + } if (parentSubagentThreadRef !== null) { return { title: parentSubagentThread?.title ?? "Parent thread", @@ -1067,7 +1097,9 @@ export default function ChatView(props: ChatViewProps) { renderedRightPanelSurface, ); const canMaximizeRightPanel = rightPanelOpen; - const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; + // An embedded side chat has no panel; a stale maximized flag would hide its own column. + const rightPanelMaximized = + !embedded && canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; const [threadPanelPopoverHandle] = useState(PopoverCreateHandle); const [chatCanvasStore] = useState(createChatCanvasStore); @@ -1144,6 +1176,8 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); + + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentUnavailable = @@ -1615,8 +1649,12 @@ export default function ChatView(props: ChatViewProps) { useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId); const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName: activeProject?.title, + activeThreadEnvironmentId: activeThread?.environmentId ?? environmentId, + primaryEnvironmentId, + remoteOpenMode: remoteOpenState.mode, }); useOpenFavoriteEditorShortcut({ enabled: showOpenInPicker && !backgroundThread, @@ -1715,13 +1753,15 @@ export default function ChatView(props: ChatViewProps) { providerSubagentModels, reportedModelSelection, ); - const showComposerContextStrip = shouldShowComposerContextStrip({ - isDraftHeroState, - persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null && !showProviderSubagentBar, - isGitRepo, - showEnvironmentIndicator: showComposerEnvironmentIndicator, - }); + const showComposerContextStrip = + !embedded && + shouldShowComposerContextStrip({ + isDraftHeroState, + persistInActiveThreads: settings.persistComposerContextStrip, + hasActiveProject: activeProject !== null && !showProviderSubagentBar, + isGitRepo, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + }); const terminalShortcutLabelOptions = { context: { terminalFocus: true, @@ -1801,14 +1841,11 @@ export default function ChatView(props: ChatViewProps) { } }; const onInterruptStable = useStableCallback(onInterrupt); - useVimAction(({ command, scope }) => { - if (command !== "input.enter" || scope !== "chat") return; - setVimMode("insert"); - focusComposer(); - return true; - }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window listener for the desktop snapshot event that focuses the composer - useEffect(() => subscribeSnapShotComposerFocus(focusComposerStable), [focusComposerStable]); + useEffect( + () => (embedded ? undefined : subscribeSnapShotComposerFocus(focusComposerStable)), + [embedded, focusComposerStable], + ); const scheduleComposerFocus = () => { window.requestAnimationFrame(() => { focusComposer(); @@ -1969,8 +2006,8 @@ export default function ChatView(props: ChatViewProps) { : linkedThreadPullRequestKey; const activeRunningTurnId = activeRuntime?.activeRunId ?? null; useProactivePanels({ - threadRef: isServerThread ? activeThreadRef : null, - enabled: settings.proactivePanelsEnabled && !shouldUsePlanSidebarSheet, + threadRef: isServerThread && !embedded ? activeThreadRef : null, + enabled: settings.proactivePanelsEnabled && !shouldUsePlanSidebarSheet && !embedded, settingsHydrated: clientSettingsHydrated, threadDetailLoading, runningTurnId: activeRunningTurnId, @@ -2101,29 +2138,30 @@ export default function ChatView(props: ChatViewProps) { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a frame to focus the composer DOM element when a thread opens with the terminal closed useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen) return; + if (embedded || !activeThread?.id || terminalUiState.terminalOpen) return; if (vimEnabled() && !isLocalDraftThread) { - const frame = requestAnimationFrame(() => focusVimNormal()); - return () => cancelAnimationFrame(frame); + const frame = window.requestAnimationFrame(() => focusVimNormal()); + return () => window.cancelAnimationFrame(frame); } - const frame = window.requestAnimationFrame(() => { - focusComposerStable(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [activeThread?.id, focusComposerStable, isLocalDraftThread, terminalUiState.terminalOpen]); + const frame = window.requestAnimationFrame(() => focusComposerStable()); + return () => window.cancelAnimationFrame(frame); + }, [ + activeThread?.id, + embedded, + focusComposerStable, + isLocalDraftThread, + terminalUiState.terminalOpen, + ]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores Vim normal-mode focus when an existing thread opens useEffect(() => { - if (!routeThreadKey || !vimEnabled() || isLocalDraftThread) return; - // Restored terminals must not take input focus when entering an existing thread. - const frame = requestAnimationFrame(() => focusVimNormal()); - return () => cancelAnimationFrame(frame); - }, [routeThreadKey, isLocalDraftThread]); + if (embedded || !routeThreadKey || !vimEnabled() || isLocalDraftThread) return; + const frame = window.requestAnimationFrame(() => focusVimNormal()); + return () => window.cancelAnimationFrame(frame); + }, [embedded, routeThreadKey, isLocalDraftThread]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; + if (embedded || !activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; let frame: number | null = null; const onWindowFocus = () => { if (frame !== null) window.cancelAnimationFrame(frame); @@ -2134,9 +2172,11 @@ export default function ChatView(props: ChatViewProps) { if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return; if ( (!vimEnabled() || getVimMode() === "insert") && + !isInSideChatPane(document.activeElement) && shouldRefocusComposerOnWindowFocus(document.activeElement) - ) + ) { focusComposerStable(); + } }); }); }; @@ -2145,7 +2185,13 @@ export default function ChatView(props: ChatViewProps) { window.removeEventListener("focus", onWindowFocus); if (frame !== null) window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposerStable, isMobileViewport, terminalUiState.terminalOpen]); + }, [ + activeThread?.id, + embedded, + focusComposerStable, + isMobileViewport, + terminalUiState.terminalOpen, + ]); // Promotion drops draftId while a send may still be dispatching, so key by thread. const [localDispatchThreadId, setLocalDispatchThreadId] = useState(threadId); @@ -2350,6 +2396,7 @@ export default function ChatView(props: ChatViewProps) { pullRequestPanelTarget, threadPanelPresentation, isServerThread, + embedded, isGitRepo, browserAvailable, canOperatePreview, @@ -2392,6 +2439,7 @@ export default function ChatView(props: ChatViewProps) { activeThread, activeThreadId, activeThreadRef, + threadDetailRef: routeThreadDetailRef, activeThreadKey, activeThreadShell, activeLatestRun, @@ -2414,7 +2462,6 @@ export default function ChatView(props: ChatViewProps) { clearUsageLimitsFor, beginLocalDispatch, resetLocalDispatch, - scheduleComposerFocus, scrollToEnd, persistThreadSettingsForNextTurn, }); @@ -2635,8 +2682,8 @@ export default function ChatView(props: ChatViewProps) { // composer effect above. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the open drawer terminal when entering a thread useEffect(() => { - if (activeThreadRef) return focusTerminalOnThreadEnter(activeThreadRef); - }, [activeThreadRef]); + if (activeThreadRef && !embedded) return focusTerminalOnThreadEnter(activeThreadRef); + }, [activeThreadRef, embedded]); const getShortcutContext = (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, @@ -2645,7 +2692,9 @@ export default function ChatView(props: ChatViewProps) { previewOpen: previewPanelOpen, editableFocus: isEditableFocused(eventTarget), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, - composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor", + composerFocus: + document.activeElement?.getAttribute("data-testid") === "composer-editor" && + isInSideChatPane(document.activeElement) === embedded, draftThreadRoute: routeKind === "draft", turnRunning: phase === "running", composerRequiresModifier: composerRequiresModifier(settings.sendShortcut, promptRef.current), @@ -2666,6 +2715,8 @@ export default function ChatView(props: ChatViewProps) { useChatShortcuts({ enabled: activeThreadId !== null && !backgroundThread, + embedded, + composerElement: composerOverlayElement, keybindings, scriptKeybindings, getShortcutContext, @@ -2720,13 +2771,56 @@ export default function ChatView(props: ChatViewProps) { // A focused desktop browser page forwards these chords as menu actions. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to desktop bridge menu actions that toggle the right panel useEffect(() => { + if (embedded) return; return window.desktopBridge?.onMenuAction((action) => { if (action === "rightPanel.toggle") toggleRightPanelStable(); else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximizedStable(); }); - }, [toggleRightPanelStable, toggleRightPanelMaximizedStable]); + }, [embedded, toggleRightPanelStable, toggleRightPanelMaximizedStable]); - usePasteToFocusComposer(activeThreadId, composerRef); + usePasteToFocusComposer(embedded ? null : activeThreadId, composerRef); + // A delegated task is a remote thread's subagent. + const isSubagentThread = + activeThread?.lineage.relationshipToParent === "subagent" || delegatedFrom !== null; + // Side chats do not nest or start from subagents, and older servers would fork a listed thread. + const sideChatsOffered = + !embedded && + canOperateThread && + activeThread?.lineage.sideChat !== true && + !isSubagentThread && + serverConfig?.environment.capabilities.sideChats === true; + const sideChat = useSideChat({ + threadRef: sideChatsOffered && isServerThread ? activeThreadRef : null, + onError: (message) => { + if (activeThread) setThreadError(activeThread.id, message); + }, + }); + // A maximized panel covers the terminal drawer the command runs in. + const runSideChatShellCommandStable = useStableCallback((command: string) => { + if (activeThreadRef) useRightPanelStore.getState().setMaximized(activeThreadRef, false); + runShellCommandStable(command); + }); + // Main-thread surfaces show this reason; `/side` sent anyway gets it as a toast. + const sideChatDisabledReason = + activeThread?.lineage.sideChat === true + ? "Side chats cannot start side chats." + : isSubagentThread + ? "Subagent threads cannot start side chats." + : serverConfig === null + ? "Side chats are not ready yet. Try again in a moment." + : serverConfig.environment.capabilities.sideChats !== true + ? "Update this environment's T3 Code server to use side chats." + : sideChatsOffered && !isServerThread + ? "Side chats start after the first message." + : sideChat.available && + orchestrationV2WorktreePending( + activeThread?.worktreePath ?? null, + serverProjection?.runs ?? [], + ) + ? "Available once the thread's worktree is set up." + : null; + const openSideChatStable = useStableCallback(() => void sideChat.open()); + const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt)); const { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } = useTimelineRewind({ environmentId, @@ -2889,6 +2983,8 @@ export default function ChatView(props: ChatViewProps) { anchorNewTurn, acknowledgeActiveThreadWoke, handleInteractionModeChange, + onSideChat: sideChat.available ? sendSideChatStable : undefined, + sideChatDisabledReason, handleNewThreadInActiveProject, persistThreadSettingsForNextTurn, }); @@ -3027,10 +3123,7 @@ export default function ChatView(props: ChatViewProps) { !serverProjection?.messages.some((committed) => committed.id === message.id), ); const scrollToEndSummary = - [ - childInputNow === null ? null : "Subagent waiting", - queuedRunCount === 0 ? null : `${queuedRunCount} queued`, - ] + [childInputNow?.waitingLabel ?? null, queuedRunCount === 0 ? null : `${queuedRunCount} queued`] .filter((part) => part !== null) .join(" · ") || null; // The end of the timeline holds the thread's present and next turns: what it is doing now, @@ -3115,7 +3208,7 @@ export default function ChatView(props: ChatViewProps) { ); if (!activeThread) { - return ; + return embedded ? null : ; } const rightPanelContent = activeThreadRef ? ( @@ -3132,6 +3225,10 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext={getShortcutContextStable} onSendAnnotation={sendPreviewAnnotationStable} onCloseSurface={closeRightPanelSurface} + sideChatParentPanel={{ + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }} terminal={{ launchContext: activeTerminalLaunchContext ?? null, keybindings, @@ -3212,64 +3309,66 @@ export default function ChatView(props: ChatViewProps) { ref={setWorkspaceLayoutElement} className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background" > - - {rightPanelControlsAtRoot ? panelLayoutControls : null} + {embedded ? null : ( + + )} + {rightPanelControlsAtRoot && !embedded ? panelLayoutControls : null}
-
- {isElectron && rightPanelControlsAtRoot ? ( - + {isElectron && rightPanelControlsAtRoot ? ( + + ) : null} + {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} + {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} + - ) : null} - {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} - -
+ + )}
scrollToEnd(false)} - vimHistoryError={serverThreadHistory.error} citationRequest={citationRequest} citationHistory={citationHistory} onCiteAssistantText={citeAssistantTextStable} - {...(activeProject ? { onRunShellCommand: runShellCommandStable } : {})} + {...(activeProject + ? { onRunShellCommand: parentPanel?.runShellCommand ?? runShellCommandStable } + : {})} isWorking={isWorking} runlessWorkActive={runlessWorkStartedAt !== null} isCompacting={isCompacting} @@ -3373,12 +3472,14 @@ export default function ChatView(props: ChatViewProps) { routeThreadKey={displayedTimelineKey} displayThreadKey={displayedTimelineKey} announceTurnStatus={!isProviderSubagent} - onOpenToolDiff={onOpenToolDiffStable} - onOpenTurnDiff={onOpenTurnDiffStable} + onOpenToolDiff={parentPanel?.openToolDiff ?? onOpenToolDiffStable} + // Side chat runs keep no checkpoints to diff, and a fork would promote the side chat. + {...(embedded + ? {} + : { onOpenTurnDiff: onOpenTurnDiffStable, onForkFromRun: onForkFromRunStable })} onOpenThread={onOpenRelatedThreadStable} parentThreadLink={parentThreadLink} workflowOpening={workflowOpening} - onForkFromRun={onForkFromRunStable} supportsConversationRollback={supportsConversationRollback} threadStartCheckpointReady={threadStartCheckpointReady} availableRevertTurnCounts={availableRevertTurnCounts} @@ -3549,6 +3650,8 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} + sideChatAvailable={sideChat.available} + sideChatDisabledReason={sideChatDisabledReason} onUsageLimitsCommand={ usageLimitsOffered && usageLimitsKey !== null && @@ -3727,10 +3830,10 @@ export default function ChatView(props: ChatViewProps) {
- {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? ( + {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser && !embedded ? ( ) : null} - {activeThreadRef && shownPreviewMiniPlayer ? ( + {activeThreadRef && shownPreviewMiniPlayer && !embedded ? ( ) : null} - + {embedded ? null : ( + <> + - + keybindings={keybindings} + availableEditors={availableEditors} + showOpenInPicker={showOpenInPicker} + openInShortcutEnabled={ + !( + rightPanelOpen && + renderedRightPanelSurface?.kind === "file" && + renderedRightPanelSurface.attachment === undefined + ) + } + gitCwd={gitCwd} + isGitRepo={isGitRepo} + envLocked={envLocked} + availableEnvironments={logicalProjectEnvironments} + autoEnvironmentLabel={autoEnvironmentLabel} + onAutoEnvironment={ + draftId && + !envLocked && + canAutoBalanceEnvironments && + loadBalancingSettings.loadBalancingEnabled + ? onAutoEnvironmentStable + : undefined + } + {...(canChangeEnvironment + ? { onEnvironmentChange: onEnvironmentChangeStable } + : {})} + onEnvModeChange={onEnvModeChangeStable} + envMode={envMode} + {...(canOverrideServerThreadEnvMode + ? { + activeThreadBranchOverride: activeThreadBranch, + onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, + } + : {})} + startFromOrigin={startFromOrigin} + onStartFromOriginChange={onStartFromOriginChange} + {...(canCheckoutPullRequestIntoThread + ? { onCheckoutPullRequestRequest: openPullRequestDialogStable } + : {})} + onComposerFocusRequest={scheduleComposerFocusStable} + {...(isServerThread && isGitRepo + ? { onOpenChanges: openChangesFromThreadPanelStable } + : {})} + onRunProjectScript={runProjectScriptStable} + onAddProjectScript={saveProjectScriptStable} + onUpdateProjectScript={updateProjectScriptStable} + onDeleteProjectScript={deleteProjectScriptStable} + usage={{ + providerInstanceId: activeProviderInstanceId, + providers: providerStatuses, + sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, + contextWindow: activeContextWindow, + }} + /> - + + + )}
- + {embedded ? null : ( + + )}
- {rightPanelPresent && activeThreadRef ? ( + {rightPanelPresent && activeThreadRef && !embedded ? ( {rightPanelContent} ) : null} - + {embedded ? null : }
diff --git a/apps/web/src/components/CommandPalette.composition.browser.test.tsx b/apps/web/src/components/CommandPalette.composition.browser.test.tsx new file mode 100644 index 0000000000000..4787885905985 --- /dev/null +++ b/apps/web/src/components/CommandPalette.composition.browser.test.tsx @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { act, render } from "../../test/render"; +import { CommandPalette } from "./CommandPalette"; +import { openCommandPalette } from "../commandPaletteBus"; +import { primaryServerKeybindingsAtom } from "../state/server"; + +vi.mock("@effect/atom-react", async (original) => ({ + ...(await original()), + useAtomValue: (atom: unknown) => + atom === primaryServerKeybindingsAtom ? DEFAULT_RESOLVED_KEYBINDINGS : [], +})); +vi.mock("../hooks/useTheme", () => ({ + useRenderedAppearance: () => "light", + useTheme: () => ({ theme: "t3-chat", resolvedTheme: "light", appearanceMode: "light" }), +})); +vi.mock("../hooks/useCustomThemes", () => ({ useCustomThemes: () => [] })); +vi.mock("../hooks/useEnvironmentTheme", () => ({ useEnvironmentThemeDefinitions: () => [] })); +vi.mock("../hooks/useSettings", async (original) => ({ + ...(await original()), + useClientSettings: () => DEFAULT_CLIENT_SETTINGS, +})); +vi.mock("../hooks/useScratchProject", () => ({ + useScratchProject: () => ({ scratchEnvironmentId: () => null }), +})); +vi.mock("../hooks/useNewProject", () => ({ useNewProject: () => async () => undefined })); +vi.mock("../hooks/useHandleNewThread", () => ({ + useHandleNewThread: () => ({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: null, + handleNewThread: async () => undefined, + }), + useNewThreadHandler: () => async () => undefined, +})); +vi.mock("../hooks/useOpenPanelPullRequestUrl", () => ({ useOpenPanelPullRequestUrl: () => null })); +vi.mock("../connection/useDesktopLocalBootstraps", () => ({ useDesktopLocalBootstraps: () => [] })); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: () => async () => ({ _tag: "Success", value: undefined }), +})); +vi.mock("../state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => async () => ({ _tag: "Success", value: undefined }), +})); +vi.mock("../state/environments", async (original) => ({ + ...(await original()), + useEnvironments: () => ({ + environments: [ + { + environmentId: "local", + label: "Local", + connection: { phase: "connected" }, + entry: { target: { _tag: "PrimaryConnectionTarget" } }, + serverConfig: { + environment: { platform: { os: "linux" }, capabilities: { projectCloneTracking: true } }, + settings: { addProjectBaseDirectory: "/workspace/" }, + }, + }, + ], + }), + usePrimaryEnvironmentId: () => "local", + useEnvironmentProviders: () => new Map(), +})); +vi.mock("../state/entities", async (original) => ({ + ...(await original()), + useProjects: () => [], + useThreadShells: () => [], + useServerConfigs: () => new Map(), + waitForProject: async () => undefined, +})); +vi.mock("../state/queries", async (original) => ({ + ...(await original()), + useThreadSearch: (_environmentIds: unknown, query: string) => ({ + matches: [], + query, + isPending: false, + }), +})); +vi.mock("../state/session", async (original) => ({ + ...(await original()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("../state/filesystem", async (original) => ({ + ...(await original()), + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), +})); +vi.mock("../state/query", async (original) => ({ + ...(await original()), + useEnvironmentQuery: () => ({ data: undefined, isLoading: false }), +})); +vi.mock("./settings/useAvailableSettingsSearchItems", () => ({ + useAvailableSettingsSearchItems: () => [], +})); + +// The real palette and Base UI input must agree that confirming composition is typing. +describe.each(["repository", "folder", "destination"] as const)("add-project %s", (step) => { + it.each([ + { isComposing: true, keyCode: 13 }, + { isComposing: false, keyCode: 229 }, + ])("keeps Enter in the input during composition: %j", async (composition) => { + await using _screen = await render({null}); + await act(() => openCommandPalette({ open: "add-project" })); + await page.getByText(step === "folder" ? "Local folder" : "Git URL", { exact: true }).click(); + const input = () => page.getByRole("combobox"); + if (step === "destination") { + await input().fill("https://example.com/team/project.git"); + await userEvent.keyboard("{Enter}"); + } + const value = step === "repository" ? "https://example.com/team/計画.git" : "/workspace/計画"; + await input().fill(value); + const event = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + ...composition, + }); + await act(() => input().element().dispatchEvent(event)); + expect(event.defaultPrevented).toBe(false); + await expect.element(input()).toHaveValue(value); + if (step === "repository") { + await userEvent.keyboard("{Enter}"); + await expect.element(input()).toHaveValue("/workspace/計画"); + } else { + await userEvent.keyboard("{Enter}"); + await expect.element(page.getByRole("combobox")).not.toBeInTheDocument(); + } + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 7ec1a3e9ac1e3..8cf712b113cf7 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -4,6 +4,7 @@ import type { Thread } from "../types"; import { makeThreadFixture } from "../test-fixtures"; import { buildNestedSearchItems, + resolveCommandPaletteProject, buildThreadActionItems, buildLinkedThreadActionItems, filterPinnedBrowseEntries, @@ -918,3 +919,22 @@ describe("returning from file and content search", () => { expect(reduceCommandPaletteUiState(closed, { _tag: "SetOpen", open: true })).toEqual(root); }); }); + +it("resolves project metadata and browse roots within the active environment", () => { + const local = { title: "Local project", workspaceRoot: "/local/repo" }; + const remote = { title: "Remote project", workspaceRoot: "/remote/repo" }; + const projects = new Map([ + ["local:shared-project", local], + ["remote:shared-project", remote], + ]); + expect( + resolveCommandPaletteProject(projects, EnvironmentId.make("local"), PROJECT_ID), + ).toBeUndefined(); + const sharedId = ProjectId.make("shared-project"); + expect(resolveCommandPaletteProject(projects, EnvironmentId.make("local"), sharedId)).toBe(local); + expect(resolveCommandPaletteProject(projects, EnvironmentId.make("remote"), sharedId)).toBe( + remote, + ); + expect(resolveCommandPaletteProject(projects, null, sharedId)).toBeUndefined(); + expect(resolveCommandPaletteProject(projects, EnvironmentId.make("local"), null)).toBeUndefined(); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 6102028889369..f9b47f75180bd 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -20,6 +20,7 @@ import { } from "@t3tools/client-runtime/operations/projects"; import { type EnvironmentId, + type ProjectId, type EnvironmentMachineKind, type FilesystemBrowseEntry, type KeybindingCommand, @@ -31,7 +32,7 @@ import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/fil import { type ReactNode } from "react"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; -import { isSidebarSubagentThread } from "./Sidebar.logic"; +import { isSidebarHiddenThread } from "./Sidebar.logic"; export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; @@ -420,7 +421,7 @@ export function buildThreadActionItems thread.archivedAt === null && !isSidebarSubagentThread(thread), + (thread) => thread.archivedAt === null && !isSidebarHiddenThread(thread), ); return visibleThreads.map((thread) => { @@ -860,3 +861,11 @@ export function overlayModeForCommand(command: string | null): SearchOverlayMode ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] : null; } + +export function resolveCommandPaletteProject( + projects: ReadonlyMap, + environmentId: EnvironmentId | null, + projectId: ProjectId | null, +) { + return environmentId && projectId ? projects.get(`${environmentId}:${projectId}`) : undefined; +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6b754eb3a599d..1fffa27d8dd0f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -160,6 +160,7 @@ import { resolveWslProjectSelection, } from "../wslPaths"; import { + resolveCommandPaletteProject, ADDON_ICON_CLASS, browseInputEndPaddingClass, buildBrowseGroups, @@ -613,7 +614,7 @@ function OpenCommandPaletteDialog(props: { return ensureBrowseDirectoryPath(baseDirectory); }; - const { projectCwdById, projectByKey, projectTitleById } = useProjectLookups(); + const { projectByKey } = useProjectLookups(); const activeThreadKey = activeThread ? scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)) @@ -639,9 +640,12 @@ function OpenCommandPaletteDialog(props: { const canTriage = useAtomValue(serverEnvironment.prepareTriage.permissionAtom(triageEnvironmentId)) && supportsTriage(triageEnvironmentId); - const currentProjectCwd = currentProjectId - ? (projectCwdById.get(currentProjectId) ?? null) - : null; + const currentProject = resolveCommandPaletteProject( + projectByKey, + currentProjectEnvironmentId, + currentProjectId, + ); + const currentProjectCwd = currentProject?.workspaceRoot ?? null; const currentProjectCwdForBrowse = browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId ? currentProjectCwd @@ -1157,7 +1161,8 @@ function OpenCommandPaletteDialog(props: { if (projects.length > 0) { const activeProjectTitle = projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? - (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); + currentProject?.title ?? + null; if (activeProjectTitle) { actionItems.push({ @@ -2289,6 +2294,12 @@ function OpenCommandPaletteDialog(props: { } function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Enter" && (event.nativeEvent.isComposing || event.keyCode === 229)) { + // Confirming an IME candidate must not submit a path or select a row. + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + return; + } + const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, context: { modelPickerOpen: false, editableFocus: true }, diff --git a/apps/web/src/components/CommandPaletteLookups.ts b/apps/web/src/components/CommandPaletteLookups.ts index f4fcfc5793bea..6c9fcc629f732 100644 --- a/apps/web/src/components/CommandPaletteLookups.ts +++ b/apps/web/src/components/CommandPaletteLookups.ts @@ -1,7 +1,6 @@ import { type ClientSettings, type EnvironmentId, - type ProjectId, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; @@ -41,16 +40,10 @@ export function useEnvironmentLookups() { export function useProjectLookups() { const projects = useProjects(); - const projectCwdById = new Map( - projects.map((project) => [project.id, project.workspaceRoot]), - ); const projectByKey = new Map( projects.map((project) => [`${project.environmentId}:${project.id}`, project]), ); - const projectTitleById = new Map( - projects.map((project) => [project.id, project.title]), - ); - return { projectCwdById, projectByKey, projectTitleById }; + return { projectByKey }; } export const selectCommandPaletteClientSettings = (settings: ClientSettings) => ({ diff --git a/apps/web/src/components/ComposerContextPermissions.tsx b/apps/web/src/components/ComposerContextPermissions.tsx index 8ee7766edc565..b4ac2a2eb6983 100644 --- a/apps/web/src/components/ComposerContextPermissions.tsx +++ b/apps/web/src/components/ComposerContextPermissions.tsx @@ -26,6 +26,7 @@ export function ComposerContextPermissions(props: { size="xs" className="ms-auto min-w-0 shrink-0" aria-label={`Permissions: ${label}`} + aria-haspopup="menu" data-composer-context-control onClick={props.onOpen} /> diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index fcd4c9e4fed30..a536f28ccfb12 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -35,6 +35,10 @@ const contextChipVariants = cva( "doc-slides": "[--context-chip-accent:var(--context-chip-doc-slides)]", "doc-epub": "[--context-chip-accent:var(--context-chip-doc-epub)]", }, + shape: { + inline: "", + bubble: "w-64 gap-2 rounded-2xl bg-message px-3 py-2 text-sm text-message-foreground", + }, state: { unresolved: "data-[state=unresolved]:[--context-chip-accent:currentColor]", invalid: @@ -49,11 +53,12 @@ type ContextChipKind = NonNullable["kin interface ContextChipProps extends useRender.ComponentProps<"span"> { kind?: ContextChipKind; state?: "unresolved" | "invalid"; + shape?: "inline" | "bubble"; } -function ContextChip({ className, kind, state, render, ...props }: ContextChipProps) { +function ContextChip({ className, kind, state, shape, render, ...props }: ContextChipProps) { const defaultProps = { - className: cn(contextChipVariants({ kind, state }), className), + className: cn(contextChipVariants({ kind, state, shape }), className), "data-slot": "context-chip", "data-state": state, }; diff --git a/apps/web/src/components/DiffFilePathCopyButton.browser.test.tsx b/apps/web/src/components/DiffFilePathCopyButton.browser.test.tsx new file mode 100644 index 0000000000000..a2bec09ca960d --- /dev/null +++ b/apps/web/src/components/DiffFilePathCopyButton.browser.test.tsx @@ -0,0 +1,30 @@ +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; +import { AnchoredToastProvider } from "./ui/toast"; + +it("explains a denied clipboard write and lets the same path be copied again", async () => { + await using screen = await render( + + + , + ); + using write = vi + .spyOn(navigator.clipboard, "writeText") + .mockRejectedValueOnce(new DOMException("Clipboard access denied", "NotAllowedError")) + .mockResolvedValue(undefined); + using _console = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const copy = screen.getByRole("button", { name: "Copy file path" }); + await copy.click(); + + await expect.element(page.getByText("Failed to copy text to the clipboard.")).toBeVisible(); + await page.getByRole("button", { name: "Dismiss notification" }).click(); + await copy.click(); + + await expect.element(page.getByText("Copied!", { exact: true })).toBeVisible(); + expect(write).toHaveBeenCalledTimes(2); + expect(write).toHaveBeenLastCalledWith("apps/web/src/index.ts"); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 89ae441367795..5c202fccf5abc 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -94,6 +94,36 @@ describe("when: on default ref without open PR", () => { }); }); +describe("when: local changes are behind upstream", () => { + it("keeps the commit action available without pushing, with or without an open PR", () => { + const behind = status({ hasWorkingTreeChanges: true, behindCount: 2 }); + const commit = { + label: "Commit", + disabled: false, + kind: "run_action", + action: "commit", + } as const; + assert.deepEqual(resolveQuickAction(behind, false), commit); + assert.deepEqual( + resolveQuickAction( + { + ...behind, + pr: { + number: 13, + title: "Open PR", + url: "https://example.com/pr/13", + baseRef: "main", + headRef: "feature/test", + state: "open", + }, + }, + false, + ), + commit, + ); + }); +}); + describe("when: ref has no upstream configured", () => { it("resolveQuickAction runs push and create PR when no upstream and commits are ahead", () => { const quick = resolveQuickAction( diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 65123285a78fc..98a55c3577e01 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -212,7 +212,7 @@ export function resolveQuickAction( } if (hasChanges) { - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { + if (isBehind || (!gitStatus.hasUpstream && !hasPrimaryRemote)) { return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; } if (hasOpenPr || isDefaultRef) { diff --git a/apps/web/src/components/GitActionsControl.permissions.test.ts b/apps/web/src/components/GitActionsControl.permissions.test.ts index c71c63e468e2d..5bc3839ca8e89 100644 --- a/apps/web/src/components/GitActionsControl.permissions.test.ts +++ b/apps/web/src/components/GitActionsControl.permissions.test.ts @@ -43,6 +43,8 @@ vi.mock("~/state/entities", () => ({ useThreadProjection: (ref: unknown) => ref === null || state.detail === null ? null : { projection: { thread: state.detail } }, useQuietThreadShell: () => state.shell, + useQuietThreadProjection: (ref: unknown) => + ref === null || state.detail === null ? null : { projection: { thread: state.detail } }, })); vi.mock("~/state/session", () => ({ useEnvironmentScope: (environmentId: unknown, scope: string) => diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d42eb5276f1fd..a5229dbf901ab 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -89,7 +89,7 @@ import { useVcsInitAction, useVcsPullAction, } from "../state/sourceControlActions"; -import { useThreadProjection, useQuietThreadShell } from "~/state/entities"; +import { useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; @@ -959,7 +959,7 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); - const activeServerThreadProjection = useThreadProjection( + const activeServerThreadProjection = useQuietThreadProjection( activeDraftThread !== null && activeServerThreadShell === null ? null : activeThreadRef, ); const activeServerThread = diff --git a/apps/web/src/components/GitHubStarPrompt.test.ts b/apps/web/src/components/GitHubStarPrompt.test.ts new file mode 100644 index 0000000000000..34f763646d6dd --- /dev/null +++ b/apps/web/src/components/GitHubStarPrompt.test.ts @@ -0,0 +1,91 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { toastManager } from "./ui/toast"; + +const { add, starred } = vi.hoisted(() => ({ + add: vi.fn(), + starred: { value: false, duringCheck: () => {} }, +})); +vi.mock("./ui/toast", () => ({ toastManager: { add, close: vi.fn() } })); +vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("../state/sourceControl", () => ({ sourceControlEnvironment: { checkStarred: {} } })); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + runAtomCommand: async () => { + starred.duringCheck(); + return { _tag: "Success", value: starred.value }; + }, +})); + +const environmentId = EnvironmentId.make("environment-1"); + +function setup({ sent = 0, writable = true, alreadyStarred = false } = {}) { + add.mockReset(); + starred.value = alreadyStarred; + starred.duringCheck = () => {}; + const store = new Map([["t3code:github-star-prompt:sent", String(sent)]]); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + if (!writable) throw new Error("QuotaExceededError"); + store.set(key, value); + }, + }, + }); + return store; +} + +// A fresh module import stands in for an app reload; storage survives it. +async function startSession() { + vi.resetModules(); + const session = await import("./GitHubStarPrompt"); + return { send: () => session.recordPromptSent(environmentId) }; +} + +describe("recordPromptSent", () => { + it("asks after five prompts, once per session, and never again once closed", async () => { + setup(); + let session = await startSession(); + for (let sent = 0; sent < 5; sent++) await session.send(); + expect(add).not.toHaveBeenCalled(); + + await session.send(); + await session.send(); + expect(add).toHaveBeenCalledOnce(); + + session = await startSession(); + await session.send(); + expect(add).toHaveBeenCalledTimes(2); + + add.mock.lastCall?.[0].onClose?.(); + session = await startSession(); + for (let sent = 0; sent < 10; sent++) await session.send(); + expect(add).toHaveBeenCalledTimes(2); + vi.unstubAllGlobals(); + }); + + it("never asks when a dismissal could not be saved", async () => { + setup({ sent: 5, writable: false }); + await (await startSession()).send(); + expect(add).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it("never asks once the environment's GitHub account starred the repo", async () => { + setup({ sent: 5, alreadyStarred: true }); + await (await startSession()).send(); + starred.value = false; + await (await startSession()).send(); + expect(add).not.toHaveBeenCalled(); + 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"); + await (await startSession()).send(); + expect(add).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); +}); diff --git a/apps/web/src/components/GitHubStarPrompt.tsx b/apps/web/src/components/GitHubStarPrompt.tsx new file mode 100644 index 0000000000000..d157d72960d93 --- /dev/null +++ b/apps/web/src/components/GitHubStarPrompt.tsx @@ -0,0 +1,94 @@ +import { type EnvironmentId, SourceControlProviderKind } from "@t3tools/contracts"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Schema from "effect/Schema"; +import { StarIcon } from "lucide-react"; + +import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; +import { readLocalApi } from "../localApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { toastManager } from "./ui/toast"; + +// Separate keys so a count written by another tab can never undo a dismissal. +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; + +let shownThisSession = false; + +function dismiss() { + try { + setLocalStorageItem(DISMISSED_KEY, true, Schema.Boolean); + } catch {} +} + +// Every close path, including both buttons, runs `onClose`, which saves the dismissal. +function showStarPrompt() { + const close = () => toastManager.close(id); + 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); + }, + }, + data: { + actionLayout: "stacked-end", + leadingIcon: , + secondaryActionProps: { children: "Dismiss", onClick: close }, + secondaryActionVariant: "ghost", + }, + }); +} + +// The server's GitHub account stands in for the user's; it may not be theirs on a shared server. +async function alreadyStarred(environmentId: EnvironmentId) { + const result = await runAtomCommand( + appAtomRegistry, + sourceControlEnvironment.checkStarred, + { + environmentId, + input: { + provider: SourceControlProviderKind.make("github"), + host: "github.com", + repository: REPOSITORY, + }, + }, + { label: "check GitHub star", 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. + */ +export async function recordPromptSent(environmentId: EnvironmentId): Promise { + if (shownThisSession) return; + try { + if (getLocalStorageItem(DISMISSED_KEY, Schema.Boolean)) return; + const sent = (getLocalStorageItem(SENT_KEY, Schema.Number) ?? 0) + 1; + setLocalStorageItem(SENT_KEY, sent, Schema.Number); + if (sent <= PROMPTS_BEFORE_ASKING) return; + } catch { + // Storage that cannot be read or written could not keep a dismissal, so never ask. + return; + } + shownThisSession = true; + if (await alreadyStarred(environmentId)) return dismiss(); + try { + // Another tab can dismiss while the check runs. + if (getLocalStorageItem(DISMISSED_KEY, Schema.Boolean)) return; + } catch { + return; + } + showStarPrompt(); +} diff --git a/apps/web/src/components/HostedEmptyState.tsx b/apps/web/src/components/HostedEmptyState.tsx index d7fbb02a28046..4041375955496 100644 --- a/apps/web/src/components/HostedEmptyState.tsx +++ b/apps/web/src/components/HostedEmptyState.tsx @@ -5,6 +5,7 @@ import { CheckIcon } from "lucide-react"; import { type ReactNode, useState } from "react"; import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "~/branding"; +import { T3_NPX_COMMAND } from "@t3tools/shared/releasePackage"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "../env"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; @@ -88,7 +89,7 @@ function T3ConnectSteps() { )} - + {isSignedIn ? (
- +
); } + function TerminalSidebarGroup({ terminalGroup, activeTerminalId, @@ -1345,6 +1369,7 @@ function TerminalSidebarGroup({ : terminalGroup.splitDirection === "vertical" ? SquareSplitVertical : SquareSplitHorizontal; + return (
{showHeader && ( @@ -1367,7 +1392,9 @@ function TerminalSidebarGroup({ {terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === activeTerminalId; const terminalLabel = terminalLabelById.get(terminalId) ?? "Terminal"; - const closeTerminalLabel = `Close ${terminalLabel}${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; + const closeTerminalLabel = `Close ${terminalLabel}${ + isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : "" + }`; return (
); } + export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, @@ -1473,6 +1501,7 @@ export default function ThreadTerminalDrawer({ const assignedTerminalIds = new Set(); const usedGroupIds = new Set(); const nextGroups: ThreadTerminalGroup[] = []; + const assignUniqueGroupId = (groupId: string): string => { if (!usedGroupIds.has(groupId)) { usedGroupIds.add(groupId); @@ -1486,6 +1515,7 @@ export default function ThreadTerminalDrawer({ usedGroupIds.add(uniqueGroupId); return uniqueGroupId; }; + for (const terminalGroup of terminalGroups) { const nextTerminalIds: string[] = []; const seenGroupTerminalIds = new Set(); @@ -1499,9 +1529,11 @@ export default function ThreadTerminalDrawer({ nextTerminalIds.push(terminalId); } if (nextTerminalIds.length === 0) continue; + for (const terminalId of nextTerminalIds) { assignedTerminalIds.add(terminalId); } + const baseGroupId = terminalGroup.id.trim().length > 0 ? terminalGroup.id.trim() @@ -1514,6 +1546,7 @@ export default function ThreadTerminalDrawer({ : {}), }); } + for (const terminalId of normalizedTerminalIds) { if (assignedTerminalIds.has(terminalId)) continue; nextGroups.push({ @@ -1521,6 +1554,7 @@ export default function ThreadTerminalDrawer({ terminalIds: [terminalId], }); } + const terminalOrderIndex = new Map( normalizedTerminalIds.map((id, index) => [id, index] as const), ); @@ -1529,6 +1563,7 @@ export default function ThreadTerminalDrawer({ Math.min(...ids.map((id) => terminalOrderIndex.get(id) ?? Number.POSITIVE_INFINITY)); return rank(left.terminalIds) - rank(right.terminalIds); }); + return nextGroups; })(); const groupIndexByTerminalId = (() => { @@ -1545,12 +1580,14 @@ export default function ThreadTerminalDrawer({ } return next; })(); + const resolvedActiveTerminalId = normalizedTerminalIds.length === 0 ? "" : normalizedTerminalIds.includes(activeTerminalId) ? activeTerminalId : (normalizedTerminalIds[0] ?? ""); + const groupIndexById = resolvedTerminalGroups.findIndex( (terminalGroup) => terminalGroup.id === activeTerminalGroupId, ); @@ -1558,6 +1595,7 @@ export default function ThreadTerminalDrawer({ groupIndexById >= 0 ? groupIndexById : (groupIndexByTerminalId.get(resolvedActiveTerminalId) ?? 0); + const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); @@ -1607,7 +1645,7 @@ export default function ThreadTerminalDrawer({ const label = terminalLabelById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { if (confirmed && readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) { - onCloseTerminal(terminalId); + onCloseTerminal(terminalId, threadRef); } }); }; @@ -1624,6 +1662,7 @@ export default function ThreadTerminalDrawer({ drawer: event.currentTarget.parentElement, }; }; + const handleResizePointerMove = (event: ReactPointerEvent) => { const resizeState = resizeStateRef.current; if (!resizeState || resizeState.pointerId !== event.pointerId) return; @@ -1638,6 +1677,7 @@ export default function ThreadTerminalDrawer({ resizeState.height = clampedHeight; resizeState.drawer?.style.setProperty(DRAG_HEIGHT_PROPERTY, `${clampedHeight}px`); }; + const handleResizePointerEnd = (event: ReactPointerEvent) => { const resizeState = resizeStateRef.current; if (!resizeState || resizeState.pointerId !== event.pointerId) return; @@ -1739,6 +1779,7 @@ export default function ThreadTerminalDrawer({ ); } + return (