diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index dc1d472d8..bb58318cf 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -2,26 +2,39 @@ name: Desktop macOS Preview on: pull_request: - types: [labeled, synchronize, reopened] + types: [labeled, unlabeled, synchronize, reopened, closed] permissions: contents: read - pull-requests: write +# Build events and cleanup events use separate groups: a push must cancel a +# stale in-flight build, but must never cancel a cleanup run mid-delete. The +# publish job re-checks PR state before uploading to cover the reverse race. concurrency: - group: desktop-macos-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true + group: desktop-macos-preview-${{ github.event.pull_request.number }}-${{ contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && 'cleanup' || 'build' }} + # Cleanup runs must complete (a close event right after an unlabel queues + # behind the running cleanup instead of canceling it mid-delete), and events + # that skip the build job, such as adding an unrelated label, must not + # cancel an in-flight build either. + cancel-in-progress: ${{ !contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') }} jobs: + # Builds run PR code, so this job keeps a read-only token. Publishing to the + # release happens in the publish job below, which never checks out PR code. build: name: Build macOS Apple Silicon preview if: >- + github.event.action != 'closed' && + github.event.action != 'unlabeled' && github.event.pull_request.head.repo.full_name == github.repository && contains(github.event.pull_request.labels.*.name, 'preview:mac') && (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') # GitHub-hosted Apple Silicon runner; the fork has no Blacksmith macOS pool. runs-on: macos-15 timeout-minutes: 30 + outputs: + dmg_name: ${{ steps.build.outputs.dmg_name }} + version: ${{ steps.version.outputs.version }} steps: - name: Checkout uses: actions/checkout@v6 @@ -94,8 +107,9 @@ jobs: fi printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT" - - id: upload - name: Upload macOS DMG + # archive: false uploads the file as its own artifact named after the + # file, so the publish job downloads by *.dmg pattern, not by name. + - name: Upload macOS DMG uses: actions/upload-artifact@v7 with: path: release/*.dmg @@ -104,13 +118,112 @@ jobs: overwrite: true retention-days: 7 + # Release assets download without a GitHub account, unlike workflow + # artifacts. All preview DMGs live on one rolling prerelease tagged + # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a + # build never notifies release watchers. This job holds the write token and + # only handles the artifact the build job produced; it never runs PR code. + publish: + name: Publish anonymous download + needs: build + runs-on: halifax-bl4ckbl1zz-t3code-db2e22d1 + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - name: Download macOS DMG + uses: actions/download-artifact@v8 + with: + pattern: "*.dmg" + merge-multiple: true + path: release + + - id: upload + name: Upload DMG to the rolling preview release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # True while the PR is open and still carries the preview label. + preview_eligible() { + [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,labels \ + --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]] + } + + # The build ran for many minutes. If the PR closed or lost the label + # meanwhile, cleanup already ran in its own concurrency group, so + # publishing now would resurrect a deleted download. + if ! preview_eligible; then + echo "PR closed or preview label removed while building. Skipping publish." + exit 0 + fi + + dmg_path="$(find release -type f -name '*.dmg' -print -quit)" + if [[ -z "$dmg_path" ]]; then + echo "No DMG found in the downloaded artifact." >&2 + exit 1 + fi + + # The filename comes out of the build, which runs PR code. Requiring + # this PR's marker keeps a build from clobbering or deleting another + # PR's asset, since those names carry a different -pr.N. marker. + if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then + echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 + exit 1 + fi + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + # "|| true" tolerates a concurrent publish job creating the + # release between the check and the create. + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$DEFAULT_BRANCH" \ + --prerelease \ + --title "Desktop preview builds" \ + --notes "Rolling unsigned desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \ + || true + fi + + # Keep one DMG per PR: drop this PR's older builds first. The + # trailing dot keeps -pr.12. from matching -pr.123. builds. + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber + + # Re-check after uploading. A cleanup run that started during the + # upload listed assets before ours existed, so it cannot delete it. + # Whichever writer acts last sees the final PR state; if the preview + # became ineligible, delete what we just uploaded. + if ! preview_eligible; then + gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset was already removed by a concurrent run." + echo "PR closed or preview label removed during upload. Removed the download." + exit 0 + fi + + echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" + - name: Comment download link + if: steps.upload.outputs.download_url != '' uses: actions/github-script@v8 env: - ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} - DMG_NAME: ${{ steps.build.outputs.dmg_name }} + DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} + DMG_NAME: ${{ needs.build.outputs.dmg_name }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PREVIEW_VERSION: ${{ steps.version.outputs.version }} + PREVIEW_VERSION: ${{ needs.build.outputs.version }} with: script: | const { data: pullRequest } = await github.rest.pulls.get({ @@ -118,7 +231,11 @@ jobs: repo: context.repo.repo, pull_number: context.payload.pull_request.number, }); - if (pullRequest.head.sha !== process.env.HEAD_SHA) { + if ( + pullRequest.head.sha !== process.env.HEAD_SHA || + pullRequest.state !== "open" || + !pullRequest.labels.some((label) => label.name === "preview:mac") + ) { core.info("Skipping the outdated macOS preview comment."); return; } @@ -128,7 +245,7 @@ jobs: marker, "### macOS preview", "", - `[Download Apple Silicon DMG](${process.env.ARTIFACT_URL})`, + `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, "", `Version: ${process.env.PREVIEW_VERSION}`, `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, @@ -138,10 +255,10 @@ jobs: `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`, "```", "", - "The download requires GitHub access and expires after 7 days.", + "No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed.", ].join("\n"); - const { data: comments } = await github.rest.issues.listComments({ + const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, @@ -164,3 +281,82 @@ jobs: body, }); } + + # The way out: closing the PR or removing the label deletes its DMG from the + # rolling release and updates the PR comment to say so. + cleanup: + name: Remove preview download + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + ((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:mac')) || + (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) + runs-on: halifax-bl4ckbl1zz-t3code-db2e22d1 + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - id: delete + name: Delete this PR's preview assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # A stale cleanup must not delete a download that became valid + # again. If the PR is open and labeled once more, the next publish + # owns this PR's assets and replaces them itself. + if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,labels \ + --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then + echo "PR is open and labeled again. Skipping cleanup." + echo "removed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "removed=true" >> "$GITHUB_OUTPUT" + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "No preview release exists. Nothing to clean up." + exit 0 + fi + + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + - name: Mark the preview comment as removed + if: steps.delete.outputs.removed == 'true' + uses: actions/github-script@v8 + with: + script: | + const marker = ""; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + if (!existing) { + return; + } + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: [ + marker, + "### macOS preview", + "", + "The preview download was removed because this PR closed or the preview label was removed.", + ].join("\n"), + }); diff --git a/PATCH.md b/PATCH.md index cf02b5edb..b36b3f865 100644 --- a/PATCH.md +++ b/PATCH.md @@ -71,7 +71,14 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera - Keeps upstream's `EnvironmentProviderSettings` inline in `apps/web/src/components/settings/SettingsPanels.tsx`; the fork carries no `ProviderSettingsPanel.tsx`. Upstream changes to that file resolve to the fork: port the behavior - into `SettingsPanels.tsx` instead of restoring the module. + into `SettingsPanels.tsx` instead of restoring the module. Upstream's "split provider settings + into list and editor" (`e2d4d12a81`) is therefore not carried: it is a master-detail redesign of + that panel plus a `mode: "list" | "editor"` rewrite of `ProviderInstanceCard`, written against + the module the fork retired, and the fork's stacked collapsible list already reaches every field. + Carried out of it: `providerStatus.ts`'s neutral disabled dot (amber read as a warning on a + provider the user turned off) and `ProviderEnvironmentSection`'s draft re-sync, which adopts an + environment the server changed underneath a mounted card while ignoring echoes of what the + editor itself just published. `providerSettingsTabs.ts` has no fork counterpart. - Runs the shared settle rules (`packages/client-runtime/src/state/threadSettled.ts`) against the fork's orchestration V2 thread shell. Upstream types them on `OrchestrationThreadShell` and reads `latestTurn`; the fork uses structural shapes (`QueuedThreadShell`/`SettlementThreadShell`, @@ -153,6 +160,29 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera `isNonRetryableProviderTurnControlFailure` succeeds the outbox item on "not active" races, `ProviderSessionManager.detach` tolerates a failing `interruptTurn`, and `ProviderRuntimeRecoveryService.recover` settles runs orphaned by a dead runtime. +- Ports the reachable half of upstream's "improve Grok skills, plans, usage, and turn reliability" + (`ead4ce52a1`) onto orchestration V2. Everything that lives in shared modules is carried as-is: + Grok skill discovery (`provider/Drivers/GrokSkills.ts`, wired through `checkGrokProviderStatus`, + which now takes the server `cwd`), the reasoning-effort model descriptors in `GrokProvider.ts`, + the `_meta`-carrying `session/set_model` path in `GrokAcpSupport.ts`, per-runtime-mode Grok spawn + arguments (`grokAcpSpawnArgs`), the plan-mode helpers and rate-limit/error prompt settlement in + `XAiAcpExtension.ts`, and the whole usage-transcript half. Two V2 seams were added to reach it: + `AcpAdapterV2RuntimeInput` now carries `runtimeMode` so Grok spawns with the thread's permission + mode, and the flavor gained `applySessionModel` / `modelOptionIdsHandledBySessionModel` so Grok's + `reasoningEffort` rides `session/set_model` `_meta` instead of `session/set_config_option` — the + fork's `configureSession` would otherwise reject the option the session never advertises. Not + carried: the commit's rewrite of the retired V1 `provider/Layers/GrokAdapter.ts` — its turn/active-tool + inactivity watchdog, the `enter_plan_mode`/`exit_plan_mode` proposed-plan gate, and + `selectGrokPermissionOptionId`'s allow*once fallback for "Always allow this session". The plan and + rate-limit helpers those used are present in `XAiAcpExtension.ts` but `GrokAdapterV2` registers no + `x.ai/exit_plan_mode` handler yet, so Grok plan mode still reaches the fork's clients as ordinary + tool calls. Upstream's mock-agent hooks for the watchdog (`T3_ACP_EMIT*\*\_THEN_HANG`) are carried so + a later port has its harness. +- Does not carry upstream's "recover stale Codex approval callbacks" (`230c5d4a5c`). It widens the + V1 `ProviderCommandReactor`'s "unknown pending approval request" matcher, and the fork deleted that + reactor with the V1 thread runtime. Orchestration V2 answers approvals through + `RuntimeRequestService` against `CodexAdapterV2`'s in-process deferred map rather than the V1 + `ProviderService` callback registry, so the stale-callback shape has no fork counterpart to match. - Does not carry upstream's V1 subagent-model buffering (`6a2608292d`) or its routine-event projection skip (`c034f51bb7`). Both edit modules the fork deleted with the V1 thread runtime (`provider/Layers/ClaudeAdapter.ts`, the thread half of @@ -230,6 +260,11 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera modules they cover, and `apps/server/src/ws.test.ts` holds the `server.getConfig` discovery-timeout cases. Upstream additions to `server.test.ts` need rehoming rather than merging, and its `Layer.mock(ExternalLauncher)` fixtures have no fork counterpart to update. +- Has no `apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts` either; it went with the + V1 thread projectors. The pipeline itself survives for the project aggregate, so upstream fixes to + it still apply and their tests get rehomed: upstream's "replay all un-applied events during + projection bootstrap" (`a6797b3b97`) is carried, and its backlog test lives in the fork's + `ProjectionPipelineBootstrap.test.ts` over `project.created` events alone. - Fetches the provider model manifest (upstream `badae6a5cc`) from **upstream's** `main` (`pingdotgg/t3code`), not the fork's. The fork adds no models of its own, so pointing at the source of the catalog keeps legacy classification current without a fork release. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c88a69239..d37ebc32f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.34", + "version": "0.0.35", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e6c0781a3..dff9996c9 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -46,6 +46,9 @@ const IOS_APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/; const IOS_DEPLOYMENT_TARGET = "18.0"; const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; +// Universal exports already contain their own rounded-square silhouette. Using one as an adaptive +// foreground makes Android draw an icon shape inside the launcher's mask. +const androidAdaptiveForeground = "./assets/android-icon-foreground.png"; if ( isIosPersonalTeamBuild && @@ -91,7 +94,7 @@ const DEVELOPMENT_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), - androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng), + androidAdaptiveForeground, androidAdaptiveBackgroundColor: "#00639B", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", @@ -102,7 +105,7 @@ const PREVIEW_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), - androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng), + androidAdaptiveForeground, androidAdaptiveBackgroundColor: "#111533", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", @@ -113,7 +116,7 @@ const RELEASE_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), - androidAdaptiveForeground: "./assets/android-icon-mark.png", + androidAdaptiveForeground, androidAdaptiveBackgroundColor: "#000000", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png new file mode 100644 index 000000000..4f4374c7e Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ diff --git a/apps/mobile/assets/android-icon-foreground.svg b/apps/mobile/assets/android-icon-foreground.svg new file mode 100644 index 000000000..8d974992a --- /dev/null +++ b/apps/mobile/assets/android-icon-foreground.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 9a9ec5f22..2576ac21f 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -5,21 +5,23 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe * Series and table order. The chart stacks providers from the bottom in this * order, so it also fixes which band sits on top of the bars. */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", + grok: "Grok Build", }; /** - * Claude's brand orange holds in both themes; Codex is neutral and must flip - * with the theme or its bars vanish against the matching background. + * Claude's brand orange holds in both themes; Codex and Grok are neutrals and + * must flip with the theme or their bars vanish against the matching background. */ export function useProviderColors(): Record { const { themeAppearance: scheme } = useAppearancePreferences(); return { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + grok: scheme === "dark" ? "#a1a1aa" : "#52525b", }; } diff --git a/apps/mobile/src/lib/mobileBranding.test.ts b/apps/mobile/src/lib/mobileBranding.test.ts deleted file mode 100644 index 48a84b3f9..000000000 --- a/apps/mobile/src/lib/mobileBranding.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveMobileStageLabel } from "./mobileBranding"; - -describe("resolveMobileStageLabel", () => { - it.each([ - ["development", "Dev"], - ["preview", "Nightly"], - ["production", "Alpha"], - [undefined, "Alpha"], - ])("maps %s builds to %s", (appVariant, expected) => { - expect(resolveMobileStageLabel(appVariant)).toBe(expected); - }); -}); diff --git a/apps/server/package.json b/apps/server/package.json index fbbadde3b..db0cc68f2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.34", + "version": "0.0.35", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index f15dc0197..cf2f2bd57 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -28,7 +28,15 @@ const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitElicitation = process.env.T3_ACP_EMIT_ELICITATION === "1"; const emitUrlElicitation = process.env.T3_ACP_EMIT_URL_ELICITATION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; +const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; +const emitXAiPlanMdWrite = process.env.T3_ACP_EMIT_XAI_PLAN_MD_WRITE === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; +const emitXAiRateLimitThenHang = process.env.T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG === "1"; +const emitXAiAskUserQuestionThenHang = + process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1"; +const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; +const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; +const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangAfterPermission = process.env.T3_ACP_HANG_AFTER_PERMISSION === "1"; @@ -65,6 +73,8 @@ const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; +const initialGrokReasoningEffort = + process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1"; const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim(); @@ -74,6 +84,11 @@ const permissionOptionIds = { allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once", }; +const omitAllowAlways = process.env.T3_ACP_OMIT_ALLOW_ALWAYS === "1"; +const permissionRequestCount = Math.max( + 1, + Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1, +); const sessionId = "mock-session-1"; let currentModeId = "ask"; @@ -285,7 +300,13 @@ function modeState(): AcpSchema.SessionModeState { } const grokAcpModels: ReadonlyArray = [ - { modelId: "grok-build", name: "Grok Build" }, + { + modelId: "grok-build", + name: "Grok Build", + ...(initialGrokReasoningEffort + ? { _meta: { reasoningEffort: initialGrokReasoningEffort } } + : {}), + }, { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, ]; @@ -727,6 +748,68 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitXAiRateLimitThenHang) { + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: promptIdFromRequestMeta(request) ?? "mock-xai-rate-limit-prompt-1", + stopReason: "rate_limit", + agentResult: null, + }); + return yield* Effect.never; + } + + if (emitContentThenHang) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "partial before stall" }, + }, + }); + return yield* Effect.never; + } + + if (emitPlanThenHang) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Wait for more ACP progress", + priority: "high", + status: "in_progress", + }, + ], + }, + }); + return yield* Effect.never; + } + + if (emitActiveToolThenHang) { + const toolCallId = "tool-call-long-running-1"; + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Long-running tool", + kind: "execute", + status: "pending", + rawInput: { command: ["long-running-tool"] }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + }, + }); + return yield* Effect.never; + } + if ( emitRunningCommandThenHang || (emitRunningCommandThenHangOnFirstPrompt && promptCount === 1) @@ -999,37 +1082,58 @@ const program = Effect.gen(function* () { }, }); - const permission = yield* agent.client.requestPermission({ - sessionId: requestedSessionId, - toolCall: { - toolCallId, - title: "`cat server/package.json`", - kind: "execute", - status: "pending", - content: [ - { - type: "content", - content: { - type: "text", - text: "Not in allowlist: cat server/package.json", + const permissionOptions: Array = [ + { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, + ...(omitAllowAlways + ? [] + : [ + { + optionId: permissionOptionIds.allowAlways, + name: "Allow always", + kind: "allow_always" as const, }, + ]), + { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, + ]; + + let cancelled = cancelledSessions.delete(requestedSessionId); + for (let index = 0; index < permissionRequestCount; index++) { + const command = + index > 0 + ? (process.env.T3_ACP_SECOND_PERMISSION_COMMAND ?? "cat server/package.json") + : "cat server/package.json"; + const permission = yield* agent.client.requestPermission({ + sessionId: requestedSessionId, + toolCall: { + toolCallId: index === 0 ? toolCallId : `${toolCallId}-${index + 1}`, + title: process.env.T3_ACP_PERMISSION_TITLE ?? `\`${command}\``, + kind: "execute", + status: "pending", + rawInput: { + variant: "Bash", + command, + description: index === 0 ? "Read package metadata" : "Read it again", }, - ], - }, - options: [ - { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, - { - optionId: permissionOptionIds.allowAlways, - name: "Allow always", - kind: "allow_always", + content: [ + { + type: "content", + content: { + type: "text", + text: `Not in allowlist: ${command}`, + }, + }, + ], }, - { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, - ], - }); - - const cancelled = - cancelledSessions.delete(requestedSessionId) || - permission.outcome.outcome === "cancelled"; + options: permissionOptions, + }); + cancelled = + cancelled || + cancelledSessions.delete(requestedSessionId) || + permission.outcome.outcome === "cancelled"; + if (cancelled) { + break; + } + } if (hangAfterPermission) { return yield* Effect.never; @@ -1273,7 +1377,7 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } - if (emitXAiAskUserQuestion) { + if (emitXAiAskUserQuestion || emitXAiAskUserQuestionThenHang) { const result = yield* agent.client.extRequest("_x.ai/ask_user_question", { method: "x.ai/ask_user_question", params: { @@ -1307,6 +1411,84 @@ const program = Effect.gen(function* () { throw new Error("Expected accepted _x.ai/ask_user_question response answers."); } + if (emitXAiAskUserQuestionThenHang) { + return yield* Effect.never; + } + + return { stopReason: "end_turn" }; + } + + if (emitXAiPlanMdWrite) { + // Match Grok's real session layout so isGrokPlanMarkdownPath accepts it. + const planRoot = process.env.T3_ACP_PLAN_ROOT ?? "/tmp/mock-home/.grok"; + const planPath = `${planRoot}/sessions/${requestedSessionId}/plan.md`; + const planBody = "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it\n"; + // enter_plan_mode first so the adapter arms planModeActive. + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "enter-plan-mode-1", + title: "enter_plan_mode", + kind: "other", + status: "completed", + rawInput: { variant: "EnterPlanMode" }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "plan-md-write-1", + title: "write", + kind: "edit", + status: "pending", + rawInput: { file_path: planPath, content: planBody }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "plan-md-write-1", + kind: "edit", + status: "completed", + title: `Write \`${planPath}\``, + rawInput: { file_path: planPath, content: planBody }, + content: [ + { + type: "diff", + path: planPath, + oldText: "", + newText: planBody, + }, + ], + }, + }); + return { stopReason: "end_turn" }; + } + + if (emitXAiExitPlanMode) { + const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", { + method: "x.ai/exit_plan_mode", + params: { + sessionId: requestedSessionId, + toolCallId: "exit-plan-mode-tool-call-1", + planContent: "# Exit plan\n\n- Step one\n- Step two\n", + }, + }); + if (typeof result !== "object" || result === null || !("outcome" in result)) { + throw new Error("Expected _x.ai/exit_plan_mode response outcome."); + } + if ( + result.outcome !== "abandoned" && + result.outcome !== "approved" && + result.outcome !== "request_changes" + ) { + throw new Error( + `Expected exit_plan_mode outcome abandoned|approved|request_changes, got ${String(result.outcome)}`, + ); + } return { stopReason: "end_turn" }; } diff --git a/apps/server/scripts/cliErrors.test.ts b/apps/server/scripts/cliErrors.test.ts deleted file mode 100644 index 91754290d..000000000 --- a/apps/server/scripts/cliErrors.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { ServerCliBuildAssetMissingError, ServerCliCommandExitError } from "./cliErrors.ts"; - -describe("server CLI errors", () => { - it("preserves failed command context without changing its message", () => { - const error = new ServerCliCommandExitError({ - command: "vp", - args: ["pm", "publish"], - cwd: "/repo", - exitCode: 17, - }); - - assert.equal(error._tag, "ServerCliCommandExitError"); - assert.equal(error.command, "vp"); - assert.deepEqual(error.args, ["pm", "publish"]); - assert.equal(error.cwd, "/repo"); - assert.equal(error.exitCode, 17); - assert.equal(error.message, "Command exited with non-zero exit code (17)"); - }); - - it("preserves a representative missing asset path", () => { - const error = new ServerCliBuildAssetMissingError({ assetPath: "/repo/server.mjs" }); - - assert.equal(error.assetPath, "/repo/server.mjs"); - assert.equal( - error.message, - "Missing build asset: /repo/server.mjs. Run the build subcommand first.", - ); - }); -}); diff --git a/apps/server/src/checkpointing/Errors.test.ts b/apps/server/src/checkpointing/Errors.test.ts deleted file mode 100644 index 4c8b9c59c..000000000 --- a/apps/server/src/checkpointing/Errors.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; - -import { - CheckpointRefUnavailableError, - CheckpointTurnRangeUnavailableError, - CheckpointWorkspacePathMissingError, -} from "./Errors.ts"; - -const threadId = ThreadId.make("thread-1"); - -it("derives checkpoint messages from structured context", () => { - const range = new CheckpointTurnRangeUnavailableError({ - operation: "CheckpointDiffQuery.getTurnDiff", - threadId, - requestedTurnCount: 4, - availableTurnCount: 2, - }); - const checkpoint = new CheckpointRefUnavailableError({ - operation: "CheckpointDiffQuery.getTurnDiff", - threadId, - turnCount: 2, - checkpoint: "to", - }); - const workspace = new CheckpointWorkspacePathMissingError({ - operation: "CheckpointDiffQuery.getFullThreadDiff", - threadId, - }); - - expect(range.message).toBe( - "Checkpoint unavailable for thread thread-1 turn 4: Turn diff range exceeds current turn count: requested 4, current 2.", - ); - expect(checkpoint.message).toBe( - "Checkpoint unavailable for thread thread-1 turn 2: Checkpoint ref is unavailable for turn 2.", - ); - expect(workspace.message).toBe( - "Checkpoint invariant violation in CheckpointDiffQuery.getFullThreadDiff: Workspace path missing for thread 'thread-1' when computing full thread diff.", - ); -}); diff --git a/apps/server/src/observability/Attributes.test.ts b/apps/server/src/observability/Attributes.test.ts deleted file mode 100644 index d9ed2e127..000000000 --- a/apps/server/src/observability/Attributes.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { normalizeModelMetricLabel } from "./Attributes.ts"; - -describe("Attributes", () => { - it("groups GPT-family models under a shared metric label", () => { - assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt"); - assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt"); - assert.strictEqual(normalizeModelMetricLabel("claude-sonnet-4"), "claude"); - }); -}); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 81b7cadaf..9552de30e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -24,6 +24,7 @@ import { type ProviderRequestKind, type ProviderThreadId, type ProviderUserInputAnswers, + type RuntimeMode, type RuntimeRequestId, type ThreadId, } from "@t3tools/contracts"; @@ -97,6 +98,12 @@ export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; export interface AcpAdapterV2RuntimeInput { readonly cwd: string; + /** + * The thread's permission mode. ACP flavors that translate it into CLI spawn + * arguments (Grok) read it here; the runtime is respawned on a mode change + * because Grok reports `supportsRuntimeModeSwitchInSession: false`. + */ + readonly runtimeMode: RuntimeMode; readonly mcpServers: ReadonlyArray; readonly interruptPromptOnCancel?: boolean; readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; @@ -202,6 +209,22 @@ export interface AcpAdapterV2Flavor { Crypto.Crypto | Scope.Scope >; readonly resolveModelId?: (selection: ModelSelection) => string | undefined; + /** + * Optional flavor-owned replacement for the plain `session/set_model` switch. + * Grok reselects the current model to change reasoning effort, which rides + * `session/set_model` `_meta` rather than an ACP config option. + */ + readonly applySessionModel?: (input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly sessionSetupResult: AcpSessionRuntimeStartResult["sessionSetupResult"]; + readonly requestedModelId: string | undefined; + readonly modelSelection: ModelSelection; + }) => Effect.Effect; + /** + * Model-option ids `applySessionModel` consumes itself. They never reach + * `session/set_config_option`, so the session does not have to expose them. + */ + readonly modelOptionIdsHandledBySessionModel?: ReadonlyArray; readonly registerExtensions?: ( context: AcpAdapterV2ExtensionContext, ) => Effect.Effect; @@ -1690,6 +1713,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV const nativeLogging = options.nativeLogging?.(input.threadId); const makeRuntimeInput = (runtimeGeneration: number): AcpAdapterV2RuntimeInput => ({ cwd: input.runtimePolicy.cwd ?? process.cwd(), + runtimeMode: input.runtimePolicy.runtimeMode, mcpServers: acpMcpServers(input.threadId), interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false, clientCapabilities: { @@ -4180,27 +4204,38 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV runtimePolicy: ProviderAdapterV2RuntimePolicy, ) { const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; - if ( - requestedModel.length > 0 && - requestedModel !== "auto" && - requestedModel !== "default" - ) { + const namedModel = + requestedModel.length > 0 && requestedModel !== "auto" && requestedModel !== "default" + ? requestedModel + : undefined; + if (flavor.applySessionModel !== undefined) { + yield* flavor.applySessionModel({ + runtime, + sessionSetupResult: startResult.sessionSetupResult, + requestedModelId: namedModel, + modelSelection, + }); + } else if (namedModel !== undefined) { const currentModel = startResult.sessionSetupResult.models?.currentModelId; - if (currentModel !== requestedModel) { + if (currentModel !== namedModel) { if (startResult.sessionSetupResult.models != null) { - yield* runtime.setSessionModel(requestedModel); + yield* runtime.setSessionModel(namedModel); } else if ( startResult.sessionSetupResult.configOptions?.some( (option) => option.category === "model", ) === true ) { - yield* runtime.setModel(requestedModel); + yield* runtime.setModel(namedModel); } } } + const sessionModelOptionIds = new Set(flavor.modelOptionIdsHandledBySessionModel ?? []); + const configSelections = (modelSelection.options ?? []).filter( + (selection) => !sessionModelOptionIds.has(selection.id), + ); const configOptions = yield* runtime.getConfigOptions; const availableConfigIds = new Set(configOptions.map((option) => option.id)); - const unsupportedConfigIds = (modelSelection.options ?? []) + const unsupportedConfigIds = configSelections .map((selection) => selection.id) .filter((id) => !availableConfigIds.has(id)); if (unsupportedConfigIds.length > 0) { @@ -4209,7 +4244,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV detail: `ACP session ${startResult.sessionId} does not expose requested configuration option(s): ${unsupportedConfigIds.join(", ")}`, }); } - for (const selection of modelSelection.options ?? []) { + for (const selection of configSelections) { yield* runtime.setConfigOption(selection.id, selection.value); } const modeState = yield* runtime.getModeState; diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index 163bb3258..3a65bcf8b 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -1,4 +1,5 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { defaultInstanceIdForDriver, GrokSettings, @@ -17,6 +18,9 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import { ServerConfig } from "../../config.ts"; import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; import { + applyGrokAcpModelSelection, + currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../../provider/acp/GrokAcpSupport.ts"; @@ -55,6 +59,9 @@ import { type AcpAdapterV2RuntimeInput, } from "./AcpAdapterV2.ts"; +/** Grok's reasoning-effort model option, advertised by `GrokProvider`. */ +const GROK_REASONING_EFFORT_OPTION_ID = "reasoningEffort"; + export const GROK_PROVIDER = ProviderDriverKind.make("grok"); export const GROK_DRIVER_KIND = GROK_PROVIDER; export const GROK_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(GROK_DRIVER_KIND); @@ -179,6 +186,23 @@ export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdap // still accepts image content blocks (verified with real screenshots). supportsImagePrompts: true, resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model), + // Grok carries reasoning effort on `session/set_model` `_meta`, not as an + // ACP config option, and reselects the current model when only the effort + // changed. Keep the id out of the config-option path so the session is not + // asked for an option it never advertises. + modelOptionIdsHandledBySessionModel: [GROK_REASONING_EFFORT_OPTION_ID], + applySessionModel: ({ runtime, sessionSetupResult, requestedModelId, modelSelection }) => + applyGrokAcpModelSelection({ + runtime, + currentModelId: currentGrokModelIdFromSessionSetup(sessionSetupResult), + currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup(sessionSetupResult), + requestedModelId, + requestedReasoningEffort: getModelSelectionStringOptionValue( + modelSelection, + GROK_REASONING_EFFORT_OPTION_ID, + ), + mapError: (cause) => cause, + }).pipe(Effect.asVoid), makeRuntime: options.makeRuntime ?? ((input) => @@ -188,6 +212,7 @@ export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdap grokSettings: options.settings, environment: options.environment, childProcessSpawner: options.childProcessSpawner, + runtimeMode: input.runtimeMode, })), registerExtensions: registerGrokAcpExtensions, extractSubagentUpdate: extractXAiAcpSubagentUpdate, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 91b514380..dc6510bff 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -136,6 +136,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti Stream.runForEach( eventStore.readFromSequence( Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, + Number.MAX_SAFE_INTEGER, ), (event) => runProjectorForEvent(projector, event), ), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipelineBootstrap.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipelineBootstrap.test.ts new file mode 100644 index 000000000..47569c142 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionPipelineBootstrap.test.ts @@ -0,0 +1,88 @@ +import { CommandId, EventId, ProjectId } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ServerConfig } from "../../config.ts"; +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; +import { + ORCHESTRATION_PROJECTOR_NAMES, + OrchestrationProjectionPipelineLive, +} from "./ProjectionPipeline.ts"; + +const BaseTestLayer = OrchestrationProjectionPipelineLive.pipe( + Layer.provideMerge(OrchestrationEventStoreLive), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-projection-bootstrap-test-" }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { + it.effect("replays a bootstrap backlog larger than the event store default limit", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-bootstrap-backlog"); + + const sequenceRows = yield* sql<{ readonly maxSequence: number | null }>` + SELECT MAX(sequence) AS "maxSequence" FROM orchestration_events + `; + const sequenceBeforeBacklog = sequenceRows[0]?.maxSequence ?? 0; + const appendedEvents = yield* Effect.forEach( + Array.from({ length: 1_001 }, (_, index) => index), + (index) => { + const eventId = EventId.make(`evt-bootstrap-backlog-${index}`); + const commandId = CommandId.make(`cmd-bootstrap-backlog-${index}`); + return eventStore.append({ + type: "project.created", + eventId, + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId, + causationEventId: null, + correlationId: commandId, + metadata: {}, + payload: { + projectId, + title: `Bootstrap backlog ${index}`, + workspaceRoot: "/tmp/project-bootstrap-backlog", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + }, + ); + const lastSequence = appendedEvents[appendedEvents.length - 1]!.sequence; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${ORCHESTRATION_PROJECTOR_NAMES.projects}, ${sequenceBeforeBacklog}, ${now}) + ON CONFLICT (projector) + DO UPDATE SET + last_applied_sequence = excluded.last_applied_sequence, + updated_at = excluded.updated_at + `; + + yield* projectionPipeline.bootstrap; + + const stateRows = yield* sql<{ readonly lastAppliedSequence: number }>` + SELECT last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.projects} + `; + assert.deepEqual(stateRows, [{ lastAppliedSequence: lastSequence }]); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 0a15de171..d152872dc 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -90,6 +90,7 @@ export const GrokDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; + const { cwd } = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -127,7 +128,7 @@ export const GrokDriver: ProviderDriver = { ); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts new file mode 100644 index 000000000..3536a37a9 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; + +const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); + +describe("parseGrokInspectSkills", () => { + it("maps inspect entries onto provider skills, sorted by name", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "writing-docs", + description: "Write user docs.", + source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, + userInvocable: true, + }, + { + name: "deploy", + description: "Deploy the app.", + source: { + type: "plugin", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + }, + userInvocable: true, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "deploy", + description: "Deploy the app.", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + scope: "plugin", + enabled: true, + }, + { + name: "writing-docs", + description: "Write user docs.", + path: "/home/dev/.grok/skills/writing-docs/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }); + + it("disables skills the CLI marks as not user-invocable", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "internal-helper", + source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" }, + userInvocable: false, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "internal-helper", + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + scope: "bundled", + enabled: false, + }, + ]); + }); + + it("skips entries without a name or a filesystem path", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, + { name: "no-path", source: { type: "user" } }, + { name: "no-source" }, + "not-an-object", + { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + ]), + ); + + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }); + + it("returns an empty list for malformed or unexpected output", () => { + expect(parseGrokInspectSkills("not json")).toEqual([]); + expect(parseGrokInspectSkills("null")).toEqual([]); + expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]); + expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]); + }); +}); + +describe("discoverGrokSkills", () => { + it.effect("spawns the inspect probe in the configured cwd", () => { + const spawnCwds: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText( + Stream.make( + inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]), + ), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + + return Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + + expect(spawnCwds).toEqual(["/workspaces/demo"]); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts new file mode 100644 index 000000000..a7c2c2ae3 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -0,0 +1,119 @@ +/** + * GrokSkills — skill discovery for the `$` picker via `grok inspect --json`. + * + * Unlike Claude Code, the Grok CLI reports its full skill catalog itself: + * `grok inspect --json` returns `skills[]` with `name`, `description`, + * `source.type` (`user` / `project` / `bundled` / `plugin`), `source.path` + * (the absolute `SKILL.md` path), and `userInvocable`. Asking the CLI beats + * scanning the filesystem because the catalog honors Grok's own skill config + * (ignore lists, disabled skills) and includes plugin skills, which live + * three levels deep under `~/.grok/installed-plugins/` where a flat scan + * cannot see them. This mirrors how the Codex app-server reports skills over + * `skills/list`. Discovery is best-effort: an older CLI without `inspect`, + * a timeout, or malformed output yields an empty list, never a degraded + * provider snapshot. + * + * @module provider/Drivers/GrokSkills + */ +import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; + +const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; + +/** + * Map `grok inspect --json` output onto provider skills. Entries without a + * name or a filesystem path are skipped; `userInvocable: false` skills are + * kept but disabled so pickers that filter on `enabled` hide them. + */ +export function parseGrokInspectSkills(stdout: string): ReadonlyArray { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return []; + } + if (typeof parsed !== "object" || parsed === null) { + return []; + } + const entries = (parsed as Record).skills; + if (!Array.isArray(entries)) { + return []; + } + + const skillsByName = new Map(); + for (const entry of entries) { + if (typeof entry !== "object" || entry === null) { + continue; + } + const record = entry as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const source = + typeof record.source === "object" && record.source !== null + ? (record.source as Record) + : undefined; + const path = typeof source?.path === "string" ? source.path.trim() : ""; + if (!name || !path) { + continue; + } + const scope = typeof source?.type === "string" ? source.type.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + skillsByName.set(name, { + name, + path, + enabled: record.userInvocable !== false, + ...(scope ? { scope } : {}), + ...(description ? { description } : {}), + }); + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +/** + * Run `grok inspect --json` and map the reported catalog onto provider + * skills. Never fails: any spawn error, non-zero exit, or timeout resolves + * to an empty list. + */ +export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( + grokSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +): Effect.fn.Return< + ReadonlyArray, + never, + ChildProcessSpawner.ChildProcessSpawner +> { + const command = grokSettings.binaryPath || "grok"; + const inspectResult = yield* Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(cwd ? { cwd } : {}), + env: environment, + shell: spawnCommand.shell, + }), + ); + }).pipe(Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(inspectResult) || Option.isNone(inspectResult.success)) { + yield* Effect.logDebug("Grok skill discovery failed; continuing without skills."); + return []; + } + const output = inspectResult.success.value; + if (output.code !== 0) { + yield* Effect.logDebug("Grok skill discovery exited non-zero; continuing without skills.", { + exitCode: output.code, + }); + return []; + } + return parseGrokInspectSkills(output.stdout); +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 311cdeef6..776a8e00e 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,6 +62,7 @@ const REASONING_EFFORT_LABELS: Readonly> = { }; const DEFAULT_SERVICE_TIER_ID = "default"; + function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } @@ -85,13 +86,18 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun return "ChatGPT Pro 5x Subscription"; case "team": return "ChatGPT Team Subscription"; + case "self_serve_business_prolite": case "self_serve_business_usage_based": case "business": return "ChatGPT Business Subscription"; + case "ent26": + case "enterprise_cbp_automation": case "enterprise_cbp_usage_based": case "enterprise": return "ChatGPT Enterprise Subscription"; case "edu": + case "edu_plus": + case "edu_pro": return "ChatGPT Edu Subscription"; case "unknown": return "ChatGPT Subscription"; diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 1c9bf1f26..42d642a3e 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,10 +6,183 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { GrokSettings } from "@t3tools/contracts"; -import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; +import { + buildGrokModelCapabilities, + buildInitialGrokProviderSnapshot, + checkGrokProviderStatus, +} from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); +describe("buildGrokModelCapabilities", () => { + it("preserves ACP-provided reasoning labels and the active default", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + reasoningEfforts: [ + { value: "xhigh", label: "Extra High Effort", default: true }, + { value: "high", label: "High Effort", default: true }, + { value: "medium", label: "Medium Effort" }, + { value: "low", label: "Low Effort" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "xhigh", + options: [ + { id: "xhigh", label: "Extra High Effort", isDefault: true }, + { id: "high", label: "High Effort" }, + { id: "medium", label: "Medium Effort" }, + { id: "low", label: "Low Effort" }, + ], + }, + ]); + }); + + it("uses raw ACP values when option labels are omitted", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "xhigh", + options: [ + { id: "xhigh", label: "xhigh" }, + { id: "medium", label: "medium" }, + ], + }, + ]); + }); + + it("keeps ACP current effort separate from its collapsed advertised default", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "medium", + reasoningEfforts: [ + { value: "xhigh", label: "Extra High Effort", default: true }, + { value: "high", label: "High Effort", default: true }, + { value: "medium", label: "Medium Effort" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "medium", + options: [ + { id: "xhigh", label: "Extra High Effort", isDefault: true }, + { id: "high", label: "High Effort" }, + { id: "medium", label: "Medium Effort" }, + ], + }, + ]); + }); + + it("preserves ACP descriptions and falls back from invalid values to valid ids", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "high", + reasoningEfforts: [ + { + id: "high", + value: "not a token", + label: "High Effort", + description: "Higher implementation quality", + default: true, + }, + { id: "bad id", value: "also invalid", label: "Invalid" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [ + { + id: "high", + label: "High Effort", + description: "Higher implementation quality", + isDefault: true, + }, + ], + }, + ]); + }); + + it("accepts an advertised ACP menu when the support flag is omitted", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + reasoningEffort: "high", + reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], + }, + }); + + expect(capabilities.optionDescriptors).toHaveLength(1); + }); + + it("honors an explicit ACP opt-out even when a menu is present", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: false, + reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([]); + }); + + it("does not synthesize a reasoning menu when ACP omits it", () => { + expect( + buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" }, + }).optionDescriptors, + ).toEqual([]); + }); + + it("keeps non-reasoning Grok models free of reasoning controls", () => { + expect( + buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, + ).toEqual([]); + }); +}); + describe("buildInitialGrokProviderSnapshot", () => { it.effect("returns a disabled snapshot when settings.enabled is false", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 5280a8b61..392e1e031 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -29,7 +29,12 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { + isValidGrokReasoningEffortToken, + makeGrokAcpRuntime, + resolveGrokAcpBaseModelId, +} from "../acp/GrokAcpSupport.ts"; +import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -98,6 +103,104 @@ function grokModelsFromSettings( return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { + readonly options: ReadonlyArray<{ + value: string; + label: string; + description?: string; + isDefault?: boolean; + }>; + readonly currentValue: string | undefined; +} { + const meta = model._meta; + if (!meta || meta.supportsReasoningEffort === false) { + return { options: [], currentValue: undefined }; + } + + const currentEffort = nonEmptyString(meta.reasoningEffort); + const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : []; + const seen = new Set(); + const options: Array<{ + value: string; + label: string; + description?: string; + advertisedDefault: boolean; + }> = []; + + for (const entry of advertisedOptions) { + if (!isRecord(entry)) { + continue; + } + const rawValue = nonEmptyString(entry.value); + const rawId = nonEmptyString(entry.id); + const value = + rawValue && isValidGrokReasoningEffortToken(rawValue) + ? rawValue + : rawId && isValidGrokReasoningEffortToken(rawId) + ? rawId + : undefined; + if (value === undefined || seen.has(value)) { + continue; + } + seen.add(value); + const description = nonEmptyString(entry.description); + options.push({ + value, + label: nonEmptyString(entry.label) ?? value, + ...(description ? { description } : {}), + advertisedDefault: entry.default === true || entry.isDefault === true, + }); + } + + const currentValue = + currentEffort && options.some((option) => option.value === currentEffort) + ? currentEffort + : undefined; + const advertisedDefaults = options.filter((option) => option.advertisedDefault); + const selectedDefault = + advertisedDefaults.find((option) => option.value === currentValue)?.value ?? + advertisedDefaults[0]?.value; + return { + options: options.map(({ value, label, description }) => ({ + value, + label, + ...(description ? { description } : {}), + ...(value === selectedDefault ? { isDefault: true } : {}), + })), + currentValue: currentValue ?? selectedDefault, + }; +} + +export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities { + const reasoning = grokReasoningOptionsFromModel(model); + return reasoning.options.length > 0 + ? createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: reasoning.options.map((option) => ({ + id: option.value, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.isDefault ? { isDefault: true } : {}), + })), + ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}), + }, + ], + }) + : EMPTY_CAPABILITIES; +} + function buildGrokDiscoveredModelsFromSessionModelState( modelState: EffectAcpSchema.SessionModelState | null | undefined, ): ReadonlyArray { @@ -116,7 +219,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( slug, name: model.name.trim() || slug, isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: buildGrokModelCapabilities(model), }; }) .filter((model): model is ServerProviderModel => model !== undefined); @@ -160,6 +263,7 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, @@ -250,6 +354,8 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } + const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, @@ -263,6 +369,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -281,6 +388,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -301,6 +409,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models, + skills, probe: { installed: true, version, diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 03428fb80..f0f907ee2 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -440,7 +440,7 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("suppresses generic placeholder tool updates until completion", () => + it.effect("emits status-only tool updates through completion", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); @@ -450,13 +450,22 @@ describe("AcpSessionRuntime", () => { }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1))); - expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]); - const toolCall = notes[0]; - expect(toolCall?._tag).toBe("ToolCallUpdated"); - if (toolCall?._tag === "ToolCallUpdated") { - expect(toolCall.toolCall.status).toBe("completed"); - expect(toolCall.toolCall.title).toBe("Read file"); + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 3))); + expect(notes.map((note) => note._tag)).toEqual([ + "ToolCallUpdated", + "ToolCallUpdated", + "ToolCallUpdated", + ]); + const toolCalls = notes.flatMap((note) => + note._tag === "ToolCallUpdated" ? [note.toolCall] : [], + ); + expect(toolCalls.map((toolCall) => toolCall.status)).toEqual([ + "pending", + "inProgress", + "completed", + ]); + for (const toolCall of toolCalls) { + expect(toolCall.title).toBe("Read file"); } }).pipe( Effect.provide( diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 3da31562e..6c1602f6a 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -12,6 +12,7 @@ import { sessionUpdateCountsAsLoadReplayActivity, sessionUpdateIsReplay, syntheticLoadSessionResponseFromInitialize, + toolCallProgressLength, type AcpToolCallState, } from "./AcpRuntimeModel.ts"; @@ -635,6 +636,130 @@ describe("AcpRuntimeModel", () => { }); }); + it("keeps a retained tail on the original text entries around non-text content", () => { + const prefix = "a".repeat(4_000); + const suffix = "b".repeat(5_000); + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "edit", + status: "in_progress", + content: [ + { type: "content", content: { type: "text", text: prefix } }, + { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, + { type: "content", content: { type: "text", text: suffix } }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + const content = event.toolCall.data.content as ReadonlyArray; + expect(content).toHaveLength(3); + const firstText = content[0]; + if (firstText?.type !== "content" || firstText.content.type !== "text") { + throw new Error("expected a bounded prefix text entry"); + } + expect(firstText.content.text.startsWith("[Earlier output truncated]")).toBe(true); + expect(firstText.content.text.endsWith("a".repeat(100))).toBe(true); + expect(content[1]).toEqual({ + type: "diff", + path: "/repo/file.ts", + oldText: "before", + newText: "after", + }); + expect(content[2]).toEqual({ + type: "content", + content: { type: "text", text: suffix }, + }); + }); + + it("bounds oversized whitespace-only tool call content that has no trimmed text", () => { + // Whitespace-only entries are skipped when extracting display text (`chunks.length === 0`) + // and used to be returned unchanged, which let a redrawing terminal persist unbounded + // buffers on `toolCall.data.content` and `rawPayload`. + const hugeWhitespace = " \n\t".repeat(30_000); + expect(hugeWhitespace.length).toBeGreaterThan(60_000); + + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: hugeWhitespace } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBeUndefined(); + const content = event.toolCall.data.content as ReadonlyArray; + const textEntry = content[0]; + if (textEntry?.type !== "content" || textEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(textEntry.content.text.length).toBeLessThan(8_100); + expect(textEntry.content.text.startsWith("[Earlier output truncated]")).toBe(true); + + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); + expect(JSON.stringify(event).length).toBeLessThan(hugeWhitespace.length); + }); + + it("bounds oversized whitespace-padded text entries even when trimmed content fits", () => { + const padded = `${" ".repeat(40_000)}ok${" ".repeat(40_000)}`; + expect(padded.length).toBeGreaterThan(60_000); + + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: padded } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBe("ok"); + const content = event.toolCall.data.content as ReadonlyArray; + const textEntry = content[0]; + if (textEntry?.type !== "content" || textEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(textEntry.content.text).toBe("ok"); + + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text).toBe("ok"); + expect(JSON.stringify(event).length).toBeLessThan(padded.length); + }); + describe("decideToolCallUpdateEmission", () => { const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) => ({ @@ -645,6 +770,17 @@ describe("AcpRuntimeModel", () => { data: {}, }) satisfies AcpToolCallState; + it("emits the first in-progress tool_call even when it has no detail", () => { + expect( + decideToolCallUpdateEmission({ + previous: undefined, + next: { toolCallId: "tool-1", title: "Grok Tool", status: "pending", data: {} }, + lastEmittedDetailLength: undefined, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + it("always emits terminal (completed/failed) status updates regardless of growth", () => { expect( decideToolCallUpdateEmission({ @@ -666,16 +802,66 @@ describe("AcpRuntimeModel", () => { }); it("skips updates whose bounded detail did not change", () => { + const previous = toolCall("frame 1", "inProgress"); expect( decideToolCallUpdateEmission({ - previous: toolCall("frame 1", "inProgress"), - next: toolCall("frame 1", "inProgress"), + previous, + next: previous, lastEmittedDetailLength: 7, skippedSinceEmit: 0, }), ).toEqual({ emit: false, skippedSinceEmit: 0 }); }); + it("coalesces command-tool updates whose content grew while detail stayed the command", () => { + const commandCall = (stdout: string): AcpToolCallState => ({ + toolCallId: "tool-1", + title: "Ran command", + status: "inProgress", + command: "ls", + detail: "ls", + data: { + command: "ls", + content: [{ type: "content", content: { type: "text", text: stdout } }], + }, + }); + + let previous: AcpToolCallState | undefined; + let lastEmittedDetailLength: number | undefined; + let skippedSinceEmit = 0; + const emissions: Array = []; + + for (let i = 1; i <= 12; i += 1) { + const next = commandCall("x".repeat(i)); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + emissions.push(decision.emit); + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + lastEmittedDetailLength = toolCallProgressLength(next); + } + previous = next; + } + + const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); + expect(emittedIndices).toEqual([1, 11]); + }); + + it("emits pending to inProgress status changes even when detail and output are unchanged", () => { + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "pending"), + next: toolCall("same", "inProgress"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + it("emits immediately when the title changes, even with no growth", () => { const decision = decideToolCallUpdateEmission({ previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} }, diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 99c80d94e..dcc388d17 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -317,6 +317,32 @@ function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | u return entry.content.text; } +// Trim is used for display `text`, so whitespace-only (or whitespace-padded) entries never +// contribute to `chunks` and used to take the early returns with the original array. Bound +// each text entry independently so those paths cannot persist an unbounded terminal buffer +// on `toolCall.data.content` / `rawPayload`. +function boundToolCallContentEntries( + content: ReadonlyArray, +): ReadonlyArray { + let changed = false; + const bounded = content.map((entry) => { + const text = toolCallContentText(entry); + if (text === undefined || text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return entry; + } + changed = true; + const trimmed = text.trim(); + return { + type: "content", + content: { + type: "text", + text: boundToolCallOutputText(trimmed.length > 0 ? trimmed : text), + }, + } as const; + }); + return changed ? bounded : content; +} + function extractTextContentFromToolCallContent( content: ReadonlyArray | null | undefined, ): ExtractedToolCallContent { @@ -331,31 +357,74 @@ function extractTextContentFromToolCallContent( } } if (chunks.length === 0) { - return { text: undefined, content }; + return { text: undefined, content: boundToolCallContentEntries(content) }; } const joined = chunks.join("\n"); if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { - return { text: joined, content }; + return { text: joined, content: boundToolCallContentEntries(content) }; } const bounded = boundToolCallOutputText(joined); - // Collapse the text entries into a single bounded one at the final contributing text entry, - // and leave every other entry kind (diffs, images, resource links) in its original relative - // order. The retained tail came from that text entry, so placing it there also preserves its - // ordering relative to interleaved non-text content and ignores later blank text entries. - const lastContributingTextIndex = content.reduce( - (lastIndex, entry, index) => (toolCallContentText(entry)?.trim() ? index : lastIndex), - -1, - ); - const boundedContent = content.flatMap((entry, index) => { + const tail = joined.slice(joined.length - TOOL_CALL_CONTENT_MAX_CHARS); + return { + text: bounded, + content: distributeRetainedTailAcrossContent(content, tail), + }; +} + +// Walk the original text entries from the joined tail window so a retained slice that +// spans entries around an image/diff stays on those entries. Non-text kinds keep their +// relative order; blank text entries are dropped; the truncation marker is prepended to +// the first remaining text entry. +function distributeRetainedTailAcrossContent( + content: ReadonlyArray, + tail: string, +): ReadonlyArray { + const textRanges: Array< + | { + readonly start: number; + readonly end: number; + readonly text: string; + } + | undefined + > = Array.from({ length: content.length }); + let offset = 0; + let seenText = false; + for (const [index, entry] of content.entries()) { + const text = toolCallContentText(entry)?.trim(); + if (!text) { + continue; + } + if (seenText) { + offset += 1; + } + seenText = true; + const start = offset; + const end = offset + text.length; + textRanges[index] = { start, end, text }; + offset = end; + } + const tailStart = Math.max(0, offset - tail.length); + let markerPending = true; + return content.flatMap((entry, index) => { if (toolCallContentText(entry) === undefined) { return [entry]; } - if (index !== lastContributingTextIndex) { + const range = textRanges[index]; + if (range === undefined) { + return []; + } + const overlapStart = Math.max(range.start, tailStart); + const overlapEnd = Math.min(range.end, offset); + if (overlapEnd <= overlapStart) { return []; } - return [{ type: "content", content: { type: "text", text: bounded } } as const]; + let piece = range.text.slice(overlapStart - range.start, overlapEnd - range.start); + if (markerPending) { + piece = `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${piece}`; + markerPending = false; + } + return [{ type: "content", content: { type: "text", text: piece } } as const]; }); - return { text: bounded, content: boundedContent }; } function normalizeToolKind(kind: unknown): string | undefined { @@ -519,6 +588,42 @@ export interface AcpToolCallEmitDecision { readonly skippedSinceEmit: number; } +function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean { + return ( + previous.data.content === next.data.content && previous.data.rawOutput === next.data.rawOutput + ); +} + +// Command tools keep `detail` equal to the command, so live stdout lives on +// `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never +// sees growth and in-progress output is held until completed/failed. +export function toolCallProgressLength(state: AcpToolCallState): number { + let contentChars = 0; + const content = state.data.content; + if (Array.isArray(content)) { + for (const entry of content) { + if (!isRecord(entry)) { + continue; + } + const text = toolCallContentText(entry as EffectAcpSchema.ToolCallContent); + if (text) { + contentChars += text.length; + } + } + } + let rawOutputChars = 0; + const rawOutput = state.data.rawOutput; + if (isRecord(rawOutput)) { + for (const field of RAW_OUTPUT_TEXT_FIELDS) { + const value = rawOutput[field]; + if (typeof value === "string") { + rawOutputChars += value.length; + } + } + } + return Math.max(state.detail?.length ?? 0, contentChars, rawOutputChars); +} + export function decideToolCallUpdateEmission( input: AcpToolCallEmitDecisionInput, ): AcpToolCallEmitDecision { @@ -526,19 +631,16 @@ export function decideToolCallUpdateEmission( if (next.status === "completed" || next.status === "failed") { return { emit: true, skippedSinceEmit: 0 }; } - if (!next.detail) { - return { emit: false, skippedSinceEmit }; - } - if (previous === undefined || previous.title !== next.title) { + if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { return { emit: true, skippedSinceEmit: 0 }; } - if (previous.detail === next.detail) { + if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { return { emit: false, skippedSinceEmit }; } + const progressLength = toolCallProgressLength(next); const grewMeaningfully = lastEmittedDetailLength === undefined || - Math.abs(next.detail.length - lastEmittedDetailLength) >= - TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; + Math.abs(progressLength - lastEmittedDetailLength) >= TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) { return { emit: true, skippedSinceEmit: 0 }; } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index a0b8dbec1..f1212ce50 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -35,6 +35,7 @@ import { extractModelConfigId, findSessionConfigOption, mergeToolCallState, + toolCallProgressLength, parseSessionModeState, parseSessionUpdateEvent, sessionUpdateCountsAsLoadReplayActivity, @@ -1231,6 +1232,7 @@ export class AcpSessionRuntime extends Context.Service< */ readonly setSessionModel: ( modelId: string, + meta?: EffectAcpSchema.SetSessionModelRequest["_meta"], ) => Effect.Effect; /** * Sends a generic ACP extension request and records it through the request logger. @@ -2224,12 +2226,13 @@ export const make = ( Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), Effect.asVoid, ), - setSessionModel: (modelId) => + setSessionModel: (modelId, meta) => getStartedState.pipe( Effect.flatMap((started) => { const requestPayload = { sessionId: started.sessionId, modelId, + ...(meta !== undefined ? { _meta: meta } : {}), } satisfies EffectAcpSchema.SetSessionModelRequest; return runLoggedRequest( "session/set_model", @@ -2321,7 +2324,7 @@ const handleSessionUpdate = ({ next.set(nextToolCall.toolCallId, { state: nextToolCall, lastEmittedDetailLength: decision.emit - ? nextToolCall.detail?.length + ? toolCallProgressLength(nextToolCall) : tracked?.lastEmittedDetailLength, skippedSinceEmit: decision.skippedSinceEmit, }); diff --git a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts index 1341377ed..4b29afd7e 100644 --- a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts @@ -1,6 +1,7 @@ /** * Optional integration check against a real `grok agent stdio` install. - * Enable with: T3_GROK_ACP_PROBE=1 bun run test GrokAcpCliProbe + * Enable with: T3_GROK_ACP_PROBE=1 vp test run GrokAcpCliProbe + * Set T3_GROK_LIVE_TURN=1 to also send a small prompt to the real model. * * The probe assumes either `XAI_API_KEY` is set in the environment or * the user has previously run `grok login`. Without credentials the @@ -10,6 +11,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { describe, expect } from "vite-plus/test"; @@ -88,4 +93,60 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () = expect(result.stopReason).toBe("end_turn"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("session/set_model accepts advertised reasoning effort metadata", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + const modelState = started.sessionSetupResult.models; + const currentModelId = modelState?.currentModelId.trim(); + expect(currentModelId).toBeDefined(); + if (!currentModelId) return; + + const currentModel = modelState?.availableModels.find( + (model) => model.modelId.trim() === currentModelId, + ); + const reasoningEffort = currentModel?._meta?.reasoningEffort; + expect(typeof reasoningEffort).toBe("string"); + if (typeof reasoningEffort !== "string") return; + + yield* runtime.setSessionModel(currentModelId, { reasoningEffort }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect.skipIf(process.env.T3_GROK_LIVE_TURN !== "1")( + "finishes a real Grok turn and streams its answer", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped(); + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeGrokAcpRuntime({ + grokSettings: { binaryPath: "grok" }, + environment: process.env, + childProcessSpawner, + cwd, + runtimeMode: "approval-required", + clientInfo: { name: "t3-grok-probe", version: "0.0.0" }, + }); + yield* runtime.start(); + const chunks: string[] = []; + const events = yield* Stream.runForEach(runtime.getEvents(), (event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined); + } + if (event._tag === "ContentDelta") { + chunks.push(event.text); + } + return Effect.void; + }).pipe(Effect.forkChild); + const result = yield* runtime.prompt({ + prompt: [{ type: "text", text: "Reply exactly GROK_T3_OK. Do not use any tools." }], + }); + yield* runtime.drainEvents; + expect(result.stopReason).toBe("end_turn"); + expect(chunks.join("")).toContain("GROK_T3_OK"); + yield* Fiber.interrupt(events); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index fb9c85cc4..333e6b741 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -6,6 +6,8 @@ import { applyGrokAcpModelSelection, buildGrokAcpSpawnInput, grokAcpRuntimeProcessOwnership, + grokAcpSpawnArgs, + isValidGrokReasoningEffortToken, resolveGrokAcpBaseModelId, } from "./GrokAcpSupport.ts"; @@ -40,6 +42,35 @@ describe("resolveGrokAcpBaseModelId", () => { }); }); +describe("grokAcpSpawnArgs", () => { + it("inherits the Grok CLI config when no T3 runtime mode is set", () => { + expect(grokAcpSpawnArgs()).toEqual(["agent", "stdio"]); + }); + + it("forces Grok to ask when T3 is Supervised", () => { + expect(grokAcpSpawnArgs("approval-required")).toEqual([ + "--permission-mode", + "default", + "agent", + "stdio", + ]); + }); + + it("maps Full access to Grok always-approve", () => { + expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]); + }); + + it("maps Auto-accept edits and Auto onto Grok permission modes", () => { + expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([ + "--permission-mode", + "acceptEdits", + "agent", + "stdio", + ]); + expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]); + }); +}); + describe("buildGrokAcpSpawnInput", () => { it("passes the T3 Code referrer through Grok OAuth env", () => { const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", { @@ -57,15 +88,38 @@ describe("buildGrokAcpSpawnInput", () => { }, }); }); + + it("puts Supervised on the Grok argv so config always-approve cannot win", () => { + const spawn = buildGrokAcpSpawnInput( + { binaryPath: "/usr/local/bin/grok" }, + "/tmp/project", + undefined, + "approval-required", + ); + expect(spawn.args).toEqual(["--permission-mode", "default", "agent", "stdio"]); + }); +}); + +describe("isValidGrokReasoningEffortToken", () => { + it("accepts future ACP tokens and rejects malformed metadata values", () => { + expect(isValidGrokReasoningEffortToken("xhigh")).toBe(true); + expect(isValidGrokReasoningEffortToken("turbo_v2")).toBe(true); + expect(isValidGrokReasoningEffortToken("not a token")).toBe(false); + expect(isValidGrokReasoningEffortToken("-leading-dash")).toBe(false); + expect(isValidGrokReasoningEffortToken("x".repeat(33))).toBe(false); + }); }); describe("applyGrokAcpModelSelection", () => { const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { - const modelCalls: Array = []; + const modelCalls: Array<{ + modelId: string; + meta?: { readonly [key: string]: unknown } | null; + }> = []; const runtime = { - setSessionModel: (modelId: string) => + setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) => Effect.gen(function* () { - modelCalls.push(modelId); + modelCalls.push(meta === undefined ? { modelId } : { modelId, meta }); if (failure) return yield* failure; return {}; }), @@ -82,11 +136,58 @@ describe("applyGrokAcpModelSelection", () => { requestedModelId: "grok-mock-alt", mapError: (cause) => cause.message, }); - expect(modelCalls).toEqual(["grok-mock-alt"]); + expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]); expect(result).toBe("grok-mock-alt"); }), ); + it.effect("applies reasoning effort through session/set_model metadata", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]); + expect(result).toBe("grok-4.6"); + }), + ); + + it.effect("does not clear reasoning when same-model selection omits effort", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: undefined, + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("grok-4.6"); + }), + ); + + it.effect("drops malformed effort metadata instead of sending it", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: "not a token", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6" }]); + }), + ); + it.effect("skips set_model when requested matches current", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 0e0705164..a4cf12c60 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { type GrokSettings, ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -29,16 +29,33 @@ interface GrokAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; +} + +export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray { + switch (runtimeMode) { + case "approval-required": + return ["--permission-mode", "default", "agent", "stdio"]; + case "auto-accept-edits": + return ["--permission-mode", "acceptEdits", "agent", "stdio"]; + case "auto": + return ["--permission-mode", "auto", "agent", "stdio"]; + case "full-access": + return ["agent", "--always-approve", "stdio"]; + default: + return ["agent", "stdio"]; + } } export function buildGrokAcpSpawnInput( grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, ): AcpSessionRuntime.AcpSpawnInput { return { command: grokSettings?.binaryPath || "grok", - args: ["agent", "stdio"], + args: [...grokAcpSpawnArgs(runtimeMode)], cwd, env: { ...environment, @@ -82,7 +99,12 @@ export const makeGrokAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), + spawn: buildGrokAcpSpawnInput( + input.grokSettings, + input.cwd, + input.environment, + input.runtimeMode, + ), authMethodId: resolveGrokAuthMethodId(input.environment), // Current Grok treats Ctrl+C cancellation as a barrier against stale // background-task wake prompts until the next genuine user turn. @@ -106,6 +128,17 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build"; } +const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; + +export function isValidGrokReasoningEffortToken(value: string): boolean { + return GROK_REASONING_EFFORT_TOKEN.test(value); +} + +export function normalizeGrokReasoningEffort(value: string | undefined): string | undefined { + const effort = value?.trim(); + return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined; +} + export function currentGrokModelIdFromSessionSetup( sessionSetupResult: | EffectAcpSchema.LoadSessionResponse @@ -115,18 +148,57 @@ export function currentGrokModelIdFromSessionSetup( return sessionSetupResult.models?.currentModelId?.trim() || undefined; } +export function currentGrokReasoningEffortFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + const modelState = sessionSetupResult.models; + if (!modelState) { + return undefined; + } + const currentModelId = modelState.currentModelId.trim(); + if (currentModelId.length === 0) { + return undefined; + } + const currentModel = modelState.availableModels.find( + (model) => model.modelId.trim() === currentModelId, + ); + const reasoningEffort = currentModel?._meta?.reasoningEffort; + return typeof reasoningEffort === "string" + ? normalizeGrokReasoningEffort(reasoningEffort) + : undefined; +} + export function applyGrokAcpModelSelection(input: { readonly runtime: Pick; readonly currentModelId: string | undefined; + readonly currentReasoningEffort?: string | undefined; readonly requestedModelId: string | undefined; + readonly requestedReasoningEffort?: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { - const shouldSwitchModel = + const modelChanged = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - if (!shouldSwitchModel) { + const reasoningProvided = input.requestedReasoningEffort !== undefined; + const reasoningEffort = reasoningProvided + ? normalizeGrokReasoningEffort(input.requestedReasoningEffort) + : undefined; + const reasoningEffortChanged = + reasoningProvided && reasoningEffort !== input.currentReasoningEffort; + const targetModelId = input.requestedModelId ?? input.currentModelId; + if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { return Effect.succeed(input.currentModelId); } + const reasoningMeta = + reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined; + // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately + // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not + // provided at all, we also send no meta, but we only reach this call when the model itself + // changed - an omitted reasoning preference must not be treated as an explicit clear of the + // CLI-advertised default (e.g. Extra High) on same-model reselections. return input.runtime - .setSessionModel(input.requestedModelId) - .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); + .setSessionModel(targetModelId, reasoningMeta) + .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId)); } diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index 00efee078..e4bf005d9 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -1,3 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -6,28 +12,52 @@ import * as Schema from "effect/Schema"; import { describe, expect } from "vite-plus/test"; import { + extractGrokPlanMarkdownFromToolCallData, extractXAiAcpBackgroundToolMutation, extractXAiAcpSubagentEndNotice, extractXAiAcpSubagentUpdate, extractXAiAskUserQuestions, extractXAiBackgroundTaskCompletion, + extractXAiExitPlanMarkdown, extractXAiKilledBackgroundTasks, extractXAiMonitorTaskId, isGenericAcpToolTitle, + isGrokPlanMarkdownPath, isXAiMonitorTool, isXAiPersistentMonitor, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeCapturedResponse, makeXAiPromptCompletionRuntime, normalizeXAiAcpToolCallState, registerXAiBackgroundTaskTracking, resolveXAiAcpToolTitle, + XAI_EMPTY_PLAN_MARKDOWN, + XAiAskUserQuestionRequest, xAiBackgroundTaskLifecycleMutation, + XAiExitPlanModeRequest, xAiPromptCompleteFromSessionUpdate, - XAiAskUserQuestionRequest, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.make({ + spawn: { + command: process.execPath, + args: [mockAgentPath], + env, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }); + return yield* makeXAiPromptCompletionRuntime(runtime); + }); + const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); describe("xAiPromptCompleteFromSessionUpdate", () => { @@ -1397,6 +1427,27 @@ describe("XAiAcpExtension", () => { }), ); + it.effect("fails a hung standard prompt from an xAI rate-limit completion", () => + Effect.gen(function* () { + const runtime = yield* makePromptCompletionRuntime({ + T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG: "1", + }); + yield* runtime.start(); + + const error = yield* Effect.flip( + runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }), + ); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32003, + errorMessage: "Grok usage limit reached. Try again later.", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("settles a hung prompt from released _x.ai/session_notification turns", () => Effect.gen(function* () { const handlers = new Map Effect.Effect>(); @@ -1652,4 +1703,170 @@ describe("XAiAcpExtension", () => { expect((yield* Fiber.join(promptFiber)).stopReason).toBe("cancelled"); }), ); + + it("extracts plan markdown from exit_plan_mode payloads", () => { + const decode = Schema.decodeUnknownSync(XAiExitPlanModeRequest); + const direct = decode({ + sessionId: "session-1", + toolCallId: "exit-1", + planContent: "# Plan\n\n- do the thing\n", + }); + expect(extractXAiExitPlanMarkdown(direct)).toBe("# Plan\n\n- do the thing"); + + const wrapped = decode({ + method: "_x.ai/exit_plan_mode", + params: { + sessionId: "session-1", + toolCallId: "exit-1", + planContent: null, + }, + }); + expect(extractXAiExitPlanMarkdown(wrapped, " # fallback plan ")).toBe("# fallback plan"); + expect(extractXAiExitPlanMarkdown(wrapped, "")).toBe(XAI_EMPTY_PLAN_MARKDOWN); + expect(extractXAiExitPlanMarkdown(wrapped)).toBe(XAI_EMPTY_PLAN_MARKDOWN); + }); + + it("builds an abandoned exit_plan_mode response that captures the plan", () => { + expect(makeXAiExitPlanModeCapturedResponse()).toEqual({ + outcome: "abandoned", + feedback: + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + }); + }); + + it("identifies Grok plan.md paths and extracts markdown from tool call data", () => { + const linuxHost = { platform: "linux" as const, environment: {} }; + const windowsHost = { platform: "win32" as const, environment: {} }; + const grokHomeHost = { + platform: "linux" as const, + environment: { GROK_HOME: "/opt/grok-data" }, + }; + const home = NodeOS.homedir().replace(/\\/g, "/"); + const sessionPlan = `${home}/.grok/sessions/abc/plan.md`; + const nestedSessionPlan = `${home}/.grok/sessions/%2Fhome%2Fproj/019fd20e-c563-70a0-b801-a6bc51815a9b/plan.md`; + expect(isGrokPlanMarkdownPath(sessionPlan, linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath(nestedSessionPlan, linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath("~/.grok/sessions/abc/plan.md", linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath("/tmp/mock-home/.grok/sessions/sess/plan.md", linuxHost)).toBe( + true, + ); + expect(isGrokPlanMarkdownPath("/home/other/.grok/sessions/sess/plan.md", linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath("/HOME/other/.grok/sessions/sess/plan.md", linuxHost)).toBe( + false, + ); + expect(isGrokPlanMarkdownPath("C:/Users/other/.grok/sessions/id/plan.md", windowsHost)).toBe( + true, + ); + expect(isGrokPlanMarkdownPath("c:/users/OTHER/.GROK/SESSIONS/id/PLAN.MD", windowsHost)).toBe( + true, + ); + expect( + isGrokPlanMarkdownPath("C:\\Users\\other\\.grok\\sessions\\id\\plan.md", windowsHost), + ).toBe(true); + expect(isGrokPlanMarkdownPath("/opt/grok-data/sessions/sess/plan.md", grokHomeHost)).toBe(true); + expect( + isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", { + platform: "win32", + environment: { GROK_HOME: "/opt/grok-data" }, + }), + ).toBe(true); + expect(isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", grokHomeHost)).toBe( + false, + ); + // Workspace plan.md must not be treated as the session plan file. + expect(isGrokPlanMarkdownPath("plan.md", linuxHost)).toBe(false); + expect(isGrokPlanMarkdownPath("/repo/docs/plan.md", linuxHost)).toBe(false); + expect(isGrokPlanMarkdownPath("/tmp/other.md", linuxHost)).toBe(false); + expect(isGrokPlanMarkdownPath("/repo/.grok/sessions/example/plan.md", linuxHost)).toBe(false); + expect( + isGrokPlanMarkdownPath(`${home}/project/.grok/sessions/example/plan.md`, linuxHost), + ).toBe(false); + expect( + isGrokPlanMarkdownPath("/home/other/.grok/sessions/../../project/plan.md", linuxHost), + ).toBe(false); + expect( + isGrokPlanMarkdownPath("/home/other/.grok/sessions/foo/../../../project/plan.md", linuxHost), + ).toBe(false); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { + file_path: sessionPlan, + content: "# From rawInput\n\n- a\n", + }, + }, + linuxHost, + ), + ).toBe("# From rawInput\n\n- a"); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + content: [ + { + type: "diff", + path: sessionPlan, + oldText: "", + newText: "# From diff\n\n- b\n", + }, + ], + }, + linuxHost, + ), + ).toBe("# From diff\n\n- b"); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: sessionPlan, content: "" }, + content: [ + { + type: "diff", + path: sessionPlan, + oldText: "", + newText: "# From diff after empty rawInput\n", + }, + ], + }, + linuxHost, + ), + ).toBe("# From diff after empty rawInput"); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: sessionPlan, content: "" }, + }, + linuxHost, + ), + ).toBe(""); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + content: [{ type: "diff", path: sessionPlan, oldText: "# old", newText: "" }], + }, + linuxHost, + ), + ).toBe(""); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: "/tmp/readme.md", content: "nope" }, + }, + linuxHost, + ), + ).toBeUndefined(); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: "/repo/docs/plan.md", content: "# Project plan\n" }, + }, + linuxHost, + ), + ).toBeUndefined(); + }); }); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index ae2ab7b27..98a4f9a3e 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; @@ -5,7 +7,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import type * as EffectAcpErrors from "effect-acp/errors"; +import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -13,6 +15,7 @@ import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; const completedXAiPromptIdLimit = 128; +const xAiRateLimitedErrorCode = -32003; const XAiPromptCompleteNotification = Schema.Struct({ sessionId: Schema.String, @@ -78,7 +81,10 @@ export function xAiPromptCompleteFromSessionUpdate( interface PendingXAiPromptCompletion { readonly sessionId: string; readonly promptId: string; - readonly deferred: Deferred.Deferred; + readonly deferred: Deferred.Deferred< + EffectAcpSchema.PromptResponse, + EffectAcpErrors.AcpRequestError + >; } export interface XAiAcpSubagentUpdate { @@ -981,6 +987,218 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan return { outcome: "cancelled" }; } +// --------------------------------------------------------------------------- +// x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window) +// --------------------------------------------------------------------------- + +const XAiExitPlanModeParams = Schema.Struct({ + sessionId: Schema.String, + toolCallId: Schema.String, + planContent: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const XAiWrappedExitPlanModeParams = Schema.Struct({ + method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]), + params: XAiExitPlanModeParams, +}); + +export const XAiExitPlanModeRequest = Schema.Union([ + XAiExitPlanModeParams, + XAiWrappedExitPlanModeParams, +]); + +type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type; +type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type; + +function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams { + return "params" in params ? params.params : params; +} + +/** Empty-state copy when Grok exits plan mode without a plan file. */ +export const XAI_EMPTY_PLAN_MARKDOWN = + "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)"; + +export function extractXAiExitPlanMarkdown( + params: XAiExitPlanModeRequest, + fallback?: string | null, +): string { + const content = unwrapExitPlanModeParams(params).planContent; + const fromRequest = typeof content === "string" ? trimmed(content) : undefined; + if (fromRequest) { + return fromRequest; + } + const fromFallback = fallback?.trim(); + if (fromFallback && fromFallback.length > 0) { + return fromFallback; + } + return XAI_EMPTY_PLAN_MARKDOWN; +} + +export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes"; + +export interface XAiExitPlanModeResponse { + readonly outcome: XAiExitPlanModeOutcome; + readonly feedback?: string; +} + +/** + * Client captured the plan for T3's proposed-plan card. Abandon the native + * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI. + */ +export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse { + return { + outcome: "abandoned", + feedback: + feedback ?? + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + }; +} + +function normalizeFsPath(value: string): string { + return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); +} + +function pathHasTraversalSegment(normalized: string): boolean { + return normalized.split("/").includes(".."); +} + +function addGrokSessionPrefix( + prefixes: Set, + homeOrRoot: string, + nestedGrokDir: boolean, +): void { + const root = normalizeFsPath(homeOrRoot); + if (!root) { + return; + } + prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`); +} + +/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */ +export interface GrokPlanPathHost { + readonly platform: NodeJS.Platform; + readonly environment: NodeJS.ProcessEnv; +} + +function grokPlanSessionPrefixes(environment: NodeJS.ProcessEnv): ReadonlySet { + const prefixes = new Set(); + addGrokSessionPrefix(prefixes, NodeOS.homedir(), true); + addGrokSessionPrefix(prefixes, "~", true); + addGrokSessionPrefix(prefixes, environment.HOME ?? "", true); + addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true); + // ACP mock and isolated Grok spawns use a HOME that is not the server process home. + addGrokSessionPrefix(prefixes, "/tmp/mock-home", true); + const grokHome = environment.GROK_HOME ?? ""; + addGrokSessionPrefix(prefixes, grokHome, false); + addGrokSessionPrefix(prefixes, grokHome, true); + return prefixes; +} + +const CANONICAL_HOME_GROK_SESSION_PATH = + /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/; +const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp( + CANONICAL_HOME_GROK_SESSION_PATH.source, + "i", +); + +/** + * True when a path is Grok's session plan file under a Grok home + * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`). + * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md + * or a repo-local `.grok/sessions/.../plan.md`). + */ +export function isGrokPlanMarkdownPath( + path: string | undefined | null, + host: GrokPlanPathHost, +): boolean { + if (typeof path !== "string") { + return false; + } + const normalized = path.trim().replace(/\\/g, "/"); + const win32 = host.platform === "win32"; + const haystack = win32 ? normalized.toLowerCase() : normalized; + if ( + normalized.length === 0 || + !haystack.endsWith("/plan.md") || + pathHasTraversalSegment(normalized) + ) { + return false; + } + for (const prefix of grokPlanSessionPrefixes(host.environment)) { + const needle = win32 ? prefix.toLowerCase() : prefix; + if (!haystack.startsWith(needle)) { + continue; + } + const rest = haystack.slice(needle.length); + // Session layout: /.grok/sessions///plan.md + if (rest !== "plan.md" && rest.endsWith("plan.md")) { + return true; + } + } + return ( + win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH + ).test(haystack); +} + +/** + * Extract plan markdown from a Grok write/edit tool call targeting plan.md. + * Used so T3 can show the plan while plan mode is still active (before exit). + */ +export function extractGrokPlanMarkdownFromToolCallData( + data: Record | undefined, + host: GrokPlanPathHost, +): string | undefined { + if (!data) { + return undefined; + } + + let sawPlanWrite = false; + const takePlanText = ( + value: string | undefined, + filePath: string | undefined, + ): string | undefined => { + if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) { + return undefined; + } + sawPlanWrite = true; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + }; + + const rawInput = data.rawInput; + if (isRecord(rawInput)) { + const filePath = + (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ?? + (typeof rawInput.path === "string" ? rawInput.path : undefined); + const content = typeof rawInput.content === "string" ? rawInput.content : undefined; + const fromRaw = takePlanText(content, filePath); + if (fromRaw !== undefined) { + return fromRaw; + } + } + + const content = data.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!isRecord(block) || block.type !== "diff") { + continue; + } + const path = typeof block.path === "string" ? block.path : undefined; + const newText = typeof block.newText === "string" ? block.newText : undefined; + const fromDiff = takePlanText(newText, path); + if (fromDiff !== undefined) { + return fromDiff; + } + } + } + + return sawPlanWrite ? "" : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined { const meta = response._meta; if (meta === null || typeof meta !== "object") { @@ -1031,7 +1249,7 @@ const registerXAiPromptCompletionFallback = ( sessionId: string, promptId: string, ) => - Deferred.make().pipe( + Deferred.make().pipe( Effect.tap((deferred) => Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), ), @@ -1040,7 +1258,7 @@ const registerXAiPromptCompletionFallback = ( const unregisterXAiPromptCompletionFallback = ( pendingRef: Ref.Ref>, - deferred: Deferred.Deferred, + deferred: Deferred.Deferred, ) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); const abortPendingPromptCompletions = ( @@ -1121,13 +1339,48 @@ const resolveXAiPromptCompletionFallback = ({ return [Effect.void, pending] as const; } return [ - Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid), + settleXAiPromptCompletion(entry.deferred, notification), [...pending.slice(0, index), ...pending.slice(index + 1)], ] as const; }).pipe(Effect.flatten); }), ); +const settleXAiPromptCompletion = ( + deferred: Deferred.Deferred, + notification: XAiPromptCompleteNotification, +) => { + if (notification.stopReason === "rate_limit") { + return Deferred.fail( + deferred, + new EffectAcpErrors.AcpRequestError({ + code: xAiRateLimitedErrorCode, + errorMessage: "Grok usage limit reached. Try again later.", + }), + ).pipe(Effect.asVoid); + } + if (notification.stopReason === "error") { + return Deferred.fail( + deferred, + EffectAcpErrors.AcpRequestError.internalError( + xAiAgentResultMessage(notification.agentResult) ?? "Grok prompt failed.", + ), + ).pipe(Effect.asVoid); + } + return Deferred.succeed(deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid); +}; + +function xAiAgentResultMessage(value: unknown): string | undefined { + if (typeof value === "string") { + return trimmed(value); + } + if (value === null || typeof value !== "object") { + return undefined; + } + const message = "message" in value ? value.message : undefined; + return typeof message === "string" ? trimmed(message) : undefined; +} + const rememberCompletedXAiPromptId = ( completedPromptIdsRef: Ref.Ref>, response: EffectAcpSchema.PromptResponse, diff --git a/apps/server/src/terminal/PtyAdapter.test.ts b/apps/server/src/terminal/PtyAdapter.test.ts deleted file mode 100644 index f4ac95165..000000000 --- a/apps/server/src/terminal/PtyAdapter.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Schema from "effect/Schema"; - -import * as PtyAdapter from "./PtyAdapter.ts"; - -const isPtySpawnError = Schema.is(PtyAdapter.PtySpawnError); - -describe("PtySpawnError", () => { - it("derives messages from structural context while preserving the full cause chain", () => { - const spawnCause = new Error("spawn /bin/zsh ENOENT"); - const adapterError = new PtyAdapter.PtySpawnError({ - adapter: "node-pty", - shell: "/bin/zsh", - cause: spawnCause, - }); - const managerError = new PtyAdapter.PtySpawnError({ - adapter: "terminal-manager", - attemptedShells: ["/bin/zsh -o nopromptsp", "/bin/bash"], - cause: adapterError, - }); - - assert(isPtySpawnError(managerError)); - assert.strictEqual( - managerError.message, - "Failed to spawn PTY process with terminal-manager. Tried shells: /bin/zsh -o nopromptsp, /bin/bash.", - ); - assert.strictEqual( - adapterError.message, - "Failed to spawn PTY process '/bin/zsh' with node-pty.", - ); - assert.strictEqual(managerError.cause, adapterError); - assert.strictEqual(adapterError.cause, spawnCause); - }); -}); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 3170c3961..f6ff15b77 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -8,6 +8,7 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "@t3tools/contracts"; @@ -27,6 +28,7 @@ import { import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../provider/acp/GrokAcpSupport.ts"; @@ -85,10 +87,18 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi const promptResult = yield* Effect.gen(function* () { const started = yield* runtime.start(); + const requestedReasoningEffort = getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ); yield* applyGrokAcpModelSelection({ runtime, currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ), requestedModelId: resolvedModel, + requestedReasoningEffort, mapError: (cause) => new TextGenerationError({ operation, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bf131ac9..224662e9d 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,9 +1,9 @@ /** * UsageService - scans provider transcripts and returns priced usage buckets. * - * The scan reads the provider CLIs' own session files rather than T3 Code's - * orchestration projections, so usage covers turns driven outside T3 Code too. - * This is the approach `ccusage` takes. + * The scan reads the provider CLIs' own session files (Claude Code, Codex, and + * Grok Build) rather than T3 Code's orchestration projections, so usage covers + * turns driven outside T3 Code too. This is the approach `ccusage` takes. * * Transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm @@ -21,6 +21,7 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -34,6 +35,7 @@ import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; @@ -123,6 +125,7 @@ export const make = Effect.gen(function* () { const config = yield* ServerConfig; const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; + const hostEnvironment = yield* HostProcessEnvironment; const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -218,10 +221,22 @@ export const make = Effect.gen(function* () { const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + // Grok Settings only expose the binary path; home is `$GROK_HOME` or `~/.grok`. + // Empty/whitespace GROK_HOME must fall back: coalescing alone would scan cwd. + const grokHomeEnv = hostEnvironment["GROK_HOME"]?.trim() ?? ""; + const grokHome = + grokHomeEnv.length > 0 + ? path.resolve(expandHomePath(grokHomeEnv)) + : path.join(NodeOS.homedir(), ".grok"); return [ { provider: "claude" as const, dir: claudeDir }, { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + { + provider: "grok" as const, + dir: path.join(grokHome, "sessions"), + fileName: "updates.jsonl", + }, ]; }); @@ -353,7 +368,7 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir } of dirs) { + for (const { provider, dir, fileName } of dirs) { const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); const exists = yield* fileSystem .exists(dir) @@ -373,7 +388,9 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + const files = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + ); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 64673e96c..1f0365f3e 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -42,12 +42,21 @@ describe("scan cache round trip", () => { ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], ]); + original.set("/grok.jsonl", { + size: 40, + mtimeMs: 300, + provider: "grok", + records: [ + record({ provider: "grok", model: "grok-4.5-build", dedupeKey: "s:p:grok-4.5-build" }), + ], + }); const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); - expect(restored.size).toBe(2); + expect(restored.size).toBe(3); expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); + expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl")); }); it("interns repeated model and session strings", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index cc15ee9ce..02daf5ebb 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -134,7 +134,7 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof raw !== "object" || raw === null) continue; const entry = raw as Partial; if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; if (!isRecordArray(entry.r)) continue; const provider: UsageProviderKind = entry.p; diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index c72f0c24d..33aef8fae 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -22,6 +22,7 @@ import { mightCarryUsage, parseClaudeLine, parseCodexLine, + parseGrokLine, type UsageRecord, } from "./usageTranscripts.ts"; @@ -37,12 +38,18 @@ export interface TranscriptFile { * Errors on individual entries are swallowed: session files rotate and get * removed while the walk is in flight, and a partial listing is far better than * failing the page. + * + * `fileName` restricts the walk to a single basename (Grok's `updates.jsonl`). + * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that + * never carry usage, so the basename filter keeps a cold scan off those files. */ export async function listTranscriptFiles( root: string, sinceMs: number, + options?: { readonly fileName?: string }, ): Promise { const found: TranscriptFile[] = []; + const fileName = options?.fileName; const walk = async (dir: string): Promise => { let entries; @@ -57,7 +64,11 @@ export async function listTranscriptFiles( await walk(child); continue; } - if (!entry.name.endsWith(".jsonl")) continue; + if (fileName !== undefined) { + if (entry.name !== fileName) continue; + } else if (!entry.name.endsWith(".jsonl")) { + continue; + } try { const stats = await NodeFSP.stat(child); if (stats.mtimeMs >= sinceMs) { @@ -129,6 +140,12 @@ export async function readTranscriptRecords( continue; } + if (provider === "grok") { + if (!mightCarryUsage(line, provider)) continue; + for (const grokRecord of parseGrokLine(line)) records.push(grokRecord); + continue; + } + if (!mightCarryUsage(line, provider)) continue; const record = parseClaudeLine(line); if (record !== null) records.push(record); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 8f86a3d83..b09db613e 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; import { + GROK_COST_USD_TICKS_PER_DOLLAR, initialCodexScanState, parseClaudeLine, parseCodexLine, + parseGrokLine, totalTokens, } from "./usageTranscripts.ts"; @@ -249,3 +251,316 @@ describe("totalTokens", () => { ).toBe(100); }); }); + +describe("parseGrokLine", () => { + /** Shaped after a real Grok Build `turn_completed` session update. */ + function turnCompleted(overrides?: { + sessionId?: string; + promptId?: string; + timestamp?: number; + agentTimestampMs?: number; + usage?: Record; + modelUsage?: Record> | null; + }): string { + const modelUsage = + overrides && "modelUsage" in overrides + ? overrides.modelUsage + : { + "grok-4.5-build": { + inputTokens: 20_272, + outputTokens: 272, + totalTokens: 20_544, + cachedReadTokens: 11_264, + cacheCreationTokens: 0, + reasoningTokens: 180, + costUsdTicks: 230_272_000, + }, + }; + + return JSON.stringify({ + timestamp: overrides?.timestamp ?? 1_786_372_566, + method: "_x.ai/session/update", + params: { + sessionId: overrides?.sessionId ?? "019fec1a-12f7-72f2-9b1f-7778a00aea3c", + update: { + sessionUpdate: "turn_completed", + prompt_id: overrides?.promptId ?? "prompt-1", + stop_reason: "end_turn", + usage: { + inputTokens: 20_272, + outputTokens: 272, + totalTokens: 20_544, + cachedReadTokens: 11_264, + cacheCreationTokens: 0, + reasoningTokens: 180, + costUsdTicks: 230_272_000, + ...(modelUsage === null ? {} : { modelUsage }), + ...overrides?.usage, + }, + }, + _meta: { + eventId: "event-1", + agentTimestampMs: overrides?.agentTimestampMs ?? 1_786_372_566_485, + }, + }, + }); + } + + it("extracts per-model totals and provider-reported cost ticks", () => { + const records = parseGrokLine(turnCompleted()); + + expect(records).toHaveLength(1); + const [record] = records; + expect(record?.provider).toBe("grok"); + expect(record?.model).toBe("grok-4.5-build"); + expect(record?.sessionId).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c"); + expect(record?.timestampMs).toBe(1_786_372_566_485); + expect(record?.totals).toEqual({ + uncachedInputTokens: 20_272 - 11_264, + cachedInputTokens: 11_264, + cacheCreationTokens: 0, + outputTokens: 272, + reasoningTokens: 180, + }); + expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); + expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build"); + }); + + it("emits one record per model when modelUsage has several entries", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 1000, + outputTokens: 50, + cachedReadTokens: 400, + reasoningTokens: 20, + costUsdTicks: 50_000_000, + }, + "grok-composer-2.5-fast": { + inputTokens: 200, + outputTokens: 30, + cachedReadTokens: 100, + reasoningTokens: 0, + costUsdTicks: 10_000_000, + }, + }, + }), + ); + + expect(records.map((record) => record.model).toSorted()).toEqual([ + "grok-4.5", + "grok-composer-2.5-fast", + ]); + expect(records.every((record) => record.provider === "grok")).toBe(true); + expect(records.find((record) => record.model === "grok-4.5")?.reportedCostUsd).toBeCloseTo( + 0.005, + 12, + ); + }); + + it("inherits top-level cost ticks for a single model without its own ticks", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5-build": { + inputTokens: 1000, + outputTokens: 10, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(1); + expect(records[0]?.reportedCostUsd).toBe(1); + }); + + it("falls back to a generic grok model when modelUsage is absent", () => { + const records = parseGrokLine(turnCompleted({ modelUsage: null })); + + expect(records).toHaveLength(1); + const [record] = records; + expect(record?.provider).toBe("grok"); + expect(record?.model).toBe("grok"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 20_272 - 11_264, + cachedInputTokens: 11_264, + cacheCreationTokens: 0, + outputTokens: 272, + reasoningTokens: 180, + }); + expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); + expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok"); + }); + + it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 300, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + "grok-composer-2.5-fast": { + inputTokens: 100, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(2); + const byModel = Object.fromEntries(records.map((record) => [record.model, record])); + expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); + expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); + const sum = + (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + + (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); + expect(sum).toBeCloseTo(1, 12); + }); + + it("pro-rates aggregate cost when a zero-token sibling carries costUsdTicks: 0", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 300, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + "grok-composer-2.5-fast": { + inputTokens: 100, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + "empty-sibling": { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + costUsdTicks: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(2); + expect(records.every((record) => record.model !== "empty-sibling")).toBe(true); + const byModel = Object.fromEntries(records.map((record) => [record.model, record])); + expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); + expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); + const sum = + (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + + (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); + expect(sum).toBeCloseTo(1, 12); + }); + + it("allocates leftover aggregate ticks to models that omit per-model ticks", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 300, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + costUsdTicks: 0.4 * GROK_COST_USD_TICKS_PER_DOLLAR, + }, + "grok-composer-2.5-fast": { + inputTokens: 100, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(2); + const byModel = Object.fromEntries(records.map((record) => [record.model, record])); + expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.4, 12); + expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.6, 12); + const sum = + (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + + (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); + expect(sum).toBeCloseTo(1, 12); + }); + + it("does not invent a colliding dedupe key when prompt_id is missing", () => { + const line = JSON.stringify({ + timestamp: 1_786_372_566, + method: "_x.ai/session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "turn_completed", + usage: { + inputTokens: 10, + outputTokens: 2, + modelUsage: { + "grok-4.5": { inputTokens: 10, outputTokens: 2 }, + }, + }, + }, + }, + }); + + expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull(); + }); + + it("ignores non-turn lines and empty usage", () => { + expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]); + expect(parseGrokLine("not json")).toEqual([]); + expect( + parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5-build": { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + costUsdTicks: 0, + }, + }, + }), + ), + ).toEqual([]); + }); + + it("falls back to the outer unix-seconds timestamp when agent meta is missing", () => { + const line = JSON.stringify({ + timestamp: 1_786_372_566, + method: "_x.ai/session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "turn_completed", + prompt_id: "p1", + usage: { + inputTokens: 10, + outputTokens: 2, + modelUsage: { + "grok-4.5": { inputTokens: 10, outputTokens: 2 }, + }, + }, + }, + }, + }); + + const records = parseGrokLine(line); + expect(records[0]?.timestampMs).toBe(1_786_372_566_000); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 49f9a1935..2aea60709 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -1,8 +1,8 @@ /** * Pure parsers for the provider CLIs' on-disk session transcripts. * - * Both parsers are line-at-a-time reducers so callers can stream large files - * without materialising them. Neither touches the filesystem. + * Each parser is a line-at-a-time reducer so callers can stream large files + * without materialising them. None of them touch the filesystem. * * @module usageTranscripts */ @@ -68,7 +68,20 @@ export function totalTokens(totals: UsageTokenTotals): number { * an order of magnitude. */ export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { - return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); + if (provider === "claude") return line.includes('"usage"'); + if (provider === "grok") return line.includes('"turn_completed"'); + return line.includes('"token_count"'); +} + +/** + * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok + * headless `total_cost_usd_ticks`. Convert to dollars for pricing. + */ +export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; + +export function grokCostTicksToUsd(ticks: unknown): number | null { + if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null; + return ticks / GROK_COST_USD_TICKS_PER_DOLLAR; } /* -------------------------------------------------------------------------- */ @@ -297,4 +310,179 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord }; } +/* -------------------------------------------------------------------------- */ +/* Grok Build */ +/* -------------------------------------------------------------------------- */ + +interface GrokUsageTotals { + readonly inputTokens: number; + readonly outputTokens: number; + readonly cachedReadTokens: number; + readonly cacheCreationTokens: number; + readonly reasoningTokens: number; + readonly costUsdTicks: number | null; +} + +function readGrokUsageTotals(value: unknown): GrokUsageTotals | null { + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + return { + inputTokens: int(record["inputTokens"]), + outputTokens: int(record["outputTokens"]), + cachedReadTokens: int(record["cachedReadTokens"]), + cacheCreationTokens: int(record["cacheCreationTokens"]), + reasoningTokens: int(record["reasoningTokens"]), + costUsdTicks: + typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"]) + ? record["costUsdTicks"] + : null, + }; +} + +function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals { + const cachedInputTokens = totals.cachedReadTokens; + const cacheCreationTokens = totals.cacheCreationTokens; + // Grok reports `inputTokens` inclusive of the cached portion, matching Codex. + const uncachedInputTokens = Math.max( + 0, + totals.inputTokens - cachedInputTokens - cacheCreationTokens, + ); + const outputTokens = totals.outputTokens; + return { + uncachedInputTokens, + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens: Math.min(outputTokens, totals.reasoningTokens), + }; +} + +/** + * Parses one line of a Grok Build `updates.jsonl` session log. + * + * Usage lands on `turn_completed` session updates. Per-model breakdowns live + * under `usage.modelUsage`; when present each model becomes its own record. + * + * Returns every record for the line (0 or more). Callers stream line-by-line + * and flatten. + */ +export function parseGrokLine(line: string): readonly UsageRecord[] { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return []; + } + if (typeof parsed !== "object" || parsed === null) return []; + + const record = parsed as Record; + const params = record["params"]; + if (typeof params !== "object" || params === null) return []; + const paramsRecord = params as Record; + + const update = paramsRecord["update"]; + if (typeof update !== "object" || update === null) return []; + const updateRecord = update as Record; + if (updateRecord["sessionUpdate"] !== "turn_completed") return []; + + const usage = updateRecord["usage"]; + if (typeof usage !== "object" || usage === null) return []; + const usageRecord = usage as Record; + + const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : ""; + const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null; + + // Prefer the high-resolution agent clock; fall back to the outer unix seconds. + const meta = paramsRecord["_meta"]; + let timestampMs: number | null = null; + if (typeof meta === "object" && meta !== null) { + const agentTimestampMs = (meta as Record)["agentTimestampMs"]; + if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) { + timestampMs = agentTimestampMs; + } + } + if (timestampMs === null) { + const timestamp = record["timestamp"]; + if (typeof timestamp === "number" && Number.isFinite(timestamp)) { + timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000; + } + } + if (timestampMs === null) return []; + + const topLevel = readGrokUsageTotals(usageRecord); + if (topLevel === null) return []; + + const modelUsage = usageRecord["modelUsage"]; + const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = []; + if (typeof modelUsage === "object" && modelUsage !== null) { + for (const [model, raw] of Object.entries(modelUsage as Record)) { + if (model.length === 0) continue; + const totals = readGrokUsageTotals(raw); + if (totals === null) continue; + modelEntries.push({ model, totals }); + } + } + + if (modelEntries.length === 0) { + if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return []; + return [ + { + provider: "grok", + timestampMs, + model: "grok", + sessionId, + totals: grokTotalsToUsage(topLevel), + reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), + // No prompt id means we cannot tell two same-second updates apart. + dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, + }, + ]; + } + + // Cost allocation: + // 1. Emitted models with their own costUsdTicks keep those values. + // 2. Remaining aggregate cost (top-level minus those per-model ticks, + // clamped at 0) is pro-rated across emitted models that lack ticks, + // by token share among the unticked models only. + // 3. When no model has per-model ticks, remaining equals the full + // aggregate and every emitted model gets a token-share slice. + // Zero-token rows are never emitted and never count toward used ticks. + const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks); + let usedTickedCostUsd = 0; + let untickedTokenDenominator = 0; + for (const entry of modelEntries) { + const tokens = totalTokens(grokTotalsToUsage(entry.totals)); + if (tokens === 0) continue; + if (entry.totals.costUsdTicks !== null) { + usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0; + } else { + untickedTokenDenominator += tokens; + } + } + const remainingCostUsd = + topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd); + + const results: UsageRecord[] = []; + for (const entry of modelEntries) { + const totals = grokTotalsToUsage(entry.totals); + if (totalTokens(totals) === 0) continue; + + let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks); + if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) { + reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator); + } + + results.push({ + provider: "grok", + timestampMs, + model: entry.model, + sessionId, + totals, + reportedCostUsd, + dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, + }); + } + return results; +} + export { EMPTY_TOTALS }; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 63101a341..729a786eb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1035,18 +1035,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notInclude(error.detail, "Git command failed in"); }), ); - - it.effect("treats removing an already-gone worktree as a no-op", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const missingWorktree = pathService.join(cwd, "missing-worktree"); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.initRepo({ cwd }); - - yield* driver.removeWorktree({ cwd, path: missingWorktree }); - }), - ); }); describe("review diff previews", () => { diff --git a/apps/web/package.json b/apps/web/package.json index b73eefc8d..ddda0a962 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.34", + "version": "0.0.35", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts deleted file mode 100644 index e4634f5b9..000000000 --- a/apps/web/src/assets/assetUrls.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveAssetUrl } from "./assetUrls"; - -describe("resolveAssetUrl", () => { - it("resolves an environment-relative asset URL", () => { - expect( - resolveAssetUrl("https://environment.example/base/", "/api/assets/signed-token/favicon.png"), - ).toBe("https://environment.example/api/assets/signed-token/favicon.png"); - }); - - it("rejects an invalid environment base URL", () => { - expect(resolveAssetUrl("not a URL", "/api/assets/signed-token/favicon.png")).toBeNull(); - }); -}); diff --git a/apps/web/src/components/StartTruncatedPath.test.tsx b/apps/web/src/components/StartTruncatedPath.test.tsx deleted file mode 100644 index 1f7b158bc..000000000 --- a/apps/web/src/components/StartTruncatedPath.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { StartTruncatedPath } from "./StartTruncatedPath"; - -describe("StartTruncatedPath", () => { - it("keeps the filename at the visible end by truncating from the start", () => { - const path = "apps/desktop/src/components/very/long/CommitDialog.tsx"; - - const markup = renderToStaticMarkup(); - - expect(markup).toContain('dir="rtl"'); - expect(markup).toMatch( - /]*>apps\/desktop\/src\/components\/very\/long\/CommitDialog\.tsx<\/bdi>/, - ); - expect(markup).toContain("overflow-hidden"); - expect(markup).toContain("text-ellipsis"); - expect(markup).toContain("whitespace-nowrap"); - expect(markup).toContain("min-w-0"); - }); - - it("shows the full path in a tooltip", () => { - const path = "apps/desktop/src/components/very/long/CommitDialog.tsx"; - - const markup = renderToStaticMarkup(); - - expect(markup).toContain('data-slot="tooltip-trigger"'); - expect(markup).toContain("data-base-ui-tooltip-trigger"); - expect(markup).toContain(path); - }); -}); diff --git a/apps/web/src/components/preview/previewAutomationClientId.test.ts b/apps/web/src/components/preview/previewAutomationClientId.test.ts deleted file mode 100644 index f51732d91..000000000 --- a/apps/web/src/components/preview/previewAutomationClientId.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { createPreviewAutomationClientId } from "./previewAutomationClientId"; - -describe("createPreviewAutomationClientId", () => { - it("creates bounded cryptographically random identities for independent host lifetimes", () => { - const clientIds = Array.from({ length: 32 }, createPreviewAutomationClientId); - - expect(new Set(clientIds).size).toBe(clientIds.length); - expect(clientIds.every((clientId) => clientId.startsWith("preview-"))).toBe(true); - expect(clientIds.every((clientId) => clientId.length <= 128)).toBe(true); - }); -}); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 629583311..bf41cf56a 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -12,7 +12,7 @@ import { } from "lucide-react"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { isProviderDriverKind, resolveProviderInstanceEnabled, @@ -203,6 +203,25 @@ function ProviderAuthEmail(props: { ); } +function providerEnvironmentsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((variable, index) => { + const other = right[index]; + return ( + other !== undefined && + variable.name === other.name && + variable.value === other.value && + variable.sensitive === other.sensitive && + variable.valueRedacted === other.valueRedacted + ); + }) + ); +} + function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; @@ -210,6 +229,30 @@ function ProviderEnvironmentSection(props: { const [rows, setRows] = useState>(() => props.environment.map(makeEnvironmentDraftRow), ); + const previousEnvironmentRef = useRef(props.environment); + const lastPublishedEnvironmentRef = useRef< + ReadonlyArray | undefined + >(undefined); + + // The card stays mounted while the server rewrites the instance underneath + // it, so adopt an environment that changed elsewhere. Echoes of what this + // editor just published are ignored: re-seeding on those would drop the + // in-progress row the user is still typing. + useEffect(() => { + const previousEnvironment = previousEnvironmentRef.current; + const lastPublishedEnvironment = lastPublishedEnvironmentRef.current; + previousEnvironmentRef.current = props.environment; + lastPublishedEnvironmentRef.current = undefined; + if ( + previousEnvironment === props.environment || + providerEnvironmentsEqual(previousEnvironment, props.environment) || + (lastPublishedEnvironment !== undefined && + providerEnvironmentsEqual(lastPublishedEnvironment, props.environment)) + ) { + return; + } + setRows(props.environment.map(makeEnvironmentDraftRow)); + }, [props.environment]); const publishRows = (nextRows: ReadonlyArray) => { const published: ProviderInstanceEnvironmentVariable[] = []; @@ -229,6 +272,7 @@ function ProviderEnvironmentSection(props: { const { id: _id, ...rest } = row; published.push({ ...rest, name }); } + lastPublishedEnvironmentRef.current = published; props.onChange(published); }; diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 06622a761..deac0e58d 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -6,7 +6,7 @@ import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/con */ export const PROVIDER_STATUS_STYLES = { disabled: { - dot: "bg-amber-400", + dot: "bg-muted-foreground/50", }, error: { dot: "bg-destructive", diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts deleted file mode 100644 index e38d5c374..000000000 --- a/apps/web/src/components/threadSidebarWidth.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares the sidebar component with its width contract. -import * as NodeFS from "node:fs"; - -import { describe, expect, it } from "vite-plus/test"; - -import { - resolveInitialThreadSidebarWidth, - THREAD_MAIN_CONTENT_MIN_WIDTH, - THREAD_SIDEBAR_DEFAULT_WIDTH, - THREAD_SIDEBAR_MIN_WIDTH, -} from "./threadSidebarWidth"; - -describe("thread sidebar width", () => { - it("uses the default width when no preference is stored", () => { - expect(resolveInitialThreadSidebarWidth(null, 1200)).toBe(THREAD_SIDEBAR_DEFAULT_WIDTH); - }); - - it("uses a stored width in the initial render", () => { - expect(resolveInitialThreadSidebarWidth(360, 1200)).toBe(360); - }); - - it("clamps a stored width to the sidebar minimum", () => { - expect(resolveInitialThreadSidebarWidth(120, 1200)).toBe(THREAD_SIDEBAR_MIN_WIDTH); - }); - - it("leaves enough room for the main content on a smaller window", () => { - const viewportWidth = 1000; - - expect(resolveInitialThreadSidebarWidth(900, viewportWidth)).toBe( - viewportWidth - THREAD_MAIN_CONTENT_MIN_WIDTH, - ); - }); - - it("keeps the sidebar minimum when the whole layout is narrower than its minimums", () => { - expect(resolveInitialThreadSidebarWidth(900, 700)).toBe(THREAD_SIDEBAR_MIN_WIDTH); - }); - - it("shows the desktop wordmark across the sidebar's full legal width range", () => { - const sidebarSource = NodeFS.readFileSync( - new URL("./sidebar/SidebarChrome.tsx", import.meta.url), - "utf8", - ); - - expect(sidebarSource).toContain("hidden h-7 w-fit min-w-0 shrink-0 items-center gap-1"); - expect(sidebarSource).toContain("md:flex"); - expect(THREAD_SIDEBAR_MIN_WIDTH).toBe(13 * 16); - }); -}); diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts index 93dd196e8..27589d8f2 100644 --- a/apps/web/src/components/threadSidebarWidth.ts +++ b/apps/web/src/components/threadSidebarWidth.ts @@ -1,5 +1,5 @@ export const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; -export const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; +const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; diff --git a/apps/web/src/components/ui/toggle-group.test.tsx b/apps/web/src/components/ui/toggle-group.test.tsx deleted file mode 100644 index c2403c9f1..000000000 --- a/apps/web/src/components/ui/toggle-group.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { Toggle, ToggleGroup } from "./toggle-group"; - -describe("toggle group segmented defaults", () => { - it("derives the segmented item size from the variant", () => { - const html = renderToStaticMarkup( - - A - , - ); - - expect(html.match(/data-size="segmented"/g)).toHaveLength(2); - expect(html).toContain("h-6"); - expect(html).toContain("dark:hover:bg-input/32"); - expect(html).toContain("dark:data-pressed:bg-input/72"); - }); -}); diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 09961213b..a4114cfdf 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -86,6 +86,7 @@ describe("buildDayColumns", () => { expect(first?.bands).toEqual([ { provider: "codex", value: 10 }, { provider: "claude", value: 20 }, + { provider: "grok", value: 0 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 79f62a65a..efad95e53 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,6 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, type Icon, OpenAI } from "../Icons"; +import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -24,6 +24,12 @@ export const PROVIDER_PRESENTATION = { color: "#d97757", mark: ClaudeAI, }, + grok: { + label: "Grok Build", + // Contrast-aware neutral between the Codex series and muted chart chrome. + color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))", + mark: GrokIcon, + }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/apps/web/src/reactGrabBoundary.test.ts b/apps/web/src/reactGrabBoundary.test.ts deleted file mode 100644 index 9481e2dd9..000000000 --- a/apps/web/src/reactGrabBoundary.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import packageJson from "../package.json" with { type: "json" }; -import mainSource from "./main.tsx?raw"; - -describe("React Grab runtime boundary", () => { - it("keeps the host renderer free of the React Grab overlay", () => { - expect(mainSource).not.toMatch(/import\(["']react-grab["']\)/); - expect(packageJson.dependencies).not.toHaveProperty("react-grab"); - expect(packageJson.devDependencies).not.toHaveProperty("react-grab"); - }); -}); diff --git a/assets/README.md b/assets/README.md index 8c53266bf..c1a16dde2 100644 --- a/assets/README.md +++ b/assets/README.md @@ -48,3 +48,17 @@ Verify every result is 1024×1024 and has the classic macOS safe area: an 824×8 ``` Do not edit the generated PNG or ICO files directly. + +## Android adaptive foreground + +`apps/mobile/assets/android-icon-foreground.svg` is the source of truth for the foreground used by +the normal Android adaptive launcher icon. Export its paired PNG after changing it: + +```sh +rsvg-convert -w 432 -h 432 \ + -o apps/mobile/assets/android-icon-foreground.png \ + apps/mobile/assets/android-icon-foreground.svg +``` + +The foreground must remain transparent and keep the T3 mark inside Android's adaptive-icon safe +zone. `android-icon-mark.png` remains a flat silhouette for Android's monochrome themed icon. diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d..7ef7b97f9 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -68,6 +68,10 @@ them on in **Settings** → the provider's card when you want to use them. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. +Grok models that support adjustable reasoning show a **Reasoning** control beside the model picker. +The available levels and default come from the installed Grok Build CLI, so they can vary by model +and CLI version. + Run the login command on the machine running the T3 Code server, not on the device you browse from. diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index f48a915cb..b5fd763c8 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -26,6 +26,9 @@ unattended until it finishes or asks a question of its own. Approvals appear inline in the conversation. Approve or reject one and the agent continues from there. +For Grok, **Always allow this session** remembers the matching command or tool input. Other +actions still ask for approval. It does not change the thread to **Full access**. + ## Choosing a Mode Use **Full access** for work in a worktree or a sandbox you can throw away. @@ -40,9 +43,10 @@ shell commands. Each provider maps these modes onto its own approval and sandbox settings. Codex, for example, translates the mode into its approval policy and sandbox level, so **Supervised** runs the CLI -with prompting enabled and a restricted workspace while **Full access** disables both. The -labels above describe what you get; the exact per-provider translation is internal and may -change. +with prompting enabled and a restricted workspace while **Full access** disables both. Grok +threads do the same: **Supervised** starts Grok in ask mode even if your Grok CLI config is +set to always-approve, and **Full access** starts Grok with always-approve. The labels above +describe what you get; the exact per-provider translation is internal and may change. Mobile offers the same four modes. It labels the first one **Approve actions** rather than **Supervised**. diff --git a/docs/user/usage.md b/docs/user/usage.md index 72d19ba77..ff38c730c 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -1,9 +1,12 @@ # Review usage -The Usage page combines Codex and Claude Code activity from your connected environments. It reads -the providers' local session history and shows API-equivalent token cost, processed tokens, cache -savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token -cost shown here. +The Usage page combines Codex, Claude Code, and Grok Build activity from your connected +environments. It reads the providers' local session history and shows API-equivalent token cost, +processed tokens, cache savings, provider shares, and model breakdowns. Subscription billing is +separate from the raw token cost shown here. + +Grok Build totals come from persisted session updates. Interactive turns that never wrote a +completed-turn record will not appear. Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the diff --git a/packages/client-runtime/src/state/session.test.ts b/packages/client-runtime/src/state/session.test.ts deleted file mode 100644 index fe1dcdbe3..000000000 --- a/packages/client-runtime/src/state/session.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import { initialConfigOption } from "./session.ts"; - -class TestConfigError extends Schema.TaggedErrorClass()("TestConfigError", { - message: Schema.String, -}) {} - -describe("environment session state", () => { - it.effect("turns an initial config failure into an empty value", () => - Effect.gen(function* () { - const result = yield* initialConfigOption( - Effect.fail(new TestConfigError({ message: "temporary failure" })), - ); - expect(Option.isNone(result)).toBe(true); - }), - ); -}); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 31fd297da..43adb980f 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -16,7 +16,7 @@ import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../ import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./runtime.ts"; -export function initialConfigOption( +function initialConfigOption( initialConfig: Effect.Effect, ): Effect.Effect> { return initialConfig.pipe( diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 6dc3b3745..ea0e88296 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.34", + "version": "0.0.35", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index cde888a61..8c099ddb3 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -2,10 +2,10 @@ * Usage reporting contract. * * Each environment scans the provider CLIs' own on-disk session transcripts - * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than - * relying on T3 Code's own orchestration projections, so usage stays complete - * even for turns that were never driven through T3 Code. This mirrors the - * approach `ccusage` takes. + * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, + * `~/.grok/sessions/**\/updates.jsonl`) rather than relying on T3 Code's own + * orchestration projections, so usage stays complete even for turns that were + * never driven through T3 Code. This mirrors the approach `ccusage` takes. * * Environments return pre-aggregated `(day, hourStart?, provider, model)` * buckets. Raw transcript records never cross the wire. @@ -21,9 +21,18 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 4 as const; +export const USAGE_CONTRACT_VERSION = 5 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +/** + * Oldest {@link UsageSummary} version a current client will still merge. + * + * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets + * remain valid, so mixed-version environments keep those totals instead of + * treating every older server as stale. + */ +export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; + +export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 9f23a1445..3622cb8f5 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -145,6 +145,55 @@ const ManualSchemas: Record = { }, }; +// Codex 0.150 added these multi-agent values before our next full protocol +// refresh. Keep every generated response namespace compatible with them. +const Codex0150DefinitionSchemas: Record = { + CollabAgentTool: { + type: "string", + enum: [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", + ], + }, + CollabAgentToolCallStatus: { + type: "string", + enum: ["inProgress", "completed", "failed", "interrupted"], + }, + PlanType: { + type: "string", + enum: [ + "free", + "go", + "plus", + "pro", + "prolite", + "team", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "business", + "ent26", + "enterprise_cbp_automation", + "enterprise_cbp_usage_based", + "enterprise", + "edu", + "edu_plus", + "edu_pro", + "unknown", + ], + }, + SubAgentActivityKind: { + type: "string", + enum: ["started", "interacted", "interrupted", "completed"], + }, +}; + const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () { const path = yield* Path.Path; const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated"); @@ -556,10 +605,12 @@ const generateFiles = Effect.fn("generateFiles")(function* () { ); for (const [definitionName, definitionSchema] of Object.entries(parsed.definitions ?? {})) { + const compatibleDefinitionSchema = + Codex0150DefinitionSchemas[definitionName] ?? definitionSchema; aggregateSchemas[localDefinitionNames.get(definitionName)!] = stripNullDefaults( normalizeNullableTypes( rewriteExternalRefs( - definitionSchema, + compatibleDefinitionSchema, localDefinitionNames, file.namespace, exportNameByQualifiedName, diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index d826df60f..34df10eb0 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -2411,11 +2411,16 @@ export type ServerNotification__PlanType = | "pro" | "prolite" | "team" + | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" + | "ent26" + | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" + | "edu_plus" + | "edu_pro" | "unknown"; export const ServerNotification__PlanType = Schema.Literals([ "free", @@ -2424,11 +2429,16 @@ export const ServerNotification__PlanType = Schema.Literals([ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", + "ent26", + "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown", ]); @@ -2616,11 +2626,16 @@ export const ServerNotification__SpendControlLimitSnapshot = Schema.Struct({ used: Schema.String, }); -export type ServerNotification__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type ServerNotification__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const ServerNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type ServerNotification__TerminalInteractionNotification = { @@ -3243,11 +3258,16 @@ export type V2AccountRateLimitsUpdatedNotification__PlanType = | "pro" | "prolite" | "team" + | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" + | "ent26" + | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" + | "edu_plus" + | "edu_pro" | "unknown"; export const V2AccountRateLimitsUpdatedNotification__PlanType = Schema.Literals([ "free", @@ -3256,11 +3276,16 @@ export const V2AccountRateLimitsUpdatedNotification__PlanType = Schema.Literals( "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", + "ent26", + "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown", ]); @@ -3331,11 +3356,16 @@ export type V2AccountUpdatedNotification__PlanType = | "pro" | "prolite" | "team" + | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" + | "ent26" + | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" + | "edu_plus" + | "edu_pro" | "unknown"; export const V2AccountUpdatedNotification__PlanType = Schema.Literals([ "free", @@ -3344,11 +3374,16 @@ export const V2AccountUpdatedNotification__PlanType = Schema.Literals([ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", + "ent26", + "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown", ]); @@ -4135,11 +4170,16 @@ export type V2GetAccountRateLimitsResponse__PlanType = | "pro" | "prolite" | "team" + | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" + | "ent26" + | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" + | "edu_plus" + | "edu_pro" | "unknown"; export const V2GetAccountRateLimitsResponse__PlanType = Schema.Literals([ "free", @@ -4148,11 +4188,16 @@ export const V2GetAccountRateLimitsResponse__PlanType = Schema.Literals([ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", + "ent26", + "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown", ]); @@ -4223,11 +4268,16 @@ export type V2GetAccountResponse__PlanType = | "pro" | "prolite" | "team" + | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" + | "ent26" + | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" + | "edu_plus" + | "edu_pro" | "unknown"; export const V2GetAccountResponse__PlanType = Schema.Literals([ "free", @@ -4236,11 +4286,16 @@ export const V2GetAccountResponse__PlanType = Schema.Literals([ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", + "ent26", + "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown", ]); @@ -4710,11 +4765,13 @@ export const V2ItemCompletedNotification__ReasoningEffort = Schema.String.annota export type V2ItemCompletedNotification__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ItemCompletedNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ItemCompletedNotification__TextElement = { @@ -5115,11 +5172,13 @@ export const V2ItemStartedNotification__ReasoningEffort = Schema.String.annotate export type V2ItemStartedNotification__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ItemStartedNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ItemStartedNotification__TextElement = { @@ -6284,11 +6343,16 @@ export const V2ReviewStartResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ReviewStartResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ReviewStartResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ReviewStartResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ReviewStartResponse__TextElement = { @@ -6720,11 +6784,16 @@ export const V2ThreadForkResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ThreadForkResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ThreadForkResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ThreadForkResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadForkResponse__TextElement = { @@ -7119,11 +7188,16 @@ export const V2ThreadListResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ThreadListResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ThreadListResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ThreadListResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadListResponse__TextElement = { @@ -7463,11 +7537,13 @@ export const V2ThreadMetadataUpdateResponse__ReasoningEffort = Schema.String.ann export type V2ThreadMetadataUpdateResponse__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ThreadMetadataUpdateResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadMetadataUpdateResponse__TextElement = { @@ -7760,11 +7836,16 @@ export const V2ThreadReadResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ThreadReadResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ThreadReadResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ThreadReadResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadReadResponse__TextElement = { @@ -8342,11 +8423,16 @@ export const V2ThreadResumeResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ThreadResumeResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ThreadResumeResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ThreadResumeResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadResumeResponse__TextElement = { @@ -8643,11 +8729,13 @@ export const V2ThreadRollbackResponse__ReasoningEffort = Schema.String.annotate( export type V2ThreadRollbackResponse__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ThreadRollbackResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadRollbackResponse__TextElement = { @@ -9051,11 +9139,13 @@ export const V2ThreadStartedNotification__ReasoningEffort = Schema.String.annota export type V2ThreadStartedNotification__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ThreadStartedNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadStartedNotification__TextElement = { @@ -9458,11 +9548,16 @@ export const V2ThreadStartResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ThreadStartResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ThreadStartResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ThreadStartResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadStartResponse__TextElement = { @@ -9791,11 +9886,13 @@ export const V2ThreadUnarchiveResponse__ReasoningEffort = Schema.String.annotate export type V2ThreadUnarchiveResponse__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ThreadUnarchiveResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadUnarchiveResponse__TextElement = { @@ -10095,11 +10192,13 @@ export const V2TurnCompletedNotification__ReasoningEffort = Schema.String.annota export type V2TurnCompletedNotification__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2TurnCompletedNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2TurnCompletedNotification__TextElement = { @@ -10385,11 +10484,13 @@ export const V2TurnStartedNotification__ReasoningEffort = Schema.String.annotate export type V2TurnStartedNotification__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2TurnStartedNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2TurnStartedNotification__TextElement = { @@ -10761,11 +10862,16 @@ export const V2TurnStartResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2TurnStartResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2TurnStartResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2TurnStartResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2TurnStartResponse__TextElement = { @@ -20357,8 +20463,17 @@ export type ServerNotification__ThreadItem = readonly reasoningEffort?: ServerNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -20580,7 +20695,7 @@ export const ServerNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -20589,6 +20704,10 @@ export const ServerNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -21440,8 +21559,17 @@ export type V2ItemCompletedNotification__ThreadItem = readonly reasoningEffort?: V2ItemCompletedNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -21668,7 +21796,7 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -21677,6 +21805,10 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -21891,8 +22023,17 @@ export type V2ItemStartedNotification__ThreadItem = readonly reasoningEffort?: V2ItemStartedNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -22119,7 +22260,7 @@ export const V2ItemStartedNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -22128,6 +22269,10 @@ export const V2ItemStartedNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -22514,8 +22659,17 @@ export type V2ReviewStartResponse__ThreadItem = readonly reasoningEffort?: V2ReviewStartResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -22739,7 +22893,7 @@ export const V2ReviewStartResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -22748,6 +22902,10 @@ export const V2ReviewStartResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -22950,8 +23108,17 @@ export type V2ThreadForkResponse__ThreadItem = readonly reasoningEffort?: V2ThreadForkResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -23175,7 +23342,7 @@ export const V2ThreadForkResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -23184,6 +23351,10 @@ export const V2ThreadForkResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -23355,8 +23526,17 @@ export type V2ThreadListResponse__ThreadItem = readonly reasoningEffort?: V2ThreadListResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -23580,7 +23760,7 @@ export const V2ThreadListResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -23589,6 +23769,10 @@ export const V2ThreadListResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -23762,8 +23946,17 @@ export type V2ThreadMetadataUpdateResponse__ThreadItem = readonly reasoningEffort?: V2ThreadMetadataUpdateResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -23990,7 +24183,7 @@ export const V2ThreadMetadataUpdateResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -23999,6 +24192,10 @@ export const V2ThreadMetadataUpdateResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -24170,8 +24367,17 @@ export type V2ThreadReadResponse__ThreadItem = readonly reasoningEffort?: V2ThreadReadResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -24395,7 +24601,7 @@ export const V2ThreadReadResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -24404,6 +24610,10 @@ export const V2ThreadReadResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -24583,8 +24793,17 @@ export type V2ThreadResumeResponse__ThreadItem = readonly reasoningEffort?: V2ThreadResumeResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -24808,7 +25027,7 @@ export const V2ThreadResumeResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -24817,6 +25036,10 @@ export const V2ThreadResumeResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -24988,8 +25211,17 @@ export type V2ThreadRollbackResponse__ThreadItem = readonly reasoningEffort?: V2ThreadRollbackResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -25216,7 +25448,7 @@ export const V2ThreadRollbackResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -25225,6 +25457,10 @@ export const V2ThreadRollbackResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -25407,8 +25643,17 @@ export type V2ThreadStartedNotification__ThreadItem = readonly reasoningEffort?: V2ThreadStartedNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -25635,7 +25880,7 @@ export const V2ThreadStartedNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -25644,6 +25889,10 @@ export const V2ThreadStartedNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -25815,8 +26064,17 @@ export type V2ThreadStartResponse__ThreadItem = readonly reasoningEffort?: V2ThreadStartResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -26040,7 +26298,7 @@ export const V2ThreadStartResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -26049,6 +26307,10 @@ export const V2ThreadStartResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -26220,8 +26482,17 @@ export type V2ThreadUnarchiveResponse__ThreadItem = readonly reasoningEffort?: V2ThreadUnarchiveResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -26448,7 +26719,7 @@ export const V2ThreadUnarchiveResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -26457,6 +26728,10 @@ export const V2ThreadUnarchiveResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -26630,8 +26905,17 @@ export type V2TurnCompletedNotification__ThreadItem = readonly reasoningEffort?: V2TurnCompletedNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -26858,7 +27142,7 @@ export const V2TurnCompletedNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -26867,6 +27151,10 @@ export const V2TurnCompletedNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -27038,8 +27326,17 @@ export type V2TurnStartedNotification__ThreadItem = readonly reasoningEffort?: V2TurnStartedNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -27266,7 +27563,7 @@ export const V2TurnStartedNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -27275,6 +27572,10 @@ export const V2TurnStartedNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -27446,8 +27747,17 @@ export type V2TurnStartResponse__ThreadItem = readonly reasoningEffort?: V2TurnStartResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -27669,7 +27979,7 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -27678,6 +27988,10 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -35971,20 +36285,33 @@ export type ServerNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const ServerNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); -export type ServerNotification__CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type ServerNotification__CollabAgentToolCallStatus = + | "inProgress" + | "completed" + | "failed" + | "interrupted"; export const ServerNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type ServerNotification__CommandExecOutputStream = "stdout" | "stderr"; @@ -38046,23 +38373,33 @@ export type V2ItemCompletedNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ItemCompletedNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ItemCompletedNotification__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ItemCompletedNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ItemCompletedNotification__CommandExecutionSource = @@ -38183,23 +38520,33 @@ export type V2ItemStartedNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ItemStartedNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ItemStartedNotification__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ItemStartedNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ItemStartedNotification__CommandExecutionSource = @@ -39200,23 +39547,33 @@ export type V2ReviewStartResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ReviewStartResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ReviewStartResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ReviewStartResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ReviewStartResponse__CommandExecutionSource = @@ -39598,20 +39955,33 @@ export type V2ThreadForkResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadForkResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); -export type V2ThreadForkResponse__CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type V2ThreadForkResponse__CollabAgentToolCallStatus = + | "inProgress" + | "completed" + | "failed" + | "interrupted"; export const V2ThreadForkResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadForkResponse__CommandExecutionSource = @@ -39955,20 +40325,33 @@ export type V2ThreadListResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadListResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); -export type V2ThreadListResponse__CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type V2ThreadListResponse__CollabAgentToolCallStatus = + | "inProgress" + | "completed" + | "failed" + | "interrupted"; export const V2ThreadListResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadListResponse__CommandExecutionSource = @@ -40131,23 +40514,33 @@ export type V2ThreadMetadataUpdateResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadMetadataUpdateResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadMetadataUpdateResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadMetadataUpdateResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadMetadataUpdateResponse__CommandExecutionSource = @@ -40265,20 +40658,33 @@ export type V2ThreadReadResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadReadResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); -export type V2ThreadReadResponse__CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type V2ThreadReadResponse__CollabAgentToolCallStatus = + | "inProgress" + | "completed" + | "failed" + | "interrupted"; export const V2ThreadReadResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadReadResponse__CommandExecutionSource = @@ -40964,23 +41370,33 @@ export type V2ThreadResumeResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadResumeResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadResumeResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadResumeResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadResumeResponse__CommandExecutionSource = @@ -41334,23 +41750,33 @@ export type V2ThreadRollbackResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadRollbackResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadRollbackResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadRollbackResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadRollbackResponse__CommandExecutionSource = @@ -41673,23 +42099,33 @@ export type V2ThreadStartedNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadStartedNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadStartedNotification__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadStartedNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadStartedNotification__CommandExecutionSource = @@ -42075,23 +42511,33 @@ export type V2ThreadStartResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadStartResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadStartResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadStartResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadStartResponse__CommandExecutionSource = @@ -42278,23 +42724,33 @@ export type V2ThreadUnarchiveResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadUnarchiveResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadUnarchiveResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadUnarchiveResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadUnarchiveResponse__CommandExecutionSource = @@ -42412,23 +42868,33 @@ export type V2TurnCompletedNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2TurnCompletedNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2TurnCompletedNotification__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2TurnCompletedNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2TurnCompletedNotification__CommandExecutionSource = @@ -42524,23 +42990,33 @@ export type V2TurnStartedNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2TurnStartedNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2TurnStartedNotification__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2TurnStartedNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2TurnStartedNotification__CommandExecutionSource = @@ -42728,20 +43204,33 @@ export type V2TurnStartResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2TurnStartResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); -export type V2TurnStartResponse__CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type V2TurnStartResponse__CollabAgentToolCallStatus = + | "inProgress" + | "completed" + | "failed" + | "interrupted"; export const V2TurnStartResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2TurnStartResponse__CommandExecutionSource = diff --git a/packages/effect-codex-app-server/src/schema.test.ts b/packages/effect-codex-app-server/src/schema.test.ts new file mode 100644 index 000000000..d7afec7db --- /dev/null +++ b/packages/effect-codex-app-server/src/schema.test.ts @@ -0,0 +1,98 @@ +import { assert, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import * as CodexSchema from "./schema.ts"; + +const isGetAccountResponse = Schema.is(CodexSchema.V2GetAccountResponse); + +it("accepts Codex 0.150 multi-agent values", () => { + const schemas = [ + CodexSchema.ServerNotification__SubAgentActivityKind, + CodexSchema.V2ItemStartedNotification__SubAgentActivityKind, + CodexSchema.V2ItemCompletedNotification__SubAgentActivityKind, + CodexSchema.V2ThreadReadResponse__SubAgentActivityKind, + CodexSchema.V2ThreadResumeResponse__SubAgentActivityKind, + ]; + + for (const schema of schemas) { + assert.equal(Schema.is(schema)("completed"), true); + } + + for (const tool of ["sendMessage", "followupTask", "interruptAgent", "listAgents"]) { + assert.equal(Schema.is(CodexSchema.ServerNotification__CollabAgentTool)(tool), true); + assert.equal(Schema.is(CodexSchema.V2ThreadResumeResponse__CollabAgentTool)(tool), true); + } + + assert.equal( + Schema.is(CodexSchema.ServerNotification__CollabAgentToolCallStatus)("interrupted"), + true, + ); + assert.equal( + Schema.is(CodexSchema.V2ThreadResumeResponse__CollabAgentToolCallStatus)("interrupted"), + true, + ); + + const resumeResponse = { + approvalPolicy: "never", + approvalsReviewer: "user", + cwd: "/tmp/project", + model: "gpt-5.6-sol", + modelProvider: "openai", + sandbox: { type: "dangerFullAccess" }, + thread: { + cliVersion: "0.150.0", + createdAt: 0, + cwd: "/tmp/project", + ephemeral: false, + id: "root-thread", + modelProvider: "openai", + preview: "", + sessionId: "session-1", + source: "cli", + status: { type: "idle" }, + turns: [ + { + id: "turn-1", + status: "completed", + items: [ + { + agentsStates: {}, + id: "item-1", + receiverThreadIds: ["child-thread"], + senderThreadId: "root-thread", + status: "interrupted", + tool: "followupTask", + type: "collabAgentToolCall", + }, + ], + }, + ], + updatedAt: 0, + }, + }; + + assert.equal(Schema.is(CodexSchema.V2ThreadResumeResponse)(resumeResponse), true); +}); + +it("accepts Codex 0.150 account plan values", () => { + const planTypes = [ + "self_serve_business_prolite", + "ent26", + "enterprise_cbp_automation", + "edu_plus", + "edu_pro", + ]; + + for (const planType of planTypes) { + const accountResponse = { + account: { + email: "user@example.com", + planType, + type: "chatgpt", + }, + requiresOpenaiAuth: true, + }; + + assert.equal(isGetAccountResponse(accountResponse), true); + } +}); diff --git a/packages/shared/src/String.test.ts b/packages/shared/src/String.test.ts deleted file mode 100644 index a32b6f28e..000000000 --- a/packages/shared/src/String.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { truncate } from "./String.ts"; - -describe("truncate", () => { - it("trims surrounding whitespace", () => { - expect(truncate(" hello world ")).toBe("hello world"); - }); - - it("returns shorter strings unchanged", () => { - expect(truncate("alpha", 10)).toBe("alpha"); - }); - - it("truncates long strings and appends an ellipsis", () => { - expect(truncate("abcdefghij", 5)).toBe("abcde..."); - }); -}); diff --git a/packages/shared/src/themePreview.test.ts b/packages/shared/src/themePreview.test.ts deleted file mode 100644 index f1cc02e10..000000000 --- a/packages/shared/src/themePreview.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - mixThemePreviewBase, - STANDARD_THEME_PREVIEW_COLORS, - THEME_PREVIEW_RENDER_SPECS, -} from "./themePreview.js"; - -describe("theme preview", () => { - it("keeps the desktop preview geometry stable across clients", () => { - expect(THEME_PREVIEW_RENDER_SPECS.light!.accent.center).toEqual([0.72, 0.22]); - expect(THEME_PREVIEW_RENDER_SPECS.dark!.accent.middleOpacity).toBe(0.62); - expect(THEME_PREVIEW_RENDER_SPECS.dark!.action.center).toEqual([0.82, 0.18]); - }); - - it("mixes the standard canvas bases in OKLab", () => { - expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.light!, "light")).toBe("#fdfdfd"); - expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.dark!, "dark")).toBe("#0a0a0a"); - }); -}); diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 3bee4a9bd..6c706395c 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -158,7 +158,7 @@ describe("mergeUsage", () => { summary( [bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b" }], - USAGE_CONTRACT_VERSION - 1, + USAGE_CONTRACT_VERSION - 2, ), ), ], @@ -169,6 +169,32 @@ describe("mergeUsage", () => { expect(merged.staleEnvironments).toEqual(["env-b"]); }); + it("keeps the previous compatible contract version so additive provider expansions still merge", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ costUsd: 10 })], + [{ provider: "claude", hostId: "mac", homePath: "/a" }], + ), + ), + environment( + "env-b", + summary( + [bucket({ costUsd: 4, provider: "codex", model: "gpt-5.6-sol" })], + [{ provider: "codex", hostId: "linux", homePath: "/b" }], + USAGE_CONTRACT_VERSION - 1, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(14); + expect(merged.staleEnvironments).toEqual([]); + }); + it("derives provider shares and cost quality", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 954139b4e..428599d51 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -6,12 +6,13 @@ * * @module usageMerge */ -import type { - EnvironmentId, - UsageBucket, - UsageProviderKind, - UsageSourceFingerprint, - UsageSummary, +import { + USAGE_MERGE_COMPATIBLE_SINCE, + type EnvironmentId, + type UsageBucket, + type UsageProviderKind, + type UsageSourceFingerprint, + type UsageSummary, } from "@t3tools/contracts"; export interface EnvironmentUsage { @@ -172,6 +173,10 @@ function bucketTokens(bucket: UsageBucket): number { ); } +function isCompatibleContractVersion(version: number, expected: number): boolean { + return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected; +} + const EMPTY_MERGED: MergedUsage = { costUsd: 0, uncachedInputTokens: 0, @@ -201,8 +206,10 @@ const EMPTY_MERGED: MergedUsage = { * Merges every connected environment's summary. * * `expectedContractVersion` guards against an environment running older server - * code: rather than blocking the page, its data is excluded and its id is - * reported so the UI can say coverage is partial. + * code: rather than blocking the page, incompatible data is excluded and its + * id is reported so the UI can say coverage is partial. Versions in + * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive + * provider expansion does not drop Claude/Codex totals from older servers. */ export function mergeUsage( environments: readonly EnvironmentUsage[], @@ -213,7 +220,7 @@ export function mergeUsage( const current: EnvironmentUsage[] = []; const staleEnvironments: EnvironmentId[] = []; for (const environment of environments) { - if (environment.summary.contractVersion === expectedContractVersion) { + if (isCompatibleContractVersion(environment.summary.contractVersion, expectedContractVersion)) { current.push(environment); } else { staleEnvironments.push(environment.environmentId); diff --git a/scripts/lib/resolve-catalog.test.ts b/scripts/lib/resolve-catalog.test.ts deleted file mode 100644 index ae9a22911..000000000 --- a/scripts/lib/resolve-catalog.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { assert, it } from "@effect/vitest"; - -import { CatalogDependencyResolutionError, resolveCatalogDependencies } from "./resolve-catalog.ts"; - -it("reports unresolved catalog dependencies with lookup context", () => { - try { - resolveCatalogDependencies({ effect: "catalog:runtime" }, {}, "apps/server"); - assert.fail("Expected catalog resolution to fail."); - } catch (error) { - assert.instanceOf(error, CatalogDependencyResolutionError); - assert.equal(error.workspacePackage, "apps/server"); - assert.equal(error.dependencyName, "effect"); - assert.equal(error.catalogSpec, "catalog:runtime"); - assert.equal(error.catalogKey, "runtime"); - assert.equal( - error.message, - "Unable to resolve 'catalog:runtime' for apps/server dependency 'effect'. Expected key 'runtime' in root workspace catalog.", - ); - } -}); diff --git a/scripts/lib/resolve-catalog.ts b/scripts/lib/resolve-catalog.ts index eb9d4cc78..0380cf48e 100644 --- a/scripts/lib/resolve-catalog.ts +++ b/scripts/lib/resolve-catalog.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -export class CatalogDependencyResolutionError extends Schema.TaggedErrorClass()( +class CatalogDependencyResolutionError extends Schema.TaggedErrorClass()( "CatalogDependencyResolutionError", { workspacePackage: Schema.String,