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,