diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 3bb3b761a050..3dacaf2a92a9 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -10,29 +10,41 @@ # # Keep entries sorted alphabetically. github:adityavardhansharma +github:arhxam github:bil0000 github:binbandit +github:Brechard github:chrisdeeming github:chuks-qua github:cursoragent +github:D3OXY github:eggfriedrice24 +github:extoci github:gbarros-dev github:gfsaaser24 github:github-actions[bot] github:gsimone +github:GuilhermeVieiraDev github:hwanseoc +github:jakeleventhal github:jamesx0416 github:jappyjan github:jasonLaster github:JoeEverest github:justsomelegs +github:kridaydave +github:lnieuwenhuis +github:Lucenx9 github:mackinleysmith github:maria-rcks +github:mwolson github:nmggithub github:Noojuno github:notkainoa github:PatrickBauer github:pc-style +github:PixPMusic +github:PollyGlot github:RakshithBhat03 github:realAhmedRoach github:Rishet11 @@ -43,6 +55,8 @@ github:shivamhwp github:StiensWout github:SunkenInTime github:tarik02 +github:tris203 github:UtkarshUsername github:Yash-Singh1 +github:yashranaway github:Ymit24 diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml new file mode 100644 index 000000000000..7875aec6f36b --- /dev/null +++ b/.github/workflows/desktop-macos-preview.yml @@ -0,0 +1,361 @@ +name: Desktop macOS Preview + +on: + pull_request: + types: [labeled, unlabeled, synchronize, reopened, closed] + +permissions: + contents: read + +# 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 }}-${{ 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') + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 30 + outputs: + dmg_name: ${{ steps.build.outputs.dmg_name }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: false + + - name: Install desktop dependencies + run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/aarch64-apple-darwin/release/t3-resource-monitor + key: resource-monitor-aarch64-apple-darwin-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + + - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + + - id: version + name: Set preview version and public configuration + shell: bash + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + base_version="$(node -p "require('./apps/desktop/package.json').version")" + preview_version="${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" + node scripts/update-release-package-versions.ts "$preview_version" + cp .env.example .env + + echo "version=$preview_version" >> "$GITHUB_OUTPUT" + + - id: build + name: Build unsigned macOS DMG + shell: bash + env: + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + PREVIEW_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + + vp run dist:desktop:artifact \ + --platform mac \ + --target dmg \ + --arch arm64 \ + --build-version "$PREVIEW_VERSION" \ + --verbose + + shopt -s nullglob + dmg_files=(release/*.dmg) + if (( ${#dmg_files[@]} != 1 )); then + printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 + exit 1 + fi + printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT" + + # 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 + if-no-files-found: error + archive: false + 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: blacksmith-8vcpu-ubuntu-2404 + 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: + DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} + DMG_NAME: ${{ needs.build.outputs.dmg_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PREVIEW_VERSION: ${{ needs.build.outputs.version }} + with: + script: | + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + 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; + } + + const marker = ""; + const body = [ + marker, + "### macOS preview", + "", + `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, + "", + `Version: ${process.env.PREVIEW_VERSION}`, + `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, + "", + "Unsigned build. Clear quarantine before opening:", + "```sh", + `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`, + "```", + "", + "No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed.", + ].join("\n"); + + 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) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + 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: blacksmith-8vcpu-ubuntu-2404 + 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/.github/workflows/release.yml b/.github/workflows/release.yml index 6abd702bf889..b8a2fab33ee4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,8 @@ on: - "v*.*.*" - "!v*-nightly.*" schedule: - - cron: "0 */3 * * *" + # Off minute zero: GitHub delays scheduled runs most at the top of the hour. + - cron: "7 */3 * * *" workflow_dispatch: inputs: channel: @@ -22,6 +23,17 @@ on: required: false type: string +# Serialize nightlies (scheduled and manual) so overlapping runs cannot build +# the same commit twice or publish out of order. Stable tag releases get their +# own group so a nightly never blocks them. Running publishers are never +# canceled, and queue: max keeps every pending run instead of the default +# newest-wins single slot, so a queued stable tag can never be silently +# dropped. Queued nightlies with no new commits skip via check_changes. +concurrency: + group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} + cancel-in-progress: false + queue: max + permissions: contents: read id-token: none @@ -100,9 +112,6 @@ jobs: cache: true run-install: true - - name: Ensure Electron runtime is installed - run: vp run --filter @t3tools/desktop ensure:electron - - id: release_meta name: Resolve release version shell: bash @@ -157,6 +166,40 @@ jobs: fi fi + - id: previous_tag + name: Resolve previous release tag + run: | + node scripts/resolve-previous-release-tag.ts \ + --channel "${{ steps.release_meta.outputs.release_channel }}" \ + --current-tag "${{ steps.release_meta.outputs.tag }}" \ + --github-output + + quality: + name: Release quality checks + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + - name: Check run: vp check @@ -166,18 +209,16 @@ jobs: - name: Test run: vp run test - - id: previous_tag - name: Resolve previous release tag - run: | - node scripts/resolve-previous-release-tag.ts \ - --channel "${{ steps.release_meta.outputs.release_channel }}" \ - --current-tag "${{ steps.release_meta.outputs.tag }}" \ - --github-output - relay_public_config: name: Resolve T3 Connect public config - needs: preflight - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + # Consumes only the commit SHA, not preflight's resolved version, so it runs + # alongside preflight instead of after it. The condition mirrors preflight's: + # check_changes is skipped on non-schedule events (skipped is neither failure + # nor success, so success() would be wrong here). + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 environment: @@ -199,7 +240,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ github.sha }} sparse-checkout: | /* !/.repos/ @@ -272,15 +313,19 @@ jobs: # machine. node-pty is N-API, so one binary works across all WSL Node versions. build_wsl_node_pty: name: Build WSL node-pty (linux-x64) - needs: [preflight] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + # Same gating as relay_public_config: only the commit SHA is needed, so this + # runs alongside preflight. See the condition comment there. + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ github.sha }} sparse-checkout: | /* !/.repos/ @@ -385,14 +430,34 @@ jobs: uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/desktop... - - --filter=t3... - - --filter=@t3tools/scripts... + cache: ${{ matrix.platform != 'win' }} + run-install: false + + - name: Resolve Windows package cache path + if: matrix.platform == 'win' + id: package_cache_path + shell: pwsh + run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' + + - name: Cache Windows packages + if: matrix.platform == 'win' + uses: actions/cache@v6 + with: + path: ${{ steps.package_cache_path.outputs.path }} + key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install desktop dependencies + run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }} + key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.rust_target }} @@ -518,6 +583,7 @@ jobs: - name: Build desktop artifact shell: bash env: + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} @@ -664,8 +730,8 @@ jobs: publish_cli: name: Publish CLI to npm - needs: [preflight, relay_public_config, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} + needs: [preflight, relay_public_config, quality, build] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 permissions: @@ -714,9 +780,8 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - name: Build web package - run: vp run --filter @t3tools/web build - + # The t3 build task depends on @t3tools/web#build, so the web client is + # built (once) as part of this step. - name: Build CLI package run: vp run --filter t3 build diff --git a/.macroscope/approvability.md b/.macroscope/approvability.md new file mode 100644 index 000000000000..cfea7fdd57c2 --- /dev/null +++ b/.macroscope/approvability.md @@ -0,0 +1 @@ +Use Macroscope's default approvability criteria. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 8ec720742759..c2f2c57c1cf2 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -47,6 +47,9 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - light-only declarations use `@variant light`; - raw `.dark` should remain only in the `dark` and `light` custom-variant definitions. - Preserve custom themes and runtime token bridges. Removing a variable or selector is safe only when all runtime, inspector, generated, and theme-palette consumers are accounted for. +- Contrast and accessibility settings that target app chrome must derive from semantic color tokens. Do not apply `filter` to `html`, `body`, or the app root: it also changes user media, previews, terminals, glass backdrop ownership, and view-transition snapshots. +- Preserve alpha and surface ownership when deriving contrast tokens. Soften translucent borders and inputs toward transparent rather than an opaque canvas, use a modest semantic-foreground mix for stronger borders, and adjust card, popover, accent, secondary, and message foregrounds against their own surfaces when the base foreground changes. +- Runtime-adjusted roles must be ordinary custom properties shared by the Tailwind bridge, global CSS, imperative style strings, and bridge snapshots sent to other renderers. Audit literal `var(--foreground)`, `var(--border)`, and related role reads so headings, markdown chrome, menus, previews, and utilities do not split into adjusted and unadjusted colors. - Inspect emitted production CSS after unusual variants, arbitrary selectors, nested pseudo-elements, or attribute matching. Source syntax that looks valid is insufficient. - Flag malformed or empty emitted selectors such as empty `:is()` or `:not(:is())`, selector branches that can never match their own class attribute, and transformations that silently drop the intended rule. - Prefer source-level logic over clever selectors when behavior depends on consumer-provided class strings. Preserve `MenuPopup`'s current defaulting contract: a string `className` containing a `w-*`, `min-w-*`, or `max-w-*` utility after variant prefixes are stripped suppresses `min-w-32`; a string without one and a functional/non-string `className` keep the default. Arbitrary width values count as width utilities, and the consumer class must be merged last so it retains control. Do not replace this with a raw class-attribute substring selector. @@ -67,6 +70,13 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - Do not treat a screenshot as proof of keyboard, overflow, scrollbar, responsive, or runtime-theme behavior. Pair visual evidence with source, computed-style, emitted-CSS, or interaction checks as appropriate. - Be alert to shared primitive color indirection. When a primitive routes icon color through a CSS variable, ensure migrated contextual icons retain their intended tone, including pressed and disabled states. +## Environment routing in shared renderers + +- A shared renderer that performs an environment-scoped action — a server RPC such as opening or revealing a file, an environment-gated capability check, or an OS-derived label — must resolve its target environment from explicit scope: the bound thread's `environmentId`, or an `environmentId` prop threaded from the owning surface. Never let it silently fall back to the globally active environment. Multi-environment surfaces (pull request panels, review annotations, cross-environment listings) can render content from environment B while environment A is active; a silent fallback sends B's paths to A's server and presents A's platform wording. +- When a call site cannot supply an explicit environment scope, suppress the environment-scoped actions at that call site rather than guessing. A hidden menu item is correct; an item that targets the wrong server is a concrete finding. +- Capability gating, action dispatch, and user-facing labels must all read from the same environment's server config that the action will execute against. Flag a renderer whose label derives from one environment while its RPC targets another. +- Flag new call sites of shared markdown, chip, or menu renderers that trigger environment actions without passing explicit scope, and flag new environment-action props whose default reintroduces an active-environment fallback. + ## Change discipline - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a34a55f16acf..d37ebc32fe0c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.33", + "version": "0.0.35", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1c17d58215ea..11030fcc5fa4 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + appearanceContrast: 100, browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, browserDefaultAppearance: "dark", @@ -35,6 +36,7 @@ const clientSettings: ClientSettings = { fontSmoothing: true, glassOpacity: 80, planModeEnabled: false, + showSkillsInSlashMenu: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarAutoSettleOnMerge: true, diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index d2a3c774b8a6..fe11ddd4c069 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -1,6 +1,9 @@ import type { VercelConfig } from "@vercel/config/v1"; export const config: VercelConfig = { + git: { + deploymentEnabled: false, + }, installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", buildCommand: "vp run --filter @t3tools/marketing build", outputDirectory: "dist", diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 5fbe6d4dff44..b0934e873a7b 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; import type { MarkdownNode } from "react-native-nitro-markdown/headless"; @@ -9,10 +9,14 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios import type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "./SelectableMarkdownText.types"; +/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ +export const MarkdownImageRendererContext = createContext(null); + type HighlightedCode = ReadonlyArray>; const highlightedCodeCache = new Map(); @@ -379,6 +383,7 @@ function NativeMarkdownImage(props: { readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { + const renderImage = useContext(MarkdownImageRendererContext); const href = props.node.href; if (!href) { return ( @@ -391,6 +396,17 @@ function NativeMarkdownImage(props: { ); } + if (renderImage) { + const rendered = renderImage({ + href, + alt: props.node.alt ?? null, + title: props.node.title ?? null, + }); + if (rendered != null) { + return <>{rendered}; + } + } + return ( = []; export type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, @@ -36,6 +38,7 @@ export function SelectableMarkdownText({ highlightCode, preserveSoftBreaks = false, onLinkPress, + renderImage, marginTop = 0, marginBottom = 0, }: SelectableMarkdownTextProps) { @@ -59,38 +62,40 @@ export function SelectableMarkdownText({ }, [markdown, preserveSoftBreaks, skills]); return ( - // A percentage width here creates a cyclic intrinsic measurement inside - // shrink-to-fit containers such as user-message bubbles. Yoga then gives - // the native text node an unbounded second pass and the parent only clips - // the resulting single-line width instead of reflowing it. - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - - ); + + {/* A percentage width here creates a cyclic intrinsic measurement inside + shrink-to-fit containers such as user-message bubbles. Yoga then gives + the native text node an unbounded second pass and the parent only clips + the resulting single-line width instead of reflowing it. */} + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); - return ( - - {content} - - ); - })} - + return ( + + {content} + + ); + })} + + ); } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx index fcb2472f6488..006d33e7259d 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types export type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 42cc3cd6fb63..00260b0c4f27 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -36,6 +36,20 @@ export interface SelectableMarkdownSkill { readonly displayName?: string | null; } +export interface MarkdownImageRequest { + readonly href: string; + readonly alt: string | null; + readonly title: string | null; +} + +/** + * App-supplied renderer for markdown images. The module cannot load + * workspace-relative image paths itself — the host app resolves them (for + * example through a signed asset URL) and returns the element to show. + * Returning null falls back to the module's plain remote-URI rendering. + */ +export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; + export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; @@ -43,6 +57,7 @@ export interface SelectableMarkdownTextProps { readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; + readonly renderImage?: MarkdownImageRenderer; readonly marginTop?: number; readonly marginBottom?: number; } diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index f13891e3ff80..20637c6ba0f4 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const RELATIVE_FILE_PATH_PATTERN = + /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = + /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSIX_FILE_ROOT_PREFIXES = [ "/Users/", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index de53a37c995b..1097f6a33762 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -81,6 +81,7 @@ "expo-constants": "~56.0.18", "expo-crypto": "~56.0.4", "expo-dev-client": "~56.0.20", + "expo-device": "~56.0.4", "expo-file-system": "~56.0.8", "expo-font": "~56.0.7", "expo-glass-effect": "~56.0.4", diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 28f7cfe57a7f..bfba418c9fce 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -33,6 +33,7 @@ export function brandTitleOffset(nativeLeadingItem: boolean): number { */ export function CompactBrandTitle( props: { + readonly allowFontScaling?: boolean; readonly nativeLeadingItem?: boolean; } = {}, ) { @@ -57,6 +58,7 @@ export function CompactBrandTitle( > ; + return ; } export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] { diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index c75d60d5fdf8..4d7b0864184b 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -33,6 +33,11 @@ vi.mock("expo-constants", () => ({ }, })); +vi.mock("expo-device", () => ({ + osVersion: "18.4.1", + modelName: "iPhone 15 Pro", +})); + vi.mock("react-native", () => ({ Platform: { OS: "ios", diff --git a/apps/mobile/src/features/home/AndroidHomeFab.tsx b/apps/mobile/src/features/home/AndroidHomeFab.tsx index 5c7d5a0b988c..c57964fce4a3 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.tsx @@ -6,8 +6,8 @@ import { SymbolView } from "../../components/AppSymbol"; import { useThemeColor } from "../../lib/useThemeColor"; /** - * Android-only wrapper that overlays a bottom-right new-task FAB on the home - * screen. Other platforms render children unchanged. + * Android-only wrapper that overlays a bottom-right new-task FAB on a thread + * list. Other platforms render children unchanged. */ export function AndroidHomeFabLayout(props: { readonly onStartNewTask: () => void; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 8061b1d1e85b..beabf66d9ea9 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -2,6 +2,7 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useState } from "react"; +import { Platform } from "react-native"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -106,7 +107,11 @@ export function HomeRouteScreen() { return ( <> [] }} + options={ + Platform.OS === "android" + ? { headerShown: false } + : { title: "", headerTitle: "", unstable_headerLeftItems: () => [] } + } /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Restore the compact title after the split branch blanks the detail - header. The brand slot doubles as the connection status surface: - while an environment reconnects, the lockup fades to a status label - in place (no layout shift in the list below). */} + {/* Restore the header after leaving split view; screen options are + shallow-merged. The brand slot also doubles as the connection + status surface while an environment reconnects. */} - navigation.navigate("SettingsSheet", { - screen: "SettingsContent", - params: { screen: "SettingsEnvironments" }, - }), - })} + options={{ + ...getConnectionAwareBrandHeaderOptions({ + onOpenEnvironments: () => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }), + }), + headerShown: true, + }} /> + ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => { + (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { setChangeRequestByKey((current) => { const existing = current.get(threadKey) ?? null; if ( (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) + (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && + (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) ) { return current; } @@ -572,10 +574,13 @@ export function HomeScreen(props: HomeScreenProps) { () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); - const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); - const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const { + loaded: shelfPreferencesLoaded, + settledShelfExpanded, + snoozedShelfExpanded, + toggleSettledShelf, + toggleSnoozedShelf, + } = useThreadListV2ShelfPreferences(); // now is quantized to the minute and ticks so the inactivity auto-settle // boundary is actually crossed while the app stays open (mirrors web); // without a clock dependency the partition memoizes a frozen "now". @@ -785,6 +790,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -794,6 +800,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -890,6 +897,7 @@ export function HomeScreen(props: HomeScreenProps) { props.onSelectThread, props.savedConnectionsById, serverConfigs, + shelfPreferencesLoaded, settlementEnvironmentIds, snoozeEnvironmentIds, threadListV2Items, diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index e00433de0ed9..9ea8eb88d42a 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -45,6 +45,7 @@ import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; +import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; @@ -435,6 +436,10 @@ function AdaptiveWorkspaceLayoutContent( }); }, [navigation]); + const handleStartNewTask = useCallback(() => { + navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + }, [navigation]); + // Minted here (root stack navigation) so the sidebar pane stays free of // navigation hooks — on iOS it renders inside an independent nav tree. const handleOpenEnvironmentSettings = useCallback(() => { @@ -528,18 +533,22 @@ function AdaptiveWorkspaceLayoutContent( pointerEvents={panes.primarySidebarVisible ? "auto" : "none"} style={sidebarAnimatedStyle} > - + + + + + ) : null} diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index 377ae82aba8b..fb9cc72d25d3 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -1,4 +1,8 @@ -import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts"; +import type { + ApprovalRequestId, + ProviderApprovalDecision, + ProviderApprovalOption, +} from "@t3tools/contracts"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; @@ -13,7 +17,14 @@ export interface PendingApprovalCardProps { ) => Promise; } +const DEFAULT_APPROVAL_OPTIONS = [ + { decision: "accept", label: "Allow once" }, + { decision: "acceptForSession", label: "Allow session" }, + { decision: "decline", label: "Decline" }, +] satisfies ReadonlyArray; + export function PendingApprovalCard(props: PendingApprovalCardProps) { + const options = props.approval.options ?? DEFAULT_APPROVAL_OPTIONS; // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed // behind this card, so a translucent surface bleeds messages through it. return ( @@ -22,7 +33,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { Approval needed - {props.approval.requestKind} + {props.approval.appName ?? props.approval.requestKind} {props.approval.detail ? ( @@ -30,29 +41,32 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { ) : null} - void props.onRespond(props.approval.requestId, "accept")} - > - Allow once - - void props.onRespond(props.approval.requestId, "acceptForSession")} - > - - Allow session - - - void props.onRespond(props.approval.requestId, "decline")} - > - Decline - + {options.map((option) => ( + void props.onRespond(props.approval.requestId, option.decision)} + > + + {option.label} + + + ))} ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index f4b78f181e7d..c771aaebcb6e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -552,7 +552,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { - await onSendMessage(); + const messageId = await onSendMessage(); + if (messageId === null) { + return; + } // Sending a prompt starts agent work: arm the lock-screen card while the // app is foregrounded and the activity token can be registered. Armed // after the send so its preference read and native Activity start don't diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d3aa65673bbb..60b397802ccc 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2,6 +2,7 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import { SymbolView } from "../../components/AppSymbol"; @@ -39,6 +40,7 @@ import { type ColorValue, useWindowDimensions, View, + type ViewStyle, } from "react-native"; import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; @@ -54,6 +56,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, + type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; @@ -73,7 +76,11 @@ import { } from "../review/nativeReviewDiffAdapter"; import { buildReviewParsedDiff } from "../review/reviewModel"; import { cn } from "../../lib/cn"; -import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout"; +import { + deriveCenteredContentHorizontalPadding, + deriveThreadFeedInitialContentInset, + type LayoutVariant, +} from "../../lib/layout"; import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, @@ -101,8 +108,9 @@ import { WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { useAssetUrl } from "../../state/assets"; +import { useAssetUrl, useAssetUrlState } from "../../state/assets"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; +import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", @@ -194,6 +202,165 @@ function MessageAttachmentImage(props: { ); } +function ThreadMarkdownImageView(props: { + readonly uri: string | null; + readonly sourceKey: string; + readonly unavailable: boolean; + readonly alt: string | null; + readonly onPressImage: (uri: string) => void; +}) { + const codeBackground = useThemeColor("--color-md-code-bg"); + const [availableWidth, setAvailableWidth] = useState(0); + const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); + const [failedUri, setFailedUri] = useState(null); + + useEffect(() => { + setSourceSize(null); + }, [props.sourceKey]); + + useEffect(() => { + setFailedUri(null); + }, [props.uri]); + + const displaySize = + sourceSize === null + ? null + : resolveMarkdownImageDisplaySize({ + sourceWidth: sourceSize.width, + sourceHeight: sourceSize.height, + availableWidth, + }); + const failed = props.unavailable || (props.uri !== null && failedUri === props.uri); + const placeholderWidth: ViewStyle["width"] = + availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%"; + const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 }; + + return ( + setAvailableWidth(event.nativeEvent.layout.width)} + style={{ alignSelf: "stretch", gap: 6 }} + > + {props.uri === null || failed ? ( + + {failed ? ( + Image unavailable + ) : ( + + )} + + ) : ( + props.onPressImage(props.uri!)} + style={{ alignSelf: "flex-start" }} + > + + setFailedUri(props.uri)} + /> + + + )} + {props.alt ? ( + + {props.alt} + + ) : null} + + ); +} + +function ThreadMarkdownImageRequest(props: { + readonly uri: string; + readonly onLoad: (sourceSize: { width: number; height: number }) => void; + readonly onError: () => void; +}) { + const [loaded, setLoaded] = useState(false); + + return ( + <> + { + setLoaded(true); + props.onLoad(event.nativeEvent.source); + }} + onError={props.onError} + style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} + /> + {loaded ? null : ( + + Loading image… + + )} + + ); +} + +/** Markdown image whose src is a workspace file — loads through a signed asset URL. */ +function ThreadMarkdownImage(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly path: string; + readonly alt: string | null; + readonly onPressImage: (uri: string) => void; +}) { + const assetUrl = useAssetUrlState(props.environmentId, { + _tag: "workspace-file", + threadId: props.threadId, + path: props.path, + }); + + return ( + + ); +} + +function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { + return ( + undefined} + /> + ); +} + const MARKDOWN_MONO_FONT = Platform.select({ ios: "ui-monospace", android: "monospace", @@ -409,7 +576,10 @@ function useReviewCommentColors(): ReviewCommentColors { ); } -function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { +function useMarkdownStyles( + onLinkPress: (href: string) => void, + renderImage: MarkdownImageRenderer, +): MarkdownStyleSets { const { appearance, themeAppearance } = useAppearancePreferences(); const markdownFontSizes = useMemo( () => resolveMarkdownFontSizes(appearance.baseFontSize), @@ -614,6 +784,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe })} ), + image: ({ node }) => + node.href + ? (renderImage({ + href: node.href, + alt: node.alt ?? null, + title: node.title ?? null, + }) ?? undefined) + : undefined, code_inline: ({ content }) => { const value = content ?? ""; return ( @@ -787,6 +965,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe nativeMarkdownTypography, onLinkPress, regularFontFamily, + renderImage, themeMode, userBubbleForegroundMuted, userBubbleSkillForeground, @@ -806,6 +985,7 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: string) => void; + readonly renderMarkdownImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -904,6 +1084,7 @@ function renderFeedEntry( reviewCommentColors={props.reviewCommentColors} skills={props.skills} onLinkPress={props.onMarkdownLinkPress} + renderImage={props.renderMarkdownImage} /> ) : null} {attachments.map((attachment) => { @@ -955,6 +1136,7 @@ function renderFeedEntry( skills={props.skills} textStyle={styles.nativeTextStyle} onLinkPress={props.onMarkdownLinkPress} + renderImage={props.renderMarkdownImage} /> ) : ( ; readonly onLinkPress: (href: string) => void; + readonly renderImage: MarkdownImageRenderer; }) { const segments = parseReviewCommentMessageSegments(props.text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); @@ -1052,6 +1235,7 @@ function UserMessageContent(props: { textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks onLinkPress={props.onLinkPress} + renderImage={props.renderImage} /> ); } @@ -1093,6 +1277,7 @@ function UserMessageContent(props: { textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks onLinkPress={props.onLinkPress} + renderImage={props.renderImage} /> ) : ( ( + (image) => { + const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); + if (imageSource._tag === "Direct") { + return ( + setExpandedImage({ uri })} + /> + ); + } + if (imageSource._tag === "Blocked") { + return ; + } + return ( + setExpandedImage({ uri })} + /> + ); + }, + [props.environmentId, props.threadId, props.workspaceRoot], + ); + const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); const reviewCommentColors = useReviewCommentColors(); // LegendList does not invalidate visible rows when only the renderItem closure changes. // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. @@ -1584,10 +1803,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // The empty↔filled key below remounts the list, which resets its imperative // content-inset override — and useKeyboardChatComposerInset (mounted above // the remount boundary) deduplicates by height, so it never re-reports the - // composer inset to the fresh instance. Without this, the remounted list's - // initial scroll-to-end computes with a zero end inset and rests one - // composer-height short of the end. Layout effect: it must land before the - // list's first positioning tick or the one-shot initial scroll misses it. + // composer inset to the fresh instance. Re-report the measured overlay height + // (composer plus any pending approval / user-input card) so the remounted + // list's scroll math gets the true value; on Android the declarative + // contentInset floor below covers the window before this effect lands. const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; @@ -1805,6 +2024,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressImage, onMarkdownLinkPress, + renderMarkdownImage, iconSubtleColor, userBubbleColor, markdownStyles, @@ -1832,6 +2052,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkRow, props.environmentId, props.skills, + renderMarkdownImage, ], ); @@ -1892,6 +2113,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // ThreadDetailScreen); this tells LegendList's scroll math about the // extra so programmatic end scrolls land at the true resting offset. contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0} + // Android: the composer overlay only exists as the keyboard + // integration's animated bottom padding, which the list's scroll + // math cannot see until the inset reports above land — and those + // arrive via runOnJS, racing the remounted list's one-shot initial + // scroll-at-end. Seed the estimated overlay height as a declarative + // contentInset floor: LegendList consumes it in JS math only + // (Android's ScrollView has no native contentInset prop) and the + // first reported override REPLACES it instead of adding to it. + // Not on iOS: there the prop would reach UIKit and inset natively + // on top of the animated padding. + {...(initialContentInset ? { contentInset: initialContentInset } : {})} // The keyboard integration's offset math (end pinning, max scroll) // must add the same UIKit-added extra, or its keyboard-open end // targets land one safe-area short of the true resting offset. diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 2e8186fa8e25..0e74e27f8743 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,4 +1,3 @@ -import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -13,17 +12,16 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; -import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { LayoutChangeEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { SearchBarCommands } from "react-native-screens"; -import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; +import { CompactBrandTitle } from "../../components/CompactBrandTitle"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; @@ -34,12 +32,12 @@ import { useProjects, useThreadShells } from "../../state/entities"; import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; +import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { hasCustomHomeListOptions, PROJECT_SORT_OPTIONS, @@ -85,6 +83,7 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, + type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "./threadListV2"; @@ -96,48 +95,7 @@ type SidebarListItem = | ThreadListV2ListItem | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; -/** - * Shared capsule behind the sidebar header buttons — a native liquid-glass - * surface on iOS 26+, a tinted pill everywhere else. - */ -function SidebarHeaderButtonGroup(props: { - readonly children: ReactNode; - readonly colorScheme: "light" | "dark"; -}) { - const fallbackBackground = useThemeColor("--color-glass-surface"); - const fallbackBorder = useThemeColor("--color-header-border"); - if (isLiquidGlassSupported) { - return ( - - {props.children} - - ); - } - - return ( - - {props.children} - - ); -} - const SIDEBAR_STICKY_HEADER_HEIGHT = 106; -const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44; -const SIDEBAR_HEADER_WASH_OPACITY = { - dark: [0.22, 0.14, 0.04], - light: [0.46, 0.3, 0.08], -} as const; interface ThreadNavigationSidebarProps { readonly width: number; @@ -194,16 +152,13 @@ function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { const insets = useSafeAreaInsets(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); - const [headerIsOverContent, setHeaderIsOverContent] = useState(false); const searchInputRef = useRef(null); const searchBarRef = useRef(null); const openSwipeableRef = useRef(null); - const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, @@ -422,15 +377,16 @@ function ThreadNavigationSidebarPane( // PR states stream in per-row. The next partition applies the configured // merge rule and the always-on close rule. const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap + ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => { + (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { setChangeRequestByKey((current) => { const existing = current.get(threadKey) ?? null; if ( (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) + (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && + (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) ) { return current; } @@ -460,10 +416,13 @@ function ThreadNavigationSidebarPane( () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); - const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); - const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const { + loaded: shelfPreferencesLoaded, + settledShelfExpanded, + snoozedShelfExpanded, + toggleSettledShelf, + toggleSnoozedShelf, + } = useThreadListV2ShelfPreferences(); // now ticks per minute so the inactivity auto-settle boundary is actually // crossed while the pane stays open; without a clock dependency the // partition memoizes a frozen "now". @@ -776,8 +735,6 @@ function ThreadNavigationSidebarPane( const borderColor = useThemeColor("--color-border"); const mutedColor = useThemeColor("--color-foreground-muted"); const placeholderColor = useThemeColor("--color-placeholder"); - const headerFadeColor = String(backgroundColor); - const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no @@ -806,19 +763,10 @@ function ThreadNavigationSidebarPane( }, [props.onSelectThread], ); - const handleScroll = useCallback((event: NativeSyntheticEvent) => { - const next = event.nativeEvent.contentOffset.y > 6; - if (headerIsOverContentRef.current === next) { - return; - } - headerIsOverContentRef.current = next; - setHeaderIsOverContent(next); - }, []); const handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); // Project shells load after the first rows draw, so the maps they feed have @@ -1008,6 +956,7 @@ function ThreadNavigationSidebarPane( return ( - - - - - - - - - - - - - {/* Title slot doubles as the connection status surface: while an - environment reconnects, "Threads" fades to a status label in + environment reconnects, the brand fades to a status label in place (no layout shift in the list below). */} - Threads - + + + } /> - + - + - - + + @@ -1416,12 +1339,6 @@ function ThreadNavigationSidebarPane( } const styles = StyleSheet.create({ - headerButtonGroup: { - alignItems: "center", - borderRadius: 22, - flexDirection: "row", - overflow: "hidden", - }, threadList: { flex: 1, }, diff --git a/apps/mobile/src/features/threads/markdownImageSize.test.ts b/apps/mobile/src/features/threads/markdownImageSize.test.ts new file mode 100644 index 000000000000..76170890d519 --- /dev/null +++ b/apps/mobile/src/features/threads/markdownImageSize.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + MARKDOWN_IMAGE_MAX_HEIGHT, + MARKDOWN_IMAGE_MAX_WIDTH, + resolveMarkdownImageDisplaySize, +} from "./markdownImageSize"; + +describe("resolveMarkdownImageDisplaySize", () => { + it("keeps small images at their intrinsic size", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 96, + sourceHeight: 96, + availableWidth: 332, + }), + ).toEqual({ width: 96, height: 96 }); + }); + + it("fits wide images to the available chat width", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 960, + sourceHeight: 540, + availableWidth: 332, + }), + ).toEqual({ width: 332, height: 186.75 }); + }); + + it("caps wide images at 480 points on larger screens", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 960, + sourceHeight: 540, + availableWidth: 900, + }), + ).toEqual({ width: MARKDOWN_IMAGE_MAX_WIDTH, height: 270 }); + }); + + it("caps tall images by height without changing their aspect ratio", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 400, + sourceHeight: 800, + availableWidth: 332, + }), + ).toEqual({ width: 240, height: MARKDOWN_IMAGE_MAX_HEIGHT }); + }); + + it("rejects dimensions that cannot produce a stable layout", () => { + expect( + resolveMarkdownImageDisplaySize({ sourceWidth: 0, sourceHeight: 100, availableWidth: 332 }), + ).toBeNull(); + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 100, + sourceHeight: Number.NaN, + availableWidth: 332, + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/markdownImageSize.ts b/apps/mobile/src/features/threads/markdownImageSize.ts new file mode 100644 index 000000000000..0fb6f8fbcc6d --- /dev/null +++ b/apps/mobile/src/features/threads/markdownImageSize.ts @@ -0,0 +1,37 @@ +export const MARKDOWN_IMAGE_MAX_WIDTH = 480; +export const MARKDOWN_IMAGE_MAX_HEIGHT = 480; + +export interface MarkdownImageDisplaySize { + readonly width: number; + readonly height: number; +} + +/** Keeps small images intrinsic while fitting larger images inside the chat viewport. */ +export function resolveMarkdownImageDisplaySize(input: { + readonly sourceWidth: number; + readonly sourceHeight: number; + readonly availableWidth: number; +}): MarkdownImageDisplaySize | null { + if ( + !Number.isFinite(input.sourceWidth) || + !Number.isFinite(input.sourceHeight) || + !Number.isFinite(input.availableWidth) || + input.sourceWidth <= 0 || + input.sourceHeight <= 0 || + input.availableWidth <= 0 + ) { + return null; + } + + const scale = Math.min( + 1, + input.availableWidth / input.sourceWidth, + MARKDOWN_IMAGE_MAX_WIDTH / input.sourceWidth, + MARKDOWN_IMAGE_MAX_HEIGHT / input.sourceHeight, + ); + + return { + width: input.sourceWidth * scale, + height: input.sourceHeight * scale, + }; +} diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx index 0c33da436a57..1895ef0d45ca 100644 --- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx +++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx @@ -1,5 +1,5 @@ import { SymbolView } from "../../components/AppSymbol"; -import { Pressable, StyleSheet } from "react-native"; +import { Pressable } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -10,31 +10,17 @@ export type SidebarFilterButtonIcon = export function SidebarFilterButton(props: { readonly accessibilityLabel: string; readonly icon: SidebarFilterButtonIcon; - /** Rendered inside a shared capsule group — no own background/border. */ - readonly grouped?: boolean; }) { const iconColor = useThemeColor("--color-foreground"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); - const idleBackgroundColor = useThemeColor("--color-glass-surface"); - const borderColor = useThemeColor("--color-header-border"); return ( [ - props.grouped - ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } - : { - backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, - borderColor, - borderWidth: StyleSheet.hairlineWidth, - }, - ]} > - + ); } diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.tsx index b0f5f1131f68..9ce77f8991bd 100644 --- a/apps/mobile/src/features/threads/sidebar-header-actions.tsx +++ b/apps/mobile/src/features/threads/sidebar-header-actions.tsx @@ -1,43 +1,28 @@ import { SymbolView } from "../../components/AppSymbol"; -import { Pressable, StyleSheet, View } from "react-native"; +import { Pressable, View } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; export interface SidebarHeaderActionsProps { readonly onOpenSettings: () => void; - /** Rendered inside a shared capsule group — buttons drop their own chrome. */ - readonly grouped?: boolean; } function FallbackHeaderButton(props: { readonly accessibilityLabel: string; readonly icon: "gearshape" | "square.and.pencil"; - readonly grouped?: boolean; readonly onPress: () => void; }) { const iconColor = useThemeColor("--color-foreground"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); - const idleBackgroundColor = useThemeColor("--color-glass-surface"); - const borderColor = useThemeColor("--color-header-border"); return ( [ - props.grouped - ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } - : { - backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, - borderColor, - borderWidth: StyleSheet.hairlineWidth, - }, - ]} > - + ); } @@ -47,7 +32,6 @@ export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index fa1e752d619f..146779280003 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,11 +3,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import { - canSnooze, - resolveSnoozePresets, - type ChangeRequestSettleSource, -} from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; @@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { + resolveThreadListV2ChangeRequestState, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, + type ThreadListV2ChangeRequestState, type ThreadListV2Status, } from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -114,6 +112,7 @@ const SNOOZE_ACCENT_DARK = "#60a5fa"; export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { readonly count: number; + readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; @@ -126,11 +125,12 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS } accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`} accessibilityRole="button" - accessibilityState={{ expanded: props.expanded }} + accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} + disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > @@ -151,6 +151,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: { readonly count: number; + readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; @@ -163,11 +164,12 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS } accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`} accessibilityRole="button" - accessibilityState={{ expanded: props.expanded }} + accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} + disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > @@ -373,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { merge and close rules. Mirrors web's onChangeRequestState. */ readonly onChangeRequestState?: ( threadKey: string, - changeRequest: ChangeRequestSettleSource | null, + changeRequest: ThreadListV2ChangeRequestState | null, ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; @@ -407,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const prUpdatedAt = pr?.updatedAt ?? null; const threadKey = `${thread.environmentId}:${thread.id}`; useEffect(() => { - onChangeRequestState?.( - threadKey, - prState === null ? null : { state: prState, updatedAt: prUpdatedAt }, - ); - }, [onChangeRequestState, prState, prUpdatedAt, threadKey]); + const changeRequest = resolveThreadListV2ChangeRequestState({ + linkedPullRequest: thread.linkedPullRequest, + state: prState, + updatedAt: prUpdatedAt, + }); + if (changeRequest === undefined) return; + onChangeRequestState?.(threadKey, changeRequest); + }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]); const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); @@ -506,7 +511,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } satisfies MenuAction, ] : []), - pinnedRow + thread.pinnedAt != null ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] @@ -517,6 +522,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { props.canMovePinnedUp, props.pinReorderSupported, props.pinningSupported, + thread.pinnedAt, ], ); const titleRegenerationMenuItems = useMemo( @@ -552,8 +558,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [pinMenuItem, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( - () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + SLIM_MENU_ACTIONS[0]!, + ...(thread.pinnedAt != null ? pinMenuItem : []), + ...titleRegenerationMenuItems, + SLIM_MENU_ACTIONS[1]!, + ], + [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c58dbb67517b..24c07eae6da1 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, + resolveThreadListV2ChangeRequestState, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -53,6 +54,48 @@ function makeThread( } const NOW = "2026-06-02T00:00:00.000Z"; +const linkedPullRequest = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", +}; + +describe("resolveThreadListV2ChangeRequestState", () => { + it("preserves the previous state while a linked pull request reloads", () => { + expect( + resolveThreadListV2ChangeRequestState({ + linkedPullRequest, + state: null, + updatedAt: null, + }), + ).toBeUndefined(); + }); + + it("clears the previous state after a pull request is unlinked", () => { + expect( + resolveThreadListV2ChangeRequestState({ + linkedPullRequest: null, + state: null, + updatedAt: null, + }), + ).toBeNull(); + }); + + it("reports a loaded linked pull request", () => { + expect( + resolveThreadListV2ChangeRequestState({ + linkedPullRequest, + state: "merged", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + ).toEqual({ + state: "merged", + updatedAt: "2026-06-02T00:00:00.000Z", + linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', + }); + }); +}); describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { @@ -260,9 +303,74 @@ describe("sortThreadsForListV2", () => { ]); expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); }); + + it("surfaces an un-settled thread at the top via its re-entry stamp", () => { + const sorted = sortThreadsForListV2([ + { + id: "old-unsettled", + createdAt: "2026-06-01T08:00:00.000Z", + unsettledAt: "2026-06-01T13:00:00.000Z", + }, + { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, + { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]); + }); }); describe("buildThreadListV2Items", () => { + it("ignores the previous pull request state after a different pull request is linked", () => { + const thread = makeThread({ + id: ThreadId.make("linked"), + title: "Linked pull request", + linkedPullRequest, + }); + const layout = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([ + [ + `${environmentId}:${thread.id}`, + { + state: "merged" as const, + linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]', + }, + ], + ]), + now: NOW, + }); + + expect(layout.settledCount).toBe(0); + expect(layout.items[0]?.variant).toBe("card"); + }); + + it("settles a thread only when the cached pull request identity matches", () => { + const thread = makeThread({ + id: ThreadId.make("linked-merged"), + title: "Linked merged pull request", + linkedPullRequest, + }); + const layout = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([ + [ + `${environmentId}:${thread.id}`, + { + state: "merged" as const, + linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', + }, + ], + ]), + now: NOW, + }); + + expect(layout.settledCount).toBe(1); + expect(layout.items[0]?.variant).toBe("slim"); + }); + it("keeps a merged thread active when auto-settle on merge is off", () => { const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); const layout = buildThreadListV2Items({ @@ -309,7 +417,7 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(1); }); - it("renders pinned threads first and exempts them from auto-settle — parity with web", () => { + it("places settled pinned threads in the settled shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -317,7 +425,6 @@ describe("buildThreadListV2Items", () => { id: ThreadId.make("pinned-settled"), title: "Pinned while settled", pinnedAt: "2026-06-01T12:00:00.000Z", - // Stale settled state (the decider clears it on pin): the pin wins. settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", }), @@ -327,8 +434,81 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]); - expect(layout.items.map((item) => item.pinned)).toEqual([true, false]); + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); + expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); + expect(layout.settledCount).toBe(1); + }); + + it("moves pinned threads to the settled shelf when their pull request merges", () => { + const merged = makeThread({ + id: ThreadId.make("pinned-merged"), + title: "Pinned merged pull request", + pinnedAt: "2026-06-01T12:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); + expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); + expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); + expect(layout.settledCount).toBe(1); + }); + + it("moves inactive pinned threads to the settled shelf", () => { + const inactive = makeThread({ + id: ThreadId.make("pinned-inactive"), + title: "Pinned inactive thread", + createdAt: "2026-05-20T00:00:00.000Z", + pinnedAt: "2026-05-21T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-inactive"), + state: "completed", + requestedAt: "2026-05-21T00:00:00.000Z", + startedAt: "2026-05-21T00:00:01.000Z", + completedAt: "2026-05-21T00:00:02.000Z", + assistantMessageId: null, + }, + }); + const layout = buildThreadListV2Items({ + threads: [inactive], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(layout.items[0]).toMatchObject({ + thread: { id: "pinned-inactive" }, + variant: "slim", + pinned: false, + }); + expect(layout.settledCount).toBe(1); + }); + + it("keeps pinned merged threads pinned when auto-settle on merge is off", () => { + const merged = makeThread({ + id: ThreadId.make("pinned-merged"), + title: "Pinned merged pull request", + pinnedAt: "2026-06-01T12:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [merged], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), + autoSettleOnMerge: false, + now: NOW, + }); + + expect(layout.items[0]).toMatchObject({ + thread: { id: "pinned-merged" }, + variant: "card", + pinned: true, + }); expect(layout.settledCount).toBe(0); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 45079bac6e7f..be3343a21bad 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -12,8 +12,11 @@ import type { } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + activeThreadAnchorTimestampMs, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -30,6 +33,35 @@ export { snoozeWakeLabel }; export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; +export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource { + readonly linkedPullRequestKey?: string | null; +} + +function linkedPullRequestKey( + linkedPullRequest: ThreadLinkedPullRequest | null | undefined, +): string | null { + if (linkedPullRequest == null) return null; + return JSON.stringify([ + linkedPullRequest.projectId, + linkedPullRequest.repository.toLowerCase(), + linkedPullRequest.number, + ]); +} + +/** Keep the previous linked PR state while its detail query reloads. */ +export function resolveThreadListV2ChangeRequestState(input: { + readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined; + readonly state: ChangeRequestSettleSource["state"] | null; + readonly updatedAt: string | null; +}): ThreadListV2ChangeRequestState | null | undefined { + if (input.state === null) return input.linkedPullRequest == null ? null : undefined; + return { + state: input.state, + updatedAt: input.updatedAt, + linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest), + }; +} + export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; @@ -162,19 +194,25 @@ function firstValidTimestampMs(...candidates: ReadonlyArray( - threads: readonly T[], -): T[] { +export function sortThreadsForListV2< + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + }, +>(threads: readonly T[]): T[] { // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 // change-by-copy array methods. return [...threads].sort( (left, right) => - parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || left.id.localeCompare(right.id), ); } @@ -322,7 +360,7 @@ export function buildThreadListV2Items(input: { readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; /** Per-row PR reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestByKey?: ReadonlyMap; + readonly changeRequestByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ @@ -384,12 +422,15 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const changeRequest = + const cachedChangeRequest = input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - // Visibility parity with web: snooze outranks everything, including a - // pin — a snoozed thread leaves the list until it wakes (or raises its - // hand). The pin (and its pinOrderKey) survives underneath, so a woken - // thread reappears at its exact spot in the pinned block. + const changeRequest = + cachedChangeRequest !== null && + (cachedChangeRequest.linkedPullRequestKey ?? null) === + linkedPullRequestKey(thread.linkedPullRequest) + ? cachedChangeRequest + : null; + // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -401,12 +442,6 @@ export function buildThreadListV2Items(input: { } continue; } - // A pin otherwise overrides the lifecycle: pinned threads render above - // the inbox and never auto-settle out of sight. - if (thread.pinnedAt != null) { - pinned.push(thread); - continue; - } if ( supportsSettlement && effectiveSettled(thread, { @@ -417,6 +452,8 @@ export function buildThreadListV2Items(input: { }) ) { settled.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); } else { active.push(thread); } diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts new file mode 100644 index 000000000000..d45993364721 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -0,0 +1,45 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback, useRef } from "react"; + +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; + +/** + * Shared persisted shelf state for the compact Home list and iPad sidebar. + * Refs advance before persistence starts so consecutive presses always toggle + * the latest value, even if React has not rendered the optimistic patch yet. + */ +export function useThreadListV2ShelfPreferences() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferencesResult); + const snoozedShelfExpanded = + loaded && preferencesResult.value.threadListV2SnoozedShelfExpanded === true; + const settledShelfExpanded = + !loaded || preferencesResult.value.threadListV2SettledShelfExpanded !== false; + const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); + const settledShelfExpandedRef = useRef(settledShelfExpanded); + snoozedShelfExpandedRef.current = snoozedShelfExpanded; + settledShelfExpandedRef.current = settledShelfExpanded; + + const toggleSnoozedShelf = useCallback(() => { + if (!loaded) return; + const expanded = !snoozedShelfExpandedRef.current; + snoozedShelfExpandedRef.current = expanded; + savePreferences({ threadListV2SnoozedShelfExpanded: expanded }); + }, [loaded, savePreferences]); + const toggleSettledShelf = useCallback(() => { + if (!loaded) return; + const expanded = !settledShelfExpandedRef.current; + settledShelfExpandedRef.current = expanded; + savePreferences({ threadListV2SettledShelfExpanded: expanded }); + }, [loaded, savePreferences]); + + return { + loaded, + settledShelfExpanded, + snoozedShelfExpanded, + toggleSettledShelf, + toggleSnoozedShelf, + } as const; +} diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 9a9ec5f2282d..2576ac21fb07 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/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index 5189c34f5806..992beed3abe4 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -1,11 +1,17 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; +import * as Device from "expo-device"; import { Platform } from "react-native"; export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { + const osMajorVersion = Number.parseInt(Device.osVersion?.split(".")[0] ?? "", 10); + const deviceModel = Device.modelName?.trim(); + return { label: "T3 Code Mobile", deviceType: "mobile", ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), + ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}), + ...(deviceModel ? { deviceModel } : {}), surface: "mobile", ...(appVersion ? { appVersion } : {}), }; diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 8ec0fb8bd892..f474c7e6ea4f 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,12 +1,18 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, - authClientMetadata, redactPairingCredential, toStableSavedRemoteConnection, } from "./connection"; +import { authClientMetadata } from "./authClientMetadata"; + +const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); +const mobileDevice = vi.hoisted(() => ({ + osVersion: "18.4.1", + modelName: "iPhone 15 Pro", +})); vi.mock("./runtime", () => ({ runtime: { @@ -15,21 +21,41 @@ vi.mock("./runtime", () => ({ })); vi.mock("react-native", () => ({ - Platform: { - OS: "ios", - }, + Platform: mobilePlatform, })); +vi.mock("expo-device", () => mobileDevice); + describe("mobile remote connection records", () => { + afterEach(() => { + mobilePlatform.OS = "ios"; + mobileDevice.osVersion = "18.4.1"; + mobileDevice.modelName = "iPhone 15 Pro"; + }); + it("identifies mobile token exchanges for authorized-client presentation", () => { expect(authClientMetadata()).toEqual({ label: "T3 Code Mobile", deviceType: "mobile", os: "iOS", + osMajorVersion: 18, + deviceModel: "iPhone 15 Pro", surface: "mobile", }); }); + it("includes only the Android major version and hardware model", () => { + mobilePlatform.OS = "android"; + mobileDevice.osVersion = "15.2.1"; + mobileDevice.modelName = "Pixel 9"; + + expect(authClientMetadata()).toMatchObject({ + os: "Android", + osMajorVersion: 15, + deviceModel: "Pixel 9", + }); + }); + it("includes the mobile app version when the client provides it", () => { expect(authClientMetadata("1.2.3")).toMatchObject({ surface: "mobile", diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index 839bc70e6d95..df26a192cd0f 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -2,8 +2,6 @@ import { EnvironmentId } from "@t3tools/contracts"; import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -export { authClientMetadata } from "./authClientMetadata"; - export interface SavedRemoteConnection { readonly environmentId: EnvironmentId; readonly environmentLabel: string; diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 6dea0beafbec..b1722a137c81 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -7,11 +7,34 @@ import { deriveFileInspectorPaneLayout, deriveLayout, deriveStableFormSheetDetent, + deriveThreadFeedInitialContentInset, deriveWorkspacePaneLayout, SPLIT_LAYOUT_MIN_HEIGHT, SPLIT_LAYOUT_MIN_WIDTH, } from "./layout"; +describe("deriveThreadFeedInitialContentInset", () => { + it("seeds Android scroll math with the composer overlay estimate", () => { + expect( + deriveThreadFeedInitialContentInset({ + platform: "android", + usesNativeAutomaticInsets: false, + bottomContentInset: 174, + }), + ).toEqual({ bottom: 174 }); + }); + + it("does not double native iOS insets", () => { + expect( + deriveThreadFeedInitialContentInset({ + platform: "ios", + usesNativeAutomaticInsets: true, + bottomContentInset: 174, + }), + ).toBeUndefined(); + }); +}); + describe("resizable pane constraints", () => { it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index eb0c45e0607d..33438a324c12 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -52,6 +52,18 @@ export interface FileInspectorPaneLayout { readonly width: number | null; } +export function deriveThreadFeedInitialContentInset(input: { + readonly platform: string; + readonly usesNativeAutomaticInsets: boolean; + readonly bottomContentInset: number; +}): { readonly bottom: number } | undefined { + if (input.platform !== "android" || input.usesNativeAutomaticInsets) { + return undefined; + } + + return { bottom: Math.max(0, input.bottomContentInset) }; +} + export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index ff57287b7412..49a8b46648e1 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -50,6 +50,24 @@ describe("resolveMarkdownLinkPresentation", () => { }); }); + it.each(["md", "html", "xml"])("recognizes a bare spaced .%s filename", (extension) => { + expect( + resolveMarkdownLinkPresentation(`Updated%20cutover%20checklist.${extension}`), + ).toMatchObject({ + kind: "file", + path: `Updated cutover checklist.${extension}`, + label: `Updated cutover checklist.${extension}`, + }); + }); + + it("recognizes spaced relative paths", () => { + expect(resolveMarkdownLinkPresentation("docs/My%20Folder/checklist.xml")).toMatchObject({ + kind: "file", + path: "docs/My Folder/checklist.xml", + label: "checklist.xml", + }); + }); + it("extracts line fragments from relative file links", () => { expect(resolveMarkdownLinkPresentation("src/main.ts#L18C2")).toMatchObject({ kind: "file", diff --git a/apps/mobile/src/lib/mobileBranding.test.ts b/apps/mobile/src/lib/mobileBranding.test.ts deleted file mode 100644 index 48a84b3f9857..000000000000 --- 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/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 7b94dc629154..fe022c1191ae 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -207,6 +207,40 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); + it("persists Thread List v2 shelf expansion preferences", async () => { + await expect( + savePreferencesPatch({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }), + ).resolves.toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + + await expect(loadPreferences()).resolves.toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + }); + + it("ignores invalid Thread List v2 shelf expansion preference types", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + baseFontSize: 17, + threadListV2SettledShelfExpanded: "false", + threadListV2SnoozedShelfExpanded: 1, + }), + 10, + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + }); + it("reconciles fallback preferences after SQLite recovers", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); await mocks.setItemAsync( diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e1d46fd858e9..e2943ebc1a0d 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; import { EventId, @@ -14,6 +15,7 @@ import { import { buildPendingUserInputAnswers, buildThreadFeed, + derivePendingApprovals, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, @@ -22,6 +24,34 @@ import { type ThreadFeedEntry, } from "./threadActivity"; +describe("Codex feedback pseudo-messages", () => { + it("keeps pending and completed feedback messages in the mobile thread body", () => { + const pending = { + id: MessageId.make("feedback-command"), + command: "/feedback The agent stopped early.", + createdAt: "2026-08-23T00:00:00.000Z", + status: "uploading" as const, + }; + const entries = [codexFeedbackMessage(pending), codexFeedbackMessage(pending, "assistant")].map( + (message) => ({ + type: "message" as const, + id: message.id, + createdAt: message.createdAt, + message, + }), + ); + + expect(deriveThreadFeedPresentation(entries, null, new Set())).toEqual(entries); + expect(entries[1]?.message.text).toBe("Sending feedback to OpenAI..."); + + const completed = codexFeedbackMessage( + { ...pending, status: "sent", feedbackId: "codex-thread-1" }, + "assistant", + ); + expect(completed.text).toContain("codex-thread-1"); + }); +}); + const singleSelectQuestion = { id: "runtime", header: "Runtime", @@ -113,6 +143,59 @@ describe("pending user input answers", () => { }); }); +describe("pending approvals", () => { + it("keeps app access approvals and persistence choices from remote environments", () => { + const options = [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ]; + const activity = makeActivity({ + id: EventId.make("approval-safari"), + kind: "approval.requested", + summary: "App access approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { + requestId: "req-safari", + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + }); + + expect(derivePendingApprovals([activity])).toEqual([ + { + requestId: "req-safari", + requestKind: "mcp-elicitation", + createdAt: "2026-08-24T00:00:00.000Z", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + ]); + }); + + it("removes an app access approval after a remote client rejects it", () => { + const requested = makeActivity({ + id: EventId.make("approval-safari-open"), + kind: "approval.requested", + summary: "App access approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { requestId: "req-safari", requestKind: "mcp-elicitation" }, + }); + const resolved = makeActivity({ + id: EventId.make("approval-safari-resolved"), + kind: "approval.resolved", + summary: "Approval resolved", + createdAt: "2026-08-24T00:00:01.000Z", + payload: { requestId: "req-safari", decision: "decline" }, + }); + + expect(derivePendingApprovals([requested, resolved])).toEqual([]); + }); +}); + function makeActivity( input: Partial & Pick, @@ -151,6 +234,44 @@ function makeThread( } describe("buildThreadFeed", () => { + it("keeps older local feedback before newer messages returned by the server", () => { + const submission = { + id: MessageId.make("feedback-command-ordering"), + command: "/feedback The agent stopped early.", + createdAt: "2026-08-23T00:00:01.000Z", + status: "sent" as const, + feedbackId: "codex-thread-1", + }; + const laterMessage = { + id: MessageId.make("later-server-message"), + role: "assistant" as const, + text: "Newer server response", + turnId: null, + createdAt: "2026-08-23T00:00:02.000Z", + updatedAt: "2026-08-23T00:00:02.000Z", + streaming: false, + }; + const thread = makeThread({ + id: ThreadId.make("thread-feedback-ordering"), + projectId: ProjectId.make("project-1"), + title: "Feedback ordering", + messages: [laterMessage], + }); + + const feed = buildThreadFeed(thread, { + localMessages: [ + codexFeedbackMessage(submission), + codexFeedbackMessage(submission, "assistant"), + ], + }); + + expect(feed.map((entry) => entry.id)).toEqual([ + "feedback-command-ordering", + "feedback-command-ordering:feedback", + "later-server-message", + ]); + }); + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), @@ -371,7 +492,7 @@ describe("buildThreadFeed", () => { expect(serializedToolOutputs).toBe(1); }); - it("folds settled turn work while leaving the terminal answer visible", () => { + it("keeps the first and terminal assistant messages visible around settled work", () => { const turnId = TurnId.make("turn-1"); const thread = makeThread({ id: ThreadId.make("thread-3"), @@ -387,9 +508,9 @@ describe("buildThreadFeed", () => { }, messages: [ { - id: MessageId.make("assistant-commentary"), + id: MessageId.make("assistant-first"), role: "assistant", - text: "I am checking.", + text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.", turnId, streaming: false, createdAt: "2026-04-01T00:00:02.000Z", @@ -424,8 +545,12 @@ describe("buildThreadFeed", () => { const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); - expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); - expect(collapsed[0]).toMatchObject({ + expect(collapsed.map((entry) => entry.id)).toEqual([ + "assistant-first", + "turn-fold:turn-1", + "assistant-final", + ]); + expect(collapsed[1]).toMatchObject({ type: "turn-fold", label: "Worked for 17s", expanded: false, @@ -433,13 +558,68 @@ describe("buildThreadFeed", () => { const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); expect(expanded.map((entry) => entry.id)).toEqual([ + "assistant-first", "turn-fold:turn-1", - "assistant-commentary", "tool-completed", "assistant-final", ]); }); + it("folds assistant messages between the first and terminal messages", () => { + const turnId = TurnId.make("turn-1"); + const thread = makeThread({ + id: ThreadId.make("thread-middle-message"), + projectId: ProjectId.make("project-1"), + title: "Bounded narration", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:06.000Z", + assistantMessageId: MessageId.make("assistant-final"), + }, + messages: [ + { + id: MessageId.make("assistant-first"), + role: "assistant", + text: "The main result is ready.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:01.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }, + { + id: MessageId.make("assistant-middle"), + role: "assistant", + text: "I am checking one more detail.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:03.000Z", + updatedAt: "2026-04-01T00:00:04.000Z", + }, + { + id: MessageId.make("assistant-final"), + role: "assistant", + text: "Verification finished.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:05.000Z", + updatedAt: "2026-04-01T00:00:06.000Z", + }, + ], + }); + + const feed = buildThreadFeed(thread); + const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + + expect(rows.map((entry) => entry.id)).toEqual([ + "assistant-first", + "turn-fold:turn-1", + "assistant-final", + ]); + }); + it("measures a steer-superseded turn from its user boundary through trailing work", () => { const firstTurnId = TurnId.make("turn-1"); const secondTurnId = TurnId.make("turn-2"); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2a..9e0cb64ae8b3 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,4 +1,9 @@ -import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; +import { + ApprovalRequestId, + isToolLifecycleItemType, + ProviderApprovalOption, + ProviderRequestKind, +} from "@t3tools/contracts"; import type { OrchestrationLatestTurn, OrchestrationThread, @@ -11,14 +16,20 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; +import * as Schema from "effect/Schema"; export interface PendingApproval { readonly requestId: ApprovalRequestId; - readonly requestKind: "command" | "file-read" | "file-change"; + readonly requestKind: ProviderRequestKind; readonly createdAt: string; readonly detail?: string; + readonly appName?: string; + readonly options?: ReadonlyArray; } +const isProviderRequestKind = Schema.is(ProviderRequestKind); +const isProviderApprovalOption = Schema.is(ProviderApprovalOption); + export interface PendingUserInput { readonly requestId: ApprovalRequestId; readonly createdAt: string; @@ -147,6 +158,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "mcp_elicitation_approval": + return "mcp-elicitation"; default: return null; } @@ -1144,9 +1157,13 @@ function deriveThreadFeedTurnFolds( feed: ReadonlyArray, latestTurn: ThreadFeedLatestTurn | null, ): ReadonlyMap { + const firstAssistantMessageIdByTurn = new Map(); const terminalAssistantMessageIdByTurn = new Map(); for (const entry of feed) { if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + if (!firstAssistantMessageIdByTurn.has(entry.message.turnId)) { + firstAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); + } terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); } } @@ -1194,17 +1211,24 @@ function deriveThreadFeedTurnFolds( continue; } + const firstAssistantMessageId = firstAssistantMessageIdByTurn.get(turnId); const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); const hiddenEntryIds = new Set( - entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id), + entries + .filter( + (entry) => + entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId, + ) + .map((entry) => entry.id), ); if (hiddenEntryIds.size === 0) { continue; } const firstEntry = entries[0]; + const firstHiddenEntry = entries.find((entry) => hiddenEntryIds.has(entry.id)); const lastEntry = entries.at(-1); - if (!firstEntry || !lastEntry) { + if (!firstEntry || !firstHiddenEntry || !lastEntry) { continue; } const terminalEntry = terminalAssistantMessageId @@ -1233,9 +1257,9 @@ function deriveThreadFeedTurnFolds( ? `Worked for ${duration}` : "Worked"; - foldsByAnchorId.set(firstEntry.id, { + foldsByAnchorId.set(firstHiddenEntry.id, { turnId, - createdAt: firstEntry.createdAt, + createdAt: firstHiddenEntry.createdAt, hiddenEntryIds, label, }); @@ -1364,13 +1388,14 @@ export function derivePendingApprovals( ? (activity.payload as Record) : null; const requestId = parseApprovalRequestId(payload?.requestId); - const requestKind = - payload?.requestKind === "command" || - payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" - ? payload.requestKind - : requestKindFromRequestType(payload?.requestType); + const requestKind = isProviderRequestKind(payload?.requestKind) + ? payload.requestKind + : requestKindFromRequestType(payload?.requestType); const detail = typeof payload?.detail === "string" ? payload.detail : undefined; + const appName = typeof payload?.appName === "string" ? payload.appName : undefined; + const options = Array.isArray(payload?.options) + ? payload.options.filter(isProviderApprovalOption) + : undefined; if (activity.kind === "approval.requested" && requestId && requestKind) { openByRequestId.set(requestId, { @@ -1378,6 +1403,8 @@ export function derivePendingApprovals( requestKind, createdAt: activity.createdAt, ...(detail ? { detail } : {}), + ...(appName ? { appName } : {}), + ...(options && options.length > 0 ? { options } : {}), }); continue; } @@ -1515,15 +1542,19 @@ export function buildThreadFeed( thread: OrchestrationThread, options?: { readonly loadedMessages?: ReadonlyArray; + readonly localMessages?: ReadonlyArray; }, ): ThreadFeedEntry[] { const loadedMessages = options?.loadedMessages ?? thread.messages; + const messages = options?.localMessages + ? [...loadedMessages, ...options.localMessages] + : loadedMessages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; const workLogEntries = deriveWorkLogEntries(thread.activities); const entries = Arr.sortWith( [ - ...loadedMessages.map((message) => ({ + ...messages.map((message) => ({ type: "message", id: message.id, createdAt: message.createdAt, diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 488766f36954..7c2c037eed33 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter" type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "@t3tools/mobile-markdown-text/types"; diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 403f32a1de48..7ee4d21b1560 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/ type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "@t3tools/mobile-markdown-text/types"; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index dfaeab9cd6ba..5d0bd8a3c9dc 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -42,6 +42,10 @@ export interface Preferences { readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; + /** Undefined preserves the default expanded Settled shelf. */ + readonly threadListV2SettledShelfExpanded?: boolean; + /** Undefined preserves the default collapsed Snoozed shelf. */ + readonly threadListV2SnoozedShelfExpanded?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -100,6 +104,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; + threadListV2SettledShelfExpanded?: boolean; + threadListV2SnoozedShelfExpanded?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -170,6 +176,12 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") { + preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded; + } + if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") { + preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded; + } return preferences; } diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index b8b827585ea2..611a1ed8b99b 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -12,18 +12,35 @@ const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)) Atom.withLabel("mobile-asset-url:empty"), ); -export function useAssetUrl( +export type AssetUrlState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Failure" } + | { readonly _tag: "Success"; readonly url: string }; + +export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, -): string | null { +): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); + if (result._tag === "Failure") { + return { _tag: "Failure" }; + } if (preparedConnection._tag === "None" || result._tag !== "Success") { - return null; + return { _tag: "Loading" }; } - return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; +} + +export function useAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): string | null { + const state = useAssetUrlState(environmentId, resource); + return state._tag === "Success" ? state.url : null; } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 721c82a0e38e..dd7ace60ad99 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert } from "react-native"; +import * as Cause from "effect/Cause"; import { CommandId, @@ -11,6 +13,13 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + codexFeedbackMessage, + parseCodexFeedbackCommand, + submitCodexFeedback, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; @@ -21,6 +30,7 @@ import { } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { buildThreadFeed } from "../lib/threadActivity"; import { appAtomRegistry } from "../state/atom-registry"; import { @@ -41,6 +51,8 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { threadEnvironment } from "./threads"; +import { useAtomCommand } from "./use-atom-command"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -74,10 +86,16 @@ export function useThreadDraftForThread(input: { } export function useThreadComposerState() { - const { selectedThread: selectedThreadShell } = useThreadSelection(); + const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< + Record> + >({}); + const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { + reportFailure: false, + }); useEffect(() => { ensureComposerDraftsLoaded(); @@ -90,10 +108,21 @@ export function useThreadComposerState() { () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), [queuedMessagesByThreadKey, selectedThreadKey], ); - const selectedThreadFeed = useMemo( - () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []), - [selectedThreadDetail], - ); + const selectedThreadFeed = useMemo(() => { + if (!selectedThreadDetail) { + return []; + } + const submissions = selectedThreadKey + ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) + : []; + return buildThreadFeed(selectedThreadDetail, { + localMessages: submissions.flatMap((submission) => + submission.status === "interrupted" + ? [] + : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], + ), + }); + }, [feedbackSubmissionsByThreadKey, selectedThreadDetail, selectedThreadKey]); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; const draftMessage = selectedDraft?.text ?? ""; @@ -143,6 +172,70 @@ export function useThreadComposerState() { return null; } + const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( + (entry) => entry.instanceId === thread.modelSelection.instanceId, + ); + const feedbackCommand = + attachments.length === 0 && + (provider?.driver === "codex" || thread.session?.providerName === "codex") + ? parseCodexFeedbackCommand(text) + : null; + if (feedbackCommand) { + if (thread.session === null) { + Alert.alert("Start a Codex thread first", "Send a message before you submit feedback."); + return null; + } + const metadata = makeQueuedMessageMetadata(); + const result = await submitCodexFeedback({ + submission: { + id: MessageId.make(metadata.messageId), + command: text, + createdAt: metadata.createdAt, + }, + clearDraft: () => clearComposerDraftContent(threadKey), + onUpdate: (submission) => { + setFeedbackSubmissionsByThreadKey((current) => { + const existing = current[threadKey] ?? []; + const found = existing.some((entry) => entry.id === submission.id); + return { + ...current, + [threadKey]: found + ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) + : [...existing, submission], + }; + }); + }, + upload: () => + uploadThreadFeedback({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + ...feedbackCommand, + }, + }), + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return null; + } + const error = Cause.squash(result.cause); + Alert.alert( + "Could not send feedback to OpenAI", + error instanceof Error ? error.message : "An error occurred.", + ); + return null; + } + const feedbackId = result.value.feedbackId; + Alert.alert("Feedback sent to OpenAI", `Thread ID: ${feedbackId}`, [ + { text: "OK", style: "cancel" }, + { + text: "Copy ID", + onPress: () => copyTextWithHaptic(feedbackId, { target: "Codex feedback thread ID" }), + }, + ]); + return null; + } + const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); // Enqueue publishes the queued atom synchronously (the durable write @@ -175,7 +268,12 @@ export function useThreadComposerState() { ); }); return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + }, [ + selectedEnvironmentRuntime?.serverConfig?.providers, + selectedThreadDetail, + selectedThreadShell, + uploadThreadFeedback, + ]); const onChangeDraftMessage = useCallback( (value: string) => { diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index a3440cd4848e..0c10d7b3fa41 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -1,9 +1,16 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + createLinkedPullRequestDetailAtomFamily, + pullRequestDetailToVcsStatus, +} from "@t3tools/client-runtime/state/pull-requests"; +import { connectionAtomRuntime } from "../connection/runtime"; import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; import { vcsEnvironment } from "./vcs"; +const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); + export { presentThreadPr, type ThreadPr, @@ -22,13 +29,36 @@ export function useThreadPr( ): ThreadPrPresentation | null { const cwd = thread.worktreePath ?? projectCwd; const gitStatus = useEnvironmentQuery( - thread.branch !== null && cwd !== null + thread.linkedPullRequest == null && thread.branch !== null && cwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd }, }) : null, ); + const linkedPullRequest = useEnvironmentQuery( + thread.linkedPullRequest == null + ? null + : linkedPullRequestDetailAtom({ + environmentId: thread.environmentId, + input: { + projectId: thread.linkedPullRequest.projectId, + repository: thread.linkedPullRequest.repository, + number: thread.linkedPullRequest.number, + }, + }), + ); + + if (thread.linkedPullRequest != null) { + const detail = linkedPullRequest.data; + return detail === null + ? null + : presentThreadPr(pullRequestDetailToVcsStatus(detail), { + kind: detail.provider, + name: detail.provider, + baseUrl: "", + }); + } const status = gitStatus.data; if (status === null || thread.branch === null || status.refName !== thread.branch) { diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 71ef59a0910c..f332b080ceea 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -8,6 +8,7 @@ import { ProviderDriverKind, type OrchestrationEvent, type OrchestrationThread, + type ProviderApprovalDecision, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -199,14 +200,14 @@ export interface OrchestrationIntegrationHarness { requestId: string, predicate: (row: { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; }) => boolean, timeoutMs?: number, ) => Effect.Effect< { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; }, never @@ -493,7 +494,7 @@ export const makeOrchestrationIntegrationHarness = ( row, ): row is { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; } => row !== null && predicate(row), `pending approval '${requestId}'`, @@ -501,7 +502,7 @@ export const makeOrchestrationIntegrationHarness = ( ) as Effect.Effect< { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; }, never diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 2a351cd6bb48..78a33364f5a3 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -116,6 +116,7 @@ const startupDependencies = Layer.mergeAll( getCapabilities: () => Effect.die("unused"), getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), + uploadFeedback: () => Effect.die("unused"), streamEvents: Stream.empty, }), ); diff --git a/apps/server/package.json b/apps/server/package.json index eb4dc7dd35ec..4d17229cd3af 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.33", + "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 90fe72c33b2c..da9b0c4bab55 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,7 +19,15 @@ const emitInterleavedAssistantToolCalls = const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "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 emitChildUpdatesWhileHanging = process.env.T3_ACP_EMIT_CHILD_UPDATES_WHILE_HANGING === "1"; @@ -42,12 +50,19 @@ 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 permissionOptionIds = { allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", 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"; @@ -282,7 +297,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" }, ]; @@ -547,6 +568,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 (emitXAiPromptCompleteThenHang) { writeJsonRpcNotification("session/update", { sessionId: requestedSessionId, @@ -681,37 +764,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; + } + } yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, @@ -798,7 +902,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: { @@ -832,6 +936,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 91754290db9a..000000000000 --- 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/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts new file mode 100644 index 000000000000..cb08d5e4b2f1 --- /dev/null +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; +import { + ATTACHMENT_UPLOAD_ROUTE_PREFIX, + deletePendingAttachment, + issueAttachmentUploadUrl, + storeAttachmentUpload, + validateAttachmentUploadToken, +} from "./AttachmentUpload.ts"; + +const testLayer = ServerSecretStore.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-attachment-upload-" })), + Layer.provideMerge(NodeServices.layer), +); + +const uploadInput = { + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, +} as const; + +describe("AttachmentUpload", () => { + it.effect("signs the attachment metadata and validates the upload token", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + expect(parseThreadSegmentFromAttachmentId(issued.attachmentId)).toBe("pending"); + + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + expect(yield* validateAttachmentUploadToken(token)).toMatchObject({ + kind: "attachment-upload", + attachmentId: issued.attachmentId, + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects tampered and malformed upload tokens", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const [payload, signature] = token.split("."); + + expect(yield* validateAttachmentUploadToken(`${payload}x.${signature}`)).toBeNull(); + expect(yield* validateAttachmentUploadToken(`${token}.extra`)).toBeNull(); + expect(yield* validateAttachmentUploadToken("garbage")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects expired upload tokens", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + + yield* TestClock.adjust("11 minutes"); + expect(yield* validateAttachmentUploadToken(token)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes expired pending uploads while issuing a new upload URL", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const staleId = "pending-00000000-0000-4000-8000-0000000000cc"; + const stalePath = NodePath.join(config.attachmentsDir, `${staleId}.png`); + NodeFS.writeFileSync(stalePath, Buffer.from("pixels")); + NodeFS.utimesSync(stalePath, 0, 0); + + yield* TestClock.adjust("25 hours"); + yield* issueAttachmentUploadUrl(uploadInput); + + expect(NodeFS.existsSync(stalePath)).toBe(false); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("stores the expected bytes without leaving temporary files", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + expect(yield* storeAttachmentUpload(claims, new Uint8Array([1, 2, 3]))).toMatchObject({ + ok: false, + status: 400, + }); + expect(yield* storeAttachmentUpload(claims, new Uint8Array(6))).toEqual({ ok: true }); + expect( + NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.png`)), + ).toBe(true); + expect( + NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")), + ).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("deletes pending uploads without deleting thread-owned copies", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const uuid = "00000000-0000-4000-8000-0000000000dd"; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${uuid}.png`); + const claimedPath = NodePath.join(config.attachmentsDir, `thread-1-${uuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + NodeFS.writeFileSync(claimedPath, Buffer.from("pixels")); + + yield* deletePendingAttachment(`pending-${uuid}`); + yield* deletePendingAttachment(`pending-${uuid}`); + yield* deletePendingAttachment(`thread-1-${uuid}`); + + expect(NodeFS.existsSync(pendingPath)).toBe(false); + expect(NodeFS.existsSync(claimedPath)).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts new file mode 100644 index 000000000000..6142b69d7342 --- /dev/null +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -0,0 +1,214 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import { + ATTACHMENT_UPLOAD_URL_TTL_MS, + type AttachmentCreateUploadUrlInput, + AttachmentUploadSigningKeyError, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + createPendingAttachmentId, + parseThreadSegmentFromAttachmentId, + PENDING_ATTACHMENT_THREAD_SEGMENT, + resolveAttachmentPathById, + sweepStalePendingAttachments, +} from "../attachmentStore.ts"; +import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { inferImageExtension } from "../imageMime.ts"; + +export const ATTACHMENT_UPLOAD_ROUTE_PREFIX = "/api/attachments/upload"; + +// Asset download tokens share this key, but their signed claim kind is different. +const SIGNING_SECRET_NAME = "asset-access-signing-key"; +const PENDING_ATTACHMENT_SWEEP_INTERVAL_MS = 15 * 60_000; +const lastPendingSweepByDirectory = new Map(); + +const AttachmentUploadClaims = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment-upload"), + attachmentId: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + expiresAt: Schema.Number, +}); +export type AttachmentUploadClaims = typeof AttachmentUploadClaims.Type; + +const attachmentUploadClaimsJson = Schema.fromJsonString(AttachmentUploadClaims); +const decodeAttachmentUploadClaims = Schema.decodeUnknownOption(attachmentUploadClaimsJson); +const encodeAttachmentUploadClaims = Schema.encodeSync(attachmentUploadClaimsJson); + +function decodeClaims(encodedPayload: string): AttachmentUploadClaims | null { + try { + return Option.getOrNull(decodeAttachmentUploadClaims(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +const loadSigningSecret = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); +}); + +export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(function* ( + input: AttachmentCreateUploadUrlInput, +) { + const secret = yield* loadSigningSecret.pipe( + Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause })), + ); + const config = yield* ServerConfig.ServerConfig; + const nowMs = yield* Clock.currentTimeMillis; + const previousSweep = lastPendingSweepByDirectory.get(config.attachmentsDir); + if ( + previousSweep === undefined || + nowMs - previousSweep >= PENDING_ATTACHMENT_SWEEP_INTERVAL_MS + ) { + lastPendingSweepByDirectory.set(config.attachmentsDir, nowMs); + const swept = sweepStalePendingAttachments({ + attachmentsDir: config.attachmentsDir, + nowMs, + }); + if (swept.deleted > 0) { + yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted }); + } + } + + const attachmentId = createPendingAttachmentId(); + const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS; + const encodedPayload = base64UrlEncode( + encodeAttachmentUploadClaims({ + version: 1, + kind: "attachment-upload", + attachmentId, + name: input.name, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + expiresAt, + }), + ); + + return { + attachmentId, + relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`, + expiresAt, + }; +}); + +export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validateToken")(function* ( + token: string, +) { + const [encodedPayload, signature, unexpectedSegment] = token.split("."); + if (!encodedPayload || !signature || unexpectedSegment) { + return null; + } + + const secret = yield* loadSigningSecret.pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the attachment upload signing key.", { cause }), + ), + Effect.orElseSucceed(() => null), + ); + if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) { + return null; + } + + const claims = decodeClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) { + return null; + } + return claims; +}); + +export type StoreAttachmentUploadResult = + | { readonly ok: true } + | { readonly ok: false; readonly status: number; readonly detail: string }; + +export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* ( + claims: AttachmentUploadClaims, + bytes: Uint8Array, +) { + if (bytes.byteLength !== claims.sizeBytes) { + return { + ok: false, + status: 400, + detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`, + } satisfies StoreAttachmentUploadResult; + } + + const config = yield* ServerConfig.ServerConfig; + const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); + const relativePath = `${claims.attachmentId}${extension}`; + const finalPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath, + }); + const partPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${relativePath}.${NodeCrypto.randomUUID()}.part`, + }); + if (!finalPath || !partPath) { + return { ok: false, status: 500, detail: "Failed to resolve attachment path." }; + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true }); + yield* fileSystem.writeFile(partPath, bytes); + yield* fileSystem.rename(partPath, finalPath); + return { ok: true } satisfies StoreAttachmentUploadResult; + }).pipe( + Effect.catch((cause) => + fileSystem.remove(partPath, { force: true }).pipe( + Effect.orElseSucceed(() => undefined), + Effect.andThen( + Effect.logError("Failed to persist attachment upload.", { + attachmentId: claims.attachmentId, + cause, + }), + ), + Effect.as({ + ok: false, + status: 500, + detail: "Failed to persist upload.", + } satisfies StoreAttachmentUploadResult), + ), + ), + ); +}); + +export const deletePendingAttachment = Effect.fn("AttachmentUpload.deletePending")(function* ( + attachmentId: string, +) { + if (parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT) { + return; + } + + const config = yield* ServerConfig.ServerConfig; + const attachmentPath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId, + }); + if (!attachmentPath) { + return; + } + + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(attachmentPath, { force: true }).pipe(Effect.orElseSucceed(() => {})); +}); diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index e21d9cf62cf5..5e782e55407f 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -7,8 +7,12 @@ import { describe, expect, it } from "vite-plus/test"; import { createAttachmentId, + createPendingAttachmentId, + parseAttachmentUuid, + planAttachmentClaim, parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, + sweepStalePendingAttachments, } from "./attachmentStore.ts"; describe("attachmentStore", () => { @@ -44,6 +48,16 @@ describe("attachmentStore", () => { expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo"); }); + it("reserves the pending attachment segment", () => { + const pendingId = createPendingAttachmentId(); + expect(parseThreadSegmentFromAttachmentId(pendingId)).toBe("pending"); + expect(parseAttachmentUuid(pendingId)).toMatch(/^[a-f0-9-]{36}$/); + expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending")!)).toBe("_pending"); + expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending_thread")!)).toBe( + "pending_thread", + ); + }); + it("resolves attachment path by id using the extension that exists on disk", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), @@ -77,4 +91,75 @@ describe("attachmentStore", () => { NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); } }); + + it("plans pending attachment claims with direct filename lookups", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"), + ); + try { + const uuid = "00000000-0000-4000-8000-000000000001"; + const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + + const claim = planAttachmentClaim({ + attachmentsDir, + threadId: "thread-1", + attachmentId: `pending-${uuid}`, + }); + expect(claim).toMatchObject({ + ok: true, + currentPath: pendingPath, + }); + if (!claim.ok) { + return; + } + expect(parseThreadSegmentFromAttachmentId(claim.finalId)).toBe("thread-1"); + expect(parseAttachmentUuid(claim.finalId)).not.toBe(uuid); + expect(claim.finalPath).toBe(NodePath.join(attachmentsDir, `${claim.finalId}.png`)); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("rejects thread-owned attachments even when thread segments collide", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-ownership-"), + ); + try { + const attachmentId = "a-b-00000000-0000-4000-8000-000000000003"; + NodeFS.writeFileSync(NodePath.join(attachmentsDir, `${attachmentId}.png`), "pixels"); + + expect(planAttachmentClaim({ attachmentsDir, threadId: "a b", attachmentId })).toEqual({ + ok: false, + reason: "attachment must be a pending upload", + }); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("removes expired pending and partial files without touching thread attachments", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-sweep-"), + ); + try { + const now = 1_800_000_000_000; + const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000; + const uuid = "00000000-0000-4000-8000-000000000002"; + const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`); + const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`); + const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`); + for (const filePath of [pendingPath, threadPath, partialPath]) { + NodeFS.writeFileSync(filePath, Buffer.from("pixels")); + NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds); + } + + expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 }); + expect(NodeFS.existsSync(pendingPath)).toBe(false); + expect(NodeFS.existsSync(partialPath)).toBe(false); + expect(NodeFS.existsSync(threadPath)).toBe(true); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db217..d0334bce09f3 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; @@ -19,6 +20,10 @@ const ATTACHMENT_ID_PATTERN = new RegExp( "i", ); +export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; +export const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000; + export function toSafeThreadAttachmentSegment(threadId: string): string | null { const segment = threadId .trim() @@ -31,7 +36,19 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null { if (segment.length === 0) { return null; } - return segment; + return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment; +} + +export function createPendingAttachmentId(): string { + return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`; +} + +export function parseAttachmentUuid(attachmentId: string): string | null { + const normalizedId = normalizeAttachmentRelativePath(attachmentId); + if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { + return null; + } + return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null; } export function createAttachmentId(threadId: string): string | null { @@ -96,6 +113,105 @@ export function resolveAttachmentPathById(input: { return null; } +export type AttachmentClaimPlan = + | { + readonly ok: true; + readonly finalId: string; + readonly currentPath: string; + readonly finalPath: string; + } + | { readonly ok: false; readonly reason: string }; + +export function planAttachmentClaim(input: { + readonly attachmentsDir: string; + readonly threadId: string; + readonly attachmentId: string; +}): AttachmentClaimPlan { + const uuid = parseAttachmentUuid(input.attachmentId); + const requestedSegment = parseThreadSegmentFromAttachmentId(input.attachmentId); + if (!uuid || !requestedSegment) { + return { ok: false, reason: "invalid attachment id" }; + } + + if (!toSafeThreadAttachmentSegment(input.threadId)) { + return { ok: false, reason: "invalid thread id" }; + } + if (requestedSegment !== PENDING_ATTACHMENT_THREAD_SEGMENT) { + return { ok: false, reason: "attachment must be a pending upload" }; + } + + const currentPath = resolveAttachmentPathById({ + attachmentsDir: input.attachmentsDir, + attachmentId: input.attachmentId, + }); + if (!currentPath) { + return { ok: false, reason: "attachment not found (removed or expired)" }; + } + const finalId = createAttachmentId(input.threadId); + if (!finalId) { + return { ok: false, reason: "failed to create attachment id" }; + } + + const expectedFinalPath = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: `${finalId}${NodePath.extname(currentPath)}`, + }); + if (!expectedFinalPath) { + return { ok: false, reason: "failed to resolve attachment path" }; + } + return { + ok: true, + finalId, + currentPath, + finalPath: expectedFinalPath, + }; +} + +export function sweepStalePendingAttachments(input: { + readonly attachmentsDir: string; + readonly nowMs: number; +}): { readonly deleted: number } { + let entries: string[]; + try { + entries = NodeFS.readdirSync(input.attachmentsDir); + } catch { + return { deleted: 0 }; + } + + let deleted = 0; + for (const entry of entries) { + const isPartial = entry.endsWith(".part"); + if (!isPartial) { + const attachmentId = parseAttachmentIdFromRelativePath(entry); + if ( + !attachmentId || + parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT + ) { + continue; + } + } + + const resolved = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: entry, + }); + if (!resolved) { + continue; + } + try { + const maxAgeMs = isPartial ? PARTIAL_UPLOAD_MAX_AGE_MS : PENDING_ATTACHMENT_MAX_AGE_MS; + if (input.nowMs - NodeFS.statSync(resolved).mtimeMs > maxAgeMs) { + NodeFS.unlinkSync(resolved); + deleted += 1; + } + } catch { + continue; + } + } + + return { deleted }; +} + export function parseAttachmentIdFromRelativePath(relativePath: string): string | null { const normalized = normalizeAttachmentRelativePath(relativePath); if (!normalized || normalized.includes("/")) { diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 790be9386e6e..25971b0c0aec 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -37,6 +37,12 @@ describe("RPC authorization scopes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); }); + it("requires permission to operate on a thread before uploading feedback", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.providerUploadFeedback)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 57b18b11f596..7522adec032a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -86,6 +86,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, + [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, + [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, + [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 3370a2299dca..8d2ee75acf2e 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -12,6 +12,7 @@ import { connectCommand } from "./cli/connect.ts"; import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; +import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; @@ -63,7 +64,13 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => export const cli = makeCli(); -if (import.meta.main) { +if ( + isEntrypoint({ + moduleUrl: import.meta.url, + entryPath: process.argv[1], + runtimeMain: import.meta.main, + }) +) { Command.run(cli, { version: packageJson.version }).pipe( Effect.scoped, Effect.provide(CliRuntimeLayer), diff --git a/apps/server/src/checkpointing/Errors.test.ts b/apps/server/src/checkpointing/Errors.test.ts deleted file mode 100644 index 4c8b9c59cc31..000000000000 --- 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/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 1314ccfb9361..a999f81b2898 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -56,17 +56,26 @@ const macPlan = { logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", }; +const macInstallerPath = + "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; +const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain("/opt/homebrew/bin/node"); expect(plist).toContain("/Users/theo/.t3/runtime/service-launcher.mjs"); expect(plist).not.toContain("versions/1.2.3"); }); +it("preserves the installer's provider search path in the launch agent", () => { + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); + + expect(plist).toContain(` PATH\n ${macInstallerPath}`); +}); + it("restarts the launch agent on the systemd cadence", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain("RunAtLoad\n "); expect(plist).toContain("KeepAlive\n "); @@ -75,7 +84,7 @@ it("restarts the launch agent on the systemd cadence", () => { }); it("appends both stdio streams to the boot service log", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain( "StandardOutPath\n /Users/theo/.t3/userdata/logs/boot-service.log", @@ -88,15 +97,17 @@ it("appends both stdio streams to the boot service log", () => { it("escapes XML in host paths", () => { const plist = BootService.renderBootServicePlist( { ...macPlan, baseDir: "/Users/theo/T3 & " }, - { homeDir: "/Users/theo" }, + { homeDir: "/Users/theo", environmentPath: "/Users/theo/Tools & :/usr/bin" }, ); expect(plist).toContain("/Users/theo/T3 & <Co>"); + expect(plist).toContain("/Users/theo/Tools & <Scripts>:/usr/bin"); }); const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", usePinnedLauncher = false, + installerPath = macInstallerPath, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -135,27 +146,33 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( }; }), }); - const service = yield* BootService.make({ - baseDir, - logsDir: path.join(baseDir, "userdata", "logs"), - cliVersion: "1.2.3", - host: { - execPath: "/usr/bin/node", - ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), - }, - }).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, runner), - Effect.provide( - Layer.mergeAll( - Layer.succeed(HostProcessPlatform, platform), - Layer.succeed(HostProcessUserId, 501), - Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), - Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), - ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })), + const makeService = (environmentPath = installerPath) => + BootService.make({ + baseDir, + logsDir: path.join(baseDir, "userdata", "logs"), + cliVersion: "1.2.3", + host: { + execPath: "/usr/bin/node", + ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), + }, + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessUserId, 501), + Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), + Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, + }), + ), + ), ), - ), - ); - return { service, fs, statePath, commands, timeouts, control }; + ); + const service = yield* makeService(); + return { service, makeService, fs, statePath, commands, timeouts, control }; }); it.layer(NodeServices.layer)("boot service install", (it) => { @@ -266,6 +283,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe( true, ); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + ` PATH\n ${macInstallerPath}:/usr/local/bin:/usr/sbin:/sbin`, + ); expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", @@ -303,6 +323,58 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("reconstructs a launch agent search path when the installer has no PATH", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness("darwin", false, ""); + const plan = yield* service.install; + + expect(yield* fs.readFileString(plan.unitPath)).toContain( + " PATH\n /usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin", + ); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect("adds missing provider directories to a minimal installer PATH", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); + const plan = yield* service.install; + + expect(yield* fs.readFileString(plan.unitPath)).toContain( + " PATH\n /usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin", + ); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect("keeps an installed launch agent current when the process PATH changes", () => + Effect.gen(function* () { + const { service, makeService } = yield* makeHarness("darwin"); + yield* service.install; + + const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin"); + expect((yield* restartedService.status).current).toBe(true); + }), + ); + + it.effect("drops PATH directories that cannot be represented in a launch agent plist", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness( + "darwin", + false, + "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", + ); + const plan = yield* service.install; + const plist = yield* fs.readFileString(plan.unitPath); + + expect(plist).toContain( + " PATH\n /opt/homebrew/bin:/usr/bin:/usr/local/bin:/bin:/usr/sbin:/sbin", + ); + expect(plist).not.toContain("\u0001"); + expect((yield* service.status).current).toBe(true); + }), + ); + it.effect("ignores a bootout for an agent that is not loaded", () => Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 795bf38e979d..6b7e13d0bbba 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -99,7 +99,7 @@ export function escapeXmlText(value: string): string { /** Pure renderer: launch agents cannot rely on the user's shell or PATH. */ export function renderBootServicePlist( plan: BootServicePlan, - options: { readonly homeDir: string }, + options: { readonly homeDir: string; readonly environmentPath: string }, ): string { // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever. @@ -127,6 +127,8 @@ export function renderBootServicePlist( ` `, ` EnvironmentVariables`, ` `, + ` PATH`, + ` ${escapeXmlText(options.environmentPath)}`, ` T3CODE_HOME`, ` ${escapeXmlText(plan.baseDir)}`, ` ${BOOT_SERVICE_UNIT_ENV}`, @@ -268,6 +270,7 @@ export function launchdManager(input: { readonly path: Path.Path; readonly homeDir: string; readonly uid: number; + readonly environmentPath: string; }): BootServiceManager { const unitPath = input.path.join( input.homeDir, @@ -287,7 +290,11 @@ export function launchdManager(input: { return { kind: "launchd", unitPath, - render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir }), + render: (plan) => + renderBootServicePlist(plan, { + homeDir: input.homeDir, + environmentPath: input.environmentPath, + }), // Without --wait, bootout returns in milliseconds while the job drains // for up to ExitTimeOut, and a bootstrap during the drain fails EIO. // --wait (present on modern macOS, absent from the man page) blocks until @@ -346,6 +353,7 @@ export function selectBootServiceManager(input: { readonly homeDir: string; readonly uid: number | undefined; readonly path: Path.Path; + readonly environmentPath: string; }): BootServiceManager | undefined { if (input.homeDir === "") { return undefined; @@ -354,7 +362,12 @@ export function selectBootServiceManager(input: { return systemdManager({ path: input.path, homeDir: input.homeDir }); } if (input.platform === "darwin" && input.uid !== undefined) { - return launchdManager({ path: input.path, homeDir: input.homeDir, uid: input.uid }); + return launchdManager({ + path: input.path, + homeDir: input.homeDir, + uid: input.uid, + environmentPath: input.environmentPath, + }); } return undefined; } @@ -441,12 +454,39 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const platform = yield* HostProcessPlatform; const uid = yield* HostProcessUserId; const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); + const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; const host = input.host ?? { execPath: hostExecPath }; - - const detectedManager = selectBootServiceManager({ platform, homeDir, uid, path }); + const xmlSafeInstallerDirectories = installerPath.split(":").filter( + (directory) => + directory.length > 0 && + Array.from(directory).every((character) => { + const code = character.charCodeAt(0); + return code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d; + }), + ); + const environmentPath = Array.from( + new Set([ + ...xmlSafeInstallerDirectories, + path.dirname(host.execPath), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]), + ).join(":"); + + const detectedManager = selectBootServiceManager({ + platform, + homeDir, + uid, + path, + environmentPath, + }); const unitPath = detectedManager?.unitPath ?? ""; const logPath = path.join(input.logsDir, "boot-service.log"); const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); @@ -664,11 +704,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { fs.readFileString(statePath).pipe(Effect.option), ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; + const normalizeUnit = (contents: string) => + detectedManager.kind === "launchd" + ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") + : contents; return { supported: true, installed: true, current: - unit === detectedManager.render(plan) && + normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && launcherExists && runtimeEntryExists && Option.isSome(runtimeSentinel) && diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 81c8d1b25e16..7d6a29bec4b7 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -8,6 +8,7 @@ */ import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -15,6 +16,8 @@ import * as LogLevel from "effect/LogLevel"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { sweepStalePendingAttachments } from "./attachmentStore.ts"; + export const DEFAULT_PORT = 3773; export const RuntimeMode = Schema.Literals(["web", "desktop"]); @@ -180,6 +183,14 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server ], { concurrency: "unbounded" }, ); + + const swept = sweepStalePendingAttachments({ + attachmentsDir: derivedPaths.attachmentsDir, + nowMs: yield* Clock.currentTimeMillis, + }); + if (swept.deleted > 0) { + yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted }); + } }); const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( diff --git a/apps/server/src/entrypoint.test.ts b/apps/server/src/entrypoint.test.ts new file mode 100644 index 000000000000..56f2c119764a --- /dev/null +++ b/apps/server/src/entrypoint.test.ts @@ -0,0 +1,89 @@ +// @effect-diagnostics nodeBuiltinImport:off - entrypoint detection is a Node filesystem boundary. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { describe, expect, it } from "vite-plus/test"; + +import { isEntrypoint } from "./entrypoint.ts"; + +const makeTempDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-test-")); + +describe("isEntrypoint", () => { + it("uses the runtime answer when Node provides one", () => { + // Node 22.18+ and 24.2+ populate `import.meta.main`; nothing else is consulted. + expect( + isEntrypoint({ + moduleUrl: "file:///somewhere/bin.mjs", + entryPath: "/elsewhere/other.mjs", + runtimeMain: true, + }), + ).toBe(true); + expect( + isEntrypoint({ + moduleUrl: "file:///somewhere/bin.mjs", + entryPath: "/somewhere/bin.mjs", + runtimeMain: false, + }), + ).toBe(false); + }); + + it("matches the entrypoint path when the runtime has no import.meta.main", () => { + // Node 22.16, 22.17 and 23.11 are inside `engines.node` but leave it undefined. + const dir = makeTempDir(); + const entry = NodePath.join(dir, "bin.mjs"); + NodeFS.writeFileSync(entry, ""); + + expect( + isEntrypoint({ + moduleUrl: NodeURL.pathToFileURL(entry).href, + entryPath: entry, + runtimeMain: undefined, + }), + ).toBe(true); + }); + + it("matches through a symlinked entrypoint, as npm and npx install it", () => { + const dir = makeTempDir(); + const real = NodePath.join(dir, "bin.mjs"); + const link = NodePath.join(dir, "t3"); + NodeFS.writeFileSync(real, ""); + NodeFS.symlinkSync(real, link); + + expect( + isEntrypoint({ + moduleUrl: NodeURL.pathToFileURL(real).href, + entryPath: link, + runtimeMain: undefined, + }), + ).toBe(true); + }); + + it("stays false for an imported module that is not the entrypoint", () => { + // This is what keeps `bin.test.ts` from launching the CLI on import. + const dir = makeTempDir(); + const entry = NodePath.join(dir, "bin.mjs"); + const imported = NodePath.join(dir, "cli.mjs"); + NodeFS.writeFileSync(entry, ""); + NodeFS.writeFileSync(imported, ""); + + expect( + isEntrypoint({ + moduleUrl: NodeURL.pathToFileURL(imported).href, + entryPath: entry, + runtimeMain: undefined, + }), + ).toBe(false); + }); + + it("stays false when there is no entrypoint argument", () => { + expect( + isEntrypoint({ + moduleUrl: "file:///somewhere/bin.mjs", + entryPath: undefined, + runtimeMain: undefined, + }), + ).toBe(false); + }); +}); diff --git a/apps/server/src/entrypoint.ts b/apps/server/src/entrypoint.ts new file mode 100644 index 000000000000..1ac083ec5873 --- /dev/null +++ b/apps/server/src/entrypoint.ts @@ -0,0 +1,38 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Entrypoint detection runs before any Effect runtime is built, so it stays on +// Node built-ins. +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; + +/** + * Whether the module identified by `moduleUrl` is the process entrypoint. + * + * `import.meta.main` answers this directly, but it only exists on Node 22.18+ + * and 24.2+. This package's `engines.node` range also accepts 22.16, 22.17 and + * 23.11, where it is `undefined`: an `if (import.meta.main)` guard never runs, + * so the process loads every module and exits 0 without output. Fall back to + * comparing the entrypoint path on those versions. + */ +export const isEntrypoint = (input: { + readonly moduleUrl: string; + readonly entryPath: string | undefined; + readonly runtimeMain: boolean | undefined; +}): boolean => { + if (input.runtimeMain !== undefined) { + return input.runtimeMain; + } + if (input.entryPath === undefined || input.entryPath === "") { + return false; + } + if (input.moduleUrl === NodeURL.pathToFileURL(input.entryPath).href) { + return true; + } + // npm and npx install the CLI as a symlink. Without `--preserve-symlinks` the + // module URL is the resolved real path while `process.argv[1]` keeps the link + // path, so the comparison above misses. + try { + return input.moduleUrl === NodeURL.pathToFileURL(NodeFS.realpathSync(input.entryPath)).href; + } catch { + return false; + } +}; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index c798796a3be8..0211914c1b59 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -95,8 +95,10 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.environmentId).toBe(second.environmentId); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.attachmentUploads).toBe(true); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..907a5d64bdfc 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -146,12 +146,14 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + attachmentUploads: true, pullRequests: true, threadSettlement: true, threadSnooze: true, threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + threadPullRequestLinking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6291b3f33b2f..9e2cf15ecb72 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -974,6 +974,75 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status finds a merged PR after its remote branch was deleted", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/merged-branch-deleted"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/merged-branch-deleted"]); + + // GitHub commonly deletes a pull request's head branch after merge. Git + // removes the remote-tracking ref, but preserves the local branch's + // remote and merge configuration as evidence that it was published. + yield* runGit(repoDir, ["push", "origin", "--delete", "feature/merged-branch-deleted"]); + const configuredRemote = yield* runGit(repoDir, [ + "config", + "--get", + "branch.feature/merged-branch-deleted.remote", + ]); + const configuredMerge = yield* runGit(repoDir, [ + "config", + "--get", + "branch.feature/merged-branch-deleted.merge", + ]); + const trackingRef = yield* runGit(repoDir, [ + "for-each-ref", + "--format=%(refname)", + "refs/remotes/origin/feature/merged-branch-deleted", + ]); + expect(configuredRemote.stdout.trim()).toBe("origin"); + expect(configuredMerge.stdout.trim()).toBe("refs/heads/feature/merged-branch-deleted"); + expect(trackingRef.stdout.trim()).toBe(""); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 215, + title: "Merged branch was deleted", + url: "https://github.com/pingdotgg/t3code/pull/215", + baseRefName: "main", + headRefName: "feature/merged-branch-deleted", + state: "MERGED", + mergedAt: "2026-04-02T15:00:00Z", + updatedAt: "2026-04-02T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.hasUpstream).toBe(false); + expect(status.pr).toEqual({ + number: 215, + title: "Merged branch was deleted", + url: "https://github.com/pingdotgg/t3code/pull/215", + baseRef: "main", + headRef: "feature/merged-branch-deleted", + state: "merged", + updatedAt: "2026-04-02T15:00:00.000Z", + }); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); + }), + ); + it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2404,6 +2473,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("create_pr targets the remote default branch when it is not main", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + // A repository whose default branch is master, with no main anywhere. + yield* runGit(repoDir, ["push", "origin", "HEAD:master"]); + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "master"]); + + yield* runGit(repoDir, ["checkout", "-b", "feature/master-default"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "master-default.txt"), "master default\n"); + yield* runGit(repoDir, ["add", "master-default.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Master default"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + // Mirrors a provider that cannot report a default branch, as the Azure + // DevOps CLI does when it cannot detect the repository. + defaultBranch: "", + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 505, + title: "Master default", + url: "https://github.com/pingdotgg/codething-mvp/pull/505", + baseRefName: "master", + headRefName: "feature/master-default", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect( + ghCalls.some((call) => + call.includes("pr create --base master --head feature/master-default"), + ), + ).toBe(true); + }), + ); + it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index c135051260fe..5ea4a0072d66 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1268,15 +1268,17 @@ export const make = Effect.gen(function* () { * cannot exist for it and asking the provider is a guaranteed-empty API call. * * `git push` writes the remote-tracking ref even without `-u` (how most - * terminal and agent pushes land), which makes this a safer "did it ever - * reach the host" test than looking for upstream config, and the glob spans - * every remote so a fork branch still counts. A repository that tracks no - * remotes at all cannot answer the question, because then every branch looks - * unpublished; it, and any failed probe, keeps the lookup. + * terminal and agent pushes land), and configured upstream metadata survives + * when a merged change request's remote branch is deleted. Together they + * distinguish branches known to have reached a host from genuinely local + * branches. The ref glob spans every remote so a fork branch still counts. A + * repository that tracks no remotes at all cannot answer the question, + * because then every branch looks unpublished; it, and any failed probe, + * keeps the lookup. */ const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( cwd: string, - headContext: Pick, + headContext: Pick, ) { if (headContext.headBranch.length === 0) { return false; @@ -1291,13 +1293,24 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.map((result) => result.stdout.trim().length > 0)); - return yield* Effect.all( - [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], - { concurrency: "unbounded" }, - ).pipe( - Effect.map(([tracksAnyRemote, tracksThisBranch]) => tracksAnyRemote && !tracksThisBranch), - Effect.orElseSucceed(() => false), - ); + return yield* Effect.gen(function* () { + const [configuredRemote, configuredMerge] = yield* Effect.all( + [ + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.remote`), + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.merge`), + ], + { concurrency: "unbounded" }, + ); + if (configuredRemote !== null && configuredMerge !== null) { + return false; + } + + const [tracksAnyRemote, tracksThisBranch] = yield* Effect.all( + [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], + { concurrency: "unbounded" }, + ); + return tracksAnyRemote && !tracksThisBranch; + }).pipe(Effect.orElseSucceed(() => false)); }); const findOpenPr = Effect.fn("findOpenPr")(function* ( @@ -1489,6 +1502,18 @@ export const make = Effect.gen(function* () { return defaultFromProvider; } + // The provider lookup can fail for reasons unrelated to the branch, so fall + // back to what the remote itself records before assuming a name. A repository + // whose default branch is master would otherwise get a base branch that does + // not exist. + const defaultFromRemote = yield* gitCore.resolvePrimaryRemoteName(cwd).pipe( + Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)), + Effect.orElseSucceed(() => null), + ); + if (defaultFromRemote) { + return defaultFromRemote; + } + return "main"; }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index da22794951fb..a73aa59d5516 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -84,6 +84,9 @@ export class GitWorkflowService extends Context.Service< readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; + readonly pruneWorktrees: (input: { + readonly cwd: string; + }) => Effect.Effect; readonly createRef: ( input: VcsCreateRefInput, ) => Effect.Effect; @@ -319,6 +322,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), ), + pruneWorktrees: (input) => + ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( + Effect.andThen(git.pruneWorktrees(input)), + ), createRef: (input) => ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( Effect.andThen(git.createRef(input)), diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index f3c1076b0ebd..8d22587095ac 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -28,6 +28,11 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { + ATTACHMENT_UPLOAD_ROUTE_PREFIX, + storeAttachmentUpload, + validateAttachmentUploadToken, +} from "./assets/AttachmentUpload.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -232,6 +237,51 @@ export const assetRouteLayer = HttpRouter.add( }), ); +export const attachmentUploadRouteLayer = HttpRouter.add( + "POST", + `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + const token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + if (!token) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const contentLengthHeader = request.headers["content-length"]; + if ( + contentLengthHeader !== undefined && + (!Number.isInteger(Number(contentLengthHeader)) || + Number(contentLengthHeader) !== claims.sizeBytes) + ) { + return HttpServerResponse.text("Content-Length must match the upload size.", { + status: 400, + }); + } + + const body = yield* request.arrayBuffer.pipe( + Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)), + Effect.orElseSucceed(() => null), + ); + if (body === null) { + return HttpServerResponse.text("Failed to read the upload body.", { status: 400 }); + } + + const stored = yield* storeAttachmentUpload(claims, new Uint8Array(body)); + return stored.ok + ? HttpServerResponse.empty({ status: 204 }) + : HttpServerResponse.text(stored.detail, { status: stored.status }); + }), +); + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 24a137d933fa..1f05604934e4 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); diff --git a/apps/server/src/observability/Attributes.test.ts b/apps/server/src/observability/Attributes.test.ts deleted file mode 100644 index d9ed2e1271f6..000000000000 --- 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/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 08ea1437bb29..ca4cb7afd9ab 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -125,6 +125,7 @@ function createProviderServiceHarness( }, }), rollbackConversation, + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..d27caed4a8e2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -174,6 +174,78 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { assert.equal(row.lastAppliedSequence, 3); } + yield* sql`CREATE TABLE thread_shell_updates (count INTEGER NOT NULL)`; + yield* sql`INSERT INTO thread_shell_updates (count) VALUES (0)`; + yield* sql` + CREATE TRIGGER count_thread_shell_updates + AFTER UPDATE ON projection_threads + WHEN NEW.thread_id = 'thread-1' + BEGIN + UPDATE thread_shell_updates SET count = count + 1; + END; + `; + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-assistant-update"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.100Z", + commandId: CommandId.make("cmd-assistant-update"), + causationEventId: null, + correlationId: CommandId.make("cmd-assistant-update"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-2"), + role: "assistant", + text: "more work", + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.100Z", + updatedAt: "2026-01-01T00:00:00.100Z", + }, + }); + yield* projectionPipeline.bootstrap; + + let threadShellUpdates = yield* sql<{ readonly count: number }>` + SELECT count FROM thread_shell_updates + `; + assert.deepEqual(threadShellUpdates, [{ count: 1 }]); + + yield* sql`UPDATE thread_shell_updates SET count = 0`; + yield* eventStore.append({ + type: "thread.activity-appended", + eventId: EventId.make("evt-routine-activity"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.200Z", + commandId: CommandId.make("cmd-routine-activity"), + causationEventId: null, + correlationId: CommandId.make("cmd-routine-activity"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-routine"), + tone: "tool", + kind: "tool.updated", + summary: "Tool made progress", + payload: {}, + turnId: null, + createdAt: "2026-01-01T00:00:00.200Z", + }, + }, + }); + yield* projectionPipeline.bootstrap; + + threadShellUpdates = yield* sql<{ readonly count: number }>` + SELECT count FROM thread_shell_updates + `; + assert.deepEqual(threadShellUpdates, [{ count: 1 }]); + yield* sql`DROP TRIGGER count_thread_shell_updates`; + yield* sql`DROP TABLE thread_shell_updates`; + // Settled lifecycle through the DB pipeline: thread.settled writes the // override + timestamp, thread.unsettled(user) flips to the active pin. yield* eventStore.append({ @@ -197,15 +269,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const settledRows = yield* sql<{ readonly settledOverride: string | null; readonly settledAt: string | null; + readonly unsettledAt: string | null; }>` SELECT settled_override AS "settledOverride", - settled_at AS "settledAt" + settled_at AS "settledAt", + unsettled_at AS "unsettledAt" FROM projection_threads WHERE thread_id = 'thread-1' `; assert.deepEqual(settledRows, [ - { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" }, + { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z", unsettledAt: null }, ]); yield* eventStore.append({ @@ -229,14 +303,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const unsettledRows = yield* sql<{ readonly settledOverride: string | null; readonly settledAt: string | null; + readonly unsettledAt: string | null; }>` SELECT settled_override AS "settledOverride", - settled_at AS "settledAt" + settled_at AS "settledAt", + unsettled_at AS "unsettledAt" FROM projection_threads WHERE thread_id = 'thread-1' `; - assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]); + // The un-settle stamps the active-list re-entry time so clients can + // surface the thread at the top of the list. + assert.deepEqual(unsettledRows, [ + { + settledOverride: "active", + settledAt: null, + unsettledAt: "2026-01-01T00:00:02.000Z", + }, + ]); }), ); }); @@ -1171,6 +1255,77 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta ); 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: CorrelationId.make(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* Effect.forEach( + Object.values(ORCHESTRATION_PROJECTOR_NAMES), + (projector) => { + const lastAppliedSequence = + projector === ORCHESTRATION_PROJECTOR_NAMES.projects + ? sequenceBeforeBacklog + : lastSequence; + return sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, ${lastAppliedSequence}, ${now}) + ON CONFLICT (projector) + DO UPDATE SET + last_applied_sequence = excluded.last_applied_sequence, + updated_at = excluded.updated_at + `; + }, + { discard: true }, + ); + + 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 }]); + }), + ); + it.effect("resumes from projector last_applied_sequence without replaying older events", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 5ce217e7f780..15c2c940e596 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -130,6 +130,29 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { ); } +// A full refresh loads all thread history, so skip events that cannot change the summary. +function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { + if (event.type === "thread.message-sent") { + return event.payload.role === "user"; + } + + if (event.type !== "thread.activity-appended") { + return true; + } + + switch (event.payload.activity.kind) { + case "approval.requested": + case "approval.resolved": + case "provider.approval.respond.failed": + case "user-input.requested": + case "user-input.resolved": + case "provider.user-input.respond.failed": + return true; + default: + return false; + } +} + function derivePendingUserInputCountFromActivities( activities: ReadonlyArray, ): number { @@ -612,12 +635,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti branch: event.payload.branch, worktreePath: event.payload.worktreePath, parentThreadId: event.payload.parentThreadId ?? null, + linkedPullRequest: null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -675,6 +700,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, settledOverride: "settled", settledAt: event.payload.settledAt, + unsettledAt: null, updatedAt: event.payload.updatedAt, }); return; @@ -691,6 +717,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, settledOverride: event.payload.reason === "user" ? "active" : null, settledAt: null, + // Re-entry stamp for active-list ordering. A thread already pinned + // active keeps its stamp: the activity reset that clears the pin + // is not a re-entry and must not reorder the list. + unsettledAt: + existingRow.value.settledOverride === "active" + ? existingRow.value.unsettledAt + : event.payload.updatedAt, updatedAt: event.payload.updatedAt, }); return; @@ -800,6 +833,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.linkedPullRequest !== undefined + ? { linkedPullRequest: event.payload.linkedPullRequest } + : {}), updatedAt: event.payload.updatedAt, }); return; @@ -866,7 +902,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, updatedAt: event.occurredAt, }); - yield* refreshThreadShellSummary(event.payload.threadId); + if (shouldRefreshThreadShellSummary(event)) { + yield* refreshThreadShellSummary(event.payload.threadId); + } return; } @@ -1508,6 +1546,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const resolvedDecision = resolvedDecisionRaw === "accept" || resolvedDecisionRaw === "acceptForSession" || + resolvedDecisionRaw === "acceptAlways" || resolvedDecisionRaw === "decline" || resolvedDecisionRaw === "cancel" ? resolvedDecisionRaw @@ -1689,6 +1728,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/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index eab0e98f7eca..15765e9e4714 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -82,6 +82,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { interaction_mode, branch, worktree_path, + linked_pull_request_json, latest_turn_id, latest_user_message_at, pending_approval_count, @@ -102,6 +103,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'default', NULL, NULL, + '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', 'turn-1', '2026-02-24T00:00:04.000Z', 1, @@ -305,6 +307,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch: null, worktreePath: null, parentThreadId: null, + linkedPullRequest: { + projectId: asProjectId("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -322,6 +330,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", @@ -425,6 +434,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch: null, worktreePath: null, parentThreadId: null, + linkedPullRequest: { + projectId: asProjectId("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -442,6 +457,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index ddac760a528d..16e56031aa1a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -24,6 +24,7 @@ import { type OrchestrationThreadShell, ModelSelection, ProjectId, + ThreadLinkedPullRequest, ThreadId, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; @@ -89,6 +90,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -423,12 +425,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", parent_thread_id AS "parentThreadId", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -460,12 +464,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", parent_thread_id AS "parentThreadId", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -499,12 +505,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", parent_thread_id AS "parentThreadId", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -942,12 +950,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", parent_thread_id AS "parentThreadId", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -1699,12 +1709,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: row.branch, worktreePath: row.worktreePath, parentThreadId: row.parentThreadId, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -1907,12 +1921,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: row.branch, worktreePath: row.worktreePath, parentThreadId: row.parentThreadId, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2044,12 +2062,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: row.branch, worktreePath: row.worktreePath, parentThreadId: row.parentThreadId, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2190,12 +2212,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: row.branch, worktreePath: row.worktreePath, parentThreadId: row.parentThreadId, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2470,12 +2496,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, parentThreadId: threadRow.value.parentThreadId, + ...(threadRow.value.linkedPullRequest === null + ? {} + : { linkedPullRequest: threadRow.value.linkedPullRequest }), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + unsettledAt: threadRow.value.unsettledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, @@ -2612,12 +2642,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, parentThreadId: threadRow.value.parentThreadId, + ...(threadRow.value.linkedPullRequest === null + ? {} + : { linkedPullRequest: threadRow.value.linkedPullRequest }), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + unsettledAt: threadRow.value.unsettledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..a3588244d827 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -150,6 +150,8 @@ describe("ProviderCommandReactor", () => { readonly requiresNewThreadForModelChange?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; + readonly interruptTurnEffect?: () => Effect.Effect; + readonly stopSessionEffect?: () => Effect.Effect; readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; @@ -235,23 +237,27 @@ describe("ProviderCommandReactor", () => { turnId: asTurnId("turn-1"), }), ); - const interruptTurn = vi.fn((_: unknown) => Effect.void); + const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); - const stopSession = vi.fn((input: unknown) => - Effect.sync(() => { - const threadId = - typeof input === "object" && input !== null && "threadId" in input - ? (input as { threadId?: ThreadId }).threadId - : undefined; - if (!threadId) { - return; - } - const index = runtimeSessions.findIndex((session) => session.threadId === threadId); - if (index >= 0) { - runtimeSessions.splice(index, 1); - } - }), + const stopSession = vi.fn((stopInput: unknown) => + (input?.stopSessionEffect?.() ?? Effect.void).pipe( + Effect.tap(() => + Effect.sync(() => { + const threadId = + typeof stopInput === "object" && stopInput !== null && "threadId" in stopInput + ? (stopInput as { threadId?: ThreadId }).threadId + : undefined; + if (!threadId) { + return; + } + const index = runtimeSessions.findIndex((session) => session.threadId === threadId); + if (index >= 0) { + runtimeSessions.splice(index, 1); + } + }), + ), + ), ); const renameBranch = vi.fn((input: unknown) => Effect.succeed({ @@ -264,6 +270,11 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); + const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); + const createWorktree = vi.fn( + (input: { readonly refName: string; readonly path: string | null }) => + Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), + ); const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, @@ -340,6 +351,7 @@ describe("ProviderCommandReactor", () => { }); }, rollbackConversation: () => unsupported(), + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, @@ -395,6 +407,8 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ renameBranch, + pruneWorktrees, + createWorktree, } satisfies Partial), ), Layer.provideMerge( @@ -499,6 +513,8 @@ describe("ProviderCommandReactor", () => { respondToUserInput, stopSession, renameBranch, + pruneWorktrees, + createWorktree, refreshStatus, generateBranchName, generateThreadTitle, @@ -1510,6 +1526,50 @@ describe("ProviderCommandReactor", () => { expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); + it("recreates a missing worktree from the thread branch before starting a turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const worktreePath = NodePath.join(harness.stateDir, "missing-worktree"); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-missing-worktree"), + threadId: ThreadId.make("thread-1"), + branch: "feature/restore", + worktreePath, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-worktree"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-worktree"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" }); + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/provider-project", + refName: "feature/restore", + path: worktreePath, + }); + expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( + harness.startSession.mock.invocationCallOrder[0]!, + ); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2486,6 +2546,218 @@ describe("ProviderCommandReactor", () => { }); }); + effectIt.effect( + "stops a running session and records the failure when provider interrupt fails", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ + interruptTurnEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.interrupt", + detail: "provider session disappeared", + }), + ), + stopSessionEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "session.stop", + detail: "provider process already exited", + }), + ), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-failure"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-provider-failure"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-1"), + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "stopped"; + }), + ); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "stopped", + activeTurnId: null, + lastError: "provider session disappeared", + }); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.interrupt.failed"), + ).toMatchObject({ + summary: "Provider turn interrupt failed", + payload: { detail: "provider session disappeared" }, + }); + expect(harness.stopSession).toHaveBeenCalledWith({ threadId: ThreadId.make("thread-1") }); + }), + ); + + effectIt.effect("stops a starting session without a bound turn when interrupt fails", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ + interruptTurnEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.interrupt", + detail: "provider session disappeared", + }), + ), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-starting"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-starting-provider-failure"), + threadId: ThreadId.make("thread-1"), + createdAt: now, + }); + + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "stopped", + activeTurnId: null, + lastError: "provider session disappeared", + }); + expect(harness.stopSession).toHaveBeenCalledWith({ threadId: ThreadId.make("thread-1") }); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.interrupt.failed"), + ).toMatchObject({ payload: { detail: "provider session disappeared" } }); + }), + ); + + effectIt.effect("does not overwrite a session that became ready while an interrupt failed", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + const completedAt = "2026-01-01T00:00:01.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-race"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + harness.interruptTurn.mockImplementation(() => + harness.engine + .dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-natural-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: completedAt, + }, + createdAt: completedAt, + }) + .pipe( + Effect.catchCause((cause) => Effect.die(cause)), + Effect.andThen( + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.interrupt", + detail: "provider session disappeared", + }), + ), + ), + ), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-race"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-1"), + createdAt: now, + }); + + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "ready", + activeTurnId: null, + lastError: null, + updatedAt: completedAt, + }); + expect(harness.stopSession).not.toHaveBeenCalled(); + expect( + thread?.activities.some((activity) => activity.kind === "provider.turn.interrupt.failed"), + ).toBe(false); + }), + ); + it("starts a fresh session when only projected session state exists", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2698,15 +2970,15 @@ describe("ProviderCommandReactor", () => { }); }); - it("surfaces stale provider approval request failures without faking approval resolution", async () => { + it("normalizes stale Codex approval callbacks without faking approval resolution", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; harness.respondToRequest.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ provider: ProviderDriverKind.make("codex"), - method: "session/request_permission", - detail: "Unknown pending permission request: approval-request-1", + method: "item/requestApproval/decision", + detail: "Unknown pending Codex approval request: approval-request-1", }), ), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cfc95f2613fb..812893d8c843 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -240,13 +241,15 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause true)); + if (exists) { + return; + } + const project = yield* resolveProject(thread.projectId); + if (!project) { + return; + } + const cwd = project.workspaceRoot; + yield* Effect.logWarning("provider command reactor recreating missing worktree", { + threadId: thread.id, + worktreePath, + branch, + }); + // A directory deleted without `git worktree remove` leaves an admin entry + // that makes `git worktree add` refuse the path; prune clears it. + yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( + Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("provider command reactor failed to recreate worktree", { + threadId: thread.id, + worktreePath, + cause: Cause.pretty(cause), + }), + ), + ); + }); + const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery .getThreadDetailById(threadId) @@ -1083,6 +1133,8 @@ const make = Effect.gen(function* () { return; } + yield* ensureThreadWorktree(thread); + const isFirstUserMessageTurn = thread.messages.filter((entry) => entry.role === "user").length === 1; if (isFirstUserMessageTurn) { @@ -1180,8 +1232,8 @@ const make = Effect.gen(function* () { if (!thread) { return; } - const hasSession = thread.session && thread.session.status !== "stopped"; - if (!hasSession) { + const session = thread.session; + if (!session || session.status === "stopped") { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.interrupt.failed", @@ -1192,8 +1244,80 @@ const make = Effect.gen(function* () { }); } + const recoverInterruptFailure = (cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + + const detail = formatFailureDetail(cause); + return Effect.gen(function* () { + const latestThread = yield* resolveThread(event.payload.threadId); + const latestSession = latestThread?.session; + if ( + !latestSession || + latestSession.status === "stopped" || + latestSession.status === "ready" || + (event.payload.turnId !== undefined && + latestSession.activeTurnId !== null && + latestSession.activeTurnId !== event.payload.turnId) + ) { + return; + } + + yield* providerService.stopSession({ threadId: event.payload.threadId }).pipe( + Effect.catchCause((stopCause) => { + if (Cause.hasInterruptsOnly(stopCause)) { + return Effect.interrupt; + } + return Effect.logWarning( + "provider command reactor failed to stop session after interrupt failure", + { + threadId: event.payload.threadId, + cause: Cause.pretty(stopCause), + originalCause: Cause.pretty(cause), + }, + ); + }), + ); + const stoppedThread = yield* resolveThread(event.payload.threadId); + const stoppedSession = stoppedThread?.session; + if ( + !stoppedSession || + stoppedSession.status === "stopped" || + stoppedSession.status === "ready" || + (event.payload.turnId !== undefined && + stoppedSession.activeTurnId !== null && + stoppedSession.activeTurnId !== event.payload.turnId) + ) { + return; + } + + yield* setThreadSession({ + threadId: event.payload.threadId, + session: { + ...stoppedSession, + status: "stopped", + activeTurnId: null, + lastError: detail, + updatedAt: event.payload.createdAt, + }, + createdAt: event.payload.createdAt, + }); + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.interrupt.failed", + summary: "Provider turn interrupt failed", + detail, + turnId: event.payload.turnId ?? null, + createdAt: event.payload.createdAt, + }); + }); + }; + // Orchestration turn ids are not provider turn ids, so interrupt by session. - yield* providerService.interruptTurn({ threadId: event.payload.threadId }); + yield* providerService + .interruptTurn({ threadId: event.payload.threadId }) + .pipe(Effect.catchCause(recoverInterruptFailure)); }); const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts index 05370781c0d0..0d262028dedf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts @@ -30,4 +30,41 @@ describe("runtimeEventToActivities approval details", () => { expect(activity?.kind).toBe("approval.requested"); expect((activity?.payload as Record | undefined)?.detail).toBe(detail); }); + + it("keeps app details and approval options available to remote clients", () => { + const options = [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ] as const; + const event = { + type: "request.opened", + eventId: EventId.make("evt-mcp-elicitation"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-08-24T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + requestId: RuntimeRequestId.make("approval-safari"), + payload: { + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + } satisfies ProviderRuntimeEvent; + + const [activity] = runtimeEventToActivities(event); + + expect(activity).toMatchObject({ + kind: "approval.requested", + summary: "App access approval requested", + payload: { + requestId: "approval-safari", + requestKind: "mcp-elicitation", + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1e1374c966b6..84858b6affe9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -125,6 +125,7 @@ function createProviderServiceHarness() { }); }, rollbackConversation: () => unsupported(), + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 953ba1ec9b0d..7ec3a7e64243 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -298,7 +298,7 @@ function sessionStatusAllowsActiveTurn( function requestKindFromCanonicalRequestType( requestType: string | undefined, -): "command" | "file-read" | "file-change" | undefined { +): "command" | "file-read" | "file-change" | "mcp-elicitation" | undefined { switch (requestType) { case "command_execution_approval": case "exec_command_approval": @@ -308,6 +308,8 @@ function requestKindFromCanonicalRequestType( case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "mcp_elicitation_approval": + return "mcp-elicitation"; default: return undefined; } @@ -388,12 +390,16 @@ export function runtimeEventToActivities( ? "File-read approval requested" : requestKind === "file-change" ? "File-change approval requested" - : "Approval requested", + : requestKind === "mcp-elicitation" + ? "App access approval requested" + : "Approval requested", payload: { requestId: toApprovalRequestId(event.requestId), ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.detail ? { detail: event.payload.detail } : {}), + ...(event.payload.appName ? { appName: event.payload.appName } : {}), + ...(event.payload.options ? { options: event.payload.options } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts new file mode 100644 index 000000000000..27a35977ffca --- /dev/null +++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts @@ -0,0 +1,318 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + type ClientOrchestrationCommand, + CommandId, + MessageId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts"; + +const testLayer = Layer.mergeAll( + WorkspacePaths.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-normalizer-attachments-" }), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const attachmentUuid = "00000000-0000-4000-8000-0000000000aa"; + +function turnStartCommand(input: { + readonly threadId?: string; + readonly attachments: ReadonlyArray< + | { readonly id: string; readonly sizeBytes: number } + | { readonly dataUrl: string; readonly sizeBytes: number } + >; +}): ClientOrchestrationCommand { + return { + type: "thread.turn.start", + commandId: CommandId.make("command-1"), + threadId: ThreadId.make(input.threadId ?? "thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "look at this", + attachments: input.attachments.map((attachment) => ({ + type: "image" as const, + name: "screenshot.png", + mimeType: "image/png", + ...attachment, + })), + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-08-01T00:00:00.000Z", + }; +} + +describe("normalizeDispatchCommand attachments", () => { + it.effect("preserves inline image attachments from existing mobile clients", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }], + }), + ); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const attachment = normalized.message.attachments[0]!; + expect(attachment.id.startsWith("thread-1-")).toBe(true); + expect( + NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${attachment.id}.png`)), + ).toEqual(Buffer.from("pixels")); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("claims uploaded attachments while retaining a retryable pending copy", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const bytes = Buffer.from("pixels"); + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, bytes); + + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], + }), + ); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const attachmentId = normalized.message.attachments[0]!.id; + expect(attachmentId.startsWith("thread-1-")).toBe(true); + expect(attachmentId).not.toBe(`thread-1-${attachmentUuid}`); + expect(NodeFS.existsSync(pendingPath)).toBe(true); + expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`))).toBe( + true, + ); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("normalizes inline and uploaded attachments in the same turn", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), + Buffer.from("pixels"), + ); + + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [ + { dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }, + { id: `pending-${attachmentUuid}`, sizeBytes: 6 }, + ], + }), + ); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + expect(normalized.message.attachments).toHaveLength(2); + expect(normalized.message.attachments[1]?.id.startsWith("thread-1-")).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("retries a failed bootstrap with a fresh thread id", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const bytes = Buffer.from("pixels"); + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), + bytes, + ); + + const first = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], + }), + ); + if (first.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + NodeFS.rmSync( + NodePath.join(config.attachmentsDir, `${first.message.attachments[0]!.id}.png`), + ); + + const retried = yield* normalizeDispatchCommand( + turnStartCommand({ + threadId: "thread-retry", + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], + }), + ); + if (retried.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + expect(retried.message.attachments[0]?.id.startsWith("thread-retry-")).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes failed attachment claims without deleting their pending uploads", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + const command = turnStartCommand({ + attachments: [ + { dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }, + { id: `pending-${attachmentUuid}`, sizeBytes: 6 }, + ], + }); + const normalized = yield* normalizeDispatchCommand(command); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const inlinePath = NodePath.join( + config.attachmentsDir, + `${normalized.message.attachments[0]!.id}.png`, + ); + const claimedPath = NodePath.join( + config.attachmentsDir, + `${normalized.message.attachments[1]!.id}.png`, + ); + yield* cleanupFailedUploadedAttachments(command, normalized); + + expect(NodeFS.existsSync(pendingPath)).toBe(true); + expect(NodeFS.existsSync(claimedPath)).toBe(false); + expect(NodeFS.existsSync(inlinePath)).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes a failed claimed copy after its pending original was removed", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + const command = turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], + }); + const normalized = yield* normalizeDispatchCommand(command); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const claimedPath = NodePath.join( + config.attachmentsDir, + `${normalized.message.attachments[0]!.id}.png`, + ); + NodeFS.rmSync(pendingPath); + + yield* cleanupFailedUploadedAttachments(command, normalized); + + expect(NodeFS.existsSync(claimedPath)).toBe(false); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps concurrent claims independent when one dispatch fails", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + const command = turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], + }); + + const [failed, succeeded] = yield* Effect.all( + [normalizeDispatchCommand(command), normalizeDispatchCommand(command)], + { concurrency: 2 }, + ); + if (failed.type !== "thread.turn.start" || succeeded.type !== "thread.turn.start") { + throw new Error("Expected thread.turn.start commands."); + } + + const failedPath = NodePath.join( + config.attachmentsDir, + `${failed.message.attachments[0]!.id}.png`, + ); + const succeededPath = NodePath.join( + config.attachmentsDir, + `${succeeded.message.attachments[0]!.id}.png`, + ); + expect(failedPath).not.toBe(succeededPath); + + yield* cleanupFailedUploadedAttachments(command, failed); + + expect(NodeFS.existsSync(pendingPath)).toBe(true); + expect(NodeFS.existsSync(failedPath)).toBe(false); + expect(NodeFS.existsSync(succeededPath)).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes earlier claimed copies when a later attachment cannot be normalized", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingId = `pending-${attachmentUuid}`; + const pendingPath = NodePath.join(config.attachmentsDir, `${pendingId}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + + const failure = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [ + { id: pendingId, sizeBytes: 6 }, + { + id: "pending-00000000-0000-4000-8000-0000000000ff", + sizeBytes: 6, + }, + ], + }), + ).pipe(Effect.flip); + + expect(failure.message).toContain("not found"); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${pendingId}.png`]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects uploaded attachments with the wrong size or thread", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), + Buffer.from("pixels"), + ); + + const wrongSize = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 999 }], + }), + ).pipe(Effect.flip); + expect(wrongSize.message).toContain("size"); + + const wrongThread = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `another-thread-${attachmentUuid}`, sizeBytes: 6 }], + }), + ).pipe(Effect.flip); + expect(wrongThread.message).toContain("pending upload"); + + const mismatchedTypeCommand = turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], + }); + if (mismatchedTypeCommand.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + const mismatchedType = yield* normalizeDispatchCommand({ + ...mismatchedTypeCommand, + message: { + ...mismatchedTypeCommand.message, + attachments: mismatchedTypeCommand.message.attachments.map((attachment) => ({ + ...attachment, + mimeType: "image/jpeg", + })), + }, + }).pipe(Effect.flip); + expect(mismatchedType.message).toContain("image type"); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 24c65900b296..bd6a8f242b87 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -10,7 +10,13 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import { + createAttachmentId, + planAttachmentClaim, + PENDING_ATTACHMENT_THREAD_SEGMENT, + parseThreadSegmentFromAttachmentId, + resolveAttachmentPath, +} from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; import { parseBase64DataUrl } from "../imageMime.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -43,6 +49,29 @@ export const canonicalizeClientCommandTimestamps = ( }; }; +const removeClaimedAttachmentPaths = Effect.fn("Normalizer.removeClaimedAttachmentPaths")( + function* (attachmentPaths: ReadonlyArray) { + if (attachmentPaths.length === 0) { + return; + } + const fileSystem = yield* FileSystem.FileSystem; + yield* Effect.forEach( + attachmentPaths, + (attachmentPath) => + fileSystem.remove(attachmentPath, { force: true }).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to remove an unclaimed attachment copy.", { + attachmentPath, + cause, + }), + ), + Effect.orElseSucceed(() => undefined), + ), + { concurrency: 1 }, + ); + }, +); + export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { const receivedAt = DateTime.formatIso(yield* DateTime.now); @@ -104,10 +133,69 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => return canonicalCommand as OrchestrationCommand; } + const claimedAttachmentPaths: string[] = []; const normalizedAttachments = yield* Effect.forEach( canonicalCommand.message.attachments, (attachment) => Effect.gen(function* () { + if (!("dataUrl" in attachment)) { + const claim = planAttachmentClaim({ + attachmentsDir: serverConfig.attachmentsDir, + threadId: canonicalCommand.threadId, + attachmentId: attachment.id, + }); + if (!claim.ok) { + return yield* new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: ${claim.reason}.`, + }); + } + + const info = yield* fileSystem.stat(claim.currentPath).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: attachment not found.`, + cause, + }), + ), + ); + if (Number(info.size) !== attachment.sizeBytes) { + return yield* new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: stored size does not match.`, + }); + } + + const normalizedAttachment = { + ...attachment, + id: claim.finalId, + mimeType: attachment.mimeType.toLowerCase(), + }; + const expectedPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment: normalizedAttachment, + }); + if (expectedPath !== claim.finalPath) { + return yield* new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: image type does not match the upload.`, + }); + } + + // Keep the pending copy until the turn succeeds. A failed thread + // bootstrap can then retry with a fresh thread id. + yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: `Failed to claim attachment '${attachment.name}' for this thread.`, + cause, + }), + ), + ); + claimedAttachmentPaths.push(claim.finalPath); + + return normalizedAttachment; + } + const parsed = parseBase64DataUrl(attachment.dataUrl); if (!parsed || !parsed.mimeType.startsWith("image/")) { return yield* new OrchestrationDispatchCommandError({ @@ -167,7 +255,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => return persistedAttachment; }), { concurrency: 1 }, - ); + ).pipe(Effect.tapError(() => removeClaimedAttachmentPaths(claimedAttachmentPaths))); return { ...canonicalCommand, @@ -177,3 +265,33 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }, } satisfies OrchestrationCommand; }); + +export const cleanupFailedUploadedAttachments = Effect.fn( + "Normalizer.cleanupFailedUploadedAttachments", +)(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) { + if (command.type !== "thread.turn.start" || normalizedCommand.type !== "thread.turn.start") { + return; + } + + const serverConfig = yield* ServerConfig; + const claimedPaths: string[] = []; + for (const [index, attachment] of normalizedCommand.message.attachments.entries()) { + const original = command.message.attachments[index]; + if ( + !original || + "dataUrl" in original || + parseThreadSegmentFromAttachmentId(original.id) !== PENDING_ATTACHMENT_THREAD_SEGMENT + ) { + continue; + } + + const claimedPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (claimedPath) { + claimedPaths.push(claimedPath); + } + } + yield* removeClaimedAttachmentPaths(claimedPaths); +}); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 20bc3475613a..26927d4499d6 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -5,6 +5,7 @@ import { ProjectId, ProviderInstanceId, ThreadId, + type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationSession, type OrchestrationThread, @@ -14,6 +15,7 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { decideOrchestrationCommand } from "./decider.ts"; +import { projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z"; const SETTLED_AT = "2025-12-30T00:00:00.000Z"; @@ -428,6 +430,42 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ); + // Command-to-projection: an accepted un-settle must land as the re-entry + // stamp clients sort by (max of createdAt and unsettledAt, see + // activeThreadAnchorTimestampMs in client-runtime), so the thread surfaces + // above threads created after it. The projector tests feed events directly; + // this one proves the decider actually emits what they consume. + it.effect("an accepted un-settle re-anchors the thread for the active list", () => + Effect.gen(function* () { + const readModel = makeReadModel("settled"); + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-anchor"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel, + }); + const events = Array.isArray(result) ? result : [result]; + const unsettled = events[0]!; + expect(unsettled.type).toBe("thread.unsettled"); + + const projected = yield* projectEvent(readModel, { + ...unsettled, + sequence: readModel.snapshotSequence + 1, + } as OrchestrationEvent); + const thread = projected.threads[0]!; + expect(thread.settledOverride).toBe("active"); + // The stamp is the decider's accept time: every thread created before + // the un-settle anchors below it. + expect(thread.unsettledAt).toBe(unsettled.occurredAt); + if (unsettled.type === "thread.unsettled") { + expect(thread.unsettledAt).toBe(unsettled.payload.updatedAt); + } + }), + ); + it.effect("prepends activity unsets for turn starts and live session updates", () => Effect.gen(function* () { const turnResult = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4ed270bf30bd..654b622ff8e2 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -848,6 +848,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.linkedPullRequest !== undefined + ? { linkedPullRequest: command.linkedPullRequest } + : {}), updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 04d54ea8effb..f7147106c7a9 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -8,7 +8,7 @@ import * as Option from "effect/Option"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; -import { normalizeDispatchCommand } from "./Normalizer.ts"; +import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts"; import { annotateEnvironmentRequest, failEnvironmentInternal, @@ -96,13 +96,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), ); - return yield* orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.catch((cause) => - failEnvironmentInternal("orchestration_dispatch_failed", cause), - ), - ); + return yield* orchestrationEngine.dispatch(normalizedCommand).pipe( + Effect.tapError(() => + cleanupFailedUploadedAttachments(args.payload, normalizedCommand), + ), + Effect.catch((cause) => + failEnvironmentInternal("orchestration_dispatch_failed", cause), + ), + ); }), ); }), diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts index 2070c44418a4..7c9395e6d2bd 100644 --- a/apps/server/src/orchestration/projector.settled.test.ts +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -62,27 +62,62 @@ it.effect("projects settled lifecycle events", () => ); expect(settled.threads[0]?.settledOverride).toBe("settled"); expect(settled.threads[0]?.settledAt).toBe(now); + expect(settled.threads[0]?.unsettledAt).toBeNull(); + const unsettleAt = "2026-01-02T00:00:00.000Z"; const userUnsettled = yield* projectEvent( settled, makeEvent({ sequence: 3, type: "thread.unsettled", - payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: unsettleAt }, }), ); expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); expect(userUnsettled.threads[0]?.settledAt).toBeNull(); + expect(userUnsettled.threads[0]?.unsettledAt).toBe(unsettleAt); + // Clearing the keep-active pin on activity is not a re-entry: the thread + // is already in the active list, so the stamp must not move it. + const activityAt = "2026-01-03T00:00:00.000Z"; const activityUnsettled = yield* projectEvent( userUnsettled, makeEvent({ sequence: 4, type: "thread.unsettled", - payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: activityAt }, }), ); expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); + expect(activityUnsettled.threads[0]?.unsettledAt).toBe(unsettleAt); + + const resettledAt = "2026-01-04T00:00:00.000Z"; + const resettled = yield* projectEvent( + activityUnsettled, + makeEvent({ + sequence: 5, + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt: resettledAt, + updatedAt: resettledAt, + }, + }), + ); + expect(resettled.threads[0]?.unsettledAt).toBeNull(); + + // Waking a settled thread on activity IS a re-entry and stamps. + const wakeAt = "2026-01-05T00:00:00.000Z"; + const woke = yield* projectEvent( + resettled, + makeEvent({ + sequence: 6, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: wakeAt }, + }), + ); + expect(woke.threads[0]?.settledOverride).toBeNull(); + expect(woke.threads[0]?.unsettledAt).toBe(wakeAt); }), ); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 28ea1ef386c3..14946a3ffc47 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -92,6 +92,7 @@ describe("orchestration projector", () => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ed2ba7f65e1c..5b1095a66078 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -304,6 +304,7 @@ export function projectEvent( archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -365,6 +366,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { settledOverride: "settled", settledAt: payload.settledAt, + unsettledAt: null, updatedAt: payload.updatedAt, }), })), @@ -372,14 +374,24 @@ export function projectEvent( case "thread.unsettled": return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - settledOverride: payload.reason === "user" ? "active" : null, - settledAt: null, - updatedAt: payload.updatedAt, - }), - })), + Effect.map((payload) => { + const existing = nextBase.threads.find((thread) => thread.id === payload.threadId); + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + // Re-entry stamp for active-list ordering. A thread already + // pinned active keeps its stamp: the activity reset that clears + // the pin is not a re-entry and must not reorder the list. + unsettledAt: + existing?.settledOverride === "active" + ? (existing.unsettledAt ?? null) + : payload.updatedAt, + updatedAt: payload.updatedAt, + }), + }; + }), ); case "thread.snoozed": @@ -457,6 +469,9 @@ export function projectEvent( : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.linkedPullRequest !== undefined + ? { linkedPullRequest: payload.linkedPullRequest } + : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index d0415fc807a6..0bf563e6c1de 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -95,6 +95,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -159,6 +160,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: "settled", settledAt: "2026-03-25T00:00:00.000Z", + unsettledAt: null, snoozedUntil: "2026-03-26T09:00:00.000Z", snoozedAt: "2026-03-25T00:00:00.000Z", pinnedAt: "2026-03-25T00:00:00.000Z", @@ -188,6 +190,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { ...row, settledOverride: "active", settledAt: null, + unsettledAt: "2026-03-26T00:00:00.000Z", snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -198,9 +201,62 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const updated = Option.getOrNull(repersisted); assert.strictEqual(updated?.settledOverride, "active"); assert.strictEqual(updated?.settledAt, null); + assert.strictEqual(updated?.unsettledAt, "2026-03-26T00:00:00.000Z"); assert.strictEqual(updated?.snoozedUntil, null); assert.strictEqual(updated?.snoozedAt, null); assert.strictEqual(updated?.pinnedAt, null); }), ); + + it.effect("round-trips a linked pull request through the thread row", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + const linkedPullRequest = { + projectId: ProjectId.make("project-linked-pr"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + + yield* threads.upsert({ + threadId: ThreadId.make("thread-linked-pr"), + projectId: ProjectId.make("project-linked-pr"), + title: "Linked pull request", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + linkedPullRequest, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + unsettledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + + const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); + assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest); + + const row = Option.getOrNull(persisted); + if (row === null) return yield* Effect.die("Expected linked thread row to exist."); + yield* threads.upsert({ ...row, linkedPullRequest: null }); + + const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); + assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 78c9d1b8b4de..19c0cabc7cf4 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,12 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, ThreadLinkedPullRequest } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -40,12 +41,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path, parent_thread_id, + linked_pull_request_json, latest_turn_id, created_at, updated_at, archived_at, settled_override, settled_at, + unsettled_at, snoozed_until, snoozed_at, pinned_at, @@ -68,12 +71,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.branch}, ${row.worktreePath}, ${row.parentThreadId}, + ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, ${row.settledOverride}, ${row.settledAt}, + ${row.unsettledAt}, ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, @@ -96,12 +101,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch = excluded.branch, worktree_path = excluded.worktree_path, parent_thread_id = excluded.parent_thread_id, + linked_pull_request_json = excluded.linked_pull_request_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, settled_override = excluded.settled_override, settled_at = excluded.settled_at, + unsettled_at = excluded.unsettled_at, snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, @@ -131,12 +138,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path AS "worktreePath", parent_thread_id AS "parentThreadId", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -168,12 +177,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path AS "worktreePath", parent_thread_id AS "parentThreadId", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 170cb3992279..8abbe87fce3e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -54,6 +54,8 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; +import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; +import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; /** * Migration loader with all migrations defined inline. @@ -107,6 +109,8 @@ export const migrationEntries = [ [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], [41, "AuthSessionClientConnection", Migration0041], + [42, "ProjectionThreadLinkedPullRequest", Migration0042], + [43, "ProjectionThreadsUnsettledAt", Migration0043], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts new file mode 100644 index 000000000000..1fe59df50729 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts @@ -0,0 +1,25 @@ +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 { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("042_ProjectionThreadLinkedPullRequest", (it) => { + it.effect("adds the linked pull request column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 41 }); + yield* runMigrations({ toMigrationInclusive: 42 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.ok(columns.some((column) => column.name === "linked_pull_request_json")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts new file mode 100644 index 000000000000..a026f39c392a --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "linked_pull_request_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN linked_pull_request_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts b/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts new file mode 100644 index 000000000000..981d3c78f3a6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "unsettled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN unsettled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a7a38ddf53fe..9a929f4c5342 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,6 +14,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadLinkedPullRequest, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -34,12 +35,14 @@ export const ProjectionThread = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), parentThreadId: Schema.NullOr(ThreadId), + linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), settledAt: Schema.NullOr(IsoDateTime), + unsettledAt: Schema.NullOr(IsoDateTime), snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1ab6166e92a1..a72b42b60b75 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -15,18 +20,30 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; -function makeMockDetachedHandle(onUnref: () => void = () => undefined) { +interface MockSpawnResult { + readonly exitCode?: number; + readonly stdout?: string; + /** Never deliver an exit code, like a child wedged on a broken desktop session. */ + readonly stall?: boolean; +} + +function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () => void } = {}) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: input.stall + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), isRunning: Effect.succeed(true), kill: () => Effect.void, unref: Effect.sync(() => { - onUnref(); + input.onUnref?.(); return Effect.void; }), stdin: Sink.drain, - stdout: Stream.empty, + stdout: + input.stdout === undefined + ? Stream.empty + : Stream.make(new TextEncoder().encode(input.stdout)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -40,6 +57,7 @@ const testLayer = (input: { readonly resolveExecutable?: (command: string) => string | undefined; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; + readonly spawnResult?: (command: ChildProcess.StandardCommand) => MockSpawnResult | undefined; }) => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -50,7 +68,10 @@ const testLayer = (input: { throw new Error("Expected a standard command"); } input.onSpawn?.(command); - return makeMockDetachedHandle(input.onUnref); + return makeMockDetachedHandle({ + ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }), + ...input.spawnResult?.(command), + }); }), ), ); @@ -132,6 +153,623 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("reveals a file in Finder with open -R on macOS", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const openPath = path.join(binDir, "open"); + yield* fileSystem.writeFileString(openPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(openPath, 0o755); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/workspace/media/linux-mini-v2.mp4", + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "darwin", + env: { PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "open"); + assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals a file in File Explorer through PowerShell on Windows", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + // resolvePowerShellPath builds `${SYSTEMROOT}\System32\...` with Windows + // separators, which on the posix test filesystem is one file name. + const systemRoot = path.join(binDir, "system-root"); + const powerShellPath = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + yield* fileSystem.makeDirectory(path.dirname(powerShellPath), { recursive: true }); + yield* fileSystem.writeFileString(powerShellPath, ""); + + let spawned: ChildProcess.StandardCommand | undefined; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "C:\\workspace with spaces\\media\\author's clip.mp4", + reveal: true, + }); + return yield* launcher.resolveFileManagerRevealKind(); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: systemRoot }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(kind, "file-explorer"); + assert.ok(spawned); + assert.equal(spawned.command, powerShellPath); + assert.deepEqual(spawned.args.slice(0, -1), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + ]); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + // explorer.exe expects `/select,""` with only the path quoted; + // PowerShell 5.1's Start-Process passes the argument string verbatim. + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", + ); + assert.equal(spawned.options.shell, false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Real-chain smoke check for the Explorer selection contract: runs the exact +// PowerShell source the reveal launch encodes, against a stub that records +// the raw argument tail it receives, and asserts a spaced path arrives as the +// single `/select,""` switch. Mock argv assertions cannot prove this — +// only Windows' own PowerShell -> CreateProcess quoting chain can, so the +// test runs only where that chain exists. +// oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime. +it.skipIf(process.platform !== "win32")( + "delivers the raw /select switch for spaced paths through real PowerShell", + { timeout: 60_000 }, + async () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); + try { + const recorderPath = NodePath.join(tempDir, "recorder.cmd"); + const outputPath = NodePath.join(tempDir, "argv.txt"); + NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`); + + const target = "C:\\workspace with spaces\\media\\author's clip.mp4"; + const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(recorderPath, target); + const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + NodeChildProcess.execFileSync( + powerShellPath, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + Buffer.from(source, "utf16le").toString("base64"), + ], + { timeout: 30_000 }, + ); + + // Start-Process returns before the recorder runs; wait for its output. + // The waits run outside the Effect runtime on purpose: the test + // exercises the real Windows process chain in real time. + // @effect-diagnostics-next-line globalTimers:off + const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis)); + // @effect-diagnostics-next-line globalDate:off + const deadline = Date.now() + 20_000; + // @effect-diagnostics-next-line globalDate:off + while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) { + await sleep(100); + } + await sleep(200); + const recorded = NodeFS.readFileSync(outputPath, "utf8").trim(); + assert.equal(recorded, `/select,"${target}"`); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }, +); + +it.effect("does not advertise reveal on Windows when PowerShell is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { + PATH: binDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + SYSTEMROOT: path.join(binDir, "missing-system-root"), + }, + }), + ), + ); + + // Plain "open in file manager" still works through explorer; only the + // reveal capability, which launches PowerShell, must stay hidden. + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals a WSL file in Windows File Explorer through its UNC path", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const kind = yield* launcher.resolveFileManagerRevealKind(); + const editors = yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { kind, editors }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(result.kind, "file-explorer"); + assert.equal(result.editors.includes("file-manager"), true); + assert.ok(spawned); + // The reveal routes through interop PowerShell so Explorer receives its + // raw `/select,""` switch even for spaced paths. + assert.equal(spawned.command, "powershell.exe"); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise reveal from WSL when interop PowerShell is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const explorerPath = path.join(binDir, "explorer.exe"); + yield* fileSystem.writeFileString(explorerPath, ""); + yield* fileSystem.chmod(explorerPath, 0o755); + + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + }), + ), + ); + + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// When interop PowerShell is missing the capability advertises the Linux +// "files" kind (or nothing), so the reveal must open the Linux file manager +// the label promised even though plain open still prefers File Explorer. +it.effect("reveals through the Linux file manager when WSL lacks interop PowerShell", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const revealKind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return revealKind; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Interop can exist without `explorer.exe` on PATH (appendWindowsPath=false) +// while WSLg still provides a working Linux file manager; the host must keep +// the Linux open/reveal path instead of losing the editor entirely. +it.effect("falls back to the Linux file manager when WSL lacks the Explorer bridge", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const editors = yield* launcher.resolveAvailableEditors(); + const kind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { editors, kind }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(result.editors.includes("file-manager"), true); + assert.equal(result.kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "falls back to opening the containing directory for WSL paths Explorer cannot select", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: '/home/t3/work "quoted"/clip.mp4', + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + // Explorer's raw switch cannot express a double quote, so the launch + // opens the parent directory instead of misparsing a /select argument. + assert.ok(spawned); + assert.equal(spawned.command, "explorer.exe"); + assert.deepEqual(spawned.args, ['\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work "quoted"']); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals by opening the containing directory on Linux", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/workspace/media/linux-mini-v2.mp4", + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + const spawned = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(spawned); + assert.deepEqual(spawned.args, ["/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager without a graphical session", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("advertises a Linux file manager when a directory handler is installed", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let probe: ChildProcess.StandardCommand | undefined; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + probe = command; + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), true); + assert.ok(probe); + assert.equal(probe.command, "xdg-mime"); + assert.deepEqual(probe.args, ["query", "default", "inode/directory"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// `xdg-open` with a display variable but no `inode/directory` handler exits +// nonzero after the launch has already detached: without this gate the server +// advertises a reveal that is a silent no-op. +it.effect("does not advertise a Linux file manager without a directory handler", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined), + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager when the handler query fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => + command.command === "xdg-mime" + ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" } + : undefined, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// The handler probe carries its own timeout because the editor scan's outer +// timeout in server.getConfig degrades to an EMPTY editor list: a wedged +// xdg-mime must cost only the file manager, never the other editors. Runs on +// the live clock so the probe's real timeout fires. +it.live("a stalled handler probe drops only the file manager", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime", "code"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stall: true } : undefined), + }), + ), + ); + + assert.equal(editors.includes("vscode"), true); + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager when xdg-mime is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("discovers editors through the service API", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 8ec928f26fc3..96e6470311f4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -15,6 +15,7 @@ import { ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, type EditorId, + type FileManagerRevealKind, type LaunchEditorInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -29,6 +30,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -99,6 +101,8 @@ const BrowserLaunchEnvConfig = Config.all({ SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), container: Config.string("container").pipe(Config.option), + DISPLAY: Config.string("DISPLAY").pipe(Config.option), + WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option), }).pipe(Config.map(compactEnv)); const CommandLookupEnvConfig = Config.all({ @@ -193,7 +197,13 @@ function resolveWslPowerShellPath(): string { return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; } -function shouldUseWindowsBrowserFromWsl( +// File reveals from WSL resolve PowerShell through the interop PATH rather +// than the fixed /mnt/c mount: the automount root is configurable, and a +// PATH-resolved command keeps the advertised capability aligned with the +// availability check `launchEditor` performs before spawning. +const WSL_POWERSHELL_COMMAND = "powershell.exe"; + +function shouldUseWindowsHostFromWsl( platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {}, ): boolean { @@ -223,17 +233,163 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa }; } -function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { +function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean { + return [env.DISPLAY, env.WAYLAND_DISPLAY].some( + (value) => value !== undefined && value.trim().length > 0, + ); +} + +function fileManagerCommandForPlatform( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): string | undefined { switch (platform) { case "darwin": return "open"; case "win32": return "explorer"; default: - return "xdg-open"; + if (shouldUseWindowsHostFromWsl(platform, env)) { + return env.WSL_DISTRO_NAME?.trim() ? "explorer.exe" : undefined; + } + return hasGraphicalLinuxSession(env) ? "xdg-open" : undefined; } } +// A graphical session variable plus an executable `xdg-open` does not prove +// that opening a directory does anything: without an `inode/directory` MIME +// handler, `xdg-open` exits nonzero after the launcher has already detached, +// so the client would see a silent no-op. Require the handler before +// advertising the file manager on Linux. +// +// The probe carries its own timeout well inside the scan timeout +// `server.getConfig` applies to editor discovery: that outer timeout degrades +// to an empty editor list, so a hung `xdg-mime` (broken D-Bus or desktop +// session) must cost only the file manager, not every discovered editor. +const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; + +const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( + function* ( + env: NodeJS.ProcessEnv, + ): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > { + if (!(yield* isCommandAvailable("xdg-mime", { env }))) { + return false; + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .spawn( + ChildProcess.make("xdg-mime", ["query", "default", "inode/directory"], { + stdin: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.flatMap((handle) => + Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], { + concurrency: "unbounded", + }), + ), + Effect.map(([stdout, exitCode]) => exitCode === 0 && stdout.trim().length > 0), + Effect.scoped, + Effect.timeout(LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT), + Effect.orElseSucceed(() => false), + ); + }, +); + +const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManagerCommand")( + function* ( + command: string, + env: NodeJS.ProcessEnv, + ): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > { + if (!(yield* isCommandAvailable(command, { env }))) { + return false; + } + return command !== "xdg-open" || (yield* hasUsableLinuxDirectoryHandler(env)); + }, +); + +// The file-manager command a launch can actually run, not just the platform +// preference. WSL hosts prefer the Windows Explorer bridge, but interop can +// exist without `explorer.exe` on PATH (appendWindowsPath=false) or without a +// distro name while WSLg still provides a working Linux file manager, so they +// keep the `xdg-open` fallback instead of losing the editor entirely. +const resolveUsableFileManagerCommand = Effect.fn( + "externalLauncher.resolveUsableFileManagerCommand", +)(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + string | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + const command = fileManagerCommandForPlatform(platform, env); + if (command !== undefined && (yield* isUsableFileManagerCommand(command, env))) { + return command; + } + if ( + shouldUseWindowsHostFromWsl(platform, env) && + hasGraphicalLinuxSession(env) && + (yield* isUsableFileManagerCommand("xdg-open", env)) + ) { + return "xdg-open"; + } + return undefined; +}); + +// Reveal on Windows and WSL runs through PowerShell (see +// resolveFileManagerRevealLaunch), not the `explorer` command that gates the +// file-manager editor itself, so the capability must probe the executables the +// reveal actually spawns. Callers gate on file-manager availability first; +// the Linux "files" kind relies on that gate for the directory-handler probe, +// while the WSL fallback re-probes because its availability may have come +// from the Explorer bridge instead. +const fileManagerRevealKindForPlatform = Effect.fn( + "externalLauncher.fileManagerRevealKindForPlatform", +)(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + FileManagerRevealKind | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") return "finder"; + if (platform === "win32") { + return (yield* isCommandAvailable(resolvePowerShellPath(env), { env })) + ? "file-explorer" + : undefined; + } + if (shouldUseWindowsHostFromWsl(platform, env)) { + if ( + env.WSL_DISTRO_NAME?.trim() && + (yield* isCommandAvailable("explorer.exe", { env })) && + (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) + ) { + return "file-explorer"; + } + return hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env)) + ? "files" + : undefined; + } + return hasGraphicalLinuxSession(env) ? "files" : undefined; +}); + +function resolveWslFileManagerPath(target: string, distroName: string): string { + const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\"); + return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`; +} + function buildBrowserLaunch( target: string, platform: NodeJS.Platform, @@ -251,7 +407,7 @@ function buildBrowserLaunch( return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env)); } - if (shouldUseWindowsBrowserFromWsl(platform, env)) { + if (shouldUseWindowsHostFromWsl(platform, env)) { return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath()); } @@ -265,13 +421,16 @@ function buildBrowserLaunch( const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { +): Effect.fn.Return< + ReadonlyArray, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { - const command = fileManagerCommandForPlatform(platform); - if (yield* isCommandAvailable(command, { env })) { + if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { available.push(editor.id); } continue; @@ -296,10 +455,18 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")( const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* buildAvailableEditors(platform, env); }); +const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( + function* () { + const platform = yield* HostProcessPlatform; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; + return yield* fileManagerRevealKindForPlatform(platform, env); + }, +); + // Editor discovery walks PATH for every known editor and runs for every // client connect (the server config embeds the available editors). Memoize // the discovered set for a bounded window so repeat connects skip even the @@ -329,6 +496,14 @@ export class ExternalLauncher extends Context.Service< ExternalLauncher, { readonly resolveAvailableEditors: () => Effect.Effect>; + /** + * Reveal kind for the host, or undefined when the executable a reveal + * actually spawns is unavailable. Only meaningful when + * `resolveAvailableEditors` includes "file-manager": on Linux that + * availability check also carries the directory-handler probe this + * capability relies on. + */ + readonly resolveFileManagerRevealKind: () => Effect.Effect; /** Launch a URL target in the default browser. */ readonly launchBrowser: (target: string) => Effect.Effect; /** @@ -346,9 +521,13 @@ export class ExternalLauncher extends Context.Service< const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, -): Effect.fn.Return { +): Effect.fn.Return< + EditorLaunch, + ExternalLauncherError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, @@ -376,14 +555,126 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } + const command = yield* resolveUsableFileManagerCommand(platform, env); + if (command === undefined) { + return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); + } + + if (input.reveal === true) { + return yield* resolveFileManagerRevealLaunch(input.cwd, platform, env, command); + } + return { editor: editorDef.id, target: input.cwd, - command: fileManagerCommandForPlatform(platform), - args: [input.cwd], + command, + args: + command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined + ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] + : [input.cwd], }; }); +/** + * PowerShell source that launches File Explorer with its raw selection + * switch. Explorer's contract is the single argument `/select,""` with + * only the path quoted; Node's default spawn quoting wraps the whole argument + * when the path has spaces and Explorer misparses it, silently opening a + * fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1 + * reaches the child's command line verbatim, preserving the raw switch. + * + * Exported so the Windows smoke test can drive the identical source through a + * real PowerShell against a recording stub instead of Explorer. + */ +export function buildFileExplorerRevealPowerShellSource( + explorerCommand: string, + target: string, +): string { + return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`; +} + +function fileExplorerRevealLaunch( + target: string, + explorerTarget: string, + powershellCommand: string, +): EditorLaunch { + return { + editor: "file-manager", + target, + command: powershellCommand, + args: [ + ...POWERSHELL_ARGUMENTS_PREFIX, + encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)), + ], + }; +} + +const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch")(function* ( + target: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + // The command resolveUsableFileManagerCommand picked; a WSL host that fell + // back to the Linux file manager must reveal through it as well. + command: string, +): Effect.fn.Return< + EditorLaunch, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") { + return { editor: "file-manager", target, command: "open", args: ["-R", target] }; + } + + if (platform === "win32") { + return fileExplorerRevealLaunch(target, target, resolvePowerShellPath(env)); + } + + if ( + command === "explorer.exe" && + shouldUseWindowsHostFromWsl(platform, env) && + env.WSL_DISTRO_NAME !== undefined + ) { + const explorerTarget = resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME); + if (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) { + // Explorer's raw switch cannot express a double quote, and unlike + // Windows paths a WSL path may legally contain one: open the containing + // directory in File Explorer instead, matching the advertised + // "file-explorer" kind. + if (explorerTarget.includes('"')) { + const path = yield* Path.Path; + return { + editor: "file-manager", + target, + command: "explorer.exe", + args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], + }; + } + return fileExplorerRevealLaunch(target, explorerTarget, WSL_POWERSHELL_COMMAND); + } + // Without interop PowerShell the capability advertised the Linux "files" + // kind when it advertised anything at all, so the reveal must open the + // Linux file manager the label promised, not File Explorer. + if (hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))) { + const path = yield* Path.Path; + return { editor: "file-manager", target, command: "xdg-open", args: [path.dirname(target)] }; + } + // Nothing was advertised here; open the parent in File Explorer as the + // best remaining effort for a stale client. + const path = yield* Path.Path; + return { + editor: "file-manager", + target, + command: "explorer.exe", + args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], + }; + } + + // Linux file managers have no portable "select this file" flag, so open + // the containing directory instead. + const path = yield* Path.Path; + return { editor: "file-manager", target, command, args: [path.dirname(target)] }; +}); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -476,7 +767,9 @@ export const make = Effect.gen(function* () { if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { return entry.value.editors; } - const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); + const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); yield* Ref.set( editorDiscoveryCache, Option.some({ @@ -489,18 +782,18 @@ export const make = Effect.gen(function* () { return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, + resolveFileManagerRevealKind: () => + provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), launchEditor: (input) => provideCommandResolutionServices( - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - ), - ), + Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess), + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ef85eb0d4853..81f123780940 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -36,6 +36,7 @@ import { } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -88,6 +89,7 @@ export type ClaudeDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers | ProviderSecretResolver @@ -128,6 +130,7 @@ export const ClaudeDriver: ProviderDriver = { const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const secretResolver = yield* ProviderSecretResolver; + const modelManifest = yield* ModelManifest.ModelManifest; const processEnv = mergeProviderInstanceEnvironment( yield* secretResolver.resolve(environment), ); @@ -168,13 +171,24 @@ export const ClaudeDriver: ProviderDriver = { }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); - const checkProvider = checkClaudeProviderStatus( - effectiveConfig, - () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), - processEnv, - cwd, - ).pipe( - Effect.map(stampIdentity), + // Kick the TTL-gated manifest refresh in the background and classify + // with the in-memory manifest, so a slow or hung fetch never delays the + // provider check. A refresh that lands mid-probe applies on the next one. + const checkProvider = modelManifest.refreshInBackground.pipe( + Effect.andThen( + Effect.zipWith( + checkClaudeProviderStatus( + effectiveConfig, + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + processEnv, + cwd, + ), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + { concurrent: true }, + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -188,7 +202,12 @@ export const ClaudeDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + Effect.zipWith( + makePendingClaudeProvider(settings.provider), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 5766c45c89fe..4d3ce4e6ed4b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -39,6 +39,7 @@ import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; @@ -79,6 +80,7 @@ export type CodexDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers | ProviderSecretResolver @@ -122,6 +124,7 @@ export const CodexDriver: ProviderDriver = { const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const secretResolver = yield* ProviderSecretResolver; + const modelManifest = yield* ModelManifest.ModelManifest; const processEnv = mergeProviderInstanceEnvironment( yield* secretResolver.resolve(environment), ); @@ -171,8 +174,19 @@ export const CodexDriver: ProviderDriver = { // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits // `makeManagedServerProvider.checkProvider`'s `R = never`. - const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( - Effect.map(stampIdentity), + // Kick the TTL-gated manifest refresh in the background and classify + // with the in-memory manifest, so a slow or hung fetch never delays the + // provider check. A refresh that lands mid-probe applies on the next one. + const checkProvider = modelManifest.refreshInBackground.pipe( + Effect.andThen( + Effect.zipWith( + checkCodexProviderStatus(effectiveConfig, undefined, processEnv), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + { concurrent: true }, + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); @@ -182,7 +196,12 @@ export const CodexDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingCodexProvider(settings.provider).pipe(Effect.map(stampIdentity)), + Effect.zipWith( + makePendingCodexProvider(settings.provider), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index e218a0590610..c0476aa44512 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 eventLoggers = yield* ProviderEventLoggers; const secretResolver = yield* ProviderSecretResolver; const processEnv = mergeProviderInstanceEnvironment( @@ -118,7 +119,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 000000000000..3536a37a9920 --- /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 000000000000..a7c2c2ae3028 --- /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/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index b4c4016e9d94..ee023b824d87 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -56,12 +56,11 @@ class FakeClaudeQuery implements AsyncIterable { private done = false; private failure: unknown | undefined; - public readonly interruptCalls: Array = []; - public readonly stopTaskCalls: Array = []; public readonly setModelCalls: Array = []; public readonly setPermissionModeCalls: Array = []; public readonly setMaxThinkingTokensCalls: Array = []; public closeCalls = 0; + public closeError: unknown | undefined; emit(message: SDKMessage): void { if (this.done) { @@ -97,14 +96,6 @@ class FakeClaudeQuery implements AsyncIterable { } } - readonly interrupt = async (): Promise => { - this.interruptCalls.push(undefined); - }; - - readonly stopTask = async (taskId: string): Promise => { - this.stopTaskCalls.push(taskId); - }; - readonly setModel = async (model?: string): Promise => { this.setModelCalls.push(model); }; @@ -119,6 +110,9 @@ class FakeClaudeQuery implements AsyncIterable { readonly close = (): void => { this.closeCalls += 1; + if (this.closeError !== undefined) { + throw this.closeError; + } this.finish(); }; @@ -421,6 +415,25 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("passes the configured auto-compaction window to Claude", () => { + const harness = makeHarness({ claudeConfig: { autoCompactWindow: "300000" } }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const options = harness.getLastCreateQueryInput()?.options; + assert.deepEqual(options?.settings, { autoCompactWindow: 300000 }); + assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -738,6 +751,39 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps compact commands intact when ultrathink is selected", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const modelSelection = createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "ultrathink" }], + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/compact", + attachments: [], + modelSelection, + }); + + const promptText = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + assert.equal(promptText, "/compact"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("embeds image attachments in Claude user messages", () => { const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); const harness = makeHarness({ @@ -1582,7 +1628,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("interruptTurn settles every acknowledged live task before interrupting", () => { + it.effect("interruptTurn settles live tasks and closes the provider session", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -1647,9 +1693,12 @@ describe("ClaudeAdapterLive", () => { ); yield* adapter.interruptTurn(session.threadId); - // Only the still-live task is stopped; interrupt always fires after. - assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]); - assert.equal(harness.query.interruptCalls.length, 1); + // Closing the session is the hard stop because SDK interrupt can leave + // resumed background work alive. + assert.equal(harness.query.closeCalls, 1); + + const sessions = yield* adapter.listSessions(); + assert.equal(sessions.length, 0); const stoppedTaskEvents = Array.from(yield* Fiber.join(stoppedTaskEventFiber)); assert.equal(stoppedTaskEvents.length, 1); @@ -1667,6 +1716,172 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps the session available when process close fails", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + harness.query.closeError = new Error("close failed"); + + const result = yield* adapter.interruptTurn(session.threadId).pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "ProviderAdapterProcessError"); + } + assert.equal(harness.query.closeCalls, 1); + assert.equal(yield* adapter.hasSession(session.threadId), true); + assert.equal((yield* adapter.listSessions())[0]?.status, "ready"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("stopAll attempts every session when one process close fails", () => { + const queries: FakeClaudeQuery[] = []; + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + const query = new FakeClaudeQuery(); + queries.push(query); + return query; + }, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const firstQuery = queries[0]; + if (!firstQuery) { + return; + } + firstQuery.closeError = new Error("close failed"); + + const result = yield* adapter.stopAll().pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.equal(queries[0]?.closeCalls, 1); + assert.equal(queries[1]?.closeCalls, 1); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + assert.equal(yield* adapter.hasSession(RESUME_THREAD_ID), false); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + + it.effect("keeps a resumed replacement session during slow stop cleanup", () => { + const queries: FakeClaudeQuery[] = []; + let signalUsageStarted: () => void = () => undefined; + const usageStarted = new Promise((resolve) => { + signalUsageStarted = resolve; + }); + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + const query = new FakeClaudeQuery(); + if (queries.length === 0) { + Object.assign(query, { + getContextUsage: async () => { + signalUsageStarted(); + return await new Promise(() => undefined); + }, + }); + } + queries.push(query); + return query; + }, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + const firstSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: firstSession.threadId, + input: "hello", + attachments: [], + }); + + const interruptFiber = yield* adapter + .interruptTurn(firstSession.threadId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => usageStarted); + assert.equal(queries[0]?.closeCalls, 1); + + const replacement = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + resumeCursor: firstSession.resumeCursor, + }); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(interruptFiber); + + const activeSessions = yield* adapter.listSessions(); + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.equal(queries.length, 2); + assert.equal(queries[1]?.closeCalls, 0); + assert.equal(activeSessions.length, 1); + assert.deepEqual(activeSessions[0]?.resumeCursor, replacement.resumeCursor); + assert.deepEqual( + runtimeEvents + .filter((event) => event.type.startsWith("session.")) + .map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "session.started", + "session.configured", + "session.state.changed", + ], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("workflow member coalescing: identical snapshots suppress, changes emit", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1836,6 +2051,84 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("a subagent snapshot that beats task_started still wins over the seed", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const taskEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type.startsWith("task.")), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "spawn an agent", + attachments: [], + }); + + // The subagent streams its first assistant snapshot before the task is + // registered, so there is no agent to refine yet. + harness.query.emit({ + type: "assistant", + parent_tool_use_id: "toolu_agent_early", + message: { + model: "claude-sonnet-5[1m]", + content: [], + }, + uuid: "early-snapshot-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-early", + description: "Agent E", + task_type: "local_agent", + tool_use_id: "toolu_agent_early", + uuid: "task-early-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-early", + description: "Agent E", + usage: { total_tokens: 100, tool_uses: 1, duration_ms: 10 }, + uuid: "task-early-progress-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + + const taskEvents = Array.from(yield* Fiber.join(taskEventsFiber)); + const started = taskEvents[0]; + assert.equal(started?.type, "task.started"); + if (started?.type === "task.started") { + assert.equal(started.payload.model, "claude-sonnet-5[1m]"); + assert.equal(started.payload.effort, "max"); + } + const progress = taskEvents[1]; + assert.equal(progress?.type, "task.progress"); + if (progress?.type === "task.progress") { + assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -4269,6 +4562,62 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("routes Claude resume compaction through the shared user-input UI", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { resume: "550e8400-e29b-41d4-a716-446655440000" }, + runtimeMode: "full-access", + }); + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const onUserDialog = harness.getLastCreateQueryInput()?.options.onUserDialog; + assert.equal(typeof onUserDialog, "function"); + if (!onUserDialog) return; + + const dialogPromise = onUserDialog( + { + dialogKind: "resume_return", + payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 }, + }, + { signal: new AbortController().signal }, + ); + + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some" || requested.value.type !== "user-input.requested") return; + const question = requested.value.payload.questions[0]; + assert.equal(question?.header, "Resume session"); + assert.match(question?.question ?? "", /2h 25m/); + assert.match(question?.question ?? "", /275,123 tokens/); + assert.deepEqual( + question?.options.map((option) => option.label), + ["Compact and continue", "Keep full history", "Don't ask again"], + ); + if (!question || !requested.value.requestId) return; + + yield* adapter.respondToUserInput( + session.threadId, + ApprovalRequestId.make(requested.value.requestId), + { [question.id]: "Compact and continue" }, + ); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag === "Some") assert.equal(resolved.value.type, "user-input.resolved"); + assert.deepEqual(yield* Effect.promise(() => dialogPromise), { + behavior: "completed", + result: "compact", + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -4558,6 +4907,73 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("denies AskUserQuestion when the signal aborted before the listener registered", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 2).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + // Abort before the call so the adapter's listener registration can + // never observe the abort event, only the recheck can. + const controller = new AbortController(); + controller.abort(); + const permissionPromise = canUseTool( + "AskUserQuestion", + { + questions: [ + { + question: "Continue?", + header: "Continue", + options: [{ label: "Yes", description: "Proceed" }], + multiSelect: false, + }, + ], + }, + { + signal: controller.signal, + toolUseID: "tool-ask-pre-aborted", + }, + ); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.deepEqual(permissionResult, { + behavior: "deny", + message: "User cancelled tool execution.", + } satisfies PermissionResult); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + ["user-input.requested", "user-input.resolved"], + ); + const resolvedEvent = runtimeEvents[1]; + if (resolvedEvent?.type === "user-input.resolved") { + assert.deepEqual(resolvedEvent.payload.answers, {}); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("stopping a session settles pending user-input waits", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3a0aa5cc866f..c8d481ca5d09 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -57,6 +57,10 @@ import { getProviderOptionDescriptors, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; +import { + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + formatClaudeResumeCompactionQuestion, +} from "@t3tools/shared/claudeCompaction"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -241,6 +245,32 @@ interface ClaudeTaskAgentState { effort: string | undefined; } +/** + * How many racing snapshot models to buffer per session. A snapshot whose + * task_started never arrives would otherwise pin its entry for the session's + * lifetime; oldest entries evict first. + */ +const PENDING_TASK_MODEL_CAP = 64; + +/** + * Buffers a subagent snapshot's authoritative model under its + * parent_tool_use_id, for snapshots that beat their task_started to the + * stream. task_started consumes the entry when it registers the task. + */ +function rememberPendingTaskModel( + pending: Map, + parentToolUseId: string, + model: string, +): void { + pending.set(parentToolUseId, model); + if (pending.size > PENDING_TASK_MODEL_CAP) { + const oldest = pending.keys().next(); + if (!oldest.done) { + pending.delete(oldest.value); + } + } +} + interface ClaudeSessionContext { session: ProviderSession; readonly promptQueue: Queue.Queue; @@ -262,6 +292,12 @@ interface ClaudeSessionContext { readonly inFlightTools: Map; readonly claudeTasks: Map; readonly taskAgents: Map; + /** + * Authoritative subagent models from assistant snapshots that arrived before + * their task_started registered the task, keyed by parent_tool_use_id. + * Written through `rememberPendingTaskModel`, consumed by task_started. + */ + readonly pendingTaskModels: Map; /** * Last emitted workflow-member fingerprint per member slot. A coordinator * task_progress repeats the FULL member array every tick; without a @@ -282,9 +318,6 @@ interface ClaudeSessionContext { } interface ClaudeQueryRuntime extends AsyncIterable { - readonly interrupt: () => Promise; - /** SDK Query.stopTask — present on real queries; optional for test doubles. */ - readonly stopTask?: (taskId: string) => Promise; readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; @@ -514,6 +547,7 @@ function makeClaudeTokenUsageSnapshot(input: { readonly totalProcessedTokens?: number; readonly lastUsedTokens?: number; readonly compactsAutomatically?: boolean; + readonly autoCompactThreshold?: number; }): ThreadTokenUsageSnapshot | undefined { const activeTokens = finiteNonNegativeInteger(input.activeTokens); if (activeTokens === undefined || activeTokens <= 0) { @@ -541,6 +575,9 @@ function makeClaudeTokenUsageSnapshot(input: { ...(input.compactsAutomatically !== undefined ? { compactsAutomatically: input.compactsAutomatically } : {}), + ...(input.autoCompactThreshold !== undefined + ? { autoCompactThreshold: input.autoCompactThreshold } + : {}), }; } @@ -575,11 +612,13 @@ function normalizeClaudeContextUsageApiSnapshot( value: SDKControlGetContextUsageResponse, totalProcessedTokens?: number, ): ThreadTokenUsageSnapshot | undefined { + const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold); return makeClaudeTokenUsageSnapshot({ activeTokens: value.totalTokens, contextWindow: value.maxTokens, ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), compactsAutomatically: value.isAutoCompactEnabled, + ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}), }); } @@ -2111,13 +2150,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } catch { return undefined; } - }); - if (!usage) { + }).pipe(Effect.timeoutOption("1 second")); + if (Option.isNone(usage) || !usage.value) { return undefined; } - context.lastKnownContextWindow = usage.maxTokens; - return normalizeClaudeContextUsageApiSnapshot(usage, totalProcessedTokens); + context.lastKnownContextWindow = usage.value.maxTokens; + return normalizeClaudeContextUsageApiSnapshot(usage.value, totalProcessedTokens); }); const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( @@ -2883,8 +2922,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const owningTaskId = agentIdForParentToolUse(context.taskAgents, assistantParentToolUseId); const snapshotModel = trimmedString(message.message.model); const owningAgent = owningTaskId ? context.taskAgents.get(owningTaskId) : undefined; - if (owningAgent && snapshotModel) { - owningAgent.model = snapshotModel; + if (snapshotModel) { + if (owningAgent) { + owningAgent.model = snapshotModel; + } else { + // The snapshot beat its task_started (or its tool_use_id was never + // recorded): hold the model until the task registers. + rememberPendingTaskModel( + context.pendingTaskModels, + assistantParentToolUseId, + snapshotModel, + ); + } } context.lastAssistantUuid = message.uuid; yield* updateResumeCursor(context); @@ -3198,12 +3247,20 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const owningAgentId = launchingTool?.agentId; // Model/effort: the Agent tool's input carries explicit overrides; // absent ones inherit the session's selection (SDK behavior). - // Subagent assistant snapshots later refine model with the - // authoritative API id. AgentInput.effort may be a named level or an - // integer. + // Subagent assistant snapshots refine model with the authoritative API + // id: one that already arrived is buffered and outranks the seed here, + // later ones refine the record in place. AgentInput.effort may be a + // named level or an integer. const launchInput = launchingTool?.input; + const toolUseId = message.tool_use_id; + const bufferedModel = toolUseId ? context.pendingTaskModels.get(toolUseId) : undefined; + if (toolUseId) { + context.pendingTaskModels.delete(toolUseId); + } const model = - trimmedString(launchInput?.model) ?? trimmedString(context.session.model ?? undefined); + bufferedModel ?? + trimmedString(launchInput?.model) ?? + trimmedString(context.session.model ?? undefined); const rawLaunchEffort = launchInput?.effort; const effort = trimmedString(rawLaunchEffort) ?? @@ -3641,8 +3698,42 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) { if (context.stopped) return; + // Schedule process termination before any cleanup that can wait on the + // provider. The SDK closes stdin, then escalates from SIGTERM to SIGKILL. + yield* Effect.try({ + try: () => context.query.close(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: "Failed to close Claude runtime query.", + cause, + }), + }); + context.stopped = true; + for (const taskId of Array.from(context.liveTaskIds)) { + if (!context.liveTaskIds.delete(taskId)) { + continue; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + payload: { + taskId: RuntimeTaskId.make(taskId), + status: "stopped", + ...taskLinkageFor(context.taskAgents, taskId), + }, + providerRefs: nativeProviderRefs(context), + }); + } + for (const [requestId, pending] of context.pendingApprovals) { yield* Deferred.succeed(pending.decision, "cancel"); const stamp = yield* makeEventStamp(); @@ -3681,26 +3772,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* Fiber.interrupt(streamFiber); } - yield* Effect.try({ - try: () => context.query.close(), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: context.session.threadId, - detail: "Failed to close Claude runtime query.", - cause, - }), - }).pipe( - Effect.catch((error) => - emitRuntimeError(context, "Failed to close Claude runtime query.", { - errorTag: error._tag, - provider: error.provider, - threadId: error.threadId, - detail: error.detail, - }), - ), - ); - const updatedAt = yield* nowIso; context.session = { ...context.session, @@ -3709,7 +3780,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( updatedAt, }; - if (options?.emitExitEvent !== false) { + if (options?.emitExitEvent !== false && sessions.get(context.session.threadId) === context) { const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.exited", @@ -3725,7 +3796,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - sessions.delete(context.session.threadId); + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } }); const requireSession = ( @@ -3770,16 +3843,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); yield* stopSessionInternal(existingContext, { emitExitEvent: false, - }).pipe( - // Replacement cleanup is best-effort: never block the new session on - // either typed failures or unexpected defects from tearing down the old one. - Effect.catchCause((cause) => - Effect.logWarning("claude.session.replace.stop-failed", { - threadId: input.threadId, - cause, - }), - ), - ); + }); } const startedAt = yield* nowIso; @@ -3808,6 +3872,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const inFlightTools = new Map(); const claudeTasks = new Map(); const taskAgents = new Map(); + const pendingTaskModels = new Map(); const workflowMemberFingerprints = new Map(); const liveTaskIds = new Set(); @@ -3903,6 +3968,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + // The signal may have aborted during the awaited event emissions + // above, before the listener existed; settle now so the dialog + // cannot hang with a lingering pending question. + if (callbackOptions.signal.aborted) { + yield* settleAsAborted; + } // Block until the user provides answers. const answers = yield* Deferred.await(answersDeferred); @@ -3951,6 +4022,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; }); + const handleResumeDialog = Effect.fn("handleResumeDialog")(function* ( + request: Parameters>[0], + callbackOptions: Parameters>[1], + ) { + if (request.dialogKind !== "resume_return") { + return { behavior: "cancelled" as const }; + } + + const context = yield* Ref.get(contextRef); + if (!context) { + return { behavior: "cancelled" as const }; + } + + // The question copy lives in @t3tools/shared/claudeCompaction because + // the web client recognizes this exact text (and the "never" answer) + // to mirror a permanent dismissal. + const question = formatClaudeResumeCompactionQuestion({ + ageMinutes: finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0, + estimatedTokens: finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0, + }); + const result = yield* handleAskUserQuestion( + context, + { + questions: [ + { + header: "Resume session", + question, + options: [ + { + label: "Compact and continue", + description: "Resume with a summary and use fewer tokens.", + }, + { + label: "Keep full history", + description: "Resume without changing the conversation.", + }, + { + label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + description: "Keep full history and skip future resume prompts.", + }, + ], + multiSelect: false, + }, + ], + }, + { + signal: callbackOptions.signal, + ...(request.toolUseID ? { toolUseID: request.toolUseID } : {}), + }, + ); + + if (result.behavior !== "allow") { + return { behavior: "cancelled" as const }; + } + + const answers = result.updatedInput.answers; + const selection = + answers && typeof answers === "object" && !Array.isArray(answers) + ? (answers as Record)[question] + : undefined; + const action = + selection === "Compact and continue" + ? "compact" + : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER + ? "never" + : "continue"; + + return { behavior: "completed" as const, result: action }; + }); + const canUseToolEffect = Effect.fn("canUseTool")(function* ( toolName: Parameters[0], toolInput: Parameters[1], @@ -4070,6 +4211,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + // Same late-listener race as handleAskUserQuestion: the signal may + // have aborted while the request event emissions were awaited. + if (callbackOptions.signal.aborted) { + onAbort(); + } const decision = yield* Deferred.await(decisionDeferred); pendingApprovals.delete(requestId); @@ -4125,6 +4271,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); + const onUserDialog: NonNullable = ( + request, + callbackOptions, + ) => runPromise(handleResumeDialog(request, callbackOptions)); const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; @@ -4160,6 +4310,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), + ...(claudeSettings.autoCompactWindow + ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) } + : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); // The attachments dir grant lets the agent Read/copy pasted images at @@ -4198,6 +4351,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], env: claudeEnvironment, additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), @@ -4291,6 +4446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( inFlightTools, claudeTasks, taskAgents, + pendingTaskModels, workflowMemberFingerprints, liveTaskIds, turnState: undefined, @@ -4491,62 +4647,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, _turnId) { const context = yield* requireSession(threadId); - // Stop-everything semantics: users reach for Stop precisely when a - // fleet ran away. interrupt() alone only ends the parent turn — - // background subagents/shells keep running and keep burning tokens. - // Stop every live task first (best-effort per task: one refusal must - // not strand the rest or block the turn interrupt), then interrupt. - if (context.query.stopTask && context.liveTaskIds.size > 0) { - const liveIds = Array.from(context.liveTaskIds); - // Bounded: a wedged child's stopTask promise may never settle - // (Effect.ignore handles rejection, not non-resolution), and the - // parent interrupt below MUST still run — Stop matters most during - // runaway fleets (review finding). Per-task timeout keeps one hung - // child from consuming the whole budget. - yield* Effect.forEach( - liveIds, - (taskId) => - Effect.gen(function* () { - const stopAcknowledged = yield* Effect.tryPromise({ - // Invoke through the query object: SDK methods rely on `this`. - try: () => context.query.stopTask!(taskId), - catch: () => undefined, - }).pipe( - Effect.timeoutOption("3 seconds"), - Effect.orElseSucceed(() => Option.none()), - ); - if (Option.isNone(stopAcknowledged) || !context.liveTaskIds.delete(taskId)) { - return; - } - - // stopTask only acknowledges the control request. Its separate - // task_notification can lose the race with interrupt(), so make - // the acknowledged stop authoritative for the durable UI state. - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "task.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState - ? { turnId: asCanonicalTurnId(context.turnState.turnId) } - : {}), - payload: { - taskId: RuntimeTaskId.make(taskId), - status: "stopped", - ...taskLinkageFor(context.taskAgents, taskId), - }, - providerRefs: nativeProviderRefs(context), - }); - }).pipe(Effect.ignore), - { concurrency: 8, discard: true }, - ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); - } - yield* Effect.tryPromise({ - try: () => context.query.interrupt(), - catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), - }); + // interrupt() can acknowledge while resumed background tasks keep the + // CLI alive. Stop is a hard session boundary for Claude, so close the + // query and let the SDK escalate to SIGKILL when graceful exit fails. + yield* stopSessionInternal(context); }, ); @@ -4619,25 +4723,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return context !== undefined && !context.stopped; }); - const stopAll: ClaudeAdapterShape["stopAll"] = () => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: true, - }), - { discard: true }, + const stopSessions = Effect.fn("stopSessions")(function* ( + contexts: ReadonlyArray, + emitExitEvent: boolean, + ) { + const results = yield* Effect.forEach(contexts, (context) => + stopSessionInternal(context, { emitExitEvent }).pipe(Effect.result), ); + for (const result of results) { + if (result._tag === "Failure") { + return yield* Effect.fail(result.failure); + } + } + }); + + const stopAll: ClaudeAdapterShape["stopAll"] = () => + stopSessions(Array.from(sessions.values()), true); + yield* Effect.addFinalizer(() => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: false, - }), - { discard: true }, - ).pipe( + stopSessions(Array.from(sessions.values()), false).pipe( Effect.catch((cause) => Effect.logError("Failed to emit Claude session shutdown event.", { cause }), ), diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 2a8f6ac9f192..e4831975ebd4 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -9,27 +9,11 @@ import * as Schema from "effect/Schema"; import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, - isLegacyClaudeModel, probeClaudeCapabilities, } from "./ClaudeProvider.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); -it("keeps only the Claude 5 family out of legacy models", () => { - assert.deepStrictEqual( - ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ - model, - isLegacyClaudeModel(model), - ]), - [ - ["claude-fable-5", false], - ["claude-opus-5", false], - ["claude-sonnet-5", false], - ["claude-opus-4-8", true], - ], - ); -}); - it("isolates Claude capability probes without dropping workspace setting sources", () => { const abortController = new AbortController(); const options = buildClaudeCapabilitiesProbeQueryOptions({ diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index a37110ef7126..69f86c16ce81 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -61,12 +61,6 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; -const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]); - -export function isLegacyClaudeModel(model: string): boolean { - return !CURRENT_CLAUDE_MODELS.has(model); -} - const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ { slug: "claude-fable-5", @@ -332,9 +326,9 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ }, ]; -const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG.map((model) => - isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model, -); +// Legacy classification happens at the driver boundary via `applyModelManifest`, +// so the catalog itself carries no `isLegacy` flags. +const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG; function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; @@ -996,7 +990,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); - const slashCommands = capabilities?.slashCommands ?? []; + const slashCommands = [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, + ...(capabilities?.slashCommands ?? []), + ]; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); if (!capabilities) { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index da7f6fb1576a..4986d02c9b67 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -104,6 +104,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { }), ); + public readonly uploadFeedbackImpl = vi.fn((_reason?: string) => + Promise.resolve({ threadId: "provider-thread-1" }), + ); + public readonly respondToRequestImpl = vi.fn( (_requestId: ApprovalRequestId, _decision: ProviderApprovalDecision): Promise => Promise.resolve(undefined), @@ -142,6 +146,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { return Effect.promise(() => this.rollbackThreadImpl(numTurns)); } + uploadFeedback(reason?: string) { + return Effect.promise(() => this.uploadFeedbackImpl(reason)); + } + respondToRequest(requestId: ApprovalRequestId, decision: ProviderApprovalDecision) { return Effect.promise(() => this.respondToRequestImpl(requestId, decision)); } @@ -328,6 +336,42 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("uploads feedback for the active Codex thread", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-feedback"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + + const result = yield* adapter.uploadFeedback({ + threadId, + reason: "The agent stopped early.", + }); + + NodeAssert.deepStrictEqual(result, { feedbackId: "provider-thread-1" }); + NodeAssert.deepStrictEqual(runtime.uploadFeedbackImpl.mock.calls, [ + ["The agent stopped early."], + ]); + }), + ); + + it.effect("rejects feedback for an unknown Codex thread", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const result = yield* adapter + .uploadFeedback({ threadId: asThreadId("thread-feedback-missing") }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); + }), + ); + it.effect("maps codex model options before sending a turn", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; @@ -513,6 +557,67 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("does not reactivate an idle child after a parent interaction", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe( + Effect.forkChild, + ); + + const childEvent = (id: string, method: string, payload: Record) => ({ + id: asEventId(id), + kind: "notification" as const, + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload, + }); + + yield* runtime.emit( + childEvent("evt-child-running", "collabAgent/turnStarted", { + agentThreadId: "child-1", + agentPath: "/root/audit", + }), + ); + yield* runtime.emit( + childEvent("evt-child-idle", "collabAgent/turnCompleted", { + agentThreadId: "child-1", + agentPath: "/root/audit", + turn: { status: "completed" }, + }), + ); + yield* runtime.emit( + childEvent("evt-child-interacted", "collabAgent/activity", { + agentThreadId: "child-1", + agentPath: "/root/audit", + activityKind: "interacted", + }), + ); + yield* runtime.emit( + childEvent("evt-other-child-running", "collabAgent/turnStarted", { + agentThreadId: "child-2", + agentPath: "/root/other", + }), + ); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepStrictEqual( + events.map((event) => + event.type === "task.updated" + ? { taskId: event.payload.taskId, status: event.payload.status } + : { type: event.type }, + ), + [ + { taskId: "child-1", status: "running" }, + { taskId: "child-1", status: "idle" }, + { taskId: "child-2", status: "running" }, + ], + ); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); @@ -961,6 +1066,79 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps MCP elicitation requests into app access approvals", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-mcp-elicitation"), + kind: "request", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-08-24T00:00:00.000Z", + method: "mcpServer/elicitation/request", + requestKind: "mcp-elicitation", + requestId: ApprovalRequestId.make("req-safari"), + turnId: asTurnId("turn-1"), + payload: { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { type: "object", properties: {} }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "request.opened") { + return; + } + NodeAssert.equal(firstEvent.value.payload.requestType, "mcp_elicitation_approval"); + NodeAssert.equal(firstEvent.value.payload.appName, "Safari"); + NodeAssert.equal(firstEvent.value.payload.detail, "Allow ChatGPT to use Safari?"); + NodeAssert.deepStrictEqual(firstEvent.value.payload.options, [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Always allow this session" }, + { decision: "acceptAlways", label: "Always allow" }, + { decision: "accept", label: "Approve" }, + ]); + }), + ); + + it.effect("preserves MCP elicitation type when an app access request resolves", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-mcp-elicitation-resolved"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-08-24T00:00:00.000Z", + method: "item/requestApproval/decision", + requestKind: "mcp-elicitation", + requestId: ApprovalRequestId.make("req-safari"), + payload: { decision: "acceptAlways" }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "request.resolved") { + return; + } + NodeAssert.equal(firstEvent.value.payload.requestType, "mcp_elicitation_approval"); + NodeAssert.equal(firstEvent.value.payload.decision, "acceptAlways"); + }), + ); + it.effect("preserves file-read request type when mapping serverRequest/resolved", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index cf82ffd40dff..0f7d999662e9 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -57,6 +57,7 @@ import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, + describeMcpElicitation, makeCodexSessionRuntime, type CodexSessionRuntimeError, type CodexSessionRuntimeOptions, @@ -302,6 +303,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { return "file_read_approval"; case "item/fileChange/requestApproval": return "file_change_approval"; + case "mcpServer/elicitation/request": + return "mcp_elicitation_approval"; case "applyPatchApproval": return "apply_patch_approval"; case "execCommandApproval": @@ -325,6 +328,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical return "file_read_approval"; case "file-change": return "file_change_approval"; + case "mcp-elicitation": + return "mcp_elicitation_approval"; default: return "unknown"; } @@ -590,14 +595,9 @@ function mapCollabAgentEvent( }, ]; } - // interacted → the child is (again) actively driven. - return [ - { - ...base, - type: "task.updated", - payload: { taskId, status: "running", ...statusLinkage }, - }, - ]; + // Reading a child's result also emits "interacted" after its turn is idle. + // Only the child's turn or thread lifecycle can prove it resumed work. + return []; } case "collabAgent/turnStarted": return [ @@ -805,6 +805,11 @@ function mapToRuntimeEvents( ]; } + const elicitation = + event.method === "mcpServer/elicitation/request" + ? readPayload(EffectCodexSchema.McpServerElicitationRequestParams, event.payload) + : undefined; + const elicitationApproval = elicitation ? describeMcpElicitation(elicitation) : undefined; const detail = (() => { switch (event.method) { case "item/commandExecution/requestApproval": { @@ -821,6 +826,8 @@ function mapToRuntimeEvents( ); return payload?.reason ?? undefined; } + case "mcpServer/elicitation/request": + return elicitation?.message; case "applyPatchApproval": { const payload = readPayload( EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, @@ -854,6 +861,12 @@ function mapToRuntimeEvents( payload: { requestType: toRequestTypeFromMethod(event.method), ...(detail ? { detail } : {}), + ...(elicitationApproval + ? { + appName: elicitationApproval.appName, + options: elicitationApproval.options, + } + : {}), ...(event.payload !== undefined ? { args: event.payload } : {}), }, }, @@ -1896,6 +1909,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); }; + const uploadFeedback: CodexAdapterShape["uploadFeedback"] = (input) => + requireSession(input.threadId).pipe( + Effect.flatMap((session) => session.runtime.uploadFeedback(input.reason)), + Effect.map(({ threadId }) => ({ feedbackId: threadId })), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(input.threadId, "feedback/upload", cause), + ), + ); + const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) => requireSession(threadId).pipe( Effect.flatMap((session) => session.runtime.respondToRequest(requestId, decision)), @@ -1983,6 +2007,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( interruptTurn, readThread, rollbackThread, + uploadFeedback, respondToRequest, respondToUserInput, stopSession, diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index a1b46e003520..5af06efb71dc 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -13,9 +13,11 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; +import { type ProviderApprovalDecision, type ProviderEvent, ThreadId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { assert, describe } from "vite-plus/test"; @@ -25,6 +27,14 @@ import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; const ROOT = wireFixture.rootThreadId; const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; const MEMORY = "memory-consolidation-thread"; +const decodeMcpElicitationResponse = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.Number, + result: Schema.Unknown, + }), + ), +); /** * The captured sequence, extended with the shapes the live capture didn't @@ -328,4 +338,116 @@ describe("CodexSessionRuntime collab integration", () => { yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + const elicitationCases = [ + { + decision: "accept", + response: { action: "accept", content: { approval: "once" } }, + }, + { + decision: "acceptForSession", + response: { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }, + }, + { + decision: "acceptAlways", + response: { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }, + }, + { decision: "decline", response: { action: "decline" } }, + { decision: "cancel", response: { action: "cancel" } }, + ] satisfies ReadonlyArray<{ + readonly decision: ProviderApprovalDecision; + readonly response: Record; + }>; + + for (const { decision, response } of elicitationCases) { + it.live(`returns the MCP elicitation ${decision} response to Codex`, () => + Effect.gen(function* () { + const scriptedRequest = { + id: 7001, + method: "mcpServer/elicitation/request", + params: { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: ROOT, + turnId: wireFixture.responses.turnStart.turn.id, + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + enum: ["once", "session", "always"], + }, + }, + required: ["approval"], + }, + }, + }; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + completeTurnOnServerResponse: true, + notifications: [], + serverRequests: [scriptedRequest], + }; + const responsesPath = `${scriptPath}.responses`; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(responsesPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(responsesPath, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-mcp-elicitation"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "auto", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const approvalRequested = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.runForEach((event) => + event.method === "mcpServer/elicitation/request" + ? Deferred.succeed(approvalRequested, event).pipe(Effect.asVoid) + : event.method === "turn/completed" + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "Open Safari" }); + const approval = yield* Deferred.await(approvalRequested); + assert.equal(approval.requestKind, "mcp-elicitation"); + assert.isDefined(approval.requestId); + if (approval.requestId === undefined) return; + + yield* runtime.respondToRequest(approval.requestId, decision); + yield* Deferred.await(turnCompleted); + + const recordedResponse = yield* decodeMcpElicitationResponse( + NodeFS.readFileSync(responsesPath, "utf8"), + ); + assert.equal(recordedResponse.id, scriptedRequest.id); + assert.deepEqual(recordedResponse.result, response); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + } }); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 7469818dcefd..2aeebdb2ccd8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,31 +1,6 @@ import { assert, it } from "@effect/vitest"; -import { - applyPreferredCodexDefaultModel, - isLegacyCodexModel, - mapCodexModelCapabilities, -} from "./CodexProvider.ts"; - -it("keeps current Codex models out of legacy models", () => { - assert.deepStrictEqual( - [ - "gpt-5.6-luna", - "gpt-5.6-terra", - "gpt-5.6-sol", - "gpt-daybreak-blue-latest", - "gpt-daybreak-red-latest", - "gpt-5.4", - ].map((model) => [model, isLegacyCodexModel(model)]), - [ - ["gpt-5.6-luna", false], - ["gpt-5.6-terra", false], - ["gpt-5.6-sol", false], - ["gpt-daybreak-blue-latest", false], - ["gpt-daybreak-red-latest", false], - ["gpt-5.4", true], - ], - ); -}); +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 6e485dd0a287..52a8fdd25dc7 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,17 +62,6 @@ const REASONING_EFFORT_LABELS: Readonly> = { }; const DEFAULT_SERVICE_TIER_ID = "default"; -const CURRENT_CODEX_MODELS = new Set([ - "gpt-5.6-luna", - "gpt-5.6-terra", - "gpt-5.6-sol", - "gpt-daybreak-blue-latest", - "gpt-daybreak-red-latest", -]); - -export function isLegacyCodexModel(model: string): boolean { - return !CURRENT_CODEX_MODELS.has(model); -} function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; @@ -201,7 +190,6 @@ function parseCodexModelListResponse( name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), - ...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } @@ -607,6 +595,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu checkedAt, models: snapshot.models, skills: snapshot.skills, + slashCommands: [ + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ], probe: { installed: true, version: snapshot.version ?? null, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index a80ef2cf56a6..6a6cec5b1e61 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -17,10 +17,12 @@ import { import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, + describeMcpElicitation, hasConfiguredMcpServer, isRecoverableThreadResumeError, makeMemoryConsolidationNotificationFilter, openCodexThread, + toMcpElicitationResponse, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -248,6 +250,208 @@ describe("buildTurnStartParams", () => { }); }); +describe("Codex MCP elicitation approvals", () => { + const request = { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + _meta: { + app_name: "Safari", + persist: ["session", "always"], + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + oneOf: [ + { const: "once", title: "Allow once" }, + { const: "session", title: "Allow for this session" }, + { const: "always", title: "Always allow Safari" }, + ], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + it("preserves the app name and advertised persistence choices", () => { + NodeAssert.deepStrictEqual(describeMcpElicitation(request), { + appName: "Safari", + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Allow for this session" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ], + }); + }); + + it("extracts the app name from a Computer Use request without metadata", () => { + const { _meta, ...requestWithoutMetadata } = request; + + NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari"); + }); + + it("returns the accepted form option to Codex", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), { + action: "accept", + content: { approval: "once" }, + }); + }); + + it("returns session-scoped approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptForSession"), { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }); + }); + + it("returns persistent approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("returns rejection without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), { + action: "decline", + }); + }); + + it("returns cancellation without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), { + action: "cancel", + }); + }); + + it("supports boolean permanent-approval fields", () => { + const booleanRequest = { + ...request, + _meta: { app_name: "Safari" }, + requestedSchema: { + type: "object", + properties: { + always: { type: "boolean", title: "Always allow Safari" }, + }, + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.ok( + describeMcpElicitation(booleanRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(booleanRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { always: true }, + }); + }); + + it("preserves valid nullable MCP form fields and persistence choices", () => { + const nullableRequest = { + ...request, + _meta: { + app_name: null, + appName: "Safari", + connector_name: null, + persist: null, + target: null, + tool_params: null, + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + title: null, + description: null, + default: null, + enum: ["once", "always"], + enumNames: null, + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.equal(describeMcpElicitation(nullableRequest).appName, "Safari"); + NodeAssert.ok( + describeMcpElicitation(nullableRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(nullableRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("declines required form fields that an approval prompt cannot collect", () => { + const inputRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + email: { type: "string", format: "email" }, + }, + required: ["email"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(inputRequest, "accept"), { + action: "decline", + }); + }); + + it("does not approve URL elicitations without opening their requested URL", () => { + const urlRequest = { + mode: "url", + message: "Finish signing in to continue.", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + elicitationId: "sign-in-1", + url: "https://example.com/authorize", + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), { + action: "decline", + }); + }); + + it("omits persistence choices that cannot satisfy required form fields", () => { + const onceOnlyRequest = { + ...request, + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + enum: ["once"], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(describeMcpElicitation(onceOnlyRequest).options, [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, + ]); + }); +}); + describe("buildCodexDeveloperInstructions", () => { it("appends runtime info after the mode instructions", () => { const instructions = buildCodexDeveloperInstructions("default", { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 29bb992611c1..b34067b7fb90 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -6,6 +6,7 @@ import { ProviderItemId, type ProviderInstanceId, type ProviderApprovalDecision, + type ProviderApprovalOption, type ProviderEvent, type ProviderInteractionMode, type ProviderRequestKind, @@ -73,6 +74,58 @@ const CodexUserInputAnswerObject = Schema.Struct({ }); const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); const isCodexUserInputAnswerObject = Schema.is(CodexUserInputAnswerObject); +const NullableMcpElicitationString = Schema.NullOr(Schema.String); +const McpElicitationMetadata = Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + appName: Schema.optionalKey(NullableMcpElicitationString), + connector_name: Schema.optionalKey(NullableMcpElicitationString), + connectorName: Schema.optionalKey(NullableMcpElicitationString), + allowPersistentApproval: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + persist: Schema.optionalKey( + Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + ), + target: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + tool_params: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), +}); +const McpElicitationFormField = Schema.Struct({ + type: Schema.optionalKey(NullableMcpElicitationString), + title: Schema.optionalKey(NullableMcpElicitationString), + description: Schema.optionalKey(NullableMcpElicitationString), + default: Schema.optionalKey(Schema.Unknown), + enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + oneOf: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + const: Schema.String, + title: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + ), +}); +const McpElicitationForm = Schema.Struct({ + properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)), + required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), +}); +const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); +const isMcpElicitationForm = Schema.is(McpElicitationForm); // TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated // `V2TurnStartParams` schema includes `collaborationMode` directly. @@ -142,6 +195,9 @@ export interface CodexSessionRuntimeShape { readonly rollbackThread: ( numTurns: number, ) => Effect.Effect; + readonly uploadFeedback: ( + reason?: string, + ) => Effect.Effect; readonly respondToRequest: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -228,6 +284,172 @@ interface PendingUserInput { readonly answers: Deferred.Deferred; } +type McpElicitationPersistenceDecision = Extract< + ProviderApprovalDecision, + "acceptForSession" | "acceptAlways" +>; + +function mcpElicitationPersistenceDecision( + value: string, +): McpElicitationPersistenceDecision | null { + const normalized = value.toLowerCase(); + if (normalized.includes("session")) return "acceptForSession"; + if ( + normalized.includes("always") || + normalized.includes("permanent") || + normalized.includes("forever") || + normalized.includes("persistent") + ) { + return "acceptAlways"; + } + return null; +} + +function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) { + if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) { + return undefined; + } + return payload.requestedSchema; +} + +function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) { + if (field.oneOf) { + return field.oneOf.map((option) => ({ value: option.const, label: option.title })); + } + return (field.enum ?? []).map((value, index) => ({ + value, + label: field.enumNames?.[index], + })); +} + +function isMcpElicitationPersistenceField( + key: string, + field: typeof McpElicitationFormField.Type, +): boolean { + return ( + mcpElicitationPersistenceDecision(key) !== null || + key.toLowerCase() === "persist" || + mcpElicitationPersistenceDecision(field.title ?? "") !== null || + mcpElicitationPersistenceDecision(field.description ?? "") !== null + ); +} + +/** Returns the app and approval choices advertised by an MCP elicitation. */ +export function describeMcpElicitation( + payload: EffectCodexSchema.McpServerElicitationRequestParams, +): { readonly appName: string; readonly options: ReadonlyArray } { + const metadata = isMcpElicitationMetadata(payload._meta) ? payload._meta : undefined; + const appName = + metadata?.app_name ?? + metadata?.appName ?? + metadata?.app ?? + metadata?.target?.app ?? + metadata?.target?.name ?? + metadata?.tool_params?.app_name ?? + metadata?.tool_params?.app ?? + payload.message.match(/^Allow ChatGPT to use (.+?)\?$/i)?.[1] ?? + metadata?.connector_name ?? + metadata?.connectorName ?? + payload.serverName; + const persistenceOptions = new Map(); + const persist = metadata?.persist; + for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) { + const decision = mcpElicitationPersistenceDecision(value); + if (decision) persistenceOptions.set(decision, ""); + } + if (metadata?.allowPersistentApproval) { + persistenceOptions.set("acceptAlways", ""); + } + + const form = mcpElicitationFormFields(payload); + for (const [key, field] of Object.entries(form?.properties ?? {})) { + for (const option of mcpElicitationFieldOptions(field)) { + const decision = mcpElicitationPersistenceDecision(option.value); + if (decision) persistenceOptions.set(decision, option.label ?? ""); + } + if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + persistenceOptions.set("acceptAlways", field.title ?? ""); + } + } + + return { + appName, + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + ...(persistenceOptions.has("acceptForSession") && + toMcpElicitationResponse(payload, "acceptForSession").action === "accept" + ? [ + { + decision: "acceptForSession" as const, + label: persistenceOptions.get("acceptForSession") || "Always allow this session", + }, + ] + : []), + ...(persistenceOptions.has("acceptAlways") && + toMcpElicitationResponse(payload, "acceptAlways").action === "accept" + ? [ + { + decision: "acceptAlways" as const, + label: persistenceOptions.get("acceptAlways") || "Always allow", + }, + ] + : []), + { decision: "accept", label: "Approve" }, + ], + }; +} + +/** Converts a T3 approval decision into the MCP elicitation wire response. */ +export function toMcpElicitationResponse( + payload: EffectCodexSchema.McpServerElicitationRequestParams, + decision: ProviderApprovalDecision, +): EffectCodexSchema.McpServerElicitationRequestResponse { + if (decision === "decline" || decision === "cancel") { + return { action: decision }; + } + + if (payload.mode === "url") { + return { action: "decline" }; + } + + const persist = + decision === "acceptForSession" + ? "session" + : decision === "acceptAlways" + ? "always" + : undefined; + const form = mcpElicitationFormFields(payload); + const content: Record = {}; + + for (const [key, field] of Object.entries(form?.properties ?? {})) { + const options = mcpElicitationFieldOptions(field); + const chosenOption = options.find((option) => + persist + ? mcpElicitationPersistenceDecision(option.value) === decision + : /once|accept|approve|allow/i.test(option.value) && + mcpElicitationPersistenceDecision(option.value) === null, + ); + if (chosenOption) { + content[key] = chosenOption.value; + } else if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + content[key] = decision === "acceptAlways"; + } else if (field.default !== undefined && field.default !== null) { + content[key] = field.default; + } + } + + if (form?.required?.some((key) => !Object.hasOwn(content, key))) { + return { action: "decline" }; + } + + return { + action: "accept", + ...(persist ? { _meta: { persist } } : {}), + ...(form ? { content } : {}), + }; +} + type CodexServerNotification = { readonly [M in CodexRpc.ServerNotificationMethod]: { readonly method: M; @@ -1541,7 +1763,7 @@ export const makeCodexSessionRuntime = ( ), ); return { - decision: resolved, + decision: resolved === "acceptAlways" ? "acceptForSession" : resolved, } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse; }), ); @@ -1599,11 +1821,76 @@ export const makeCodexSessionRuntime = ( ), ); return { - decision: resolved, + decision: resolved === "acceptAlways" ? "acceptForSession" : resolved, } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse; }), ); + yield* client.handleServerRequest("mcpServer/elicitation/request", (payload) => + Effect.gen(function* () { + if (toMcpElicitationResponse(payload, "accept").action !== "accept") { + yield* Effect.logWarning("Declined an unsupported MCP elicitation.", { + serverName: payload.serverName, + mode: payload.mode, + }); + return { + action: "decline", + } satisfies EffectCodexSchema.McpServerElicitationRequestResponse; + } + + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("mcp-elicitation-request")); + const turnId = payload.turnId + ? TurnId.make(payload.turnId) + : (yield* Ref.get(sessionRef)).activeTurnId; + const jsonRpcId = payload.mode === "url" ? payload.elicitationId : requestId; + const decision = yield* Deferred.make(); + + yield* Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.set(requestId, { + requestId, + jsonRpcId, + requestKind: "mcp-elicitation", + turnId, + itemId: undefined, + decision, + }); + return next; + }); + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.set(jsonRpcId, { + requestId, + requestKind: "mcp-elicitation", + turnId, + itemId: undefined, + }); + return next; + }); + + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "mcpServer/elicitation/request", + requestId, + requestKind: "mcp-elicitation", + ...(turnId ? { turnId } : {}), + payload, + }); + + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + return toMcpElicitationResponse(payload, resolved); + }), + ); + yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => Effect.gen(function* () { const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); @@ -1914,6 +2201,16 @@ export const makeCodexSessionRuntime = ( }); return parseThreadSnapshot(response); }), + uploadFeedback: (reason) => + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + return yield* client.request("feedback/upload", { + classification: "bug", + includeLogs: true, + ...(reason ? { reason } : {}), + threadId: providerThreadId, + }); + }), respondToRequest: (requestId, decision) => Effect.gen(function* () { const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId); diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 6cb71660a74c..eeee17d9ac6a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -26,7 +26,13 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; -import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts"; +import { + grokPromptSettlementBelongsToContext, + isGrokEnterPlanModeToolCall, + makeGrokAdapter, + nextGrokPlanModeActive, + selectGrokPermissionOptionId, +} from "./GrokAdapter.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -89,6 +95,90 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); +it("detects enter_plan_mode tool calls from title and rawInput", () => { + assert.isTrue( + isGrokEnterPlanModeToolCall({ + title: "enter_plan_mode", + data: { toolCallId: "1" }, + }), + ); + assert.isTrue( + isGrokEnterPlanModeToolCall({ + title: "Plan mode entered", + data: { toolCallId: "1", rawInput: { variant: "EnterPlanMode" } }, + }), + ); + assert.isFalse( + isGrokEnterPlanModeToolCall({ + title: "write", + data: { toolCallId: "1", rawInput: { file_path: "/tmp/x", content: "y" } }, + }), + ); +}); + +it("only sets planModeActive after a successful enter_plan_mode", () => { + const enter = { + title: "enter_plan_mode", + data: { toolCallId: "1" }, + }; + assert.isFalse(nextGrokPlanModeActive(false, { ...enter, status: "pending" })); + assert.isTrue(nextGrokPlanModeActive(false, { ...enter, status: "inProgress" })); + assert.isTrue(nextGrokPlanModeActive(false, { ...enter, status: "completed" })); + assert.isFalse(nextGrokPlanModeActive(false, { ...enter, status: "failed" })); + assert.isFalse(nextGrokPlanModeActive(true, { ...enter, status: "failed" })); + assert.isTrue( + nextGrokPlanModeActive(true, { + title: "write", + status: "completed", + data: { toolCallId: "2" }, + }), + ); +}); + +function grokPermissionRequest( + options: ReadonlyArray<{ + readonly optionId: string; + readonly kind: "allow_once" | "allow_always" | "reject_once" | "reject_always"; + }>, +) { + return { + sessionId: "mock-session-1", + toolCall: { + toolCallId: "tool-call-1", + title: "cat package.json", + kind: "execute" as const, + status: "pending" as const, + }, + options: options.map((option) => ({ + optionId: option.optionId, + name: option.kind, + kind: option.kind, + })), + }; +} + +it("maps Always allow to allow_once when Grok omits allow_always", () => { + const request = grokPermissionRequest([ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "reject-once", kind: "reject_once" }, + ]); + + assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-once"); + assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once"); + assert.equal(selectGrokPermissionOptionId(request, "decline"), "reject-once"); +}); + +it("prefers allow_always when Grok offers it", () => { + const request = grokPermissionRequest([ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "allow-always", kind: "allow_always" }, + { optionId: "reject-once", kind: "reject_once" }, + ]); + + assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-always"); + assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once"); +}); + it("requires a settlement to match the live Grok turn", () => { const staleTurnId = TurnId.make("stale-turn"); const replacementTurnId = TurnId.make("replacement-turn"); @@ -418,6 +508,362 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("does not time out a Grok turn before ACP emits progress", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-silent-turn"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_PROMPT_FOREVER: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnStarted = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "turn.started") { + yield* Deferred.succeed(turnStarted, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "silence forever", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(turnStarted); + + yield* TestClock.adjust("5 seconds"); + yield* Effect.yieldNow; + const steerSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "keep reasoning", attachments: [] }) + .pipe(Effect.forkChild); + for (let yieldAttempt = 0; yieldAttempt < 12; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* TestClock.adjust("5 seconds"); + yield* Effect.yieldNow; + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + + yield* adapter.interruptTurn(threadId); + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + yield* Fiber.interrupt(steerSendTurnFiber); + + assert.equal(completed.payload.state, "cancelled"); + const session = (yield* adapter.listSessions()).find( + (candidate) => candidate.threadId === threadId, + ); + assert.equal(session?.status, "ready"); + assert.isUndefined(session?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("fails a Grok turn that stalls after ACP content begins", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-content-stall"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_CONTENT_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const contentDelta = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "content.delta") { + yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "start then stall", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(contentDelta).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + + yield* TestClock.adjust("999 millis"); + yield* Effect.yieldNow; + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + + yield* TestClock.adjust("1 millis"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + + assert.equal(completed.payload.state, "failed"); + assert.equal( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ).length, + 1, + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("refreshes Grok liveness when a turn is steered", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-steer"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_CONTENT_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const contentDelta = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "content.delta") { + yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "start then steer", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(contentDelta).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + + yield* TestClock.adjust("999 millis"); + const steerSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "continue working", attachments: [] }) + .pipe(Effect.forkChild); + for (let yieldAttempt = 0; yieldAttempt < 12; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + yield* TestClock.adjust("1 millis"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + + yield* Fiber.interrupt(steerSendTurnFiber); + yield* adapter.interruptTurn(threadId); + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(firstSendTurnFiber); + assert.equal(completed.payload.state, "cancelled"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("refreshes Grok liveness when ACP updates its plan", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-plan-stall"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_PLAN_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const planUpdated = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.plan.updated") { + yield* Deferred.succeed(planUpdated, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "update plan then stall", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(planUpdated).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + + yield* TestClock.adjust("1 second"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + + assert.equal(completed.payload.state, "failed"); + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("settles a stalled Grok turn after the active-tool deadline", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-active-tool"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + activeToolInactivityTimeoutMs: 5_000, + }); + const activeTool = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "item.updated") { + yield* Deferred.succeed(activeTool, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "run a long tool", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(activeTool).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + yield* TestClock.adjust("4999 millis"); + yield* Effect.yieldNow; + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + assert.equal( + (yield* adapter.listSessions()).find((candidate) => candidate.threadId === threadId) + ?.status, + "running", + ); + + yield* TestClock.adjust("1 millis"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + assert.equal(completed.payload.state, "failed"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt"); @@ -943,6 +1389,64 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("surfaces Grok usage limits without clearing the selected model", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-usage-limit-error"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "hit the usage limit", + attachments: [], + }), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + const terminalEvents = runtimeEvents.filter( + (event) => event.type === "turn.completed" && event.threadId === threadId, + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.include(error.message, "Grok usage limit reached. Try again later."); + assert.equal(readySession?.status, "ready"); + assert.equal(readySession?.model, "grok-build"); + assert.isUndefined(readySession?.activeTurnId); + assert.lengthOf(terminalEvents, 1); + const [terminalEvent] = terminalEvents; + assert.equal(terminalEvent?.type, "turn.completed"); + if (terminalEvent?.type === "turn.completed") { + assert.equal(terminalEvent.payload.state, "failed"); + assert.include( + terminalEvent.payload.errorMessage ?? "", + "Grok usage limit reached. Try again later.", + ); + } + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("ignores replayed session/load updates when resuming a Grok session", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-load-replay-filter"); @@ -1097,6 +1601,247 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("captures xAI exit_plan_mode as a proposed plan and unblocks the turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-exit-plan-mode"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const proposed = + yield* Deferred.make>(); + const turnCompleted = yield* Deferred.make(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "turn.proposed.completed") { + return Deferred.succeed(proposed, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + return Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId, input: "present the plan", attachments: [] }); + + const proposedEvent = yield* Deferred.await(proposed); + assert.equal(proposedEvent.type, "turn.proposed.completed"); + assert.equal(proposedEvent.payload.planMarkdown, "# Exit plan\n\n- Step one\n- Step two"); + assert.equal(proposedEvent.raw?.method, "_x.ai/exit_plan_mode"); + yield* Deferred.await(turnCompleted); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces plan.md writes as a proposed plan while plan mode is active", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-plan-md-write"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_PLAN_MD_WRITE: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const proposed = + yield* Deferred.make>(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "turn.proposed.completed") { + return Deferred.succeed(proposed, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId, input: "write the plan", attachments: [] }); + + const proposedEvent = yield* Deferred.await(proposed); + assert.equal( + proposedEvent.payload.planMarkdown, + "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it", + ); + assert.equal(proposedEvent.raw?.method, "session/update"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a Grok turn running when Always allow has no allow_always option", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-always-allow-without-allow-always"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_OMIT_ALLOW_ALWAYS: "1", + T3_ACP_PERMISSION_REQUEST_COUNT: "2", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const openedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Effect.gen(function* () { + yield* Ref.update(openedCount, (count) => count + 1); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "acceptForSession", + ); + }) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: "approve this session", + attachments: [], + }); + + assert.equal(yield* Ref.get(openedCount), 1); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const permissionResults = requests.filter( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "optionId" in entry.result.outcome, + ); + assert.equal(permissionResults.length, 2); + assert.isTrue( + permissionResults.every( + (entry) => + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "optionId" in entry.result.outcome && + entry.result.outcome.optionId === "allow-once", + ), + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("asks before a different command after Always allow this session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-session-approval-scope"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_OMIT_ALLOW_ALWAYS: "1", + T3_ACP_PERMISSION_REQUEST_COUNT: "2", + T3_ACP_PERMISSION_TITLE: "Terminal", + T3_ACP_SECOND_PERMISSION_COMMAND: "rm server/package.json", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const openedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Effect.gen(function* () { + const count = yield* Ref.updateAndGet(openedCount, (value) => value + 1); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + count === 1 ? "acceptForSession" : "decline", + ); + }) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "check approval scope", attachments: [] }); + assert.equal(yield* Ref.get(openedCount), 2); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("captures a plan under the provider instance GROK_HOME", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-instance-plan-home"); + const grokHome = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-instance-home-")), + ); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_PLAN_MD_WRITE: "1", + T3_ACP_PLAN_ROOT: grokHome, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + environment: { ...process.env, GROK_HOME: grokHome }, + }); + const plans = yield* Ref.make>([]); + const completed = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.type === "turn.proposed.completed") { + return Ref.update(plans, (current) => [...current, event.payload.planMarkdown]); + } + return event.type === "turn.completed" + ? Deferred.succeed(completed, undefined).pipe(Effect.asVoid) + : Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "write the plan", attachments: [] }); + yield* Deferred.await(completed); + assert.deepEqual(yield* Ref.get(plans), [ + "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it", + ]); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("handles xAI ask_user_question extension requests", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-xai-ask-user-question"); @@ -1159,6 +1904,82 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("settles a stalled Grok turn after its first activity is user input", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-ask-user-question"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const requested = + yield* Deferred.make>(); + const resolved = + yield* Deferred.make>(); + const completed = + yield* Deferred.make>(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "user-input.requested") { + return Deferred.succeed(requested, event).pipe(Effect.ignore); + } + if (event.type === "user-input.resolved") { + return Deferred.succeed(resolved, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + return Deferred.succeed(completed, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "ask before continuing", attachments: [] }) + .pipe(Effect.forkChild); + + const requestedEvent = yield* Deferred.await(requested); + assert.equal(requestedEvent.payload.questions.length, 1); + assert.equal(requestedEvent.payload.questions[0]?.id, "Which scope should Grok use?"); + assert.equal(requestedEvent.payload.questions[0]?.question, "Which scope should Grok use?"); + assert.equal(requestedEvent.raw?.method, "_x.ai/ask_user_question"); + + yield* adapter.respondToUserInput( + threadId, + ApprovalRequestId.make(String(requestedEvent.requestId)), + { + "Which scope should Grok use?": "Workspace", + }, + ); + + const resolvedEvent = yield* Deferred.await(resolved); + assert.deepEqual(resolvedEvent.payload.answers, { + "Which scope should Grok use?": "Workspace", + }); + assert.equal(String(resolvedEvent.turnId), String(requestedEvent.turnId)); + + yield* TestClock.adjust("1 second"); + const completedEvent = yield* Deferred.await(completed).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + assert.equal(completedEvent.payload.state, "failed"); + yield* Fiber.join(sendTurnFiber); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("continues streaming events when native notification logging fails", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-native-log-failure"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 858d862e6d5f..d0b704b93d15 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -12,9 +12,14 @@ import { type ThreadId, TurnId, } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { stableStringify } from "@t3tools/shared/relaySigning"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -22,6 +27,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -56,15 +62,21 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, + normalizeGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; import { + extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, + extractXAiExitPlanMarkdown, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeCapturedResponse, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -73,6 +85,15 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonStri const PROVIDER = ProviderDriverKind.make("grok"); const GROK_RESUME_VERSION = 1 as const; +const NANOS_PER_MILLI = 1_000_000n; +// ACP does not expose Grok's private `streaming_reasoning` phase. Once it has +// emitted standard ACP progress, ten silent minutes is long enough to avoid +// treating legitimate reasoning as a stalled stream. +const DEFAULT_GROK_TURN_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1_000; +// A tool can legitimately run without emitting text for much longer than +// reasoning. It still needs a deadline so a lost tool update cannot leave the +// turn working forever. +const DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS = 30 * 60 * 1_000; function encodeJsonStringForDiagnostics(input: unknown): string | undefined { const result = encodeUnknownJsonStringExit(input); @@ -84,6 +105,10 @@ export interface GrokAdapterLiveOptions { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly instanceId?: ProviderInstanceId; + /** Override the conservative ACP turn liveness timeout in focused tests. */ + readonly turnInactivityTimeoutMs?: number; + /** Override the longer active-tool liveness timeout in focused tests. */ + readonly activeToolInactivityTimeoutMs?: number; } interface PendingApproval { @@ -98,6 +123,10 @@ interface PendingUserInput { readonly resolution: Deferred.Deferred; } +interface GrokTurnLivenessSignal { + readonly turnId: TurnId; +} + interface GrokSessionContext { readonly threadId: ThreadId; readonly acpSessionId: string; @@ -109,6 +138,14 @@ interface GrokSessionContext { readonly pendingUserInputs: Map; turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; + /** + * Latest plan.md body + turn it was emitted for. Dedupe is turn-scoped so a + * later turn re-proposing the same text still gets a new proposed-plan card. + */ + lastKnownProposedPlanMarkdown: string | undefined; + lastKnownProposedPlanTurnId: TurnId | undefined; + /** True after enter_plan_mode until the turn ends or exit_plan_mode resolves. */ + planModeActive: boolean; activeTurnId: TurnId | undefined; /** Turns already interrupted; late prompt RPCs must not resurrect them. */ interruptedTurnIds: Set; @@ -116,7 +153,15 @@ interface GrokSessionContext { * >0 means a turn is actively running, so a new sendTurn is a steer that * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; + readonly livenessSignals: Queue.Queue; + livenessTurnId: TurnId | undefined; + lastTurnActivityAtNanos: bigint | undefined; + readonly activeToolCallIds: Set; + livenessUpdatesInFlight: number; + /** Prompt RPCs that returned before their turn settlement acquired the lock. */ + promptResponsesReady: number; currentModelId: string | undefined; + currentReasoningEffort: string | undefined; stopped: boolean; } @@ -164,6 +209,54 @@ const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; +function clearProposedPlanFallback(ctx: GrokSessionContext): void { + ctx.lastKnownProposedPlanMarkdown = undefined; + ctx.lastKnownProposedPlanTurnId = undefined; + ctx.planModeActive = false; +} + +/** Detect Grok's enter_plan_mode tool call from ACP tool state. */ +export function isGrokEnterPlanModeToolCall(toolCall: { + readonly title?: string; + readonly data: Record; +}): boolean { + const title = toolCall.title?.trim().toLowerCase() ?? ""; + if ( + title === "enter_plan_mode" || + title === "plan: enter" || + title === "plan mode entered" || + title.includes("enter_plan_mode") + ) { + return true; + } + const rawInput = toolCall.data.rawInput; + if (isRecord(rawInput) && rawInput.variant === "EnterPlanMode") { + return true; + } + return false; +} + +/** Failed enter_plan_mode must not leave planModeActive stuck on. */ +export function nextGrokPlanModeActive( + currentlyActive: boolean, + toolCall: { + readonly title?: string; + readonly status?: "pending" | "inProgress" | "completed" | "failed"; + readonly data: Record; + }, +): boolean { + if (!isGrokEnterPlanModeToolCall(toolCall)) { + return currentlyActive; + } + if (toolCall.status === "failed") { + return false; + } + if (toolCall.status === "completed" || toolCall.status === "inProgress") { + return true; + } + return currentlyActive; +} + const resolveSessionCallbackTurnId = ( sessions: ReadonlyMap, threadId: ThreadId, @@ -179,26 +272,38 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined { return { sessionId: raw.sessionId.trim() }; } -function selectPermissionOptionId( +export function selectGrokPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, ): string | undefined { - const kind = + const preferredKind = decision === "acceptForSession" ? "allow_always" : decision === "accept" ? "allow_once" : "reject_once"; - const option = request.options.find((entry) => entry.kind === kind); - return option?.optionId.trim() || undefined; + const preferred = request.options.find((entry) => entry.kind === preferredKind); + const preferredId = preferred?.optionId.trim(); + if (preferredId) { + return preferredId; + } + // Grok 4.6 often omits allow_always. T3 still offers "Always allow this session". + if (decision === "acceptForSession") { + const once = request.options.find((entry) => entry.kind === "allow_once"); + const onceId = once?.optionId.trim(); + if (onceId) { + return onceId; + } + } + return undefined; } function selectAutoApprovedPermissionOption( request: EffectAcpSchema.RequestPermissionRequest, ): string | undefined { return ( - selectPermissionOptionId(request, "acceptForSession") ?? - selectPermissionOptionId(request, "accept") + selectGrokPermissionOptionId(request, "acceptForSession") ?? + selectGrokPermissionOptionId(request, "accept") ); } @@ -240,10 +345,31 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + const hostPlatform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; + const grokPlanPathHost = { + platform: hostPlatform, + environment: options?.environment ?? hostEnvironment, + }; const sessions = new Map(); const threadLocksRef = yield* SynchronizedRef.make(new Map()); const runtimeEventPubSub = yield* PubSub.unbounded(); + const requestedTurnInactivityTimeoutMs = options?.turnInactivityTimeoutMs; + const turnInactivityTimeoutMs = + typeof requestedTurnInactivityTimeoutMs === "number" && + Number.isFinite(requestedTurnInactivityTimeoutMs) + ? Math.max(1, Math.floor(requestedTurnInactivityTimeoutMs)) + : DEFAULT_GROK_TURN_INACTIVITY_TIMEOUT_MS; + const turnInactivityTimeoutNanos = BigInt(turnInactivityTimeoutMs) * NANOS_PER_MILLI; + const requestedActiveToolInactivityTimeoutMs = options?.activeToolInactivityTimeoutMs; + const activeToolInactivityTimeoutMs = + typeof requestedActiveToolInactivityTimeoutMs === "number" && + Number.isFinite(requestedActiveToolInactivityTimeoutMs) + ? Math.max(1, Math.floor(requestedActiveToolInactivityTimeoutMs)) + : DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS; + const activeToolInactivityTimeoutNanos = + BigInt(activeToolInactivityTimeoutMs) * NANOS_PER_MILLI; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = crypto.randomUUIDv4.pipe( @@ -294,6 +420,146 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const withThreadLock = (threadId: string, effect: Effect.Effect) => Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + const signalTurnLiveness = (ctx: GrokSessionContext, turnId: TurnId) => + Queue.offer(ctx.livenessSignals, { turnId }).pipe(Effect.asVoid); + + const beginTurnLiveness = (ctx: GrokSessionContext, turnId: TurnId) => + Effect.sync(() => { + ctx.livenessTurnId = turnId; + // Do not start a deadline until ACP has made observable progress. + // Grok's private reasoning phase is not present in the ACP stream. + ctx.lastTurnActivityAtNanos = undefined; + ctx.activeToolCallIds.clear(); + }); + + const clearTurnLiveness = (ctx: GrokSessionContext) => { + const turnId = ctx.livenessTurnId; + ctx.livenessTurnId = undefined; + ctx.lastTurnActivityAtNanos = undefined; + ctx.activeToolCallIds.clear(); + ctx.livenessUpdatesInFlight = 0; + ctx.promptResponsesReady = 0; + return turnId === undefined ? Effect.void : signalTurnLiveness(ctx, turnId); + }; + + const recordTurnActivity = Effect.fn("GrokAdapter.recordTurnActivity")(function* ( + ctx: GrokSessionContext, + turnId: TurnId, + event: Extract< + AcpSessionRuntime.AcpSessionRuntimeEvent, + { + _tag: + | "AssistantItemStarted" + | "AssistantItemCompleted" + | "PlanUpdated" + | "ToolCallUpdated" + | "ContentDelta"; + } + >, + ) { + if ( + ctx.livenessTurnId !== turnId || + (event._tag === "ContentDelta" && event.text.length === 0) + ) { + return; + } + ctx.livenessUpdatesInFlight += 1; + try { + const activityAtNanos = yield* Clock.monotonicTimeNanos; + if (ctx.livenessTurnId !== turnId || ctx.interruptedTurnIds.has(turnId)) { + return; + } + if (event._tag === "ToolCallUpdated") { + if (event.toolCall.status === "completed" || event.toolCall.status === "failed") { + ctx.activeToolCallIds.delete(event.toolCall.toolCallId); + } else { + // A tool update without a terminal status receives a longer + // deadline so a long-running tool is not mistaken for a stall. + ctx.activeToolCallIds.add(event.toolCall.toolCallId); + } + } + ctx.lastTurnActivityAtNanos = activityAtNanos; + } finally { + // Decrement before signaling. The watchdog treats in-flight updates as a + // pause; if it consumed a signal while the counter was still > 0 it would + // wait on the next take with no follow-up wake after this decrement. + ctx.livenessUpdatesInFlight = Math.max(0, ctx.livenessUpdatesInFlight - 1); + yield* signalTurnLiveness(ctx, turnId); + } + }); + + const hasLivenessPause = (ctx: GrokSessionContext) => + ctx.pendingApprovals.size > 0 || + ctx.pendingUserInputs.size > 0 || + ctx.livenessUpdatesInFlight > 0; + + const livenessTimeoutFor = (ctx: GrokSessionContext) => + ctx.activeToolCallIds.size > 0 + ? { + milliseconds: activeToolInactivityTimeoutMs, + nanos: activeToolInactivityTimeoutNanos, + } + : { milliseconds: turnInactivityTimeoutMs, nanos: turnInactivityTimeoutNanos }; + + const signalSessionTurnLiveness = (threadId: ThreadId, turnId: TurnId | undefined) => { + const ctx = sessions.get(threadId); + return ctx && turnId !== undefined ? signalTurnLiveness(ctx, turnId) : Effect.void; + }; + + const resumeSessionTurnLiveness = Effect.fn("GrokAdapter.resumeSessionTurnLiveness")(function* ( + threadId: ThreadId, + turnId: TurnId | undefined, + ) { + const ctx = sessions.get(threadId); + if (!ctx || turnId === undefined || ctx.livenessTurnId !== turnId) { + return; + } + // An approval or user-input wait can last longer than the watchdog. + // Its resolution gives the provider a fresh window to resume output. + ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos; + yield* signalTurnLiveness(ctx, turnId); + }); + + const refreshSessionTurnLiveness = Effect.fn("GrokAdapter.refreshSessionTurnLiveness")( + function* (threadId: ThreadId, turnId: TurnId | undefined) { + const ctx = sessions.get(threadId); + if ( + !ctx || + turnId === undefined || + ctx.livenessTurnId !== turnId || + ctx.lastTurnActivityAtNanos === undefined + ) { + return; + } + ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos; + yield* signalTurnLiveness(ctx, turnId); + }, + ); + + const markPromptResponseReady = Effect.fn("GrokAdapter.markPromptResponseReady")(function* ( + threadId: ThreadId, + acpSessionId: string, + turnId: TurnId, + ) { + const ctx = sessions.get(threadId); + if ( + ctx && + ctx.acpSessionId === acpSessionId && + !ctx.stopped && + !ctx.interruptedTurnIds.has(turnId) && + ctx.livenessTurnId === turnId && + ctx.activeTurnId === turnId && + ctx.session.activeTurnId === turnId + ) { + ctx.promptResponsesReady += 1; + yield* signalTurnLiveness(ctx, turnId); + } + }); + + const consumePromptResponseReady = (ctx: GrokSessionContext) => { + ctx.promptResponsesReady = Math.max(0, ctx.promptResponsesReady - 1); + }; + const settlePromptInFlight = ( threadId: ThreadId, turnId: TurnId, @@ -373,6 +639,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt, }; } + yield* clearTurnLiveness(liveCtx); return; } settleTurnId = fallbackTurnId; @@ -389,6 +656,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } liveCtx.promptsInFlight = remainingPrompts; } + yield* clearTurnLiveness(liveCtx); const updatedAt = yield* nowIso; const canEmitTurnCompletion = liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; @@ -397,6 +665,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte options?.completedStopReason !== undefined && canEmitTurnCompletion; const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; liveCtx.activeTurnId = undefined; + // Drop turn-scoped plan fallback so a later empty exit_plan cannot + // resurrect this turn's markdown as a fresh proposal. + clearProposedPlanFallback(liveCtx); liveCtx.session = { ...readySession, status: "ready", @@ -432,6 +703,104 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } }); + const isLiveTurn = (ctx: GrokSessionContext, turnId: TurnId) => + ctx.promptsInFlight > 0 && + ctx.promptsInFlight > ctx.promptResponsesReady && + ctx.activeTurnId === turnId && + ctx.session.activeTurnId === turnId && + (ctx.session.status === "running" || ctx.session.status === "connecting"); + + const settleStalledTurn = Effect.fn("GrokAdapter.settleStalledTurn")(function* ( + ctx: GrokSessionContext, + turnId: TurnId, + ) { + return yield* withThreadLock( + ctx.threadId, + Effect.gen(function* () { + const liveCtx = sessions.get(ctx.threadId); + if ( + liveCtx !== ctx || + ctx.stopped || + !isLiveTurn(ctx, turnId) || + ctx.interruptedTurnIds.has(turnId) || + hasLivenessPause(ctx) + ) { + return; + } + const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; + if (lastActivityAtNanos === undefined) { + return; + } + const nowNanos = yield* Clock.monotonicTimeNanos; + if ( + ctx.interruptedTurnIds.has(turnId) || + !isLiveTurn(ctx, turnId) || + hasLivenessPause(ctx) || + nowNanos - lastActivityAtNanos < livenessTimeoutFor(ctx).nanos + ) { + return; + } + + // Mark before cancel/drain so notifications already in flight finish + // before the terminal event, while late notifications are dropped. + ctx.interruptedTurnIds.add(turnId); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, ctx.threadId, "session/cancel", error), + ), + ), + ); + yield* Effect.ignore(ctx.acp.drainEvents); + yield* settlePromptInFlight(ctx.threadId, turnId, ctx.acpSessionId, { + errorMessage: `Grok ACP turn stalled without content or tool progress for ${livenessTimeoutFor(ctx).milliseconds}ms.`, + settleAllPrompts: true, + }); + }), + ); + }); + + const runTurnLivenessWatchdog = Effect.fn("GrokAdapter.runTurnLivenessWatchdog")( + function* (ctx: GrokSessionContext) { + while (true) { + if (ctx.stopped) { + return; + } + const turnId = ctx.livenessTurnId; + if ( + turnId === undefined || + ctx.interruptedTurnIds.has(turnId) || + !isLiveTurn(ctx, turnId) || + hasLivenessPause(ctx) + ) { + yield* Queue.take(ctx.livenessSignals); + continue; + } + + const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; + if (lastActivityAtNanos === undefined) { + yield* Queue.take(ctx.livenessSignals); + continue; + } + const nowNanos = yield* Clock.monotonicTimeNanos; + const remainingNanos = livenessTimeoutFor(ctx).nanos - (nowNanos - lastActivityAtNanos); + if (remainingNanos <= 0n) { + yield* settleStalledTurn(ctx, turnId); + continue; + } + + const wakeReason = yield* Effect.raceFirst( + Effect.sleep(Duration.nanos(remainingNanos)).pipe(Effect.as("timeout" as const)), + Queue.take(ctx.livenessSignals).pipe(Effect.as("activity" as const)), + ); + if (wakeReason === "timeout") { + yield* settleStalledTurn(ctx, turnId); + } + } + }, + Effect.catch(() => Effect.void), + ); + const logNative = (threadId: ThreadId, method: string, payload: unknown) => Effect.gen(function* () { if (!nativeEventLogger) return; @@ -495,6 +864,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); }); + /** Surface Grok plan.md as T3's proposed-plan card (while writing + on exit). */ + const emitProposedPlanCompleted = ( + ctx: GrokSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + planMarkdown: string, + raw: { readonly method: string; readonly payload: unknown }, + ) => + Effect.gen(function* () { + const trimmed = planMarkdown.trim(); + if (trimmed.length === 0) { + ctx.lastKnownProposedPlanMarkdown = ""; + ctx.lastKnownProposedPlanTurnId = turnId; + return; + } + // Turn-scoped dedupe: identical text on a later turn must still emit. + if ( + ctx.lastKnownProposedPlanMarkdown === trimmed && + ctx.lastKnownProposedPlanTurnId === turnId + ) { + return; + } + ctx.lastKnownProposedPlanMarkdown = trimmed; + ctx.lastKnownProposedPlanTurnId = turnId; + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { planMarkdown: trimmed }, + raw: { + source: "acp.grok.extension", + method: raw.method, + payload: raw.payload, + }, + }); + }); + const requireSession = ( threadId: ThreadId, ): Effect.Effect => { @@ -556,6 +964,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const pendingApprovals = new Map(); const pendingUserInputs = new Map(); + const sessionApprovedOperations = new Set(); const sessionScope = yield* Scope.make("sequential"); let sessionScopeTransferred = false; yield* Effect.addFinalizer(() => @@ -575,6 +984,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(options?.environment ? { environment: options.environment } : {}), childProcessSpawner, cwd, + runtimeMode: input.runtimeMode, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, ...(mcpSession @@ -621,6 +1031,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const resolution = yield* Deferred.make(); const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); pendingUserInputs.set(requestId, { resolution }); + yield* signalSessionTurnLiveness(input.threadId, turnId); yield* offerRuntimeEvent({ type: "user-input.requested", ...(yield* makeEventStamp()), @@ -637,6 +1048,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const resolved = yield* Deferred.await(resolution); pendingUserInputs.delete(requestId); + yield* resumeSessionTurnLiveness(input.threadId, turnId); const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {}; yield* offerRuntimeEvent({ type: "user-input.resolved", @@ -663,12 +1075,77 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); + // Grok intercepts exit_plan_mode and reverse-requests client approval. + // Capture plan into T3 proposed-plan UI and abandon the native gate so + // the turn does not hang (Claude ExitPlanMode pattern). + yield* Effect.forEach( + ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const, + (method) => + acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + const ctx = sessions.get(input.threadId); + const planMarkdown = extractXAiExitPlanMarkdown( + params, + ctx?.lastKnownProposedPlanMarkdown, + ); + if (ctx) { + yield* emitProposedPlanCompleted( + ctx, + turnId, + yield* makeEventStamp(), + planMarkdown, + { method, payload: params }, + ); + ctx.planModeActive = false; + } else { + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { planMarkdown }, + raw: { + source: "acp.grok.extension", + method, + payload: params, + }, + }); + } + return makeXAiExitPlanModeCapturedResponse(); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { yield* logNative(input.threadId, "session/request_permission", params); - if (input.runtimeMode === "full-access") { - const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + const permissionRequest = parsePermissionRequest(params); + const command = permissionRequest.toolCall?.command; + const { kind, title, rawInput, locations } = params.toolCall; + let operationInput = rawInput; + if (isRecord(rawInput) && rawInput.variant === "Bash") { + const { description: _description, ...shellInput } = rawInput; + operationInput = shellInput; + } + // Remember the operation, not the tool-call id or every future tool. + // Generic titles without input cannot identify an operation safely. + const approvalKey = + command || (isRecord(rawInput) && Object.keys(rawInput).length > 0) + ? stableStringify({ kind, title, command, input: operationInput, locations }) + : undefined; + const alreadyApproved = + approvalKey !== undefined && sessionApprovedOperations.has(approvalKey); + if (input.runtimeMode === "full-access" || alreadyApproved) { + const autoApprovedOptionId = + input.runtimeMode === "full-access" + ? selectAutoApprovedPermissionOption(params) + : selectGrokPermissionOptionId(params, "accept"); if (autoApprovedOptionId !== undefined) { return { outcome: { @@ -678,12 +1155,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }; } } - const permissionRequest = parsePermissionRequest(params); const requestId = ApprovalRequestId.make(yield* randomUUIDv4); const runtimeRequestId = RuntimeRequestId.make(requestId); const decision = yield* Deferred.make(); const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); pendingApprovals.set(requestId, { decision }); + yield* signalSessionTurnLiveness(input.threadId, turnId); yield* offerRuntimeEvent( makeAcpRequestOpenedEvent({ stamp: yield* makeEventStamp(), @@ -704,6 +1181,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const resolved = yield* Deferred.await(decision); pendingApprovals.delete(requestId); + yield* resumeSessionTurnLiveness(input.threadId, turnId); yield* offerRuntimeEvent( makeAcpRequestResolvedEvent({ stamp: yield* makeEventStamp(), @@ -716,7 +1194,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ); const selectedOptionId = - resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + resolved === "cancel" + ? undefined + : selectGrokPermissionOptionId(params, resolved); + if ( + resolved === "acceptForSession" && + selectedOptionId && + approvalKey !== undefined + ) { + sessionApprovedOperations.add(approvalKey); + } return { outcome: selectedOptionId ? { @@ -738,10 +1225,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedStartModelId = grokModelSelection?.model ? resolveGrokAcpBaseModelId(grokModelSelection.model) : undefined; + const currentStartModelId = currentGrokModelIdFromSessionSetup( + started.sessionSetupResult, + ); + const currentStartReasoningEffort = currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ); + const requestedStartReasoningEffort = getModelSelectionStringOptionValue( + grokModelSelection, + "reasoningEffort", + ); const boundModelId = yield* applyGrokAcpModelSelection({ runtime: acp, - currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + currentModelId: currentStartModelId, + currentReasoningEffort: currentStartReasoningEffort, requestedModelId: requestedStartModelId, + requestedReasoningEffort: requestedStartReasoningEffort, mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); @@ -774,10 +1273,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte pendingUserInputs, turns: [], lastPlanFingerprint: undefined, + lastKnownProposedPlanMarkdown: undefined, + lastKnownProposedPlanTurnId: undefined, + planModeActive: false, activeTurnId: undefined, interruptedTurnIds: new Set(), promptsInFlight: 0, + livenessSignals: yield* Queue.sliding(1), + livenessTurnId: undefined, + lastTurnActivityAtNanos: undefined, + activeToolCallIds: new Set(), + livenessUpdatesInFlight: 0, + promptResponsesReady: 0, currentModelId: boundModelId, + currentReasoningEffort: + requestedStartReasoningEffort !== undefined + ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) + : currentStartReasoningEffort, stopped: false, }; @@ -807,6 +1319,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ) { return; } + if ( + event._tag === "AssistantItemStarted" || + event._tag === "AssistantItemCompleted" || + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* recordTurnActivity(ctx, notificationTurnId, event); + } const stamp = yield* makeEventStamp(); switch (event._tag) { @@ -844,7 +1365,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "session/update", ); return; - case "ToolCallUpdated": + case "ToolCallUpdated": { yield* offerRuntimeEvent( makeAcpToolCallEvent({ stamp, @@ -855,7 +1376,30 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte rawPayload: event.rawPayload, }), ); + ctx.planModeActive = nextGrokPlanModeActive(ctx.planModeActive, event.toolCall); + // Only promote session plan.md writes while plan mode is + // active — avoids treating unrelated plan files as proposals. + // Fresh stamp: must not share eventId with the tool lifecycle event. + if (ctx.planModeActive) { + const planMarkdown = extractGrokPlanMarkdownFromToolCallData( + event.toolCall.data, + grokPlanPathHost, + ); + if (planMarkdown !== undefined) { + yield* emitProposedPlanCompleted( + ctx, + notificationTurnId, + yield* makeEventStamp(), + planMarkdown, + { + method: "session/update", + payload: event.rawPayload, + }, + ); + } + } return; + } case "ContentDelta": yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ @@ -887,6 +1431,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); + yield* runTurnLivenessWatchdog(ctx).pipe(Effect.forkIn(ctx.scope), Effect.asVoid); sessionScopeTransferred = true; yield* offerRuntimeEvent({ @@ -933,6 +1478,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // Bind the turn id before cooperative yields so interruptTurn can // settle this prompt even if stop arrives during preparation. ctx.activeTurnId = turnId; + // New turn: do not fall back to a previous turn's plan.md body when + // exit_plan_mode omits planContent. + if (steeringTurnId === undefined) { + clearProposedPlanFallback(ctx); + } ctx.session = { ...ctx.session, status: steeringTurnId === undefined ? "connecting" : "running", @@ -948,13 +1498,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedTurnModelId = turnModelSelection?.model ? resolveGrokAcpBaseModelId(turnModelSelection.model) : undefined; - const currentModelId = yield* applyGrokAcpModelSelection({ - runtime: ctx.acp, - currentModelId: ctx.currentModelId, - requestedModelId: requestedTurnModelId, - mapError: (cause) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), - }); + const requestedTurnReasoningEffort = getModelSelectionStringOptionValue( + turnModelSelection, + "reasoningEffort", + ); const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( @@ -1003,7 +1550,21 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); } + const currentModelId = yield* applyGrokAcpModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + currentReasoningEffort: ctx.currentReasoningEffort, + requestedModelId: requestedTurnModelId, + requestedReasoningEffort: requestedTurnReasoningEffort, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); ctx.currentModelId = currentModelId; + if (requestedTurnReasoningEffort !== undefined) { + ctx.currentReasoningEffort = normalizeGrokReasoningEffort( + requestedTurnReasoningEffort, + ); + } const displayModel = currentModelId ? resolveGrokAcpBaseModelId(currentModelId) : undefined; @@ -1032,6 +1593,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt: yield* nowIso, ...(displayModel ? { model: displayModel } : {}), }; + if (steeringTurnId === undefined) { + yield* beginTurnLiveness(ctx, turnId); + } else { + yield* refreshSessionTurnLiveness(input.threadId, turnId); + } if (steeringTurnId === undefined) { yield* offerRuntimeEvent({ @@ -1082,10 +1648,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }) .pipe( Effect.tap((promptResult) => - Effect.all([ - Ref.set(promptRpcSucceeded, true), - Ref.set(promptResultRef, promptResult), - ]), + Effect.all( + [ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId), + ], + { discard: true }, + ), ), Effect.tapError((error) => Ref.set( @@ -1126,6 +1696,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte yield* Effect.yieldNow; } yield* prepared.acp.drainEvents; + consumePromptResponseReady(ctx); if (ctx.interruptedTurnIds.has(prepared.turnId)) { yield* Ref.set(promptSettled, true); return { @@ -1184,6 +1755,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt: completedAt, ...(prepared.displayModel ? { model: prepared.displayModel } : {}), }; + yield* clearTurnLiveness(ctx); const completedStopReason = completedStopReasonFromPromptResponse(result); yield* offerRuntimeEvent({ type: "turn.completed", @@ -1240,6 +1812,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte if (ctx.interruptedTurnIds.has(prepared.turnId)) { return; } + consumePromptResponseReady(ctx); if ( ctx.promptsInFlight <= 0 || ctx.activeTurnId !== prepared.turnId || @@ -1356,6 +1929,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte status: "ready", updatedAt, }; + yield* clearTurnLiveness(ctx); } }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index eab2b6352fcf..587ce2878158 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -10,7 +10,11 @@ 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); @@ -47,6 +51,175 @@ const writeMockGrokCli = () => return grokPath; }); +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 bf33116c7abb..22ac4ee40c79 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -32,9 +32,11 @@ import { import { grokAuthFailureFromAcpCause, grokAuthFromAcpAuthenticate, + isValidGrokReasoningEffortToken, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; +import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -104,6 +106,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 { @@ -122,7 +222,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( slug, name: model.name.trim() || slug, isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: buildGrokModelCapabilities(model), }; }) .filter((model): model is ServerProviderModel => model !== undefined); @@ -169,6 +269,7 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, @@ -259,6 +360,8 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } + const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); + const discoveryExit = yield* probeGrokViaAcp(grokSettings, environment).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, @@ -273,6 +376,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -293,6 +397,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -313,6 +418,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/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 93f4b97995dc..7c07fe5ad4b8 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -239,19 +239,16 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { name: "openclaw-review", description: "Review OpenClaw workflow changes.", location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - content: "---\nname: openclaw-review\n---\n", }, { name: "openclaw-triage", description: "Triage OpenClaw routing issues.", location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - content: "---\nname: openclaw-triage\n---\n", }, { name: "missing-location", description: "This incomplete SDK row should be skipped.", location: "", - content: "---\nname: missing-location\n---\n", }, ], }; diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 316e9462a43b..977cd2f812e4 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -40,6 +40,7 @@ const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = { hasSession: vi.fn(), readThread: vi.fn(), rollbackThread: vi.fn(), + uploadFeedback: vi.fn(), stopAll: vi.fn(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a7c4243a1701..56a0a0004770 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -46,6 +46,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ProviderDriverError } from "../Errors.ts"; +import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; @@ -54,6 +55,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -113,6 +115,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => customModels: [], nativeTaskRedirect: true, launchArgs: "", + autoCompactWindow: "", ...overrides, }); @@ -155,6 +158,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ProviderSecretResolverPassthroughLayer), + Layer.provideMerge(ModelManifest.layerTest), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -321,6 +325,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ProviderSecretResolverPassthroughLayer), + Layer.provideMerge(ModelManifest.layerTest), ); it.live("boots one instance of every shipped driver from a single config map", () => @@ -373,7 +378,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* makeProviderInstanceRegistry({ drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], configMap, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index c36deb6fc2b1..58b3eaab33bc 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -34,6 +34,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -394,6 +395,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te shortDescription: "Debug failing GitHub Actions checks", }, ]); + assert.deepStrictEqual(status.slashCommands, [ + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ]); }), ); @@ -1716,6 +1724,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the @@ -1899,6 +1908,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { @@ -2022,6 +2032,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), @@ -2045,7 +2056,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); it.effect( - "keeps cursor disabled and skips probing when the provider setting is disabled", + "keeps Cursor disabled and skips provider probing when settings use their defaults", () => Effect.gen(function* () { const serverSettings = yield* makeMutableServerSettingsService( @@ -2055,9 +2066,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te codex: { enabled: false, }, - cursor: { - enabled: false, - }, grok: { enabled: false, }, @@ -2085,6 +2093,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( @@ -2772,6 +2781,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "review", description: "Review a pull request", @@ -2815,6 +2828,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "ui", description: "Explore and refine UI", diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 67b4bd9bd37c..bd89dc4f8812 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -9,6 +9,8 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderTurnStartResult, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -197,6 +199,13 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { Effect.succeed({ threadId, turns: [] }), ); + const uploadFeedback = vi.fn( + ( + input: ProviderUploadFeedbackInput, + ): Effect.Effect => + Effect.succeed({ feedbackId: `feedback-${input.threadId}` }), + ); + const stopAll = vi.fn( (): Effect.Effect => Effect.sync(() => { @@ -219,6 +228,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { hasSession, readThread, rollbackThread, + ...(provider === CODEX_DRIVER ? { uploadFeedback } : {}), stopAll, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -254,6 +264,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { hasSession, readThread, rollbackThread, + uploadFeedback, stopAll, }; } @@ -595,6 +606,68 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance const routing = makeProviderServiceLayer(); +it.effect( + "ProviderServiceLive uploads feedback through the adapter that recovered the session", + () => + Effect.gen(function* () { + const original = makeFakeCodexAdapter(); + const replacement = makeFakeCodexAdapter(); + const baseRegistry = makeAdapterRegistryMock({ [CODEX_DRIVER]: original.adapter }); + let swapAfterFirstLookup = false; + let feedbackLookupCount = 0; + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { + ...baseRegistry, + getByInstance: (instanceId) => { + if (instanceId !== codexInstanceId) { + return baseRegistry.getByInstance(instanceId); + } + const useReplacement = swapAfterFirstLookup && feedbackLookupCount++ > 0; + return Effect.succeed(useReplacement ? replacement.adapter : original.adapter); + }, + }; + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-adapter-replacement"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* original.stopSession(threadId); + original.uploadFeedback.mockClear(); + replacement.uploadFeedback.mockClear(); + swapAfterFirstLookup = true; + + const result = yield* provider.uploadFeedback({ threadId }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.strictEqual(original.uploadFeedback.mock.calls.length, 0); + assert.deepStrictEqual(replacement.uploadFeedback.mock.calls, [[{ threadId }]]); + }).pipe(Effect.provide(providerLayer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); @@ -941,6 +1014,93 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes feedback to the Codex adapter and returns its feedback ID", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-route"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + routing.codex.uploadFeedback.mockClear(); + + const result = yield* provider.uploadFeedback({ + threadId, + reason: "The agent stopped early.", + }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.deepStrictEqual(routing.codex.uploadFeedback.mock.calls, [ + [{ threadId, reason: "The agent stopped early." }], + ]); + }), + ); + + it.effect("recovers a stopped Codex session before uploading feedback", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-recover"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/feedback-project", + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(threadId); + routing.codex.startSession.mockClear(); + routing.codex.uploadFeedback.mockClear(); + + const result = yield* provider.uploadFeedback({ threadId }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.strictEqual(routing.codex.startSession.mock.calls.length, 1); + assert.deepStrictEqual(routing.codex.uploadFeedback.mock.calls, [[{ threadId }]]); + }), + ); + + it.effect("rejects feedback for providers that do not support uploads", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-claude"); + yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + + const error = yield* provider.uploadFeedback({ threadId }).pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.include(error.issue, "does not support feedback uploads"); + routing.claude.startSession.mockClear(); + }), + ); + + it.effect("does not restart an unsupported provider before rejecting feedback", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-unsupported-stopped"); + yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* routing.claude.stopSession(threadId); + routing.claude.startSession.mockClear(); + + const error = yield* provider.uploadFeedback({ threadId }).pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.include(error.issue, "does not support feedback uploads"); + assert.strictEqual(routing.claude.startSession.mock.calls.length, 0); + }), + ); + it.effect("appends attachment file paths to the turn input text", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index c8758b3453b8..c21f5306857b 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -19,6 +19,7 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, + ProviderUploadFeedbackInput, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, @@ -1134,6 +1135,47 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); + const uploadFeedback: ProviderServiceMethod<"uploadFeedback"> = Effect.fn("uploadFeedback")( + function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.uploadFeedback", + schema: ProviderUploadFeedbackInput, + payload: rawInput, + }); + let routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.uploadFeedback", + allowRecovery: false, + }); + if (routed.adapter.uploadFeedback === undefined) { + return yield* toValidationError( + "ProviderService.uploadFeedback", + `Provider '${routed.adapter.provider}' does not support feedback uploads.`, + ); + } + if (!routed.isActive) { + routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.uploadFeedback", + allowRecovery: true, + }); + } + const uploadFeedback = routed.adapter.uploadFeedback; + if (uploadFeedback === undefined) { + return yield* toValidationError( + "ProviderService.uploadFeedback", + `Provider '${routed.adapter.provider}' does not support feedback uploads.`, + ); + } + yield* Effect.annotateCurrentSpan({ + "provider.operation": "upload-feedback", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + }); + return yield* uploadFeedback(input); + }, + ); + const runStopAll = Effect.fn("runStopAll")(function* () { const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; @@ -1205,6 +1247,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( getCapabilities, getInstanceInfo, rollbackConversation, + uploadFeedback, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 1281b2f70fe8..0b1bc9e149f7 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -184,6 +184,7 @@ describe("ProviderSessionReaper", () => { }); }, rollbackConversation: () => unsupported(), + uploadFeedback: () => unsupported(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts new file mode 100644 index 000000000000..fdcfa9335424 --- /dev/null +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -0,0 +1,185 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + BUNDLED_MODEL_MANIFEST, + classifyModels, + isLegacyModel, + make, + type ModelManifestData, +} from "./ModelManifest.ts"; + +const CODEX = ProviderDriverKind.make("codex"); +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const CURSOR = ProviderDriverKind.make("cursor"); + +describe("isLegacyModel (bundled manifest)", () => { + it("keeps current Codex models out of legacy models", () => { + assert.deepStrictEqual( + [ + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest", + "gpt-5.4", + ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CODEX, model)]), + [ + ["gpt-5.6-luna", false], + ["gpt-5.6-terra", false], + ["gpt-5.6-sol", false], + ["gpt-daybreak-blue-latest", false], + ["gpt-daybreak-red-latest", false], + ["gpt-5.4", true], + ], + ); + }); + + it("keeps only the Claude 5 family out of legacy models", () => { + assert.deepStrictEqual( + ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ + model, + isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model), + ]), + [ + ["claude-fable-5", false], + ["claude-opus-5", false], + ["claude-sonnet-5", false], + ["claude-opus-4-8", true], + ], + ); + }); + + it("leaves driver kinds without a manifest entry unflagged", () => { + assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5")); + }); +}); + +const model = (overrides: Partial): ServerProviderModel => ({ + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities: null, + ...overrides, +}); + +describe("classifyModels", () => { + it("flags non-current models, clears stale flags, and skips custom models", () => { + const models = [ + model({ slug: "gpt-5.6-sol" }), + // Stale flag from a previous classification pass must be cleared. + model({ slug: "gpt-5.6-luna", isLegacy: true }), + model({ slug: "gpt-5.4" }), + // Custom models are user-defined and never reclassified. + model({ slug: "my-own-model", isCustom: true }), + ]; + assert.deepStrictEqual( + classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [ + entry.slug, + entry.isLegacy ?? false, + ]), + [ + ["gpt-5.6-sol", false], + ["gpt-5.6-luna", false], + ["gpt-5.4", true], + ["my-own-model", false], + ], + ); + }); +}); + +const REMOTE_MANIFEST: ModelManifestData = { + version: 1, + currentModels: { + codex: ["gpt-5.4"], + claudeAgent: ["claude-fable-5"], + }, +}; + +const httpClientLayer = (handler: () => Response) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler()))), + ); + +const serviceLayers = (input: { + readonly prefix: string; + readonly response: () => Response; + readonly settings?: Parameters[0]; +}) => + ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettings.layerTest(input.settings ?? {})), + Layer.provideMerge(httpClientLayer(input.response)), + ); + +describe("ModelManifest service", () => { + it.live("prefers a fetched manifest over the bundle and caches it to disk", () => + Effect.gen(function* () { + const service = yield* make; + const refreshed = yield* service.refresh; + assert.deepStrictEqual(refreshed, REMOTE_MANIFEST); + assert.isTrue(isLegacyModel(refreshed, CODEX, "gpt-5.6-sol")); + assert.isFalse(isLegacyModel(refreshed, CODEX, "gpt-5.4")); + + // A fresh service instance sees the disk cache without another fetch: + // its HTTP layer is still stubbed, but `current` never fetches at all. + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-fetch-test", + response: () => Response.json(REMOTE_MANIFEST), + }), + ), + ), + ); + + it.live("keeps the bundled manifest when the remote payload is malformed", () => + Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-malformed-test", + response: () => Response.json({ version: 999, nonsense: true }), + }), + ), + ), + ); + + it.live("does not fetch when provider update checks are disabled", () => + Effect.gen(function* () { + let fetchCount = 0; + const service = yield* make.pipe( + Effect.provide( + httpClientLayer(() => { + fetchCount += 1; + return Response.json(REMOTE_MANIFEST); + }), + ), + ); + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.strictEqual(fetchCount, 0); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-optout-test", + response: () => Response.json(REMOTE_MANIFEST), + settings: { enableProviderUpdateChecks: false }, + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts new file mode 100644 index 000000000000..cb9494992287 --- /dev/null +++ b/apps/server/src/provider/ModelManifest.ts @@ -0,0 +1,221 @@ +/** + * ModelManifest — decides which provider models are current and which belong + * in the model picker's legacy section. + * + * The classification data (current slugs per driver kind) lives in + * `model-manifest.json` next to this file. The bundled copy ships with every + * release. At runtime the service refreshes it from the same file on `main` + * via raw.githubusercontent.com, so a new model can leave the legacy section + * with a commit to `main` instead of a release. Preference order is remote, + * then the on-disk copy of the last successful fetch, then the bundle. A + * failed fetch never fails a provider check. + * + * Drivers apply the manifest to snapshot drafts with `applyModelManifest` + * before publishing, so every path that produces models (pending, probe, + * error fallbacks) is classified the same way. + */ +import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import bundledManifestJson from "./model-manifest.json" with { type: "json" }; +import type { ServerProviderDraft } from "./providerSnapshot.ts"; + +const MODEL_MANIFEST_URL = + "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; + +/** How long a fetched manifest stays fresh before the next probe re-fetches. */ +const MANIFEST_TTL_MS = 60 * 60 * 1000; + +/** Minimum gap between fetch attempts after a failure, so an offline server + * does not pay a network timeout on every provider check. */ +const MANIFEST_RETRY_MS = 5 * 60 * 1000; + +const FETCH_TIMEOUT_MS = 10_000; + +/** + * `version` gates breaking schema changes: a build only accepts remote + * manifests whose version it understands, and keeps its bundled copy + * otherwise. `currentModels` is keyed by driver kind; kinds absent from the + * map have no legacy concept and their models are left unflagged. + */ +const ModelManifestSchema = Schema.Struct({ + version: Schema.Literal(1), + currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), +}); +export type ModelManifestData = typeof ModelManifestSchema.Type; + +const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); + +export const BUNDLED_MODEL_MANIFEST: ModelManifestData = + Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); + +/** On-disk shape of the last successfully fetched manifest. */ +const ManifestCacheFile = Schema.Struct({ + fetchedAtMs: Schema.Number, + manifest: ModelManifestSchema, +}); +const decodeManifestCache = Schema.decodeUnknownEffect( + Schema.fromJsonString( + ManifestCacheFile as unknown as Schema.Codec, + ), +); +const encodeManifestCache = Schema.encodeEffect( + Schema.fromJsonString( + ManifestCacheFile as unknown as Schema.Codec, + ), +); + +/** True when the manifest classifies `slug` as legacy for `driverKind`. */ +export function isLegacyModel( + manifest: ModelManifestData, + driverKind: ProviderDriverKind, + slug: string, +): boolean { + const currentModels = manifest.currentModels[driverKind]; + if (!currentModels) return false; + return !currentModels.includes(slug); +} + +/** + * Reclassifies every built-in model on a snapshot draft against the manifest. + * Custom models are user-defined and never reclassified. + */ +export function applyModelManifest( + draft: ServerProviderDraft, + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ServerProviderDraft { + return { ...draft, models: classifyModels(draft.models, manifest, driverKind) }; +} + +/** Model-level half of `applyModelManifest`, exported for focused tests. */ +export function classifyModels( + models: ReadonlyArray, + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ReadonlyArray { + return models.map((model) => { + if (model.isCustom) return model; + if (isLegacyModel(manifest, driverKind, model.slug)) { + return model.isLegacy ? model : { ...model, isLegacy: true }; + } + if (!model.isLegacy) return model; + const { isLegacy: _isLegacy, ...rest } = model; + return rest; + }); +} + +export class ModelManifest extends Context.Service< + ModelManifest, + { + /** Manifest already in memory (disk cache or bundle); never fetches. + * Snapshot classification reads this, so it never waits on the network. */ + readonly current: Effect.Effect; + /** Manifest after a TTL-gated remote refresh; never fails. */ + readonly refresh: Effect.Effect; + /** Forks `refresh` into the service's own scope. Drivers call this from + * provider checks: the fetch is process-shared state, so it must survive + * the teardown of whichever instance happened to trigger it. */ + readonly refreshInBackground: Effect.Effect; + } +>()("t3/provider/ModelManifest") {} + +/** Constant service for tests and callers that only need the bundled data. */ +export const BundledOnlyModelManifest: ModelManifest["Service"] = { + current: Effect.succeed(BUNDLED_MODEL_MANIFEST), + refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + refreshInBackground: Effect.void, +}; + +export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + const serviceScope = yield* Effect.scope; + + const cachePath = path.join(config.stateDir, "model-manifest.json"); + let manifest = BUNDLED_MODEL_MANIFEST; + let fetchedAtMs: number | null = null; + let lastAttemptMs: number | null = null; + const refreshSemaphore = yield* Semaphore.make(1); + + // `Effect.cached` makes concurrent first readers await the same disk load + // rather than racing a "loaded" flag. Only `refreshed` takes the fetch + // semaphore; `current` must never wait behind an in-flight network refresh. + const ensureDiskCacheLoaded = yield* Effect.cached( + Effect.gen(function* () { + const fromDisk = yield* fileSystem.readFileString(cachePath).pipe( + Effect.flatMap((raw) => decodeManifestCache(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fromDisk === null) return; + // The disk copy is the last-seen remote manifest, so it outranks the + // bundle even when stale: it is refreshed on the next successful fetch. + manifest = fromDisk.manifest; + fetchedAtMs = fromDisk.fetchedAtMs; + }), + ); + + const refresh = Effect.fn("ModelManifest.refresh")(function* () { + yield* ensureDiskCacheLoaded; + const now = yield* Clock.currentTimeMillis; + // A timestamp in the future means the wall clock moved backwards (the + // disk cache crosses restarts, so monotonic time cannot cover it). Treat + // it as expired: the refetch rewrites both timestamps and self-heals. + const isWithin = (sinceMs: number | null, windowMs: number) => + sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; + if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; + if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; + + // The same switch that gates provider CLI update checks. It stops network + // fetches only: a manifest already cached on disk from an earlier fetch + // stays in effect, since the setting is about phoning home, not about + // discarding data the server already holds. + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings !== null && !settings.enableProviderUpdateChecks) return manifest; + + lastAttemptMs = now; + const fetched = yield* httpClient.get(MODEL_MANIFEST_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap((json) => decodeManifest(json)), + Effect.timeout(FETCH_TIMEOUT_MS), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fetched === null) return manifest; + + manifest = fetched; + fetchedAtMs = now; + yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)), + Effect.catchCause(() => Effect.void), + ); + return manifest; + }); + + const guardedRefresh = refreshSemaphore.withPermits(1)(refresh()); + + return ModelManifest.of({ + current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), + refresh: guardedRefresh, + refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(ModelManifest, make); diff --git a/apps/server/src/provider/Services/CodexAdapter.ts b/apps/server/src/provider/Services/CodexAdapter.ts index 33fe0fa12be0..a0d9c0c28e9e 100644 --- a/apps/server/src/provider/Services/CodexAdapter.ts +++ b/apps/server/src/provider/Services/CodexAdapter.ts @@ -16,4 +16,8 @@ import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; * CodexAdapterShape — per-instance Codex adapter contract. Carries * a branded driver kind as the nominal discriminant. */ -export interface CodexAdapterShape extends ProviderAdapterShape {} +export interface CodexAdapterShape extends ProviderAdapterShape { + readonly uploadFeedback: NonNullable< + ProviderAdapterShape["uploadFeedback"] + >; +} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd7..634745832b37 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -16,6 +16,8 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, ThreadId, ProviderTurnStartResult, TurnId, @@ -114,6 +116,13 @@ export interface ProviderAdapterShape { numTurns: number, ) => Effect.Effect; + /** + * Upload a thread to the provider when the adapter supports feedback. + */ + readonly uploadFeedback?: ( + input: ProviderUploadFeedbackInput, + ) => Effect.Effect; + /** * Stop all sessions owned by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 4d4cb4fa01a7..545641d2e866 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -21,6 +21,8 @@ import type { ProviderSession, ProviderSessionStartInput, ProviderStopSessionInput, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, ThreadId, ProviderTurnStartResult, } from "@t3tools/contracts"; @@ -105,6 +107,13 @@ export interface ProviderServiceShape { readonly numTurns: number; }) => Effect.Effect; + /** + * Upload a thread and return the provider's shareable feedback identifier. + */ + readonly uploadFeedback: ( + input: ProviderUploadFeedbackInput, + ) => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b1ef0d3e5953..93ffc63806f6 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -330,7 +330,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(); @@ -340,13 +340,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 bbe82bf2cb9e..ba8e85215c5b 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -10,6 +10,7 @@ import * as TestClock from "effect/testing/TestClock"; import type * as EffectAcpSchema from "effect-acp/schema"; import { + decideToolCallUpdateEmission, extractModelConfigId, mergeToolCallState, parsePermissionRequest, @@ -17,7 +18,9 @@ import { parseSessionUpdateEvent, sessionUpdateIsReplay, syntheticLoadSessionResponseFromInitialize, + toolCallProgressLength, waitForPromptStreamStall, + type AcpToolCallState, type PromptStreamActivity, } from "./AcpRuntimeModel.ts"; @@ -393,6 +396,468 @@ describe("AcpRuntimeModel", () => { }, }); }); + + it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => { + // Mirrors Grok's ACP CLI resending the ENTIRE accumulated terminal output on every + // tool_call_update notification instead of a delta (see upstream #6556). + const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join( + "\n", + ); + expect(hugeText.length).toBeGreaterThan(60_000); + + const result = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + // Real ACP `tool_call_update` deltas typically omit `title` (already established by + // the initial `tool_call`); that is also the shape that surfaces raw content as detail. + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: hugeText } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(result.events).toHaveLength(1); + const event = result.events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBeDefined(); + const detail = event.toolCall.detail!; + // 8000 chars of tail plus the truncation marker, regardless of input size. + expect(detail.length).toBe(8_028); + expect(detail.startsWith("[Earlier output truncated]")).toBe(true); + expect(detail.endsWith(hugeText.slice(-100))).toBe(true); + + // The raw payload threaded through for logging/persistence must not smuggle the full + // cumulative buffer back in either. + 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(hugeText.length); + }); + + it("coalesces 1000 rapid cumulative tool_call_update notifications for a redrawing progress bar", () => { + let previous: AcpToolCallState | undefined; + let lastEmittedDetailLength: number | undefined; + let skippedSinceEmit = 0; + let emittedCount = 0; + let emittedBytes = 0; + let notificationBytes = 0; + let largestEmittedEventBytes = 0; + let finalDetail: string | undefined; + let cumulativeBuffer = ""; + + for (let i = 0; i < 1_000; i += 1) { + // Grok resends the FULL accumulated buffer, not a delta, on every redraw. + cumulativeBuffer += `frame ${i}: ${"#".repeat(50)}\n`; + const isLast = i === 999; + + const notification = { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: isLast ? "completed" : "in_progress", + content: [{ type: "content", content: { type: "text", text: cumulativeBuffer } }], + }, + } satisfies EffectAcpSchema.SessionNotification; + notificationBytes += JSON.stringify(notification).length; + + const { events } = parseSessionUpdateEvent(notification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + continue; + } + + const merged = mergeToolCallState(previous, event.toolCall); + const decision = decideToolCallUpdateEmission({ + previous, + next: merged, + lastEmittedDetailLength, + skippedSinceEmit, + }); + previous = merged; + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + emittedCount += 1; + const eventBytes = JSON.stringify({ + toolCall: merged, + rawPayload: event.rawPayload, + }).length; + emittedBytes += eventBytes; + largestEmittedEventBytes = Math.max(largestEmittedEventBytes, eventBytes); + lastEmittedDetailLength = merged.detail?.length; + finalDetail = merged.detail; + } + } + + // The flood as the CLI sends it: 1000 cumulative redraws, ~31.6 MB of JSON. + expect(notificationBytes).toBeGreaterThan(31_000_000); + + // 1000 cumulative redraws collapse into a fixed, small number of runtime events... + expect(emittedCount).toBe(114); + // ...each individually bounded, no matter how long the tool call runs... + expect(largestEmittedEventBytes).toBeLessThan(25_000); + // ...so the whole flooding tool call costs ~2.5 MB of runtime events instead of ~31.6 MB. + expect(emittedBytes).toBeLessThan(2_600_000); + // ...while the FINAL state (forced by the completed status) still reflects the real, + // latest output rather than a stale coalesced value. + expect(finalDetail).toBeDefined(); + expect(finalDetail?.endsWith(`frame 999: ${"#".repeat(50)}`)).toBe(true); + }); + + it("keeps non-text tool call content entries in order when bounding oversized text", () => { + const hugePrefix = "x".repeat(25_000); + const hugeTail = "y".repeat(25_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: hugePrefix } }, + { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, + { type: "content", content: { type: "text", text: hugeTail } }, + { type: "diff", path: "/repo/other.ts", oldText: "old", newText: "new" }, + { type: "content", content: { type: "text", text: " " } }, + ], + }, + } 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); + expect(content[0]).toEqual({ + type: "diff", + path: "/repo/file.ts", + oldText: "before", + newText: "after", + }); + const lastEntry = content[1]; + if (lastEntry?.type !== "content" || lastEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(lastEntry.content.text.length).toBeLessThan(8_100); + expect(lastEntry.content.text.endsWith(hugeTail.slice(-100))).toBe(true); + expect(content[2]).toEqual({ + type: "diff", + path: "/repo/other.ts", + oldText: "old", + newText: "new", + }); + }); + + 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"]) => + ({ + toolCallId: "tool-1", + title: "Grok Tool", + ...(status ? { status } : {}), + ...(detail ? { detail } : {}), + 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({ + previous: toolCall("same", "inProgress"), + next: toolCall("same", "completed"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "inProgress"), + next: toolCall("same", "failed"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 3, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("skips updates whose bounded detail did not change", () => { + const previous = toolCall("frame 1", "inProgress"); + expect( + decideToolCallUpdateEmission({ + 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: {} }, + next: { toolCallId: "tool-1", title: "Ran command", detail: "x", data: {} }, + lastEmittedDetailLength: 1, + skippedSinceEmit: 0, + }); + expect(decision).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("coalesces small deltas but forces an emission after the coalesce limit", () => { + let lastEmittedDetailLength: number | undefined = 0; + let skippedSinceEmit = 0; + const emissions: Array = []; + let previous: AcpToolCallState | undefined; + + for (let i = 1; i <= 12; i += 1) { + // Grows by 1 char per update — well under the 256-char growth threshold, so this + // exercises the coalesce-count fallback rather than the growth-based trigger. + const next = toolCall("x".repeat(i), "inProgress"); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + emissions.push(decision.emit); + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + lastEmittedDetailLength = next.detail?.length; + } + previous = next; + } + + // First update always emits (no previous state yet); after that, small per-update + // growth should be coalesced until the coalesce limit forces a periodic emission. + const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); + expect(emittedIndices).toEqual([1, 11]); + }); + + it("retains the latest replacement snapshot when equal-length updates are coalesced", () => { + let previous: AcpToolCallState = toolCall("frame-a", "inProgress"); + const lastEmittedDetailLength = previous.detail?.length; + let skippedSinceEmit = 0; + + for (const detail of ["frame-b", "frame-c"]) { + const next = mergeToolCallState(previous, toolCall(detail, "inProgress")); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + expect(decision.emit).toBe(false); + skippedSinceEmit = decision.skippedSinceEmit; + previous = next; + } + + const completed = mergeToolCallState(previous, toolCall(undefined, "completed")); + expect(completed.detail).toBe("frame-c"); + expect( + decideToolCallUpdateEmission({ + previous, + next: completed, + lastEmittedDetailLength, + skippedSinceEmit, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + }); }); describe("waitForPromptStreamStall", () => { diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 240822be275d..a7d5ae8359dc 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -264,25 +264,166 @@ function extractToolCallCommand(rawInput: unknown, title: string | undefined): s return extractCommandFromTitle(title); } +// Some ACP agents (observed with Grok's CLI) resend the ENTIRE accumulated tool-call +// output on every `tool_call_update` notification instead of a delta, so a redrawing +// terminal progress bar can balloon a single tool call to hundreds of KB per update at +// several updates per second. Cap what we retain/emit to a bounded tail so one busy tool +// call cannot flood runtime event ingestion. We always keep the tail: `tool_call_update` +// deltas routinely omit `kind`, so there is no reliable way to tell a redrawing terminal +// from another tool here, and the end is the useful part of any live-growing output. +const TOOL_CALL_CONTENT_MAX_CHARS = 8_000; +const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n"; + +function boundToolCallOutputText(text: string): string { + if (text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return text; + } + const tail = text.slice(text.length - TOOL_CALL_CONTENT_MAX_CHARS); + return `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${tail}`; +} + +const RAW_OUTPUT_TEXT_FIELDS = ["content", "stdout", "stderr", "output"] as const; + +// `rawOutput` is provider-defined and, for terminal-shaped tools, mirrors the same +// cumulative text-growth problem as `content` (see the comment above). Bound its known +// text-bearing fields the same way so a chatty provider cannot smuggle unbounded output +// through this field instead. +function boundToolCallRawOutput(rawOutput: unknown): unknown { + if (!isRecord(rawOutput)) { + return rawOutput; + } + let changed = false; + const bounded: Record = { ...rawOutput }; + for (const field of RAW_OUTPUT_TEXT_FIELDS) { + const value = rawOutput[field]; + if (typeof value === "string" && value.length > TOOL_CALL_CONTENT_MAX_CHARS) { + bounded[field] = boundToolCallOutputText(value); + changed = true; + } + } + return changed ? bounded : rawOutput; +} + +interface ExtractedToolCallContent { + readonly text: string | undefined; + readonly content: ReadonlyArray | undefined; +} + +function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | undefined { + if (entry.type !== "content" || entry.content.type !== "text") { + return undefined; + } + 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, -): string | undefined { - if (!content) return undefined; +): ExtractedToolCallContent { + if (!content) { + return { text: undefined, content: undefined }; + } const chunks: Array = []; for (const entry of content) { - if (entry.type !== "content") { - continue; + const text = toolCallContentText(entry)?.trim(); + if (text) { + chunks.push(text); } - const nestedContent = entry.content; - if (nestedContent.type !== "text") { + } + if (chunks.length === 0) { + return { text: undefined, content: boundToolCallContentEntries(content) }; + } + const joined = chunks.join("\n"); + if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return { text: joined, content: boundToolCallContentEntries(content) }; + } + const bounded = boundToolCallOutputText(joined); + 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; } - const text = nestedContent.text.trim(); - if (text.length > 0) { - chunks.push(text); + if (seenText) { + offset += 1; } + seenText = true; + const start = offset; + const end = offset + text.length; + textRanges[index] = { start, end, text }; + offset = end; } - return chunks.length > 0 ? chunks.join("\n") : undefined; + const tailStart = Math.max(0, offset - tail.length); + let markerPending = true; + return content.flatMap((entry, index) => { + if (toolCallContentText(entry) === undefined) { + return [entry]; + } + 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 []; + } + 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]; + }); } function normalizeToolKind(kind: unknown): string | undefined { @@ -326,7 +467,8 @@ function makeToolCallState( } const title = input.title?.trim() || undefined; const command = extractToolCallCommand(input.rawInput, title); - const textContent = extractTextContentFromToolCallContent(input.content); + const extractedContent = extractTextContentFromToolCallContent(input.content); + const textContent = extractedContent.text; const normalizedTitle = title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" ? title @@ -343,10 +485,10 @@ function makeToolCallState( data.rawInput = input.rawInput; } if (input.rawOutput !== undefined) { - data.rawOutput = input.rawOutput; + data.rawOutput = boundToolCallRawOutput(input.rawOutput); } if (input.content !== undefined) { - data.content = input.content; + data.content = extractedContent.content ?? input.content; } if (input.locations !== undefined) { data.locations = input.locations; @@ -424,6 +566,86 @@ export function mergeToolCallState( }; } +// Even with bounded content (see TOOL_CALL_CONTENT_MAX_CHARS above), a redrawing terminal +// can still shift its bounded tail window on nearly every notification, which would emit +// a runtime event per redraw. Coalesce those: only emit early when the tool call's detail +// has grown meaningfully since the last emission, otherwise batch up to a small number of +// skipped updates before emitting anyway, so the UI still gets periodic progress and the +// final (completed/failed) state is always emitted immediately. +const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256; +const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10; + +export interface AcpToolCallEmitDecisionInput { + readonly previous: AcpToolCallState | undefined; + readonly next: AcpToolCallState; + readonly lastEmittedDetailLength: number | undefined; + readonly skippedSinceEmit: number; +} + +export interface AcpToolCallEmitDecision { + readonly emit: boolean; + 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 { + const { previous, next, lastEmittedDetailLength, skippedSinceEmit } = input; + if (next.status === "completed" || next.status === "failed") { + return { emit: true, skippedSinceEmit: 0 }; + } + if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { + return { emit: true, skippedSinceEmit: 0 }; + } + if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { + return { emit: false, skippedSinceEmit }; + } + const progressLength = toolCallProgressLength(next); + const grewMeaningfully = + lastEmittedDetailLength === undefined || + 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 }; + } + return { emit: false, skippedSinceEmit: skippedSinceEmit + 1 }; +} + export function parsePermissionRequest( params: EffectAcpSchema.RequestPermissionRequest, ): AcpPermissionRequest { @@ -544,6 +766,33 @@ export function syntheticLoadSessionResponseFromInitialize( }; } +// The parsed AcpToolCallState already carries bounded content (see makeToolCallState / +// extractTextContentFromToolCallContent above), but the raw JSON-RPC notification is also +// threaded through as `rawPayload` for logging/debugging and ends up persisted on the +// runtime event. Substitute the same bounded `content`/`rawOutput` there so an oversized +// cumulative update cannot smuggle the unbounded buffer back in through the raw payload. +function boundToolCallRawPayload( + params: EffectAcpSchema.SessionNotification, + update: AcpToolCallUpdate, + toolCall: AcpToolCallState, +): unknown { + const boundedContent = toolCall.data.content; + const boundedRawOutput = toolCall.data.rawOutput; + const contentBounded = update.content !== undefined && boundedContent !== update.content; + const rawOutputBounded = update.rawOutput !== undefined && boundedRawOutput !== update.rawOutput; + if (!contentBounded && !rawOutputBounded) { + return params; + } + return { + ...params, + update: { + ...update, + ...(contentBounded ? { content: boundedContent } : {}), + ...(rawOutputBounded ? { rawOutput: boundedRawOutput } : {}), + }, + }; +} + export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { readonly modeId?: string; readonly events: ReadonlyArray; @@ -587,7 +836,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ToolCallUpdated", toolCall, - rawPayload: params, + rawPayload: boundToolCallRawPayload(params, upd, toolCall), }); } break; @@ -598,7 +847,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ToolCallUpdated", toolCall, - rawPayload: params, + rawPayload: boundToolCallRawPayload(params, upd, toolCall), }); } break; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index f70183caa630..b07af7c5064a 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -24,9 +24,11 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectSessionConfigOptionValues, + decideToolCallUpdateEmission, extractModelConfigId, findSessionConfigOption, mergeToolCallState, + toolCallProgressLength, parseSessionModeState, parseSessionUpdateEvent, sessionUpdateIsReplay, @@ -39,6 +41,12 @@ import { type AcpToolCallState, } from "./AcpRuntimeModel.ts"; +interface AcpToolCallTrackedState { + readonly state: AcpToolCallState; + readonly lastEmittedDetailLength: number | undefined; + readonly skippedSinceEmit: number; +} + function formatConfigOptionValue(value: string | boolean): string { return JSON.stringify(value); } @@ -236,6 +244,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. @@ -289,7 +298,7 @@ export const make = ( const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); - const toolCallsRef = yield* Ref.make(new Map()); + const toolCallsRef = yield* Ref.make(new Map()); const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => @@ -901,12 +910,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", @@ -963,7 +973,7 @@ const handleSessionUpdate = ({ }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; - readonly toolCallsRef: Ref.Ref>; + readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; @@ -981,18 +991,31 @@ const handleSessionUpdate = ({ queue, assistantSegmentRef, }); - const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => { - const previous = current.get(event.toolCall.toolCallId); + const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => { + const tracked = current.get(event.toolCall.toolCallId); + const previous = tracked?.state; const nextToolCall = mergeToolCallState(previous, event.toolCall); + const decision = decideToolCallUpdateEmission({ + previous, + next: nextToolCall, + lastEmittedDetailLength: tracked?.lastEmittedDetailLength, + skippedSinceEmit: tracked?.skippedSinceEmit ?? 0, + }); const next = new Map(current); if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { next.delete(nextToolCall.toolCallId); } else { - next.set(nextToolCall.toolCallId, nextToolCall); + next.set(nextToolCall.toolCallId, { + state: nextToolCall, + lastEmittedDetailLength: decision.emit + ? toolCallProgressLength(nextToolCall) + : tracked?.lastEmittedDetailLength, + skippedSinceEmit: decision.skippedSinceEmit, + }); } - return [{ previous, merged: nextToolCall }, next] as const; + return [{ merged: nextToolCall, decision }, next] as const; }); - if (!shouldEmitToolCallUpdate(previous, merged)) { + if (!decision.emit) { continue; } yield* Queue.offer(queue, { @@ -1038,19 +1061,6 @@ function updateModeState(modeState: AcpSessionModeState, nextModeId: string): Ac : modeState; } -function shouldEmitToolCallUpdate( - previous: AcpToolCallState | undefined, - next: AcpToolCallState, -): boolean { - if (next.status === "completed" || next.status === "failed") { - return true; - } - if (!next.detail) { - return false; - } - return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; -} - const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; diff --git a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts index 6897aa4805ab..e45c384fc812 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"; @@ -79,4 +84,60 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () = yield* runtime.setSessionModel(currentModelId); }).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 0b70df83e063..b867b9982b03 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -6,8 +6,10 @@ import * as EffectAcpErrors from "effect-acp/errors"; import { applyGrokAcpModelSelection, buildGrokAcpSpawnInput, + grokAcpSpawnArgs, grokAuthFailureFromAcpCause, grokAuthFromAcpAuthenticate, + isValidGrokReasoningEffortToken, resolveGrokAcpBaseModelId, } from "./GrokAcpSupport.ts"; @@ -19,6 +21,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", { @@ -36,6 +67,26 @@ 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("grokAuthFromAcpAuthenticate", () => { @@ -87,11 +138,14 @@ describe("grokAuthFailureFromAcpCause", () => { 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 {}; }), @@ -108,11 +162,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 d5e23bf61305..27c4801de698 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,4 +1,9 @@ -import { type GrokSettings, ProviderDriverKind, type ServerProviderAuth } from "@t3tools/contracts"; +import { + type GrokSettings, + ProviderDriverKind, + type RuntimeMode, + type ServerProviderAuth, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -32,16 +37,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, @@ -118,7 +140,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), }).pipe( Layer.provide( @@ -138,6 +165,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 @@ -147,18 +185,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 c435269fd76d..28f5f29f4987 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -9,11 +10,17 @@ import * as Schema from "effect/Schema"; import { describe, expect } from "vite-plus/test"; import { + extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, + extractXAiExitPlanMarkdown, + isGrokPlanMarkdownPath, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeCapturedResponse, makeXAiPromptCompletionRuntime, + XAI_EMPTY_PLAN_MARKDOWN, XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -299,6 +306,27 @@ describe("XAiAcpExtension", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + 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("ignores stale xAI completion from an already settled prompt", () => Effect.gen(function* () { const runtime = yield* makePromptCompletionRuntime({ @@ -329,4 +357,170 @@ describe("XAiAcpExtension", () => { }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + 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 d36a5fcfc895..543edb39bb6d 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,8 +1,11 @@ +import * as NodeOS from "node:os"; + import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -19,11 +22,15 @@ type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; interface PendingXAiPromptCompletion { readonly sessionId: string; readonly promptId: string; - readonly deferred: Deferred.Deferred; + readonly deferred: Deferred.Deferred< + EffectAcpSchema.PromptResponse, + EffectAcpErrors.AcpRequestError + >; } const completedXAiPromptIdLimit = 128; const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; +const xAiRateLimitedErrorCode = -32003; const XAiAskUserQuestionOption = Schema.Struct({ label: Schema.String, @@ -196,6 +203,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); +} + /** * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime. * The underlying runtime remains unaware of xAI methods and metadata. @@ -278,7 +497,7 @@ const registerXAiPromptCompletionFallback = ( sessionId: string, promptId: string, ) => - Deferred.make().pipe( + Deferred.make().pipe( Effect.tap((deferred) => Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), ), @@ -287,7 +506,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 = ( @@ -358,13 +577,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/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json new file mode 100644 index 000000000000..7022ce226170 --- /dev/null +++ b/apps/server/src/provider/model-manifest.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "currentModels": { + "codex": [ + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest" + ], + "claudeAgent": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"] + } +} diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 8d5ba353389d..f02bf997c5d1 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -258,7 +258,7 @@ describe("parseAgentListCliOutput", () => { }); describe("parseSkillsCliOutput", () => { - it("parses skill metadata from the CLI JSON output", () => { + it("parses only skill metadata from the CLI JSON output", () => { const result = parseSkillsCliOutput( JSON.stringify([ { @@ -275,7 +275,6 @@ describe("parseSkillsCliOutput", () => { name: "review-pr", description: "Review a pull request.", location: "/tmp/review-pr/SKILL.md", - content: "---\nname: review-pr\n---\n", }, ]); }); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 8b22a52a2060..7db63745eafe 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -4,13 +4,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { OpencodeClient } from "@opencode-ai/sdk/v2"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); -it.layer(testLayer)("loadOpenCodeInventory", (it) => { +it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { it.effect("keeps provider inventory when skill discovery fails", () => Effect.gen(function* () { const runtime = yield* OpenCodeRuntime; @@ -38,4 +45,121 @@ it.layer(testLayer)("loadOpenCodeInventory", (it) => { NodeAssert.deepEqual(inventory.skills, []); }), ); + + it.effect("keeps only SDK skill metadata in inventory", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const client = { + provider: { + list: () => + Promise.resolve({ + data: { + connected: ["openai"], + all: [], + default: {}, + }, + }), + }, + app: { + agents: () => Promise.resolve({ data: [] }), + skills: () => + Promise.resolve({ + data: [ + { + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + content: "unused skill content", + }, + ], + }), + }, + } as unknown as OpencodeClient; + + const inventory = yield* runtime.loadOpenCodeInventory(client); + + NodeAssert.deepEqual(inventory.skills, [ + { + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + }, + ]); + }), + ); + + it.effect("drops oversized CLI skill output without losing the model inventory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostEnvironment = yield* HostProcessEnvironment; + const executablePath = yield* HostProcessExecutablePath; + const hostPlatform = yield* HostProcessPlatform; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" }); + const isWindows = hostPlatform === "win32"; + const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); + const scriptPath = path.join(tempDir, "opencode.mjs"); + const oversizedContentBytes = 8 * 1024 * 1024 + 1; + + yield* fs.writeFileString( + scriptPath, + [ + 'if (process.argv[2] === "models") {', + ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);', + '} else if (process.argv[2] === "debug") {', + ` const content = "x".repeat(${oversizedContentBytes});`, + ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);', + "}", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + binaryPath, + [ + ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), + isWindows + ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' + : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', + "", + ].join("\n"), + ); + if (!isWindows) { + yield* fs.chmod(binaryPath, 0o755); + } + + const runtime = yield* OpenCodeRuntime; + const inventory = yield* runtime.loadInventoryFromCli({ + binaryPath, + cwd: tempDir, + environment: { + ...hostEnvironment, + T3_TEST_NODE_BINARY: executablePath, + T3_TEST_OPENCODE_SCRIPT: scriptPath, + }, + }); + + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + NodeAssert.equal(inventory.skills.length, 0); + }), + ); + + it.effect("caps and drains command stdout and stderr when requested", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const executablePath = yield* HostProcessExecutablePath; + const outputBytes = 2 * 1024 * 1024; + const result = yield* runtime.runOpenCodeCommand({ + binaryPath: executablePath, + args: [ + "-e", + `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`, + ], + maxOutputBytes: 64, + }); + + NodeAssert.equal(result.stdout, "o".repeat(64)); + NodeAssert.equal(result.stderr, "e".repeat(64)); + NodeAssert.equal(result.code, 0); + }), + ); }); diff --git a/apps/server/src/provider/opencodeRuntime.permissions.test.ts b/apps/server/src/provider/opencodeRuntime.permissions.test.ts new file mode 100644 index 000000000000..ad95e38d1495 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.permissions.test.ts @@ -0,0 +1,44 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { buildOpenCodePermissionRules } from "./opencodeRuntime.ts"; + +function actionFor( + runtimeMode: Parameters[0], + permission: string, +) { + return buildOpenCodePermissionRules(runtimeMode).find((rule) => rule.permission === permission) + ?.action; +} + +describe("buildOpenCodePermissionRules", () => { + it("pre-approves edits once the user has chosen to auto-accept them", () => { + NodeAssert.equal(actionFor("auto-accept-edits", "edit"), "allow"); + }); + + it("still asks before editing when approval is required", () => { + NodeAssert.equal(actionFor("approval-required", "edit"), "ask"); + }); + + // Documented in docs/user/permission-modes.md: providers without an AI + // reviewer, OpenCode among them, fall back to Supervised for "auto". + it("leaves auto asking, as the docs say it does without a reviewer", () => { + NodeAssert.equal(actionFor("auto", "edit"), "ask"); + }); + + it("keeps asking for everything else in the auto modes", () => { + for (const runtimeMode of ["auto-accept-edits", "auto"] as const) { + NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "*"), "ask"); + } + }); + + it("allows everything only under full access", () => { + NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [ + { permission: "*", pattern: "*", action: "allow" }, + ]); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 2ff4fa1292f2..80329a6794d5 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -51,6 +51,7 @@ export function resolveOpenCodeConfigContent( const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; +const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; export interface OpenCodeServerProcess { readonly url: string; readonly exitCode: Effect.Effect; @@ -124,14 +125,12 @@ export interface OpenCodeSkill { readonly name?: string | null; readonly description?: string | null; readonly location?: string | null; - readonly content?: string | null; } const OpenCodeSkillSchema = Schema.Struct({ name: Schema.optionalKey(Schema.NullOr(Schema.String)), description: Schema.optionalKey(Schema.NullOr(Schema.String)), location: Schema.optionalKey(Schema.NullOr(Schema.String)), - content: Schema.optionalKey(Schema.NullOr(Schema.String)), }); const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), @@ -169,6 +168,7 @@ export interface OpenCodeRuntimeShape { readonly args: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; readonly cwd?: string; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly createOpenCodeSdkClient: (input: { readonly baseUrl: string; @@ -373,10 +373,16 @@ export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): Permissi return [{ permission: "*", pattern: "*", action: "allow" }]; } + // "Auto-accept edits" is documented as "auto-approve edits, ask before other + // actions", so prompting for every edit ignores the mode the user picked. + // "auto" is left asking on purpose: the docs say providers without an AI + // reviewer, OpenCode among them, fall back to Supervised for that mode. + const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; + return [ { permission: "*", pattern: "*", action: "ask" }, { permission: "bash", pattern: "*", action: "ask" }, - { permission: "edit", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: editAction }, { permission: "webfetch", pattern: "*", action: "ask" }, { permission: "websearch", pattern: "*", action: "ask" }, { permission: "codesearch", pattern: "*", action: "ask" }, @@ -447,8 +453,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); + const collectOptions = + input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; const [stdout, stderr, code] = yield* Effect.all( - [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode], + [ + collectStreamAsString(child.stdout, collectOptions), + collectStreamAsString(child.stderr, collectOptions), + child.exitCode, + ], { concurrency: "unbounded" }, ); const exitCode = Number(code); @@ -702,7 +714,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const loadSkills = (client: OpencodeClient) => runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( - Effect.map((result) => (result.data ?? []) as ReadonlyArray), + Effect.map((result) => + (result.data ?? []).map((skill) => ({ + name: skill.name, + ...(skill.description === undefined ? {} : { description: skill.description }), + location: skill.location, + })), + ), Effect.orElseSucceed((): ReadonlyArray => []), ); @@ -732,6 +750,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["debug", "skill"], + maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, ...commandContext, }).pipe(Effect.exit); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 03b4cf4a3b6e..adbe110d9408 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -255,5 +255,6 @@ export function buildServerProvider(input: { export const collectStreamAsString = ( stream: Stream.Stream, + options?: { readonly maxBytes?: number | undefined }, ): Effect.Effect => - collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text)); + collectUint8StreamText({ stream, ...options }).pipe(Effect.map((collected) => collected.text)); diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index f06e984c9aa5..d3bdee367125 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -17,6 +17,7 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); let turnStartCount = 0; +let activeTurn; const rl = NodeReadline.createInterface({ input: process.stdin }); rl.on("line", (line) => { @@ -27,6 +28,23 @@ rl.on("line", (line) => { return; } const { id, method } = message; + if (method === undefined && script.serverRequests?.some((request) => request.id === id)) { + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.responses`, + `${JSON.stringify({ id, result: message.result, error: message.error })}\n`, + ); + if (script.completeTurnOnServerResponse && activeTurn) { + write({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + threadId: script.rootThreadId, + turn: { ...activeTurn, status: "completed" }, + }, + }); + } + return; + } if (method === "initialize") { write({ id, @@ -48,6 +66,7 @@ rl.on("line", (line) => { const turn = turnId ? { ...fixture.responses.turnStart.turn, id: turnId } : fixture.responses.turnStart.turn; + activeTurn = turn; turnStartCount += 1; write({ id, result: { ...fixture.responses.turnStart, turn } }); const rootThreadId = script.rootThreadId; @@ -61,6 +80,9 @@ rl.on("line", (line) => { for (const notification of script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } + for (const request of script.serverRequests ?? []) { + write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params }); + } if (script.holdTurnOpen !== true) { write({ jsonrpc: "2.0", diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index f711e0417fd0..8d2b16f226af 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import type { OrchestrationProjectShell, ProjectId, @@ -2421,6 +2422,42 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o }), ); +it.effect("returns the refreshed listing on the first read after its cache expires", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ + items: [changeRequest(hostCalls, "2026-07-02T00:00:00Z")], + truncated: false, + continues: false, + }); + }, + }), + ], + }); + + const first = yield* service.list({ state: "open" }); + assert.deepStrictEqual( + first.entries.map((entry) => entry.number), + [1], + ); + + yield* TestClock.adjust("31 seconds"); + const refreshed = yield* service.list({ state: "open" }); + + assert.strictEqual(hostCalls, 2); + assert.deepStrictEqual( + refreshed.entries.map((entry) => entry.number), + [2], + ); + }), +); + it.effect("a listing narrowed to some projects is its own cache entry", () => Effect.gen(function* () { const asked: ReadonlyArray[] = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index c3e3e9938891..b3d51becfcbe 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -110,16 +110,7 @@ const LIST_STATS_CACHE_TTL = Duration.seconds(60); * all only so opening a change request on two devices costs one read. */ const FILES_VIEWED_CACHE_TTL = Duration.seconds(15); -/** - * How long a cache's last success may still be served while a fresh read runs behind it. - * Bounded by how the page actually revalidates: clients re-read on mount and once a minute - * while open, and every one of those reads repopulates the cache in the background — so in - * steady use a "stale" answer is at most a refresh cycle old, and the window only stretches - * that far when nobody has looked at the page for minutes. An explicit refresh or a mutation - * bumps the epochs and skips held answers entirely. - */ -const LIST_STALE_WINDOW = Duration.minutes(10); -const DETAIL_STALE_WINDOW = Duration.minutes(5); +/** A diff can stay interactive while its next cached value is fetched off the critical path. */ const DIFF_STALE_WINDOW = Duration.minutes(10); /** How long one host's signed-in login is believed without asking its CLI again. */ const VIEWER_CACHE_TTL = Duration.minutes(10); @@ -1936,30 +1927,22 @@ export const make = Effect.gen(function* () { const runFork = Effect.runForkWith(context); /** - * Stale answers served while a fresh one is fetched behind them. Every read here leaves the - * process for a CLI whose wall clock is the host's — seconds on a good day, tens of them on a - * slow network — and the short cache windows below mean almost every page visit pays that - * clock again. The last success per key is therefore held a while longer: a read inside the - * window answers with it at once and refreshes the cache in the background, so the next read - * is fresh without anyone having waited on it. - * - * Correctness leans on the epochs: an explicit refresh or a mutation bumps them, the epoch is - * part of every key, and a held answer under the old key is simply never asked for again — so - * "give me truly fresh" still means exactly that. + * The diff is not live-polled and is expensive enough to keep its stale-while-revalidate path. + * Explicit refreshes and mutations still strand held values through the reference epoch. */ - const staleWhileRevalidate = (staleFor: Duration.Duration, capacity: number) => { - const staleMs = Duration.toMillis(staleFor); - const held = new Map(); - const record = (key: string, value: A) => + const staleDiff = (() => { + const staleMs = Duration.toMillis(DIFF_STALE_WINDOW); + const held = new Map(); + const record = (key: string, value: PullRequestDiffResult) => Effect.map(Clock.currentTimeMillis, (at) => { held.delete(key); - if (held.size >= capacity) { + if (held.size >= DIFF_CACHE_CAPACITY) { const oldest = held.keys().next().value; if (oldest !== undefined) held.delete(oldest); } held.set(key, { at, value }); }); - return (key: string, read: Effect.Effect): Effect.Effect => { + return (key: string, read: Effect.Effect) => { const recorded = read.pipe(Effect.tap((value) => record(key, value))); return Effect.flatMap(Clock.currentTimeMillis, (now) => { const snapshot = held.get(key); @@ -1970,7 +1953,7 @@ export const make = Effect.gen(function* () { return Effect.sync(() => runFork(Effect.ignore(recorded))).pipe(Effect.as(snapshot.value)); }); }; - }; + })(); // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the // epoch strands every entry made under the old one — no enumerating a cache whose keys @@ -2062,10 +2045,6 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_CACHE_TTL : Duration.zero), }, ); - const staleList = staleWhileRevalidate( - LIST_STALE_WINDOW, - LIST_CACHE_CAPACITY, - ); const list: PullRequestService["Service"]["list"] = (input) => { const key = JSON.stringify([ listingsEpoch, @@ -2092,7 +2071,7 @@ export const make = Effect.gen(function* () { ? null : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)), ]); - return staleList(key, Cache.get(listCache, key)); + return Cache.get(listCache, key); }; const detailCache = yield* Cache.makeWith( @@ -2105,13 +2084,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); - const staleDetail = staleWhileRevalidate( - DETAIL_STALE_WINDOW, - DETAIL_CACHE_CAPACITY, - ); const detail: PullRequestService["Service"]["detail"] = (input) => { const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); - return staleDetail(key, Cache.get(detailCache, key)); + return Cache.get(detailCache, key); }; const activityCache = yield* Cache.makeWith( @@ -2124,13 +2099,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); - const staleActivity = staleWhileRevalidate( - DETAIL_STALE_WINDOW, - DETAIL_CACHE_CAPACITY, - ); const activity: PullRequestService["Service"]["activity"] = (input) => { const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); - return staleActivity(key, Cache.get(activityCache, key)); + return Cache.get(activityCache, key); }; const diffCache = yield* Cache.makeWith( @@ -2160,10 +2131,6 @@ export const make = Effect.gen(function* () { }, }, ); - const staleDiff = staleWhileRevalidate( - DIFF_STALE_WINDOW, - DIFF_CACHE_CAPACITY, - ); const diff: PullRequestService["Service"]["diff"] = (input) => { const key = JSON.stringify([ refEpoch(input), @@ -2220,10 +2187,6 @@ export const make = Effect.gen(function* () { // shares between clients like every other read. Refs are sorted so one page's worth of rows // is one key however the client assembled them, and the listings epoch rides along so the // refresh that forgets the listing forgets its decorations with it. - const staleListStats = staleWhileRevalidate( - LIST_STALE_WINDOW, - LIST_STATS_CACHE_CAPACITY, - ); const listStats: PullRequestService["Service"]["listStats"] = (input) => { if (input.refs.length === 0) return Effect.succeed({ stats: [] }); const key = JSON.stringify([ @@ -2234,7 +2197,7 @@ export const make = Effect.gen(function* () { `${left[0]} ${left[1]} ${left[2]}`.localeCompare(`${right[0]} ${right[1]} ${right[2]}`), ), ]); - return staleListStats(key, Cache.get(listStatsCache, key)); + return Cache.get(listStatsCache, key); }; const invalidate: PullRequestService["Service"]["invalidate"] = (input) => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 40e96f595ff4..1d064083576c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -102,7 +102,11 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import { makeRoutesLayerWith } from "./server.ts"; -import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; +import { + isThreadDetailEvent, + resolveAvailableEditorsForConfig, + resolveFileManagerRevealKindForConfig, +} from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; @@ -117,6 +121,8 @@ import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; +import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -391,6 +397,7 @@ const buildAppUnderTest = (options?: { layers?: { keybindings?: Partial; providerRegistry?: Partial; + providerService?: Partial; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -711,18 +718,24 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), - refresh: () => Effect.succeed([]), - refreshInstance: () => Effect.succeed([]), - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), - ), - setProviderMaintenanceActionState: () => Effect.succeed([]), - streamChanges: Stream.empty, - ...options?.layers?.providerRegistry, - }), + Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + refresh: () => Effect.succeed([]), + refreshInstance: () => Effect.succeed([]), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + ), + setProviderMaintenanceActionState: () => Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.providerRegistry, + }), + Layer.mock(ProviderService.ProviderService)({ + uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"), + ...options?.layers?.providerService, + }), + ), ), Layer.provide( Layer.mock(ServerSettings.ServerSettingsService)({ @@ -738,6 +751,7 @@ const buildAppUnderTest = (options?: { Layer.mergeAll( Layer.mock(ExternalLauncher.ExternalLauncher)({ resolveAvailableEditors: () => Effect.succeed([]), + resolveFileManagerRevealKind: () => Effect.sync((): undefined => undefined), ...options?.layers?.externalLauncher, }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ @@ -4099,10 +4113,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); assert.equal(response.auth.policy, "desktop-managed-local"); assert.equal(response.shellResumeCompletionMarker, true); + assert.isUndefined(response.shellRevealInFileManager); + assert.isUndefined(response.shellRevealInFileManagerKind); assert.equal(response.threadResumeCompletionMarker, true); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("advertises the usable file manager and its reveal label", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + externalLauncher: { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), + }, + }, + }); + + const { cookie } = yield* bootstrapBrowserSession(); + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookie?.split(";")[0] ?? "", + ); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.deepEqual(response.availableEditors, ["file-manager"]); + assert.equal(response.shellRevealInFileManager, true); + assert.equal(response.shellRevealInFileManagerKind, "file-explorer"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not block server config when editor discovery never resolves", () => Effect.gen(function* () { const discoveryInterrupted = yield* Deferred.make(); @@ -4120,6 +4162,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); + it.effect("does not block server config when file manager reveal discovery never resolves", () => + Effect.gen(function* () { + const discoveryInterrupted = yield* Deferred.make(); + const responseFiber = yield* resolveFileManagerRevealKindForConfig( + Effect.never.pipe( + Effect.onInterrupt(() => Deferred.succeed(discoveryInterrupted, undefined)), + ), + ).pipe(Effect.forkChild); + + yield* TestClock.adjust(Duration.seconds(5)); + + const revealKind = yield* Fiber.join(responseFiber); + yield* Deferred.await(discoveryInterrupted); + assert.isUndefined(revealKind); + }), + ); + it.effect( "rejects websocket rpc handshake when a session token is only provided via query string", () => @@ -4529,6 +4588,114 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("uploads Codex thread feedback through websocket rpc", () => + Effect.gen(function* () { + const input = { + threadId: ThreadId.make("thread-feedback"), + reason: "The agent stopped early.", + }; + const uploadFeedback = vi.fn( + () => Effect.succeed({ feedbackId: "codex-thread-feedback" }), + ); + yield* buildAppUnderTest({ + layers: { + providerService: { uploadFeedback }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.providerUploadFeedback](input)), + ); + + assert.deepStrictEqual(response, { feedbackId: "codex-thread-feedback" }); + assert.deepStrictEqual(uploadFeedback.mock.calls, [[input]]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("uploads image bytes through a signed URL issued by websocket rpc", () => + Effect.gen(function* () { + const config = yield* buildAppUnderTest(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const wsUrl = yield* getWsServerUrl("/ws"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const issued = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }); + const rejected = yield* HttpClient.post(issued.relativeUrl, { + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3]), "image/png"), + }); + assert.equal(rejected.status, 400); + + const response = yield* HttpClient.post(issued.relativeUrl, { + headers: { origin: crossOriginClientOrigin }, + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6]), "image/png"), + }); + assert.equal(response.status, 204); + assertBrowserApiCorsResponseHeaders(response.headers); + + const attachmentPath = path.join(config.attachmentsDir, `${issued.attachmentId}.png`); + assert.isTrue(yield* fileSystem.exists(attachmentPath)); + + yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: issued.attachmentId }); + assert.isFalse(yield* fileSystem.exists(attachmentPath)); + + const streamed = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + name: "streamed.png", + mimeType: "image/png", + sizeBytes: 6, + }); + const streamedResponse = yield* HttpClient.post(streamed.relativeUrl, { + body: HttpBody.stream(Stream.make(new Uint8Array([1, 2, 3, 4, 5, 6])), "image/png"), + }); + assert.equal(streamedResponse.status, 204); + yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: streamed.attachmentId }); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("keeps feedback errors structured across websocket rpc", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-feedback-failure"); + yield* buildAppUnderTest({ + layers: { + providerService: { + uploadFeedback: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "feedback/upload", + detail: "private provider detail", + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const error = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.providerUploadFeedback]({ threadId }).pipe(Effect.flip), + ), + ); + + assert.strictEqual(error._tag, "ProviderUploadFeedbackError"); + if (error._tag === "ProviderUploadFeedbackError") { + assert.strictEqual(error.threadId, threadId); + assert.strictEqual(error.message, `Failed to upload feedback for thread ${threadId}.`); + assert.isDefined(error.cause); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("shares one preview automation broker across websocket sessions", () => Effect.scoped( Effect.gen(function* () { @@ -4606,6 +4773,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { + const path = yield* Path.Path; const providers = [ { instanceId: ProviderInstanceId.make("codex"), @@ -4659,7 +4827,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(first.config.keybindings, []); assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); - assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true); + assert.equal(path.basename(first.config.observability.logsDirectoryPath), "logs"); assert.equal(first.config.observability.localTracingEnabled, true); assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); assert.equal(first.config.observability.otlpTracesEnabled, true); @@ -5169,7 +5337,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createdAt: "2026-01-01T00:00:00.000Z", }) as const; - const wsUrl = yield* getWsServerUrl("/ws?clientSurface=mobile&clientAppVersion=1.2.3"); + const wsUrl = yield* getWsServerUrl( + "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro", + ); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => Effect.gen(function* () { @@ -5202,7 +5372,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "analytics:client.thread.started", ]); assert.deepEqual(analyticsProperties, [ - { surface: "mobile", appVersion: "1.2.3" }, + { + surface: "mobile", + appVersion: "1.2.3", + os: "iOS", + osMajorVersion: 18, + deviceModel: "iPhone 15 Pro", + }, { surface: "mobile", appVersion: "1.2.3" }, ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -8002,12 +8178,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("cleans up created bootstrap threads when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const createWorktree = vi.fn( (_: Parameters[0]) => Effect.die(new Error("worktree exploded")), ); - yield* buildAppUnderTest({ + const config = yield* buildAppUnderTest({ layers: { gitVcsDriver: { createWorktree, @@ -8025,40 +8203,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const createdAt = "2026-01-01T00:00:00.000Z"; const wsUrl = yield* getWsServerUrl("/ws"); + let pendingAttachmentId: string | undefined; const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-bootstrap-turn-start-defect"), - threadId: ThreadId.make("thread-bootstrap-defect"), - message: { - messageId: MessageId.make("msg-bootstrap-defect"), - role: "user", - text: "hello", - attachments: [], - }, - modelSelection: defaultModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - bootstrap: { - createThread: { - projectId: defaultProjectId, - title: "Bootstrap Thread", - modelSelection: defaultModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - createdAt, + Effect.gen(function* () { + const upload = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }); + pendingAttachmentId = upload.attachmentId; + const uploadResponse = yield* HttpClient.post(upload.relativeUrl, { + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6]), "image/png"), + }); + assert.equal(uploadResponse.status, 204); + + return yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-defect"), + threadId: ThreadId.make("thread-bootstrap-defect"), + message: { + messageId: MessageId.make("msg-bootstrap-defect"), + role: "user", + text: "hello", + attachments: [ + { + type: "image", + id: upload.attachmentId, + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }, + ], }, - prepareWorktree: { - projectCwd: "/tmp/project", - baseBranch: "main", - branch: "t3code/bootstrap-refName", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: false, }, - runSetupScript: false, - }, - createdAt, + createdAt, + }); }), ).pipe(Effect.result), ); @@ -8071,6 +8271,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { dispatchedCommands.map((command) => command.type), ["thread.create", "thread.delete"], ); + assert.isDefined(pendingAttachmentId); + assert.isTrue( + yield* fileSystem.exists(path.join(config.attachmentsDir, `${pendingAttachmentId}.png`)), + ); + assert.deepEqual(yield* fileSystem.readDirectory(config.attachmentsDir), [ + `${pendingAttachmentId}.png`, + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e2412fab6db9..b6833510a28c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -13,6 +13,7 @@ import * as ServerConfig from "./config.ts"; import { otlpTracesProxyRouteLayer, assetRouteLayer, + attachmentUploadRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, @@ -31,6 +32,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; @@ -150,7 +152,10 @@ const PtyAdapterLive = Layer.unwrap( }), ); -const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)); +const ServerSettingsLayerLive = ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(SqlitePersistenceLayerLive), +); const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe( Layer.provide(ResourceMonitorBinary.layer), @@ -403,7 +408,10 @@ const RuntimeCoreDependenciesWithoutThreadBootstrapLive = ReactorLayerLive.pipe( // `ProviderService` (canonical stream, written after event normalization). // Provided once at the runtime level so every consumer sees the same // logger instances. - Layer.provideMerge(ProviderEventLoggers.layer), + // `ModelManifest.layer` is the legacy-model classification data, refreshed + // from the repo's `model-manifest.json` on `main` and applied by the + // Codex/Claude drivers. + Layer.provideMerge(Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer)), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but // the rewritten registry reads snapshots off the instance registry and @@ -475,6 +483,7 @@ export const makeRoutesLayerWith = (mcpToolkitDependencies: Layer.La ), otlpTracesProxyRouteLayer, assetRouteLayer, + attachmentUploadRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 60cb8d61bc06..485cd5bb08a4 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -55,6 +55,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray = []) => getCapabilities: () => Effect.die("unused"), getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), + uploadFeedback: () => Effect.die("unused"), streamEvents: Stream.empty, }) satisfies ProviderService.ProviderService["Service"]; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index df824081eb0b..4efe7e151b8d 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_SERVER_SETTINGS, ProviderDriverKind, ProviderInstanceId, + resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, } from "@t3tools/contracts"; @@ -16,8 +17,10 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; +import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); @@ -26,6 +29,7 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const makeServerSettingsLayer = () => ServerSettingsModule.layer.pipe( Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), Layer.provideMerge( Layer.fresh( ServerConfig.layerTest(process.cwd(), { @@ -47,6 +51,27 @@ const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) }), ); +const recordProviderUsage = (provider: string, instanceId: string | null = provider) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_instance_id, + updated_at + ) + VALUES ( + ${`thread-${instanceId ?? provider}`}, + ${"ready"}, + ${provider}, + ${instanceId}, + ${"2026-08-25T00:00:00.000Z"} + ) + `; + }); + it.layer(NodeServices.layer)("server settings", (it) => { it.effect("preserves context when reading a provider environment secret fails", () => { const platformCause = PlatformError.systemError({ @@ -67,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); const settingsLayer = ServerSettingsModule.layer.pipe( Layer.provide(makeFailingSecretStoreLayer(cause)), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), Layer.provideMerge(configLayer), ); @@ -92,6 +118,23 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(settingsLayer)); }); + it.effect("identifies provider history query failures", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const sql = yield* SqlClient.SqlClient; + yield* sql`DROP TABLE projection_thread_sessions`; + + const error = yield* Effect.flip(serverSettings.getSettings); + + assert.deepInclude(error, { + _tag: "ServerSettingsError", + operation: "read-provider-history", + settingsPath: serverConfig.settingsPath, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("decodes nested settings patches", () => Effect.gen(function* () { assert.deepEqual( @@ -191,6 +234,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: ["claude-custom"], launchArgs: "", nativeTaskRedirect: true, + autoCompactWindow: "", }); assert.deepEqual( next.textGenerationModelSelection, @@ -488,6 +532,251 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("enables previously used providers from sparse settings files", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providers":{"opencode":{"serverUrl":"http://127.0.0.1:4096"}}}', + ); + yield* recordProviderUsage("opencode"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isTrue(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves existing provider instances without explicit enabled flags", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"cursor_work":{"driver":"cursor","config":{}},"grok":{"driver":"grok","config":{}},"opencode_work":{"driver":"opencode","config":{"serverUrl":"http://127.0.0.1:4096"}},"opencode_unused":{"driver":"opencode","config":{}}}}', + ); + yield* recordProviderUsage("cursor", "cursor_work"); + yield* recordProviderUsage("grok", null); + yield* recordProviderUsage("opencode", "opencode_work"); + + const settings = yield* serverSettings.getSettings; + + assert.isTrue(settings.providers.cursor.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("opencode_work")]?.enabled); + const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; + assert.isDefined(unused); + assert.isFalse(resolveProviderInstanceEnabled(unused)); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves explicit provider disables in existing settings files", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providers":{"grok":{"enabled":false},"opencode":{"enabled":false},"cursor":{"enabled":false}},"providerInstances":{"grok":{"driver":"grok","enabled":false,"config":{}},"opencode":{"driver":"opencode","config":{"enabled":false}},"cursor":{"driver":"cursor","enabled":false,"config":{}}}}', + ); + yield* recordProviderUsage("grok"); + yield* recordProviderUsage("opencode"); + yield* recordProviderUsage("cursor"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + assert.isFalse(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); + assert.isFalse(settings.providerInstances[ProviderInstanceId.make("opencode")]?.enabled); + assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("keeps unused providers disabled in existing sparse settings files", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves provider history when no settings file exists", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* recordProviderUsage("grok"); + + const settings = yield* serverSettings.getSettings; + + assert.isTrue(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves provider history when the settings file is invalid", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{invalid json"); + yield* recordProviderUsage("cursor"); + + const settings = yield* serverSettings.getSettings; + + assert.isTrue(settings.providers.cursor.enabled); + assert.isFalse(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves valid provider flags when another settings field is invalid", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"addProjectBaseDirectory":42,"providers":{"cursor":{"enabled":false},"grok":{"enabled":true}}}', + ); + yield* recordProviderUsage("cursor"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.cursor.enabled); + assert.isTrue(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("restores providers from persisted runtime sessions", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + status, + last_seen_at + ) + VALUES ( + ${"thread-opencode-runtime"}, + ${"opencode"}, + ${"opencode"}, + ${"opencode"}, + ${"ready"}, + ${"2026-08-25T00:00:00.000Z"} + ) + `; + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isTrue(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("persists explicit disables after a provider has been used", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* recordProviderUsage("grok"); + + assert.isTrue((yield* serverSettings.getSettings).providers.grok.enabled); + + const settings = yield* serverSettings.updateSettings({ + providers: { grok: { enabled: false } }, + }); + assert.isFalse(settings.providers.grok.enabled); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.isFalse(JSON.parse(raw).providers.grok.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("persists explicit provider enables before their first use", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + + yield* serverSettings.updateSettings({ + providers: { + cursor: { enabled: true }, + grok: { enabled: true }, + opencode: { enabled: true }, + }, + }); + yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw); + assert.isTrue(persisted.providers.cursor.enabled); + assert.isTrue(persisted.providers.grok.enabled); + assert.isTrue(persisted.providers.opencode.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("keeps optional providers disabled after a new installation writes settings", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + + const initial = yield* serverSettings.getSettings; + assert.isFalse(initial.providers.grok.enabled); + assert.isFalse(initial.providers.opencode.enabled); + assert.isFalse(initial.providers.cursor.enabled); + + const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: "~/Development", + providerInstances: { + [ProviderInstanceId.make("grok")]: { + driver: ProviderDriverKind.make("grok"), + config: {}, + }, + }, + }); + + assert.isFalse(next.providers.grok.enabled); + assert.isFalse(next.providers.opencode.enabled); + assert.isFalse(next.providers.cursor.enabled); + const grok = next.providerInstances[ProviderInstanceId.make("grok")]; + assert.isDefined(grok); + assert.isFalse(resolveProviderInstanceEnabled(grok)); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw); + assert.isFalse(persisted.providers.cursor.enabled); + assert.isFalse(persisted.providers.grok.enabled); + assert.isFalse(persisted.providers.opencode.enabled); + assert.isUndefined(persisted.providerInstances.grok.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("folds a legacy in-config enabled flag into the envelope on load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -583,6 +872,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: [], nativeTaskRedirect: true, launchArgs: "", + autoCompactWindow: "", }); assert.deepEqual(next.providers.opencode, { // OpenCode is disabled by default; this update only touches paths. @@ -637,7 +927,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); - it.effect("writes only non-default server settings to disk", () => + it.effect("writes non-default settings and explicit optional provider defaults to disk", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const serverConfig = yield* ServerConfig.ServerConfig; @@ -674,7 +964,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + cursor: { + enabled: false, + }, + grok: { + enabled: false, + }, opencode: { + enabled: false, serverUrl: "http://127.0.0.1:4096", serverPassword: "secret-password", }, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 1bf37335271b..5a8650b7e405 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -42,6 +42,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; @@ -230,6 +231,66 @@ export const layerTest = (overrides: DeepPartial = {}) => const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); +const PersistedOptionalProviderSettings = Schema.Struct({ + providers: Schema.optionalKey( + Schema.Struct({ + cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + }), + ), +}); +const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit( + fromLenientJson(PersistedOptionalProviderSettings), +); + +function restoreUsedProviders( + settings: ServerSettings, + persisted: typeof PersistedOptionalProviderSettings.Type, + providerHistory: ReadonlyArray<{ + readonly providerName: string; + readonly providerInstanceId: string | null; + }>, +): ServerSettings { + const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName)); + const usedProviderInstances = new Set( + providerHistory.map( + ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName, + ), + ); + const providerInstances = Object.fromEntries( + Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ + instanceId, + instance.enabled === undefined && + (instance.driver === "cursor" || + instance.driver === "grok" || + instance.driver === "opencode") && + usedProviderInstances.has(instanceId) + ? { ...instance, enabled: true } + : instance, + ]), + ); + + return { + ...settings, + providers: { + ...settings.providers, + cursor: { + ...settings.providers.cursor, + enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"), + }, + grok: { + ...settings.providers.grok, + enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"), + }, + opencode: { + ...settings.providers.opencode, + enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"), + }, + }, + providerInstances, + }; +} function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) @@ -265,6 +326,17 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "textGenerationModelSelection", ]); +// Preserve both enabled states because provider history cannot recover a new opt-in. +const PERSISTED_SERVER_SETTINGS_DEFAULTS = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, + grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined }, + opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined }, + }, +}; + function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { return Equal.equals(current, defaults) ? undefined : current; @@ -304,6 +376,7 @@ const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const secretStore = yield* ServerSecretStore.ServerSecretStore; + const sql = yield* SqlClient.SqlClient; const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; const changesPubSub = yield* PubSub.unbounded(); @@ -338,21 +411,59 @@ const make = Effect.gen(function* () { ); const loadSettingsFromDisk = Effect.gen(function* () { - if (!(yield* readConfigExists)) { - return DEFAULT_SERVER_SETTINGS; + let settings = DEFAULT_SERVER_SETTINGS; + let persisted: typeof PersistedOptionalProviderSettings.Type = {}; + + if (yield* readConfigExists) { + const raw = yield* readRawConfig; + const decoded = decodeServerSettingsJsonExit(raw); + const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw); + if (persistedSettings._tag === "Success") { + persisted = persistedSettings.value; + } + if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { + const failure = decoded._tag === "Failure" ? decoded : persistedSettings; + if (failure._tag === "Failure") { + yield* Effect.logWarning("failed to parse settings.json, using defaults", { + path: settingsPath, + issues: Cause.pretty(failure.cause), + cause: failure.cause, + }); + } + } else { + settings = decoded.value; + } } - const raw = yield* readRawConfig; - const decoded = decodeServerSettingsJsonExit(raw); - if (decoded._tag === "Failure") { - yield* Effect.logWarning("failed to parse settings.json, using defaults", { - path: settingsPath, - issues: Cause.pretty(decoded.cause), - cause: decoded.cause, - }); - return DEFAULT_SERVER_SETTINGS; - } - return foldProviderInstanceEnabledFlags(decoded.value); + const providerHistory = yield* sql<{ + readonly providerName: string; + readonly providerInstanceId: string | null; + }>` + SELECT DISTINCT + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId" + FROM projection_thread_sessions + WHERE provider_name IN ('cursor', 'grok', 'opencode') + UNION + SELECT DISTINCT + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId" + FROM provider_session_runtime + WHERE provider_name IN ('cursor', 'grok', 'opencode') + `.pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-provider-history", + cause, + }), + ), + ); + + return foldProviderInstanceEnabledFlags( + restoreUsedProviders(settings, persisted, providerHistory), + ); }); const settingsCache = yield* Cache.make({ @@ -528,7 +639,7 @@ const make = Effect.gen(function* () { const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, + stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, ); return yield* writeFileStringAtomically({ diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 00fb4e4106df..68f3c346759d 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -27,6 +27,7 @@ import { SERVICE_STATE_FILE, SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; +import { isEntrypoint } from "./entrypoint.ts"; const HANDOFF_DELAY_MS = 2_000; const PREPARED_TIMEOUT_MS = 120_000; @@ -611,7 +612,13 @@ async function main(): Promise { await new Launcher(baseDir, state).run(); } -if (import.meta.main) { +if ( + isEntrypoint({ + moduleUrl: import.meta.url, + entryPath: process.argv[1], + runtimeMain: import.meta.main, + }) +) { main().catch((cause: unknown) => { const error = cause instanceof Error ? cause : new Error(String(cause)); process.stderr.write(`[service-launcher] ${error.message}\n`); diff --git a/apps/server/src/terminal/PtyAdapter.test.ts b/apps/server/src/terminal/PtyAdapter.test.ts deleted file mode 100644 index f4ac9516537d..000000000000 --- 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 1cf3d13e2252..0b24b260cadc 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"; @@ -26,6 +27,7 @@ import { import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../provider/acp/GrokAcpSupport.ts"; @@ -83,10 +85,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 0bf131ac973b..224662e9dca7 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 64673e96c090..1f0365f3e7d9 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 cc15ee9cee62..02daf5ebbd70 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 c72f0c24db65..33aef8fae25c 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 8f86a3d836bd..b09db613ed85 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 49f9a1935ccc..2aea60709666 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/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index b9ef992122ae..6cf4400c62eb 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -289,6 +289,10 @@ export class GitVcsDriver extends Context.Service< ) => Effect.Effect; readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + readonly resolveDefaultBranchName: ( + cwd: string, + remoteName: string, + ) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( @@ -306,6 +310,10 @@ export class GitVcsDriver extends Context.Service< readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; + /** Drops worktree admin entries whose directory is already gone (`git worktree prune`). */ + readonly pruneWorktrees: (input: { + readonly cwd: string; + }) => Effect.Effect; readonly renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 66dc7b96a73e..587a3e4abbde 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -739,13 +739,13 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const pathService = yield* Path.Path; - const missingWorktree = pathService.join(cwd, "missing-worktree"); + const fileSystem = yield* FileSystem.FileSystem; + const notAWorktree = pathService.join(cwd, "not-a-worktree"); + yield* fileSystem.makeDirectory(notAWorktree); const driver = yield* GitVcsDriver.GitVcsDriver; yield* driver.initRepo({ cwd }); - const error = yield* driver - .removeWorktree({ cwd, path: missingWorktree }) - .pipe(Effect.flip); + const error = yield* driver.removeWorktree({ cwd, path: notAWorktree }).pipe(Effect.flip); assert.deepInclude(error, { _tag: "GitCommandError", @@ -755,6 +755,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { cwd, }); assert.notProperty(error, "cause"); + assert.notProperty(error, "stderr"); assert.notInclude(error.detail, "Git command failed in"); }), ); @@ -1374,6 +1375,92 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("checks out submodules in a new worktree", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + // Git refuses `file:` submodule transports by default (CVE-2022-39253) + // and ignores repo-level config for it, so a local fixture needs the + // env allowance. Real submodules are https/ssh and need none of this. + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // A real submodule: `git worktree add` leaves these empty, which is + // what silently strips shared tooling out of every new worktree. + const submoduleRepo = yield* makeTmpDir("git-submodule-"); + yield* initRepoWithCommit(submoduleRepo); + yield* writeTextFile(submoduleRepo, "SHARED.md", "# shared\n"); + yield* git(submoduleRepo, ["add", "."]); + yield* git(submoduleRepo, ["commit", "-m", "shared"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "submodule-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/submodules", + }); + + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "shared", "SHARED.md")), + true, + ); + }), + ); + + it.effect("still creates the worktree when submodule checkout fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + // Points at a repository that does not exist, so the checkout fails the + // way an unreachable private remote would. Creation must still succeed. + yield* writeTextFile( + cwd, + ".gitmodules", + '[submodule "missing"]\n\tpath = missing\n\turl = /nonexistent/repo.git\n', + ); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add unreachable submodule"]); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "broken-submodule-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/broken-submodules", + }); + + assert.equal(created.worktree.path, worktreePath); + assert.equal(yield* fileSystem.exists(worktreePath), true); + }), + ); + it.effect("creates and removes a worktree for a new refName", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1401,6 +1488,57 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(yield* fileSystem.exists(worktreePath), false); }), ); + + it.effect("removes the same worktree path twice without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "shared"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/shared", + }); + + // Two threads can record the same worktree path; the second delete + // must be a no-op instead of exit 128. + yield* driver.removeWorktree({ cwd, path: worktreePath }); + yield* driver.removeWorktree({ cwd, path: worktreePath }); + }), + ); + + it.effect("prunes stale registrations when removing an already-gone worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreesRoot = yield* makeTmpDir("git-worktrees-"); + const stalePath = pathService.join(worktreesRoot, "stale"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: stalePath, + refName: initialBranch, + newRefName: "feature/stale", + }); + // Delete the directory behind git's back so the registration goes stale. + yield* fileSystem.remove(stalePath, { recursive: true }); + + yield* driver.removeWorktree({ + cwd, + path: pathService.join(worktreesRoot, "never-registered"), + }); + + const registered = yield* git(cwd, ["worktree", "list", "--porcelain"]); + assert.notInclude(registered, "stale"); + }), + ); }); describe("remote operations", () => { @@ -1687,6 +1825,111 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("publishes a branch tracking its base under its own name, not the base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* git(cwd, ["checkout", "-b", "dev"]); + yield* git(cwd, ["push", "-u", "origin", "dev"]); + const devSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add feature", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/x", + upstreamBranch: "origin/feature/x", + setUpstream: true, + }); + assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "feature/x"]), "Add feature"); + assert.equal(yield* git(remote, ["rev-parse", "dev"]), devSha); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/x", + ); + assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev"); + }), + ); + + it.effect("keeps a recorded merge base when publishing a tracked branch", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* git(cwd, ["checkout", "-b", "feature/y", "origin/main"]); + yield* git(cwd, ["config", "branch.feature/y.gh-merge-base", "release/v2"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add feature", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/y", + upstreamBranch: "origin/feature/y", + setUpstream: true, + }); + assert.equal( + yield* driver.readConfigValue(cwd, "branch.feature/y.gh-merge-base"), + "release/v2", + ); + }), + ); + + it.effect("still pushes a git-mangled tracking alias to its upstream head", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "my-org/upstream", remote]); + yield* git(cwd, ["push", "my-org/upstream", "main:effect-atom"]); + yield* git(cwd, ["fetch", "my-org/upstream"]); + // `checkout --track my-org/upstream/effect-atom` cannot name the local + // branch `effect-atom`, so git keeps `upstream/effect-atom`. Its + // upstream is still its published head. + yield* git(cwd, ["checkout", "--track", "my-org/upstream/effect-atom"]); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]), + "upstream/effect-atom", + ); + yield* writeTextFile(cwd, "alias.txt", "alias\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add alias update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "upstream/effect-atom", + upstreamBranch: "my-org/upstream/effect-atom", + setUpstream: false, + }); + assert.equal( + yield* git(remote, ["log", "-1", "--pretty=%s", "effect-atom"]), + "Add alias update", + ); + }), + ); + it.effect("pushes to the requested remote instead of the primary remote", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index cd16c70291a4..71e478cbaa3d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -432,6 +432,17 @@ function isUnbornHeadStderr(stderr: string): boolean { ); } +// Matches `git worktree remove` on a path git no longer tracks: "is not a +// working tree" when the registration is gone, "cannot remove working tree" +// when older gits fail validation on a registered-but-deleted directory. +function isMissingWorktreeStderr(stderr: string): boolean { + const normalized = stderr.toLowerCase(); + return ( + normalized.includes("is not a working tree") || + normalized.includes("cannot remove working tree") + ); +} + interface Trace2Monitor { readonly env: NodeJS.ProcessEnv; readonly flush: Effect.Effect; @@ -1996,6 +2007,55 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { + // A branch tracking a differently named ref was cut from it, the way + // `git checkout -b feature origin/dev` and our own worktree flow leave + // it. That upstream is the branch's base, not its publish target, and + // pushing HEAD onto it would write feature commits to a shared branch + // (bare `git push` refuses this under push.default=simple). The one + // same-repo tracking setup that legitimately differs is a git-mangled + // alias such as local `upstream/effect-atom` for my-org/upstream's + // `effect-atom`: the branch name ends in the upstream head while the + // upstream ref ends in the branch name. + const isAliasOfUpstreamHead = + branch === currentUpstream.branchName || + (branch.endsWith(`/${currentUpstream.branchName}`) && + currentUpstream.upstreamRef.endsWith(`/${branch}`)); + if (!isAliasOfUpstreamHead) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + const remoteName = publishRemoteName ?? currentUpstream.remoteName; + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + // `-u` retargets the upstream to the published branch, so keep the + // base recorded first; base resolution reads gh-merge-base before the + // upstream ref. + const configuredMergeBase = yield* runGitStdout( + "GitVcsDriver.pushCurrentBranch.readMergeBase", + cwd, + ["config", "--get", `branch.${branch}.gh-merge-base`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (configuredMergeBase.length === 0) { + yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [ + "config", + `branch.${branch}.gh-merge-base`, + currentUpstream.branchName, + ]); + } + yield* runGit( + "GitVcsDriver.pushCurrentBranch.pushOwnBranch", + cwd, + ["push", "-u", remoteName, `HEAD:refs/heads/${publishBranch}`], + { timeoutMs: null }, + ); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${remoteName}/${publishBranch}`, + setUpstream: true, + }; + } + yield* runGit( "GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, @@ -2776,6 +2836,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* timeoutMs: WORKTREE_ADD_TIMEOUT_MS, }); + // `git worktree add` leaves submodules empty, so a repo that keeps agent + // skills, tooling or source in one gets a worktree that is quietly missing + // them. Best-effort: the objects are usually already in the parent's + // `.git/modules`, but a first-ever clone needs the network, and failing to + // populate a submodule must not roll back the caller's thread. + const hasSubmodules = yield* fileSystem + .exists(path.join(worktreePath, ".gitmodules")) + .pipe(Effect.orElseSucceed(() => false)); + if (hasSubmodules) { + yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [ + "submodule", + "update", + "--init", + "--recursive", + ]).pipe( + Effect.catch((cause) => + Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { + worktreePath, + cause, + }), + ), + ); + } + if (input.newRefName && input.baseRefName) { const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); const parsedBaseRef = parseRemoteRefWithRemoteNames( @@ -2987,9 +3071,47 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* args.push("--force"); } args.push(input.path); - yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { + const result = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.removeWorktree", + input.cwd, + args, + { timeoutMs: 15_000, allowNonZeroExit: true }, + ); + if (result.exitCode === 0) { + return; + } + // Threads can share a worktree path, and worktrees get removed or pruned + // outside the app, so a worktree that is already gone is a no-op rather + // than an error. Prune so no stale registration lingers to block a later + // `worktree add` at the same path. + const alreadyGone = + isMissingWorktreeStderr(result.stderr) && + !(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false))); + if (alreadyGone) { + yield* pruneWorktrees({ cwd: input.cwd }); + return; + } + // Raw stderr stays out of both the wire error and the log (it can carry + // secrets); log bounded diagnostics so a genuine failure is visible + // server-side. + yield* Effect.logWarning( + `GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`, + ); + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.removeWorktree", cwd: input.cwd, args }), + detail: "git worktree remove failed", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + }); + + const pruneWorktrees: GitVcsDriver.GitVcsDriver["Service"]["pruneWorktrees"] = Effect.fn( + "pruneWorktrees", + )(function* (input) { + yield* executeGit("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], { timeoutMs: 15_000, - fallbackErrorDetail: "git worktree remove failed", + fallbackErrorDetail: "git worktree prune failed", }); }); @@ -3189,6 +3311,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, + resolveDefaultBranchName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), remoteExists, resolveRemoteTrackingCommit, @@ -3197,6 +3320,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), + pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8869ba3e522d..de906c84ff75 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -16,6 +16,8 @@ import { ClientSurface, CommandId, type DiscoveredLocalServerList, + type EditorId, + type FileManagerRevealKind, type OrchestrationClientOrigin, type OrchestrationCommand, type GitActionProgressEvent, @@ -39,6 +41,7 @@ import { ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, + ProviderUploadFeedbackError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, type ServerSelfUpdateError, @@ -69,7 +72,10 @@ import { projectActivityEvent, projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; -import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; +import { + cleanupFailedUploadedAttachments, + normalizeDispatchCommand, +} from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ThreadBootstrap from "./orchestration/Services/ThreadBootstrap.ts"; @@ -79,6 +85,7 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -88,6 +95,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; +import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -127,16 +135,25 @@ import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(5); +const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); -export const resolveAvailableEditorsForConfig = ( - discovery: Effect.Effect, E, R>, +const resolveDiscoveryForConfig = ( + discovery: Effect.Effect, + onTimeout: () => A, ) => discovery.pipe( - Effect.timeoutOption(EDITOR_DISCOVERY_TIMEOUT), - Effect.map(Option.getOrElse(() => [])), + Effect.timeoutOption(CONFIG_DISCOVERY_TIMEOUT), + Effect.map(Option.getOrElse(onTimeout)), ); +export const resolveAvailableEditorsForConfig = ( + discovery: Effect.Effect, E, R>, +) => resolveDiscoveryForConfig(discovery, () => []); + +export const resolveFileManagerRevealKindForConfig = ( + discovery: Effect.Effect, +) => resolveDiscoveryForConfig(discovery, () => undefined); + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -325,6 +342,7 @@ function toAuthAccessStreamEvent( const isClientSurface = Schema.is(ClientSurface); const MAX_CLIENT_APP_VERSION_LENGTH = 64; +const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; // Optional client identity announced on the /ws upgrade URL next to wsTicket. // Lenient by design: absent or malformed values degrade to {} so a connection @@ -351,6 +369,28 @@ const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), }); +function readMobileDeviceAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url) || url.value.searchParams.get("clientSurface") !== "mobile") { + return {}; + } + + const os = url.value.searchParams.get("clientOs"); + const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? ""; + const osMajorVersion = Number(rawOsMajorVersion); + const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? ""; + + return { + ...(os === "iOS" || os === "Android" ? { os } : {}), + ...(rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0 + ? { osMajorVersion } + : {}), + ...(deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH + ? { deviceModel } + : {}), + }; +} + const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, @@ -403,6 +443,7 @@ const makeWsRpcLayer = ( const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const providerService = yield* ProviderService.ProviderService; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; @@ -767,6 +808,14 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ); + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ) + : undefined; return { environment, @@ -776,9 +825,7 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ), + availableEditors, // Same discovery-with-timeout treatment as editors: a slow probe // must not stall server.getConfig, so it degrades to no targets. remoteOpenTargets: yield* resolveAvailableEditorsForConfig( @@ -798,6 +845,12 @@ const makeWsRpcLayer = ( }, settings, shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), threadResumeCompletionMarker: true, threadSnapshotPagination: true, }; @@ -846,7 +899,9 @@ const makeWsRpcLayer = ( ), ) : false; - const result = yield* dispatchNormalizedCommand(normalizedCommand); + const result = yield* dispatchNormalizedCommand(normalizedCommand).pipe( + Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), + ); yield* recordClientCommandAnalytics(normalizedCommand); if (parkingCommand) { const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; @@ -1224,6 +1279,20 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.providerUploadFeedback]: (input) => + observeRpcEffect( + WS_METHODS.providerUploadFeedback, + providerService.uploadFeedback(input).pipe( + Effect.mapError( + (cause) => + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause, + }), + ), + ), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, @@ -1634,6 +1703,16 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.attachmentsCreateUploadUrl]: (input) => + observeRpcEffect(WS_METHODS.attachmentsCreateUploadUrl, issueAttachmentUploadUrl(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.attachmentsDelete]: (input) => + observeRpcEffect( + WS_METHODS.attachmentsDelete, + deletePendingAttachment(input.attachmentId), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, @@ -2101,7 +2180,10 @@ export const websocketRpcRouteLayer = Layer.unwrap( ); const clientOrigin = readClientConnectionOrigin(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); - yield* analytics.record("client.connected", clientOriginAnalyticsProps(clientOrigin)); + yield* analytics.record("client.connected", { + ...clientOriginAnalyticsProps(clientOrigin), + ...readMobileDeviceAnalyticsProps(request), + }); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, }).pipe( diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 72e2cd9e98eb..7ac564bb41f3 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -421,31 +421,6 @@ describe("superseded tool.updated snapshot dedup", () => { expect(projectedIds([anonymous, completed])).toEqual([anonymous.id, completed.id]); }); - it("does not filter live activity-appended events", () => { - const update = makeToolLifecycleActivity("upd-live-event", "tool.updated"); - const event = { - sequence: 11, - eventId: EventId.make("event-tool-updated"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-projection"), - occurredAt: "2026-07-27T00:00:03.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.activity-appended", - payload: { - threadId: ThreadId.make("thread-projection"), - activity: update, - }, - } satisfies Extract; - - const projected = projectActivityEvent(event); - expect( - projected.type === "thread.activity-appended" ? projected.payload.activity.id : undefined, - ).toEqual(update.id); - }); - it("leaves the collapsed work log identical to the full history", () => { const activities = [ makeToolLifecycleActivity("upd-1", "tool.updated", { detail: "writing" }), @@ -566,29 +541,4 @@ describe("context-window snapshot dedup", () => { }); expect(projected.thread.activities).toEqual([projectActivityPayload(fixtures[4]!)]); }); - - it("does not filter live activity-appended events", () => { - const activity = makeContextWindowActivity("ctx-live", 4_000); - const event = { - sequence: 9, - eventId: EventId.make("event-ctx"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-projection"), - occurredAt: "2026-07-27T00:00:02.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.activity-appended", - payload: { - threadId: ThreadId.make("thread-projection"), - activity, - }, - } satisfies Extract; - - const projected = projectActivityEvent(event); - expect( - projected.type === "thread.activity-appended" ? projected.payload.activity : undefined, - ).toEqual(activity); - }); }); diff --git a/apps/web/package.json b/apps/web/package.json index 598feaec0ce9..ddda0a9628cc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.33", + "version": "0.0.35", "private": true, "type": "module", "scripts": { @@ -34,6 +34,7 @@ "class-variance-authority": "^0.7.1", "culori": "^4.0.2", "effect": "catalog:", + "heic-to": "^1.5.2", "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", diff --git a/apps/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts new file mode 100644 index 000000000000..3e6c1fad0448 --- /dev/null +++ b/apps/web/src/appearanceContrast.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { applyAppearanceContrast } from "./appearanceContrast"; + +function makeRoot() { + const setProperty = vi.fn(); + return { + root: { style: { setProperty } } as unknown as HTMLElement, + setProperty, + }; +} + +describe("applyAppearanceContrast", () => { + it("boosts semantic contrast above the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 135); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%"); + }); + + it("supports the maximum contrast boost", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 200); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "25%"); + }); + + it("softens semantic contrast below the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 70); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); + }); + + it("disables contrast mixing at the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 100); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); + }); +}); diff --git a/apps/web/src/appearanceContrast.ts b/apps/web/src/appearanceContrast.ts new file mode 100644 index 000000000000..a26dca0131e6 --- /dev/null +++ b/apps/web/src/appearanceContrast.ts @@ -0,0 +1,10 @@ +import type { AppearanceContrast } from "@t3tools/contracts/settings"; + +export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void { + root.style.setProperty("--appearance-contrast-base", `${Math.min(contrast, 100)}%`); + root.style.setProperty("--appearance-contrast-boost", `${Math.max(contrast - 100, 0)}%`); + root.style.setProperty( + "--appearance-contrast-border-boost", + `${Math.max(contrast - 100, 0) / 4}%`, + ); +} diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts deleted file mode 100644 index e4634f5b98db..000000000000 --- 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/browser/annotationTheme.ts b/apps/web/src/browser/annotationTheme.ts index e12c667d23d7..cb3382449598 100644 --- a/apps/web/src/browser/annotationTheme.ts +++ b/apps/web/src/browser/annotationTheme.ts @@ -10,17 +10,17 @@ export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme { colorScheme: root.classList.contains("dark") ? "dark" : "light", radius: readVariable(styles, "--radius", "0.625rem"), background: readVariable(styles, "--background", "white"), - foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"), + foreground: readVariable(styles, "--contrast-foreground", "oklch(0.269 0 0)"), popover: readVariable(styles, "--popover", "white"), - popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"), + popoverForeground: readVariable(styles, "--contrast-popover-foreground", "oklch(0.269 0 0)"), primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"), primaryForeground: readVariable(styles, "--primary-foreground", "white"), muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"), - mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"), + mutedForeground: readVariable(styles, "--contrast-muted-foreground", "oklch(0.556 0 0)"), accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"), - accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"), - border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"), - input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"), + accentForeground: readVariable(styles, "--contrast-accent-foreground", "oklch(0.269 0 0)"), + border: readVariable(styles, "--contrast-border", "rgb(0 0 0 / 8%)"), + input: readVariable(styles, "--contrast-input", "rgb(0 0 0 / 10%)"), ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"), fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"), fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"), diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index a8c82552f9bb..2ca9ad4ae311 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,151 @@ -import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { orderedListGutterStyle } from "./ChatMarkdown"; +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../state/session", async (importOriginal) => ({ + ...(await importOriginal()), + usePreparedConnection: () => ({ _tag: "Loading" }), +})); +vi.mock("../state/entities", () => ({ + readThreadShell: () => null, + useProjects: () => [], +})); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => undefined, + matchesLinkedPullRequestUrl: () => false, + parseChangeRequestUrl: () => null, + useOpenChangeRequestLink: () => vi.fn(), +})); + +import ChatMarkdown, { + canUseMarkdownFileShellActions, + hasMarkdownFilePrimaryAction, + orderedListGutterStyle, + shouldUseMarkdownFileBrowserPrimaryAction, +} from "./ChatMarkdown"; + +describe("canUseMarkdownFileShellActions", () => { + const environmentId = EnvironmentId.make("environment-1"); + + it("allows editor and file manager actions for local environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true); + }); + + it("hides shell actions until the environment mode is resolved", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false); + }); + + it("hides editor and file manager actions for remote environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false); + expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false); + }); + + it("hides shell actions when no environment owns the markdown", () => { + expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false); + }); +}); + +describe("hasMarkdownFilePrimaryAction", () => { + it("keeps the chip interactive when an editor, browser, or panel can open it", () => { + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: true, + canOpenInBrowser: false, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: false, + canOpenInPanel: true, + }), + ).toBe(true); + }); + + it("removes the link affordance when no primary action can open the file", () => { + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: false, + canOpenInPanel: false, + }), + ).toBe(false); + }); +}); + +describe("ChatMarkdown file option chips", () => { + it("keeps the fallback button text selectable", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(" { + it("uses the browser when it is the only available primary action", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); + }); + + it("preserves the normal editor and panel defaults for HTML files", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(false); + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(false); + }); + + it("continues to open PDF files in the browser by default", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.pdf", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(true); + }); +}); describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -42,3 +187,105 @@ describe("orderedListGutterStyle", () => { expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" }); }); }); + +describe("ChatMarkdown Windows file links", () => { + const environmentId = EnvironmentId.make("env-windows"); + + it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); + expect(html).toContain("chat-markdown-file-link"); + }); + + it.each([true, false])("normalizes backslashes with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); + expect(html).toContain("chat-markdown-file-link"); + }); + + it.each([true, false])( + "distinguishes same-named backslash paths with parseRawHtml=%s", + (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("index.ts · project/src"); + expect(html).toContain("index.ts · project/test"); + }, + ); + + it.each([true, false])( + "does not disambiguate the same file in links and inline code with parseRawHtml=%s", + (parseRawHtml) => { + const path = String.raw`C:\Users\shawn\project\src\main.ts`; + const html = renderToStaticMarkup( + , + ); + + expect(html.match(/chat-markdown-file-link/g)).toHaveLength(2); + expect(html).not.toContain("main.ts ·"); + }, + ); + + it.each([true, false])("preserves reference links with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); + expect(html).toContain("chat-markdown-file-link"); + }); + + it.each([true, false])("still rejects unsafe schemes with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("javascript:"); + expect(html).not.toContain("d:alert"); + expect(html).not.toContain("chat-markdown-file-link"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 81f901d7f015..dd21a4e1bf40 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -13,12 +13,18 @@ import { TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; -import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; +import type { + EnvironmentId, + ScopedThreadRef, + ServerProviderSkill, + ThreadLinkedPullRequest, +} from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { @@ -47,6 +53,10 @@ import { remarkGithubAlerts } from "../markdown-github-alerts"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { + revealInFileExplorerLabelForKind, + revealInFileExplorerLabelForOs, +} from "./preview/fileExplorerLabel"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, @@ -59,7 +69,12 @@ import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; -import { useOpenInPreferredEditor } from "../editorPreferences"; +import { + PreferredEditorEnvironmentRequiredError, + useOpenInPreferredEditor, + usePreferredEditor, +} from "../editorPreferences"; +import { openInEditorMenuLabel } from "../editorLabels"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; @@ -79,29 +94,40 @@ import { resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, + shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; +import { useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; +import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; -import { useActiveEnvironmentId } from "../state/entities"; +import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; +import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; +import { threadEnvironment } from "../state/threads"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; -import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; +import { + findProjectForChangeRequest, + matchesLinkedPullRequestUrl, + parseChangeRequestUrl, + useOpenChangeRequestLink, +} from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, @@ -113,6 +139,8 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; + /** Environment that owns non-thread markdown, such as a pull request panel. */ + environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; @@ -123,9 +151,39 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; } +export function canUseMarkdownFileShellActions( + environmentId: EnvironmentId | null, + remoteOpenMode: RemoteOpenMode, + isRemoteOpenResolved: boolean, +): boolean { + return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; +} + +export function hasMarkdownFilePrimaryAction(input: { + canOpenInEditor: boolean; + canOpenInBrowser: boolean; + canOpenInPanel: boolean; +}): boolean { + return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; +} + +export function shouldUseMarkdownFileBrowserPrimaryAction(input: { + iconPath: string; + canOpenInEditor: boolean; + canOpenInBrowser: boolean; + canOpenInPanel: boolean; +}): boolean { + return ( + input.canOpenInBrowser && + (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || + (!input.canOpenInEditor && !input.canOpenInPanel)) + ); +} + const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; +const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; @@ -179,6 +237,36 @@ export function orderedListGutterStyle( return { "--list-gutter": `${markerWidth + 1}ch` }; } +type MarkdownHtmlAstNode = { + type?: string; + tagName?: string; + properties?: Record; + children?: MarkdownHtmlAstNode[]; +}; + +/** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */ +function rehypeNormalizeWindowsImageSrc() { + return (tree: MarkdownHtmlAstNode) => { + const visit = (node: MarkdownHtmlAstNode) => { + const src = node.properties?.src; + if ( + node.type === "element" && + node.tagName === "img" && + typeof src === "string" && + WINDOWS_DRIVE_PATH_REGEX.test(src) + ) { + node.properties = { + ...node.properties, + src: `file:///${src.replaceAll("\\", "/")}`, + }; + } + node.children?.forEach(visit); + }; + + visit(tree); + }; +} + const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { @@ -190,6 +278,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], + src: [...(defaultSchema.protocols?.src ?? []), "file"], }, } satisfies Parameters[0]; @@ -198,7 +287,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, - remarkTagInlineCode, + remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -207,11 +296,12 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, - remarkTagInlineCode, + remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, + rehypeNormalizeWindowsImageSrc, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; @@ -292,6 +382,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { type MarkdownAstNode = { type?: string; meta?: unknown; + url?: string; data?: { hProperties?: Record; }; @@ -318,15 +409,20 @@ function remarkPreserveCodeMeta() { } /** - * Fenced code also lands on the `code` component, and inline vs block is no - * longer distinguishable there once both render `` — so inline spans are - * tagged on the mdast, where the distinction still exists. Code inside a link - * label stays untagged: linkifying it would nest an anchor inside the link's - * anchor and steal its clicks. + * Preserve Windows drive links as allowed `file:` URLs before sanitization. + * The same traversal tags inline code while it can still be distinguished + * from fenced code. Code inside links stays untagged to avoid nested anchors. */ -function remarkTagInlineCode() { +function remarkNormalizeLinksAndTagInlineCode() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode, insideLink: boolean) => { + if ( + (node.type === "link" || node.type === "definition") && + typeof node.url === "string" && + WINDOWS_DRIVE_PATH_REGEX.test(node.url) + ) { + node.url = `file:///${node.url.replaceAll("\\", "/")}`; + } if (node.type === "inlineCode" && !insideLink) { node.data = { ...node.data, @@ -823,14 +919,19 @@ interface MarkdownFileLinkProps { copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; - onOpen: (targetPath: string) => Promise>; + onOpen?: ((targetPath: string) => Promise>) | undefined; onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; + openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; + onReveal?: (() => Promise>) | undefined; + /** Platform-specific menu label ("Reveal in Finder", ...); required for the + reveal item to show. */ + revealLabel?: string | undefined; className?: string | undefined; } -const MARKDOWN_FILE_LINK_CLASS_NAME = - "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; +const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link"; +const MARKDOWN_FILE_LINK_CLASS_NAME = `${MARKDOWN_FILE_CHIP_CLASS_NAME} cursor-pointer transition-colors hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70`; function pathParentSegments(path: string): string[] { const normalized = path.replaceAll("\\", "/"); @@ -841,14 +942,12 @@ function pathParentSegments(path: string): string[] { function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { const groups = new Map>(); for (const filePath of filePaths) { - const pathSegments = filePath - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment.length > 0); + const normalizedPath = filePath.replaceAll("\\", "/"); + const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0); const basename = pathSegments[pathSegments.length - 1]; if (!basename) continue; const group = groups.get(basename) ?? new Set(); - group.add(filePath); + group.add(normalizedPath); groups.set(basename, group); } @@ -909,7 +1008,10 @@ function extractInlineCodeSpans(text: string): string[] { function normalizeMarkdownLinkHrefKey(href: string): string { const normalizedHref = normalizeMarkdownLinkDestination(href); - return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) + ? rewrittenHref.replaceAll("\\", "/") + : rewrittenHref; } const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; @@ -943,6 +1045,62 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: ); }); +const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = + "h-auto w-auto max-h-[30rem] max-w-[min(100%,30rem)] object-contain"; + +// block! outranks the unlayered `.chat-markdown img { display: inline-block }` +// rule, keeping workspace images on the same block layout as their placeholder. +const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( + CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, + "my-1 block! rounded-lg border border-border/40", +); + +function ChatMarkdownImageFallback(props: { readonly alt: string }) { + return ( + + + {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} + + ); +} + +/** Markdown images whose src is a workspace file path load through a signed asset URL. */ +const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: { + readonly threadRef: ScopedThreadRef; + readonly path: string; + readonly alt: string; +}) { + const assetUrl = useAssetUrlState(props.threadRef.environmentId, { + _tag: "workspace-file", + threadId: props.threadRef.threadId, + path: props.path, + }); + const [failedUrl, setFailedUrl] = useState(null); + + if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { + return ; + } + if (assetUrl._tag !== "Success") { + return ( + + ); + } + return ( + {props.alt} setFailedUrl(assetUrl.url)} + /> + ); +}); + function leadingExternalLinkTextLength(text: string): number { const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; if (protocol) return protocol.length; @@ -1120,10 +1278,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ threadRef, onOpen, onOpenInPanel, + openInEditorMenuLabel, onOpenInBrowser, + onReveal, + revealLabel, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { + if (!onOpen) { + return; + } void (async () => { try { const result = await onOpen(targetPath); @@ -1204,6 +1368,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }, [onOpenInBrowser, targetPath]); + const handleRevealInFileManager = useCallback(() => { + if (!onReveal) { + return; + } + void (async () => { + try { + const result = await onReveal(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "reveal-file-in-file-manager", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "reveal-file-in-file-manager", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } + })(); + }, [onReveal, targetPath]); + const handleCopy = useCallback( (value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { @@ -1243,25 +1445,23 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ [targetPath], ); - const handleContextMenu = useCallback( - async (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - + const showFileContextMenu = useCallback( + async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; try { const clicked = await api.contextMenu.show( [ - { id: "open", label: "Open in editor" }, + ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), + ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, - { x: event.clientX, y: event.clientY }, + position, ); if (clicked === "open") { @@ -1272,6 +1472,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } + if (clicked === "reveal") { + handleRevealInFileManager(); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1286,34 +1490,100 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } }, - [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], + [ + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + handleRevealInFileManager, + onOpenInBrowser, + onOpen, + onReveal, + openInEditorMenuLabel, + revealLabel, + targetPath, + ], ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const position = + event.clientX === 0 && event.clientY === 0 + ? (() => { + const bounds = event.currentTarget.getBoundingClientRect(); + return { x: bounds.left, y: bounds.bottom }; + })() + : { x: event.clientX, y: event.clientY }; + void showFileContextMenu(position); + }, + [showFileContextMenu], + ); + + const canOpenInEditor = onOpen !== undefined; + const canOpenInBrowser = onOpenInBrowser !== undefined; + const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath); + const hasPrimaryAction = hasMarkdownFilePrimaryAction({ + canOpenInEditor, + canOpenInBrowser, + canOpenInPanel, + }); + const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath, + canOpenInEditor, + canOpenInBrowser, + canOpenInPanel, + }); + return ( { - event.preventDefault(); - event.stopPropagation(); - if (shouldOpenMarkdownFileLinkInEditor(event)) { - handleOpenInEditor(); - return; - } - if (onOpenInBrowser) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - + hasPrimaryAction ? ( + { + event.preventDefault(); + event.stopPropagation(); + if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { + handleOpenInEditor(); + return; + } + if (useBrowserPrimaryAction) { + handleOpenInBrowser(); + return; + } + handleOpenInFilePreview(); + }} + onContextMenu={handleContextMenu} + > + + + ) : ( + + ) } /> {/* The full path: the chip already shows the shortened form, and a link to the workspace root collapses to a bare label that repeats it. */} -
+
{targetPath}
@@ -1347,7 +1617,10 @@ function areMarkdownFileLinkPropsEqual( previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && previous.onOpenInPanel === next.onOpenInPanel && + previous.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && + previous.onReveal === next.onReveal && + previous.revealLabel === next.revealLabel && previous.className === next.className ); } @@ -1356,6 +1629,7 @@ function ChatMarkdown({ text, cwd, threadRef, + environmentId: explicitEnvironmentId, onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, @@ -1373,12 +1647,52 @@ function ChatMarkdown({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); - const environmentId = useActiveEnvironmentId(); - const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const openInPreferredEditor = useOpenInPreferredEditor( + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; + const remoteOpen = useRemoteOpenResolution(environmentId); + const canUseShellActions = canUseMarkdownFileShellActions( environmentId, - serverConfig?.availableEditors ?? [], + remoteOpen.state.mode, + remoteOpen.isResolved, + ); + const preparedConnection = usePreparedConnection(environmentId); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const threadServerConfig = useAtomValue( + serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), + ); + const projects = useProjects(); + const availableEditors = serverConfig?.availableEditors ?? []; + const [preferredEditor] = usePreferredEditor(availableEditors); + const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); + const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); + const revealInFileManagerLabel = + environmentId !== null && + serverConfig?.shellRevealInFileManager === true && + serverConfig.availableEditors.includes("file-manager") + ? serverConfig.shellRevealInFileManagerKind === undefined + ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) + : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) + : undefined; + const revealFileInFileManager = useCallback( + (filePath: string) => { + if (environmentId === null) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })), + ), + ); + } + return openInEditor({ + environmentId, + input: { cwd: filePath, editor: "file-manager", reveal: true }, + }); + }, + [environmentId, openInEditor], ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { @@ -1429,6 +1743,54 @@ function ChatMarkdown({ event.clipboardData.setData("text/html", payload.html); }, []); const openChangeRequestLink = useOpenChangeRequestLink(threadRef); + const resolveThreadPullRequest = useCallback( + (href: string): ThreadLinkedPullRequest | null => { + if ( + threadRef === undefined || + readThreadShell(threadRef) === null || + threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true + ) { + return null; + } + const parsed = parseChangeRequestUrl(href); + if (parsed === null) return null; + const project = findProjectForChangeRequest( + projects.filter((candidate) => candidate.environmentId === threadRef.environmentId), + parsed, + ); + if (project === undefined) return null; + return { + projectId: project.id, + repository: project.repositoryIdentity?.displayName ?? parsed.repository, + number: parsed.number, + url: href, + }; + }, + [projects, threadRef, threadServerConfig], + ); + const updateThreadPullRequestLink = useCallback( + async (href: string, linked: boolean) => { + if (threadRef === undefined) return; + const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null; + if (linked && linkedPullRequest === null) { + throw new Error("The pull request is not available in this environment."); + } + if (!linked) { + const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest; + if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) { + return; + } + } + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, linkedPullRequest }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + throw squashAtomCommandFailure(result); + } + }, + [resolveThreadPullRequest, threadRef, updateThreadMetadata], + ); const openExternalLinkInPreview = useCallback( (url: string) => { if (!threadRef) { @@ -1472,6 +1834,26 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + const findWorkspaceBasenameMatch = useCallback( + async (workspaceRelativePath: string) => { + if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + return null; + } + const result = await searchProjectEntries({ + environmentId, + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + return result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + }, + [cwd, environmentId, searchProjectEntries], + ); // A bare filename resolves to the workspace root, which is rarely where the // file is, so ask the index before opening. const openFileInPanel = useCallback( @@ -1487,24 +1869,23 @@ function ChatMarkdown({ return; } void (async () => { - const result = await searchProjectEntries({ - environmentId: threadRef.environmentId, - input: { - cwd, - query: workspaceRelativePath, - limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, - kind: "file", - }, - }); - const match = - result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) - : null; + const match = await findWorkspaceBasenameMatch(workspaceRelativePath); if (!isLatestLookup()) return; openAt(match ?? workspaceRelativePath); })(); }, - [cwd, searchProjectEntries, threadRef], + [cwd, findWorkspaceBasenameMatch, threadRef], + ); + const revealMarkdownFileInFileManager = useCallback( + async (fileLinkMeta: MarkdownFileLinkMeta) => { + const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; + const match = workspaceRelativePath + ? await findWorkspaceBasenameMatch(workspaceRelativePath) + : null; + const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; + return revealFileInFileManager(filePath); + }, + [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that @@ -1515,7 +1896,9 @@ function ChatMarkdown({ copyMarkdown: string, className?: string, ) => { - const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const parentSuffix = fileLinkParentSuffixByPath.get( + fileLinkMeta.filePath.replaceAll("\\", "/"), + ); const labelParts = [fileLinkMeta.basename]; if (typeof parentSuffix === "string" && parentSuffix.length > 0) { labelParts.push(parentSuffix); @@ -1538,8 +1921,15 @@ function ChatMarkdown({ copyMarkdown={copyMarkdown} theme={resolvedTheme} threadRef={threadRef} - onOpen={openInPreferredEditor} + {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})} onOpenInPanel={openFileInPanel} + openInEditorMenuLabel={preferredEditorMenuLabel} + onReveal={ + canUseShellActions && revealInFileManagerLabel !== undefined + ? () => revealMarkdownFileInFileManager(fileLinkMeta) + : undefined + } + revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1659,9 +2049,20 @@ function ChatMarkdown({ event.stopPropagation(); const api = readLocalApi(); if (!api) return; + const pullRequest = resolveThreadPullRequest(href); + const currentPullRequest = + threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; + const threadLinkAction = + currentPullRequest != null && + matchesLinkedPullRequestUrl(currentPullRequest, href) + ? "unlink-from-thread" + : pullRequest === null + ? undefined + : "link-to-thread"; void showExternalLinkContextMenu({ href, canOpenInPreview, + threadLinkAction, position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), openInPreview: async (target) => { @@ -1675,8 +2076,25 @@ function ChatMarkdown({ }, openExternal: (target) => api.shell.openExternal(target), copyLink: (target) => writeTextToClipboard(target, "link"), + updateThreadLink: updateThreadPullRequestLink, reportFailure: (operation, cause) => { reportMarkdownActionFailure({ operation, target: href }, cause); + if ( + operation === "link-pull-request-to-thread" || + operation === "unlink-pull-request-from-thread" + ) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: + operation === "link-pull-request-to-thread" + ? "Unable to link pull request" + : "Unable to unlink pull request", + description: + cause instanceof Error ? cause.message : "The request failed.", + }), + ); + } }, }); }} @@ -1712,9 +2130,6 @@ function ChatMarkdown({ props.className, ); }, - img({ node: _node, title: _title, ...props }) { - return ; - }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); @@ -1731,6 +2146,32 @@ function ChatMarkdown({
); }, + img({ node: _node, title: _title, src, alt, ...props }) { + const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : ""; + const altText = alt ?? ""; + const imageSource = classifyMarkdownImageSource(srcString, cwd); + if (imageSource._tag === "Direct") { + return ( + {altText} + ); + } + if (imageSource._tag === "WorkspaceFile" && threadRef) { + return ( + + ); + } + return ; + }, table({ node: _node, ...props }) { return ; }, @@ -1767,6 +2208,7 @@ function ChatMarkdown({ }, }; }, [ + canUseShellActions, cwd, diffThemeName, fileLinkParentSuffixByPath, @@ -1776,12 +2218,18 @@ function ChatMarkdown({ onTaskListChange, openFileInPanel, openInPreferredEditor, + openChangeRequestLink, openExternalLinkInPreview, openMarkdownFileInPreview, + preferredEditorMenuLabel, + resolveThreadPullRequest, resolvedTheme, + revealMarkdownFileInFileManager, + revealInFileManagerLabel, skills, text, threadRef, + updateThreadPullRequestLink, ]); /* eslint-enable react/no-unstable-nested-components */ diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx new file mode 100644 index 000000000000..37a0f27a0ba3 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -0,0 +1,147 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + resources: [] as Array, + assetState: "success" as "success" | "loading", +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../assets/assetUrls", () => ({ + useAssetUrlState: (_environmentId: unknown, resource: unknown) => { + testState.resources.push(resource); + return testState.assetState === "loading" + ? { _tag: "Loading" } + : { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; + }, +})); +vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../state/session", async (importOriginal) => ({ + ...(await importOriginal()), + usePreparedConnection: () => ({ _tag: "Loading" }), +})); +vi.mock("../state/entities", () => ({ + readThreadShell: () => null, + useProjects: () => [], +})); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => undefined, + matchesLinkedPullRequestUrl: () => false, + parseChangeRequestUrl: () => null, + useOpenChangeRequestLink: () => vi.fn(), +})); + +import ChatMarkdown from "./ChatMarkdown"; + +const threadRef = { + environmentId: EnvironmentId.make("env-windows"), + threadId: ThreadId.make("thread-windows"), +}; + +function render(markdown: string): string { + return renderToStaticMarkup( + , + ); +} + +function renderWithoutThread(markdown: string): string { + return renderToStaticMarkup(); +} + +describe("ChatMarkdown workspace images", () => { + beforeEach(() => { + testState.resources = []; + testState.assetState = "success"; + }); + + it("loads every Windows workspace path form through a signed asset URL", () => { + const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg"; + const html = render( + [ + "![relative](.t3/workspace-image.svg)", + `![absolute](${imagePath})`, + `![file URL](file:///${imagePath})`, + "![UNC file URL](file://server/share/workspace-image.svg)", + ].join("\n\n"), + ); + + expect(testState.resources).toEqual([ + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", + }, + { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "\\\\server\\share\\workspace-image.svg", + }, + ]); + expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4); + expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4); + expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4); + expect(html).not.toContain("Image unavailable"); + }); + + it("normalizes a drive-absolute src in raw image HTML", () => { + const html = render(String.raw`raw`); + + expect(testState.resources).toEqual([ + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "D:/screens/workspace-image.svg", + }, + ]); + expect(html).toContain("https://signed.test/workspace-image.svg"); + }); + + it("uses a static placeholder while a signed asset URL loads", () => { + testState.assetState = "loading"; + + const html = render("![loading](.t3/workspace-image.svg)"); + + expect(html).toContain('aria-label="Loading image"'); + expect(html).not.toContain("animate-pulse"); + }); + + it("never passes a workspace source to a raw image when thread context is unavailable", () => { + const html = renderWithoutThread( + "![file URL](file:///C:/Users/shawn/project/workspace-image.svg)", + ); + + expect(testState.resources).toEqual([]); + expect(html).toContain("Image unavailable"); + expect(html).not.toContain("file://"); + }); + + it("blocks unsupported image schemes instead of passing them to a raw image", () => { + const html = render("![unsupported](content://media/image/1)"); + + expect(testState.resources).toEqual([]); + expect(html).toContain("Image unavailable"); + expect(html).not.toContain("content://"); + }); + + it("keeps remote images directly loadable", () => { + const html = render("![remote](https://example.com/image.png)"); + + expect(testState.resources).toEqual([]); + expect(html).toContain('src="https://example.com/image.png"'); + expect(html).toContain("max-w-[min(100%,30rem)]"); + expect(html).toContain("max-h-[30rem]"); + expect(html).not.toContain("Image unavailable"); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 3a85f1441059..f204d9932880 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -35,6 +35,7 @@ import { scheduleEnvironmentReconnectWarning, startNewThreadForProject, shouldDockDraftHeroForSubmission, + shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -78,6 +79,114 @@ describe("draft hero submission transition", () => { }); }); +describe("shouldReleaseTimelineAnchorForToolActivity", () => { + const activeTurnId = TurnId.make("active-turn"); + const anchorMessageId = MessageId.make("anchored-message"); + const activeToolEntry = { + id: "tool-entry", + kind: "work" as const, + createdAt: now, + entry: { + id: "active-tool", + createdAt: now, + turnId: activeTurnId, + label: "Run command", + tone: "tool" as const, + command: "git status", + }, + }; + + it("releases the send anchor for tool activity in the active turn", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }), + ).toBe(true); + }); + + it("keeps the anchor while the user reads history", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: false, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }), + ).toBe(false); + }); + + it("ignores tool activity from earlier turns", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [ + { + ...activeToolEntry, + entry: { + ...activeToolEntry.entry, + turnId: TurnId.make("previous-turn"), + }, + }, + ], + }), + ).toBe(false); + }); + + it("ignores thinking and error rows without tool activity", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [ + { + ...activeToolEntry, + entry: { + id: "thinking-entry", + createdAt: now, + turnId: activeTurnId, + label: "Thinking", + tone: "thinking", + }, + }, + { + ...activeToolEntry, + id: "error-entry", + entry: { + id: "error-entry", + createdAt: now, + turnId: activeTurnId, + label: "Provider error", + tone: "error", + }, + }, + ], + }), + ).toBe(false); + }); + + it("does nothing without an anchor or running turn", () => { + const input = { + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }; + + expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( + false, + ); + expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( + false, + ); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 7c20a1cbb240..40681a9f0315 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -2,6 +2,7 @@ import { type EnvironmentId, isProviderDriverKind, ProjectId, + type MessageId, type ModelSelection, type ProviderDriverKind, type ServerProvider, @@ -22,6 +23,7 @@ import { } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; +import type { TimelineEntry } from "../session-logic"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -42,6 +44,31 @@ export function shouldDockDraftHeroForSubmission(input: { ); } +export function shouldReleaseTimelineAnchorForToolActivity(input: { + anchorMessageId: MessageId | null; + liveFollowEnabled: boolean; + runningTurnId: TurnId | null; + timelineEntries: ReadonlyArray; +}): boolean { + if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) { + return false; + } + + return input.timelineEntries.some((timelineEntry) => { + if (timelineEntry.kind !== "work" || timelineEntry.entry.turnId !== input.runningTurnId) { + return false; + } + + const entry = timelineEntry.entry; + return ( + entry.tone === "tool" || + entry.itemType !== undefined || + entry.requestKind !== undefined || + (entry.command?.trim().length ?? 0) > 0 + ); + }); +} + export function resolveDraftHeroState(input: { isLocalDraftThread: boolean; hasTimelineEntries: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f3789b6c567d..aee15e2eddf1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -33,6 +33,12 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; +import { + codexFeedbackMessage, + parseCodexFeedbackCommand, + submitCodexFeedback, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; import { parseScopedThreadKey, scopedThreadKey, @@ -76,6 +82,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; @@ -124,6 +131,7 @@ import { type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; @@ -176,6 +184,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + Minimize2Icon, PaperclipIcon, WifiOffIcon, } from "lucide-react"; @@ -193,7 +202,11 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, +} from "../providerInstances"; import { useClientSettings, useClientSettingsHydrated, @@ -201,6 +214,7 @@ import { } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -219,6 +233,7 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRo import { beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, + composerDraftHasUserContent, type ComposerImageAttachment, type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, @@ -298,8 +313,15 @@ import { import { resolveDisplayedThreadPr, threadChangeRequestSnapshotsAtom, + useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { + hasAvailableClaudeCompactionProvider, + hasDismissedResumeCompaction, + shouldOfferResumeCompaction, +} from "./chat/ContextWindowMeter.logic"; +import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -326,6 +348,7 @@ import { hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldDockDraftHeroForSubmission, + shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -349,6 +372,12 @@ import { import type { ThreadSyncPhase } from "../threadSync"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; +import { + awaitAttachmentUploads, + getUploadedAttachments, + releaseAttachmentUploads, + startAttachmentUpload, +} from "../lib/attachmentUploadQueue"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; @@ -1250,6 +1279,7 @@ function ChatViewContent(props: ChatViewProps) { const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); + const { settleThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -1275,6 +1305,9 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { + reportFailure: false, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1354,6 +1387,9 @@ function ChatViewContent(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const composerHasUnsentContent = useComposerDraftStore((store) => + composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), + ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const setComposerDraftTerminalContexts = useComposerDraftStore( @@ -1390,6 +1426,16 @@ function ChatViewContent(props: ChatViewProps) { const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< + Record> + >({}); + const feedbackSubmissions = useMemo( + () => feedbackSubmissionsByThreadKey[routeThreadKey] ?? [], + [feedbackSubmissionsByThreadKey, routeThreadKey], + ); + const feedbackUploading = feedbackSubmissions.some( + (submission) => submission.status === "uploading", + ); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< @@ -1451,6 +1497,7 @@ function ChatViewContent(props: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); useLayoutEffect(() => { @@ -1742,6 +1789,9 @@ function ChatViewContent(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; + const activeRunningTurnId = + (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ?? + (activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null); // Reading a finished thread clears the sidebar's Done badge. The visit is // stamped at the turn's completion time — not now/updatedAt — so it clears // exactly the completion the user is looking at: a wake or completion that @@ -2076,6 +2126,10 @@ function ChatViewContent(props: ChatViewProps) { : (primaryEnvironment?.serverConfig ?? null); const pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; + const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; + const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; + const supportsAttachmentUploads = + attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2268,6 +2322,10 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + const activeContextWindow = useMemo( + () => deriveLatestContextWindowSnapshot(threadActivities), + [threadActivities], + ); const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the @@ -2610,16 +2668,29 @@ function ChatViewContent(props: ChatViewProps) { return changed ? { ...message, attachments } : message; }); - if (optimisticUserMessages.length === 0) { + const localMessages = [ + ...optimisticUserMessages, + ...feedbackSubmissions.flatMap((submission) => + submission.status === "interrupted" + ? [] + : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], + ), + ]; + if (localMessages.length === 0) { return serverMessagesWithPreviewHandoff; } const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); - const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); + const pendingMessages = localMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); + }, [ + attachmentPreviewHandoffByMessageId, + displayServerMessages, + feedbackSubmissions, + optimisticUserMessages, + ]); const timelineEntries = useMemo( () => deriveTimelineEntries( @@ -2718,6 +2789,29 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; + const compactionProviderAvailable = useMemo( + () => + hasAvailableClaudeCompactionProvider({ + providers: applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + ), + instanceId: activeProviderInstanceId, + lockedInstanceId: lockedProvider + ? (activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + null) + : null, + }), + [ + activeProviderInstanceId, + activeThread?.modelSelection.instanceId, + activeThread?.session?.providerInstanceId, + lockedProvider, + providerStatuses, + settings, + ], + ); const activeProviderStatus = useMemo(() => { if (activeProviderInstanceId) { return ( @@ -2727,6 +2821,25 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = + useLocalStorage( + `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, + false, + Schema.Boolean, + ); + const nativeResumeCompactionDismissed = useMemo( + () => hasDismissedResumeCompaction(threadActivities), + [threadActivities], + ); + useEffect(() => { + if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) { + setResumeCompactionPermanentlyDismissed(true); + } + }, [ + nativeResumeCompactionDismissed, + resumeCompactionPermanentlyDismissed, + setResumeCompactionPermanentlyDismissed, + ]); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -3369,24 +3482,48 @@ function ChatViewContent(props: ChatViewProps) { ); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. - const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; + const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( + (number: number) => { + if (!supportsPullRequests || !activeThreadRef) { + return; + } + const projectId = linkedThreadPullRequest?.projectId ?? activeProject?.id; + const repository = linkedThreadPullRequest?.repository ?? activeProjectRepository; + if (projectId === undefined || repository === null) return; + useRightPanelStore.getState().openPullRequest(activeThreadRef, { + projectId, + repository, + number, + }); + }, + [ + activeProject, + activeProjectRepository, + activeThreadRef, + linkedThreadPullRequest, + supportsPullRequests, + ], + ); + const openProjectPullRequest = useCallback( (number: number) => { if ( !supportsPullRequests || !activeThreadRef || !activeProject || - threadRepository === null + activeProjectRepository === null ) { return; } useRightPanelStore.getState().openPullRequest(activeThreadRef, { projectId: activeProject.id, - repository: threadRepository, + repository: activeProjectRepository, number, }); }, - [activeProject, activeThreadRef, supportsPullRequests, threadRepository], + [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests], ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; @@ -3868,6 +4005,8 @@ function ChatViewContent(props: ChatViewProps) { liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); @@ -3876,6 +4015,28 @@ function ChatViewContent(props: ChatViewProps) { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); + useLayoutEffect(() => { + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } + + if ( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId: timelineAnchorMessageId, + liveFollowEnabled: timelineLiveFollowEnabled, + runningTurnId: activeRunningTurnId, + timelineEntries, + }) + ) { + scrollToEnd(); + } + }, [ + activeRunningTurnId, + scrollToEnd, + timelineAnchorMessageId, + timelineEntries, + timelineLiveFollowEnabled, + ]); useEffect(() => { let removeListeners: (() => void) | null = null; let frame: number | null = null; @@ -4240,11 +4401,17 @@ function ChatViewContent(props: ChatViewProps) { : null; const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); + const linkedPullRequestStatus = useLinkedThreadPullRequest( + activeThreadRef?.environmentId ?? null, + linkedThreadPullRequest, + ); const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, + linkedPullRequest: linkedThreadPullRequest, + linkedPullRequestStatus, }); // The right panel offers the thread's own change request, so it can only offer it once the // branch has one; until then the picker says so rather than opening an empty panel. @@ -4417,18 +4584,6 @@ function ChatViewContent(props: ChatViewProps) { // Dismissal lives in a module-level set (survives remounts); this tick just // forces a re-render so the banner leaves immediately. const [, setBranchMismatchDismissTick] = useState(0); - const composerHasDraftContent = useComposerDraftStore((store) => { - const draft = store.getComposerDraft(composerDraftTarget); - return Boolean( - draft && - (draft.prompt.trim().length > 0 || - draft.images.length > 0 || - draft.terminalContexts.length > 0 || - draft.elementContexts.length > 0 || - draft.previewAnnotations.length > 0 || - draft.reviewComments.length > 0), - ); - }); const activeBranchMismatchKey = branchMismatchKey( activeThread?.id ?? null, localCheckoutBranchMismatch, @@ -4436,7 +4591,7 @@ function ChatViewContent(props: ChatViewProps) { const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ hasMismatch: localCheckoutBranchMismatch !== null, isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), - composerHasContent: composerHasDraftContent, + composerHasContent: composerHasUnsentContent, wasShownForCurrentMismatch: revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, }); @@ -4688,6 +4843,107 @@ function ChatViewContent(props: ChatViewProps) { isUnsnoozing, isUnsettling, ]); + // Session-scoped dismissals, one key per (thread, snapshot). A set rather + // than a single slot so dismissing the banner on one thread does not + // resurface it on another thread dismissed earlier. + const [dismissedResumeCompactionKeys, setDismissedResumeCompactionKeys] = useState< + ReadonlySet + >(new Set()); + const resumeCompactionKey = + activeThread && activeContextWindow + ? `${activeThread.id}:${activeContextWindow.updatedAt}` + : null; + const compactDisabled = + !activeThread || + !activeProject || + !isServerThread || + selectedProvider !== "claudeAgent" || + !compactionProviderAvailable || + isWorking || + threadDetailLoading || + isPreparingWorktree || + activeEnvironmentUnavailable || + feedbackUploading || + pendingApprovals.length > 0 || + pendingUserInputs.length > 0 || + showPlanFollowUpPrompt || + composerHasUnsentContent; + const compactDisabledReason = compactDisabled + ? composerHasUnsentContent + ? "Send or clear your draft before compacting" + : !activeProject + ? "Choose a project before compacting" + : !compactionProviderAvailable + ? "Enable a Claude provider before compacting" + : "Compacting is unavailable right now" + : null; + const resumeCompactionBannerItem = useMemo(() => { + if ( + !activeThread || + !activeContextWindow || + resumeCompactionKey === null || + dismissedResumeCompactionKeys.has(resumeCompactionKey) || + resumeCompactionPermanentlyDismissed || + nativeResumeCompactionDismissed || + pendingUserInputs.length > 0 || + phase === "running" || + !shouldOfferResumeCompaction({ + provider: selectedProvider, + usedTokens: activeContextWindow.usedTokens, + updatedAt: activeContextWindow.updatedAt, + now: `${nowMinute}:00.000Z`, + }) + ) { + return null; + } + + const dismiss = () => + setDismissedResumeCompactionKeys((keys) => new Set(keys).add(resumeCompactionKey)); + const compactAction = ( + + ); + return { + id: `resume-compaction:${resumeCompactionKey}`, + variant: "info", + icon: , + title: "Resume with less context", + description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older session`, + actions: compactDisabledReason ? ( + + {compactAction}} /> + {compactDisabledReason} + + ) : ( + compactAction + ), + dismissLabel: "Keep full history", + onDismiss: dismiss, + }; + }, [ + activeContextWindow, + activeThread, + compactDisabled, + compactDisabledReason, + composerRef, + dismissedResumeCompactionKeys, + nativeResumeCompactionDismissed, + nowMinute, + pendingUserInputs.length, + phase, + resumeCompactionKey, + resumeCompactionPermanentlyDismissed, + selectedProvider, + ]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -4702,6 +4958,8 @@ function ChatViewContent(props: ChatViewProps) { const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; + const resumeCompactionItems = + resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { @@ -4709,6 +4967,7 @@ function ChatViewContent(props: ChatViewProps) { ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, + ...resumeCompactionItems, ...wokeThreadItems, ...parkedThreadItems, ]; @@ -4717,6 +4976,7 @@ function ChatViewContent(props: ChatViewProps) { ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, + ...resumeCompactionItems, ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, @@ -4766,6 +5026,7 @@ function ChatViewContent(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, wokeThreadBannerItem, @@ -4891,6 +5152,29 @@ function ChatViewContent(props: ChatViewProps) { }); if (!command) return; + if (command === "thread.settle") { + event.preventDefault(); + event.stopPropagation(); + if (!isServerThread || !activeThreadRef || !supportsSettlement) return; + if (activeThreadSettled) { + void handleUnsettleActiveThread(); + return; + } + + void settleThread(activeThreadRef).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + return; + } + if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); @@ -4994,6 +5278,8 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeRightPanelSurface, addTerminalSurface, + activeThreadRef, + activeThreadSettled, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, @@ -5005,7 +5291,11 @@ function ChatViewContent(props: ChatViewProps) { splitTerminal, splitPanelTerminal, keybindings, + handleUnsettleActiveThread, + isServerThread, onToggleDiff, + settleThread, + supportsSettlement, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -5096,7 +5386,8 @@ function ChatViewContent(props: ChatViewProps) { isSendBusy || isConnecting || threadDetailLoading || - sendInFlightRef.current + sendInFlightRef.current || + feedbackUploadsInFlightRef.current.has(routeThreadKey) ) { notifyDirectAnnotationAttached(); return; @@ -5171,6 +5462,101 @@ function ChatViewContent(props: ChatViewProps) { composerPreviewAnnotations.length + composerReviewComments.length, }); + const feedbackCommand = + ctxSelectedProvider === "codex" && + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0 + ? parseCodexFeedbackCommand(trimmed) + : null; + if (feedbackCommand) { + if (!isServerThread || activeThread.session === null) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Start a Codex thread first", + description: "Send a message before you submit feedback.", + }), + ); + return; + } + feedbackUploadsInFlightRef.current.add(routeThreadKey); + const result = await submitCodexFeedback({ + submission: { + id: newMessageId(), + command: trimmed, + createdAt: new Date().toISOString(), + }, + clearDraft: () => { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + scrollToEnd(); + }, + onUpdate: (submission) => { + setFeedbackSubmissionsByThreadKey((current) => { + const existing = current[routeThreadKey] ?? []; + const found = existing.some((entry) => entry.id === submission.id); + return { + ...current, + [routeThreadKey]: found + ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) + : [...existing, submission], + }; + }); + }, + upload: () => + uploadThreadFeedback({ + environmentId, + input: { + threadId: activeThread.id, + ...feedbackCommand, + }, + }), + }).finally(() => { + feedbackUploadsInFlightRef.current.delete(routeThreadKey); + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not send feedback to OpenAI", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + } + return; + } + const feedbackId = result.value.feedbackId; + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Feedback sent to OpenAI", + description: `Thread ID: ${feedbackId}`, + timeout: 0, + actionProps: { + children: "Copy ID", + onClick: () => { + void writeTextToClipboard(feedbackId, "Codex feedback thread ID").catch( + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy thread ID", + description: chatActionErrorMessage(error), + }), + ); + }, + ); + }, + }, + }), + ); + return; + } if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, @@ -5283,9 +5669,21 @@ function ChatViewContent(props: ChatViewProps) { return; } + sendInFlightRef.current = true; + if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) { + for (const image of composerImagesSnapshot) { + startAttachmentUpload({ environmentId, image }); + } + await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id)); + if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) { + sendInFlightRef.current = false; + setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending."); + return; + } + } + const resolvedSubmissionIntent = submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; - sendInFlightRef.current = true; if ( shouldDockDraftHeroForSubmission({ isDraftHeroState, @@ -5316,13 +5714,22 @@ function ChatViewContent(props: ChatViewProps) { const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), - })), + composerImagesSnapshot.map(async (image) => { + if (supportsAttachmentUploads) { + const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0]; + if (!uploaded) { + throw new Error(`Image '${image.name}' did not finish uploading.`); + } + return uploaded; + } + return { + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + }; + }), ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, @@ -5509,6 +5916,9 @@ function ChatViewContent(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + if (supportsAttachmentUploads) { + releaseAttachmentUploads(composerImagesSnapshot); + } acknowledgeActiveThreadWoke(); if (backgroundThreadRef) { markPromotedDraftThreadByRef(backgroundThreadRef); @@ -6390,7 +6800,7 @@ function ChatViewContent(props: ChatViewProps) { context={ isThreadOwnPullRequest( { - projectId: activeProject?.id ?? null, + projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null, repository: threadRepository, number: activeThreadPr?.number ?? null, }, @@ -6463,9 +6873,9 @@ function ChatViewContent(props: ChatViewProps) { > {!rightPanelOpen ? panelLayoutControls : null} { type: "loading", title: "Updating provider", }); + expect(shouldShowPrimaryProviderUpdateToast(view)).toBe(false); + }); + + it("keeps the initial prompt and terminal outcomes visible as toasts", () => { + expect( + shouldShowPrimaryProviderUpdateToast( + getProviderUpdateInitialToastView({ + updateProviders: [updateCandidate({ driver: driver("codex") })], + oneClickProviders: [updateCandidate({ driver: driver("codex") })], + }), + ), + ).toBe(true); + expect( + shouldShowPrimaryProviderUpdateToast(getProviderUpdateRejectedToastView(1, "boom")), + ).toBe(true); }); it("uses server failure state for failed progress", () => { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 55999d2a31d8..8d8abf73e312 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -231,6 +231,10 @@ export function getProviderUpdateInitialToastView(input: { }; } +export function shouldShowPrimaryProviderUpdateToast(view: ProviderUpdateToastView): boolean { + return view.phase !== "running"; +} + export function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { return { phase: "running", diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index 00112ccec198..639f07c38c13 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -16,8 +16,8 @@ import { getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, - getProviderUpdateRunningToastView, providerUpdateNotificationKey, + shouldShowPrimaryProviderUpdateToast, type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast"; @@ -31,7 +31,6 @@ type ActiveProviderUpdateToast = | { readonly kind: "update"; readonly key: string; - readonly toastId: ProviderUpdateToastId; readonly providerInstanceIds: ReadonlySet; readonly providerCount: number; }; @@ -57,20 +56,16 @@ function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) ); } -function updateProviderUpdateToast(input: { - readonly toastId: ProviderUpdateToastId; +function addProviderUpdateToast(input: { readonly view: ProviderUpdateToastView; - readonly openSettings: () => void; + readonly openSettings: (toastId: ProviderUpdateToastId) => void; }) { if (input.view.type === "loading" || input.view.type === "success") { - toastManager.update(input.toastId, { + return toastManager.add({ type: input.view.type, title: input.view.title, description: input.view.description, timeout: 0, - // Base UI merges toast updates and omits `undefined` keys, so `undefined` - // would leave the prompt's Update button in place. Replace it with a - // defined empty action so the CTA cannot linger while the update runs. actionProps: hiddenToastActionProps, data: { hideCopyButton: true, @@ -79,11 +74,10 @@ function updateProviderUpdateToast(input: { : {}), }, }); - return; } - toastManager.update( - input.toastId, + let toastId!: ProviderUpdateToastId; + toastId = toastManager.add( stackedThreadToast({ type: input.view.type, title: input.view.title, @@ -91,7 +85,7 @@ function updateProviderUpdateToast(input: { timeout: 0, actionProps: { children: "Settings", - onClick: input.openSettings, + onClick: () => input.openSettings(toastId), }, actionVariant: "outline", data: { @@ -99,10 +93,7 @@ function updateProviderUpdateToast(input: { }, }), ); -} - -function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { - return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded"; + return toastId; } /** @@ -126,10 +117,10 @@ export function ProviderUpdatePrimaryNotification() { useEffect(() => { return () => { const activeToast = activeToastRef.current; - if (activeToast) { + if (activeToast?.kind === "prompt") { toastManager.close(activeToast.toastId); - activeToastRef.current = null; } + activeToastRef.current = null; }; }, []); @@ -149,10 +140,14 @@ export function ProviderUpdatePrimaryNotification() { const activeToast = activeToastRef.current; if (toastId !== undefined) { toastManager.close(toastId); - } else if (activeToast) { + } else if (activeToast?.kind === "prompt") { toastManager.close(activeToast.toastId); } - if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) { + if ( + activeToast && + (toastId === undefined || + (activeToast.kind === "prompt" && activeToast.toastId === toastId)) + ) { activeToastRef.current = null; } void navigate({ to: "/settings/providers" }); @@ -173,15 +168,12 @@ export function ProviderUpdatePrimaryNotification() { providers: activeProviders, providerCount: activeToast.providerCount, }); - updateProviderUpdateToast({ - toastId: activeToast.toastId, - view, - openSettings: () => openProviderSettings(activeToast.toastId), - }); - - if (isTerminalProviderUpdateToastView(view)) { - activeToastRef.current = null; + if (!shouldShowPrimaryProviderUpdateToast(view)) { + return; } + + addProviderUpdateToast({ view, openSettings: openProviderSettings }); + activeToastRef.current = null; }, [providers, openProviderSettings]); useEffect(() => { @@ -219,19 +211,15 @@ export function ProviderUpdatePrimaryNotification() { const providerCount = oneClickProviders.length; const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId)); - activeToastRef.current = { + const activeUpdate: ActiveProviderUpdateToast = { kind: "update", key: notificationKey, - toastId, providerInstanceIds, providerCount, }; + activeToastRef.current = activeUpdate; - updateProviderUpdateToast({ - toastId, - view: getProviderUpdateRunningToastView(providerCount), - openSettings, - }); + toastManager.close(toastId); void (async () => { const results = []; @@ -248,16 +236,15 @@ export function ProviderUpdatePrimaryNotification() { } const activeUpdateToast = activeToastRef.current; - if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { + if (activeUpdateToast !== activeUpdate) { return; } const failedMessage = firstFailedProviderUpdateMessage(results); if (failedMessage) { - updateProviderUpdateToast({ - toastId, + addProviderUpdateToast({ view: getProviderUpdateRejectedToastView(providerCount, failedMessage), - openSettings, + openSettings: openProviderSettings, }); activeToastRef.current = null; return; @@ -271,13 +258,8 @@ export function ProviderUpdatePrimaryNotification() { providers: updatedProviderSnapshots, providerCount, }); - updateProviderUpdateToast({ - toastId, - view, - openSettings, - }); - - if (isTerminalProviderUpdateToastView(view)) { + if (shouldShowPrimaryProviderUpdateToast(view)) { + addProviderUpdateToast({ view, openSettings: openProviderSettings }); activeToastRef.current = null; } })(); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5cc421db3542..9d057a3d2980 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -10,7 +10,6 @@ import { TerminalSquare, Volume2, VolumeOff, - X, } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, @@ -33,6 +32,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; import { faviconUrlForOrigin } from "~/lib/favicon"; import { useTheme } from "~/hooks/useTheme"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; @@ -823,29 +823,24 @@ export function RightPanelTabs(props: RightPanelTabsProps) { : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - + ) : null} + {audio === "none" || !audioRuntimeTabId ? null : ( { expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); }); + + it("surfaces an un-settled thread at the top via its re-entry stamp", () => { + const sorted = sortThreadsForSidebar([ + { + id: "old-unsettled", + createdAt: "2026-03-09T08:00:00.000Z", + unsettledAt: "2026-03-09T13:00:00.000Z", + }, + sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), + sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]); + }); + + it("ignores a re-entry stamp older than the thread's creation", () => { + const sorted = sortThreadsForSidebar([ + { + id: "stale-stamp", + createdAt: "2026-03-09T10:00:00.000Z", + unsettledAt: "2026-03-09T09:00:00.000Z", + }, + sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "stale-stamp"]); + }); }); describe("pinOrderKeyBetween", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 250807371dd5..5f2ab2243880 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -3,6 +3,7 @@ import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { + activeThreadAnchorTimestampMs, getThreadSortTimestamp, sortThreads, toSortableTimestamp, @@ -15,7 +16,7 @@ import { isLatestTurnSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. Each prewarmed // thread holds a live, fully hydrated detail subscription (all messages and @@ -538,16 +539,23 @@ export function firstValidTimestamp( return null; } -// Sidebar sort: static creation order, newest thread on top. Activity NEVER -// reorders the list — a row holds its position from open until settled, so -// the screen only moves at lifecycle transitions. Status (including pending -// approval) is carried by each card's edge strip, not by position. +// Sidebar sort: static order, newest anchor on top. Activity NEVER reorders +// the list — a row holds its position between lifecycle transitions, so the +// screen only moves when a thread enters or leaves the active list. The +// anchor is creation time until an un-settle re-anchors it (see +// activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the +// top instead of sinking back to its creation-order slot. Status (including +// pending approval) is carried by each card's edge strip, not by position. export function sortThreadsForSidebar< - T extends { readonly id: string; readonly createdAt: string }, + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + }, >(threads: readonly T[]): T[] { return [...threads].toSorted( (left, right) => - parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || left.id.localeCompare(right.id), ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7801258f6635..333a4a15e7df 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -90,6 +90,7 @@ import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { @@ -143,6 +144,7 @@ import { sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, sortThreadsForSidebar, + useThreadJumpHintVisibility, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { @@ -157,6 +159,7 @@ import { threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, type TerminalStatusIndicator, + useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, @@ -657,6 +660,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { // The /draft/$draftId route redirects home on its own when the draft // it renders disappears, so discarding the open draft needs no // special-casing here. + releaseComposerDraftUploads(draftId); clearDraftThread(draftId); }, [clearDraftThread], @@ -707,13 +711,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; - // Renders the pin glyph. Pinned cards keep the full settle/snooze quick - // actions: settling clears the pin server-side, and snoozing hides the - // card until wake with the pin intact underneath. The glyph is also the - // in-row pin state cue (the pinned block has no header), so it always - // shows while pinned; it only becomes a clickable unpin quick-action once - // the pinning capability is confirmed, and stays a passive marker while - // the descriptor is not loaded. Pinning itself lives in the context menu. + // Pinned threads show the same pin marker in active, settled, and snoozed + // rows. The marker can unpin the thread when the server supports pinning. pinningSupported: boolean; isPinned: boolean; // Present only on pinned cards whose server supports reordering: dnd-kit @@ -797,6 +796,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const terminalProcessCount = runningTerminalIds.length; const gitCwd = thread.worktreePath ?? props.projectCwd; + const linkedPullRequestStatus = useLinkedThreadPullRequest( + thread.environmentId, + thread.linkedPullRequest, + ); const gitStatus = useEnvironmentQuery( (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ @@ -811,6 +814,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, + linkedPullRequest: thread.linkedPullRequest, + linkedPullRequestStatus, }); // Same semantics as the legacy sidebar (never-visited counts as read): @@ -911,6 +916,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, + linkedPullRequest: thread.linkedPullRequest, + linkedPullRequestStatus, }); const prStatus = prStatusIndicator(pr, prProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; @@ -920,15 +927,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, + linkedPullRequest: thread.linkedPullRequest, + linkedPullRequestStatus, }); if (nextSnapshot === undefined) return; onChangeRequestSnapshot(threadKey, nextSnapshot); }, [ changeRequestSnapshot, gitStatus.data, + linkedPullRequestStatus, onChangeRequestSnapshot, retainTerminalOnBranchMismatch, thread.branch, + thread.linkedPullRequest, threadKey, ]); @@ -1199,6 +1210,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; + const pinIndicator = props.isPinned ? ( + props.pinningSupported ? ( + + + } + > + + + Unpin thread + + ) : ( + + ) + ) : null; if (variant === "slim") { return ( @@ -1240,6 +1276,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> {title} + {pinIndicator} {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1409,31 +1446,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} - {props.isPinned ? ( - props.pinningSupported ? ( - - - } - > - - - Unpin thread - - ) : ( - - ) - ) : null} + {pinIndicator} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim @@ -2089,23 +2102,17 @@ export default function Sidebar() { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const snapshot = changeRequestSnapshotByKey.get(threadKey); const changeRequest = - snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) + snapshot != null && + (thread.linkedPullRequest == null + ? thread.worktreePath === null || snapshot.branch === thread.branch + : snapshot.linkedPullRequest?.projectId === thread.linkedPullRequest.projectId && + snapshot.linkedPullRequest.repository === thread.linkedPullRequest.repository && + snapshot.linkedPullRequest.number === thread.linkedPullRequest.number) ? snapshot.pr : null; - // Snooze outranks everything, including a pin: "hide until Tuesday" - // temporarily suspends "keep on top". The pin survives underneath — - // and so does its pinOrderKey, so on wake the thread reappears at - // its exact slot in the pinned block. (For unpinned threads - // this is also the snooze-beats-auto-settle rule: the wake time is a - // stronger statement about when the thread matters again.) + // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); - // A pin otherwise overrides the lifecycle: pinned threads never - // auto-settle out of sight. (The decider clears settled state on - // pin and the pin on settle, so pin-vs-settled conflicts only - // arise from stale or raced writes.) - } else if (thread.pinnedAt != null) { - pinned.push(thread); } else if ( supportsSettlement && effectiveSettled(thread, { @@ -2116,6 +2123,8 @@ export default function Sidebar() { }) ) { settled.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); } else { active.push(thread); } @@ -2357,7 +2366,7 @@ export default function Sidebar() { } return mapping; }, [keybindings, orderedThreadKeys]); - const [showJumpHints, setShowJumpHints] = useState(false); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); // Settled threads are live shells, so opening one is plain navigation: // history stays readable without un-settling, and sending a message or @@ -3391,8 +3400,8 @@ export default function Sidebar() { }, ); useEffect(() => { - setShowJumpHints(shouldShowJumpHintsNow); - }, [shouldShowJumpHintsNow]); + updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); + }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { if (!node) return; @@ -3579,7 +3588,7 @@ export default function Sidebar() { All projects @@ -3591,7 +3600,7 @@ export default function Sidebar() { key={scopeKey} value={scopeKey} closeOnClick - className="h-8 min-h-8 px-1 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2" + className="h-8 min-h-8 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2" > { - 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/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 91a5829b95ad..3710bcea8b8e 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -101,6 +101,12 @@ describe("resolveThreadPr", () => { describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { const featureBranch = "feature/current"; const mergedPr = mergedFeaturePr(); + const linkedPullRequest = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; const provider = { kind: "github" as const, name: "GitHub", @@ -132,6 +138,119 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { ).toEqual(provider); }); + it("shows a linked pull request when the checkout has a different branch", () => { + const linkedPullRequestStatus = { + pr: mergedPr, + sourceControlProvider: provider, + }; + + expect( + resolveDisplayedThreadPr({ + threadBranch: "feature/other", + gitStatus: status({ refName: "feature/other", pr: null }), + snapshot: undefined, + retainTerminalOnBranchMismatch: false, + linkedPullRequest, + linkedPullRequestStatus, + }), + ).toEqual(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: "feature/other", + gitStatus: status({ refName: "feature/other", pr: null }), + snapshot: undefined, + retainTerminalOnBranchMismatch: false, + linkedPullRequest, + linkedPullRequestStatus, + }), + ).toEqual(provider); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "feature/other", + gitStatus: status({ refName: "feature/other", pr: null }), + snapshot: undefined, + retainTerminalOnBranchMismatch: false, + linkedPullRequest, + linkedPullRequestStatus, + }), + ).toEqual({ + branch: "feature/other", + pr: mergedPr, + sourceControlProvider: provider, + linkedPullRequest, + }); + }); + + it("keeps a matching linked pull request snapshot while its status reloads", () => { + const snapshot = { + ...snapshotFor(featureBranch, mergedPr, provider), + linkedPullRequest, + }; + + expect( + resolveDisplayedThreadPr({ + threadBranch: null, + gitStatus: null, + snapshot, + retainTerminalOnBranchMismatch: false, + linkedPullRequest, + linkedPullRequestStatus: null, + }), + ).toEqual(mergedPr); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: null, + gitStatus: null, + snapshot, + retainTerminalOnBranchMismatch: false, + linkedPullRequest, + linkedPullRequestStatus: null, + }), + ).toBeUndefined(); + }); + + it("clears an old snapshot when a different pull request is linked", () => { + const snapshot = { + ...snapshotFor(featureBranch, mergedPr, provider), + linkedPullRequest: { ...linkedPullRequest, number: 41 }, + }; + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: null, + snapshot, + retainTerminalOnBranchMismatch: true, + linkedPullRequest, + linkedPullRequestStatus: null, + }), + ).toBeNull(); + }); + + it("removes a linked pull request snapshot after the link is cleared", () => { + const snapshot = { + ...snapshotFor(featureBranch, mergedPr, provider), + linkedPullRequest, + }; + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: null, + snapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + it("after caching a merged PR, resolves main status back to the cached feature PR", () => { const matchingStatus = status({ refName: featureBranch, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index cfb726271966..843d310dd441 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -3,7 +3,8 @@ import { scopedThreadKey, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import type { VcsStatusResult } from "@t3tools/contracts"; +import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; +import type { EnvironmentId, ThreadLinkedPullRequest, VcsStatusResult } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; @@ -11,6 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; +import { linkedPullRequestDetailAtom } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; @@ -37,6 +39,44 @@ export interface TerminalStatusIndicator { export type ThreadPr = VcsStatusResult["pr"]; +export interface LinkedThreadPullRequestStatus { + readonly pr: NonNullable; + readonly sourceControlProvider: NonNullable; +} + +export function useLinkedThreadPullRequest( + environmentId: EnvironmentId | null, + linkedPullRequest: ThreadLinkedPullRequest | null | undefined, +): LinkedThreadPullRequestStatus | null { + const detail = useEnvironmentQuery( + environmentId === null || linkedPullRequest == null + ? null + : linkedPullRequestDetailAtom({ + environmentId, + input: { + projectId: linkedPullRequest.projectId, + repository: linkedPullRequest.repository, + number: linkedPullRequest.number, + }, + }), + ).data; + + return useMemo( + () => + detail === null + ? null + : { + pr: pullRequestDetailToVcsStatus(detail), + sourceControlProvider: { + kind: detail.provider, + name: detail.provider, + baseUrl: "", + }, + }, + [detail], + ); +} + export function settledPrHoverColorClass(state: NonNullable["state"]): string { switch (state) { case "open": @@ -136,6 +176,7 @@ export interface ThreadChangeRequestSnapshot { readonly branch: string; readonly pr: NonNullable; readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; + readonly linkedPullRequest?: ThreadLinkedPullRequest; } export const threadChangeRequestSnapshotsAtom = Atom.make< @@ -157,6 +198,19 @@ function sourceControlProvidersEqual( return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; } +function linkedPullRequestsEqual( + left: ThreadLinkedPullRequest | null | undefined, + right: ThreadLinkedPullRequest | null | undefined, +): boolean { + if (left == null || right == null) return left == null && right == null; + return ( + left.projectId === right.projectId && + left.repository === right.repository && + left.number === right.number && + left.url === right.url + ); +} + export function threadChangeRequestSnapshotsEqual( left: ThreadChangeRequestSnapshot, right: ThreadChangeRequestSnapshot, @@ -170,7 +224,8 @@ export function threadChangeRequestSnapshotsEqual( left.pr.headRef === right.pr.headRef && left.pr.state === right.pr.state && (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && - sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) + sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && + linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) ); } @@ -206,10 +261,32 @@ export function nextThreadChangeRequestSnapshot(input: { gitStatus: VcsStatusResult | null; snapshot: ThreadChangeRequestSnapshot | null | undefined; retainTerminalOnBranchMismatch: boolean; + linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): ThreadChangeRequestSnapshot | null | undefined { - const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + const { + threadBranch, + gitStatus, + snapshot, + retainTerminalOnBranchMismatch, + linkedPullRequest, + linkedPullRequestStatus, + } = input; + if (linkedPullRequest != null) { + if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) { + return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) + ? undefined + : null; + } + return { + branch: threadBranch ?? linkedPullRequestStatus.pr.headRef, + pr: linkedPullRequestStatus.pr, + sourceControlProvider: linkedPullRequestStatus.sourceControlProvider, + linkedPullRequest, + }; + } if (gitStatus === null) { - return undefined; + return snapshot?.linkedPullRequest === undefined ? undefined : null; } if (threadBranch === null) { return null; @@ -217,6 +294,7 @@ export function nextThreadChangeRequestSnapshot(input: { if (gitStatus.refName !== threadBranch) { return retainTerminalOnBranchMismatch && snapshot != null && + snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ? undefined : null; @@ -225,6 +303,7 @@ export function nextThreadChangeRequestSnapshot(input: { if ( retainTerminalOnBranchMismatch && snapshot != null && + snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ) { return undefined; @@ -250,8 +329,25 @@ export function resolveDisplayedThreadPr(input: { gitStatus: VcsStatusResult | null; snapshot: ThreadChangeRequestSnapshot | null | undefined; retainTerminalOnBranchMismatch: boolean; + linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): ThreadPr | null { - const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + const { + threadBranch, + gitStatus, + snapshot, + retainTerminalOnBranchMismatch, + linkedPullRequest, + linkedPullRequestStatus, + } = input; + if (linkedPullRequest != null) { + return ( + linkedPullRequestStatus?.pr ?? + (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) + ? (snapshot?.pr ?? null) + : null) + ); + } if ( threadBranch !== null && gitStatus !== null && @@ -265,6 +361,7 @@ export function resolveDisplayedThreadPr(input: { threadBranch !== null && retainTerminalOnBranchMismatch && snapshot != null && + snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ) { return snapshot.pr; @@ -278,8 +375,25 @@ export function resolveDisplayedThreadPrProvider(input: { gitStatus: VcsStatusResult | null; snapshot: ThreadChangeRequestSnapshot | null | undefined; retainTerminalOnBranchMismatch: boolean; + linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): VcsStatusResult["sourceControlProvider"] | undefined { - const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + const { + threadBranch, + gitStatus, + snapshot, + retainTerminalOnBranchMismatch, + linkedPullRequest, + linkedPullRequestStatus, + } = input; + if (linkedPullRequest != null) { + return ( + linkedPullRequestStatus?.sourceControlProvider ?? + (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) + ? snapshot?.sourceControlProvider + : undefined) + ); + } if ( threadBranch !== null && gitStatus !== null && @@ -293,6 +407,7 @@ export function resolveDisplayedThreadPrProvider(input: { threadBranch !== null && retainTerminalOnBranchMismatch && snapshot != null && + snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ) { return snapshot.sourceControlProvider; @@ -417,19 +532,28 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar ); const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd; + const linkedPullRequest = useLinkedThreadPullRequest( + thread.environmentId, + thread.linkedPullRequest, + ); const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + thread.linkedPullRequest == null && + (thread.branch != null || thread.worktreePath !== null) && + gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); - const pr = resolveThreadPr({ - threadBranch: thread.branch, - gitStatus: gitStatus.data, - }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const pr = + thread.linkedPullRequest == null + ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) + : (linkedPullRequest?.pr ?? null); + const prStatus = prStatusIndicator( + pr, + linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, + ); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ec2e63d4146d..abd9bf9edfd5 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -6,11 +6,11 @@ import { import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; import { Plus, + Square, SquareSplitHorizontal, SquareSplitVertical, TerminalSquare, Trash2, - XIcon, } from "lucide-react"; import { type ContextMenuItem, @@ -33,6 +33,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; +import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; @@ -42,7 +43,7 @@ import { } from "~/terminal/ghostty/surface"; import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; import { useOpenInPreferredEditor } from "../editorPreferences"; -import { isTerminalLinkActivation, resolvePathLinkTarget } from "../terminal-links"; +import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; import { isDiffToggleShortcut, isTerminalClearShortcut, @@ -749,7 +750,7 @@ export function TerminalViewport({ if (!isTerminalLinkActivation(event)) return; const latestTerminal = terminalRef.current; if (!latestTerminal) return; - if (/^https?:\/\//u.test(text)) { + if (isTerminalUrl(text)) { if (!localApi) { writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); return; @@ -1622,85 +1623,76 @@ export default function ThreadTerminalDrawer({
- {resolvedTerminalGroups.map((terminalGroup, groupIndex) => { + {resolvedTerminalGroups.map((terminalGroup) => { const isGroupActive = terminalGroup.terminalIds.includes(resolvedActiveTerminalId); const groupActiveTerminalId = isGroupActive ? resolvedActiveTerminalId : (terminalGroup.terminalIds[0] ?? resolvedActiveTerminalId); + const terminalCount = terminalGroup.terminalIds.length; + const isSplitGroup = terminalCount > 1; + const groupLabel = !isSplitGroup + ? "Single" + : terminalGroup.splitDirection === "vertical" + ? "Stacked" + : "Side by side"; + const GroupIcon = !isSplitGroup + ? Square + : terminalGroup.splitDirection === "vertical" + ? SquareSplitVertical + : SquareSplitHorizontal; return (
{showGroupHeaders && ( )} -
+
{terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === resolvedActiveTerminalId; - const closeTerminalLabel = `Close ${ - terminalLabelById.get(terminalId) ?? "terminal" - }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; + const terminalLabel = terminalLabelById.get(terminalId) ?? "Terminal"; + const closeTerminalLabel = `Close ${terminalLabel}${ + isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : "" + }`; return (
- {showGroupHeaders && ( - + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} + > + confirmCloseTerminal(terminalId)} + tooltip={closeTerminalLabel} + > + + - {normalizedTerminalIds.length > 1 && ( - - confirmCloseTerminal(terminalId)} - aria-label={closeTerminalLabel} - /> - } - > - - - - {closeTerminalLabel} - - - )}
); })} diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 1212030ba339..906bf4c34cb4 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -66,7 +66,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { className={cn( "flex items-center justify-between gap-2 rounded-xl", expanded && - "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]", + "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--contrast-foreground)_2.5%,var(--background))]", )} >
@@ -2893,6 +3018,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
@@ -3096,9 +3222,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) - removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) - } + {...(supportsAttachmentUploads + ? { + uploadsByImageId, + onRetryUpload: (image: ComposerImageAttachment) => + retryAttachmentUpload({ environmentId, image }), + } + : {})} + onRemove={(annotationId) => { + releaseAttachmentUpload(annotationId); + removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId); + }} onExpandImage={(imageId) => { const preview = buildExpandedImagePreview(composerImages, imageId); if (preview) onExpandImage(preview); @@ -3148,66 +3282,104 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (annotation) => annotation.id === image.id, ), ) - .map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + {upload?.status === "uploading" && ( + + {formatAttachmentUploadProgress(upload.progress)} + + )} + {upload?.status === "failed" && ( + + + retryAttachmentUpload({ environmentId, image }) + } + aria-label={`Retry upload for ${image.name}`} + /> + } + > + + + + {upload.reason} + + + )} + +
+ ); + })}
)} @@ -3408,6 +3580,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + compactDisabled={ + compactDisabled || noProviderAvailable || isSendBusy || isConnecting + } + compactDisabledReason={resolvedCompactDisabledReason} + {...(selectedProvider === "claudeAgent" + ? { onCompactContext: compactThreadContext } + : {})} /> diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index f07836ab32a6..33d0d17eed7d 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -53,6 +53,7 @@ describe("ComposerBannerStack", () => { expect(markup).not.toContain("data-composer-banner-stack-expanded-items"); expect(markup).toContain("chat-composer-drawer-surface"); expect(markup).toContain("chat-composer-drawer-attached"); + expect(markup).not.toContain("before:mask-none"); expect(markup).toContain("text-xs"); expect(markup).toContain('data-composer-banner-drawer="true"'); expect(markup).toContain('data-variant="warning"'); @@ -76,4 +77,32 @@ describe("ComposerBannerStack", () => { expect(markup).toContain("branch-surface"); expect(markup).toContain("branch-actions"); }); + + it("renders a disabled compaction action on the shared accessible banner surface", () => { + const markup = renderToStaticMarkup( +