Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent
c2043b206a
ci: pass publish-server-image digests via GHCR handoff tags
Build jobs already push each architecture by digest. Tag those manifests
with ephemeral ci-${run_id}-${image}-${arch} refs instead of uploading
digest marker files to Actions artifact storage, which is blocked by the
account-wide quota.

Publish resolves handoff tags back into RUNNER_TEMP digest markers, then
assembles semver/edge tags and attestation as before. Handoff tags are
removed after publish with continue-on-error.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 02:27:10 +00:00
532 changed files with 7279 additions and 64459 deletions

View File

@ -24,7 +24,7 @@ These workflows may fail without blocking product delivery. Do **not** add them
| Check / workflow | Why infra |
| --- | --- |
| `macOS setup screenshot (infra)` (`desktop-macos-screenshot.yml`) | Native screenshot capture; skips when the runner has no display |
| Playwright report handoff | Soft-fail (`continue-on-error`); the report stays on the evo runner host for `publish Playwright report` |
| Playwright / desktop artifact uploads | Soft-fail (`continue-on-error`) when GitHub artifact quota is exhausted |
| `publish-server-image.yml` (PR paths) | Docker image validation for infra changes |
| `mobile Android screenshots` | Manual `workflow_dispatch` only |
| `nightly verification` | Scheduled visual regression |

View File

@ -71,10 +71,7 @@ jobs:
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
set -euo pipefail
conclusions=()
while IFS= read -r conclusion; do
[[ -n "$conclusion" ]] && conclusions+=("$conclusion")
done < <(
mapfile -t conclusions < <(
gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --paginate \
--jq '.check_runs[] | select(.name == "Product CI") | .conclusion'
)

View File

@ -5,87 +5,19 @@ name: ci
on:
push:
branches: [main]
paths:
- "apps/**"
- "packages/**"
- "infra/**"
- "scripts/**"
- "ops/**"
- ".github/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "biome.json"
- "vitest.config.ts"
- "tsconfig*.json"
pull_request:
permissions:
contents: read
pull-requests: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Jobs that only need Node/pnpm/bash use [self-hosted, evoo-ci] so macOS runners (air, mini)
# can share load. Linux-only work keeps the Linux label (Docker, xvfb, local Postgres :5433,
# Homebrew ffmpeg with libass/subtitles filter for media unit tests).
jobs:
changes:
name: Detect product changes
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 10
outputs:
product: ${{ steps.product.outputs.product }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
fetch-depth: 0
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
continue-on-error: true
with:
filters: |
product:
- 'apps/**'
- 'packages/**'
- 'infra/**'
- 'scripts/**'
- 'ops/**'
- '.github/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'biome.json'
- 'vitest.config.ts'
- 'tsconfig*.json'
- name: Product path decision
id: product
if: always()
run: |
set -euo pipefail
if [[ "${{ steps.filter.outcome }}" == "success" ]]; then
if [[ "${{ steps.filter.outputs.product }}" == "true" ]]; then
echo "product=true" >> "$GITHUB_OUTPUT"
else
echo "product=false" >> "$GITHUB_OUTPUT"
fi
else
echo "Change detection failed; running full Product CI." >&2
echo "product=true" >> "$GITHUB_OUTPUT"
fi
lint:
name: Lint
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 20
runs-on: [self-hosted, evoo-ci, Linux]
steps:
- uses: actions/checkout@v5
with:
@ -113,15 +45,13 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
check:
name: Typecheck
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 20
runs-on: [self-hosted, evoo-ci, Linux]
steps:
- uses: actions/checkout@v5
with:
@ -149,6 +79,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
env:
@ -157,8 +88,6 @@ jobs:
build:
name: Production builds
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 10
steps:
@ -188,6 +117,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
env:
@ -196,13 +126,19 @@ jobs:
ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters
- name: Electron smoke
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts
- name: Upload desktop setup screenshots
if: always()
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-e2e-screenshots
path: apps/desktop/e2e/screenshots
if-no-files-found: ignore
retention-days: 7
test:
name: Unit tests
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 45
steps:
- uses: actions/checkout@v5
with:
@ -230,6 +166,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
env:
@ -238,8 +175,6 @@ jobs:
test-integration:
name: Postgres journeys
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 10
steps:
@ -269,28 +204,24 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:integration
test-e2e:
name: Web E2E
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
uses: ./.github/workflows/playwright.yml
with:
hand_off_report: true
upload_artifacts: true
product-gate:
name: Product CI
if: always()
needs: [changes, lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 5
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
steps:
- name: Verify product checks
env:
CHANGES: ${{ needs.changes.result }}
PRODUCT: ${{ needs.changes.outputs.product }}
LINT: ${{ needs.lint.result }}
CHECK: ${{ needs.check.result }}
BUILD: ${{ needs.build.result }}
@ -299,23 +230,11 @@ jobs:
TEST_E2E: ${{ needs.test-e2e.result }}
run: |
set -euo pipefail
product="$PRODUCT"
if [[ "$CHANGES" != "success" ]]; then
echo "Change detection did not finish cleanly; requiring downstream product checks." >&2
product=true
fi
if [[ "$product" != "true" ]]; then
echo "No product-impacting paths changed; Product CI satisfied."
exit 0
fi
failed=0
for name in LINT CHECK BUILD TEST TEST_INTEGRATION TEST_E2E; do
result="${!name}"
printf '%s=%s\n' "$name" "$result"
if [[ "$result" == "skipped" ]]; then
echo "$name was skipped although product paths changed." >&2
failed=1
elif [[ "$result" != "success" ]]; then
if [[ "$result" != "success" ]]; then
failed=1
fi
done
@ -327,9 +246,9 @@ jobs:
publish-mobile-update:
name: Publish compatible mobile update
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.product == 'true'
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
needs: [changes, lint, check, build, test, test-integration, test-e2e]
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 20
concurrency:
@ -470,6 +389,7 @@ jobs:
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
with:
node-version: 24
cache: pnpm
- name: Set up Expo
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
@ -477,9 +397,6 @@ jobs:
eas-version: latest
packager: pnpm
token: ${{ secrets.EXPO_TOKEN }}
# Its default caches go to the GitHub cache service; keep them on the runner.
eas-cache: false
expo-cache: false
- name: Install dependencies
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile

View File

@ -50,6 +50,7 @@ jobs:
if: steps.display.outputs.available == 'true'
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
if: steps.display.outputs.available == 'true'
- run: pnpm --filter @rakazo/desktop build
@ -76,3 +77,14 @@ jobs:
pnpm --filter @rakazo/desktop exec playwright test
--config e2e/playwright.config.ts setup.spec.ts
--grep "first run asks whether to use a local or existing instance"
- name: Upload native macOS setup screenshot
if: always() && steps.display.outputs.available == 'true'
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-e2e-macos-screenshot
path: |
apps/desktop/e2e/screenshots/08-setup-macos-native-controls.png
apps/desktop/e2e/screenshots/09-macos-dock.png
if-no-files-found: warn
retention-days: 1

View File

@ -39,39 +39,41 @@ jobs:
with:
persist-credentials: false
# Runner-host caches only: never GitHub's cache or artifact storage.
- name: Restore screenshot APK
id: screenshot-apk
env:
APK_KEY: ${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', 'turbo.json', 'tsconfig*.json', '.github/workflows/mobile-android-screenshots.yml', 'apps/mobile/package.json', 'apps/mobile/app.json', 'apps/mobile/app.config.ts', 'apps/mobile/metro.config.js', 'apps/mobile/metro-resolver.js', 'apps/mobile/assets/**', 'apps/mobile/app/**', 'apps/mobile/components/**', 'apps/mobile/lib/**', 'apps/mobile/modules/**', 'apps/mobile/plugins/**', 'packages/chat-ui/**', 'packages/contracts/**', 'packages/core/**', 'packages/ui-tokens/**') }}
run: |
set -euo pipefail
cache="$HOME/.cache/evoo-ci/mobile-android-apk"
echo "path=$cache/v2-$APK_KEY.apk" >> "$GITHUB_OUTPUT"
if [[ -f "$cache/v2-$APK_KEY.apk" ]]; then
mkdir -p apps/mobile/android/app/build/outputs/apk/release
cp "$cache/v2-$APK_KEY.apk" apps/mobile/android/app/build/outputs/apk/release/app-release.apk
echo "cache-hit=true" >> "$GITHUB_OUTPUT"
fi
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
key: mobile-android-apk-v2-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', 'turbo.json', 'tsconfig*.json', '.github/workflows/mobile-android-screenshots.yml', 'apps/mobile/package.json', 'apps/mobile/app.json', 'apps/mobile/app.config.ts', 'apps/mobile/metro.config.js', 'apps/mobile/metro-resolver.js', 'apps/mobile/assets/**', 'apps/mobile/app/**', 'apps/mobile/components/**', 'apps/mobile/lib/**', 'apps/mobile/modules/**', 'apps/mobile/plugins/**', 'packages/chat-ui/**', 'packages/contracts/**', 'packages/core/**', 'packages/ui-tokens/**') }}
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 22
cache: pnpm
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4.4.3
if: steps.screenshot-apk.outputs.cache-hit != 'true'
with:
cache-read-only: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Restore Maestro CLI
id: maestro-cache
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.maestro
key: maestro-${{ runner.os }}-2.6.1-3440825f
- name: Install pinned Maestro CLI
if: steps.maestro-cache.outputs.cache-hit != 'true'
run: |
set -euo pipefail
if [[ -f "$HOME/.maestro/.installed-2.6.1-3440825f" ]]; then
exit 0
fi
maestro_tmp=$(mktemp -d)
trap 'rm -rf "$maestro_tmp"' EXIT
base_url=https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.6.1
@ -86,10 +88,8 @@ jobs:
(cd "$maestro_tmp" && sha256sum --check --strict -)
(cd "$maestro_tmp" && sha256sum --check --strict checksums_sha256.txt)
unzip -q "$maestro_tmp/maestro.zip" -d "$maestro_tmp"
rm -rf "$HOME/.maestro"
mkdir -p "$HOME/.maestro"
cp -R "$maestro_tmp/maestro/." "$HOME/.maestro/"
touch "$HOME/.maestro/.installed-2.6.1-3440825f"
- name: Configure Maestro CLI
run: |
@ -112,14 +112,10 @@ jobs:
- name: Save screenshot APK
if: steps.screenshot-apk.outputs.cache-hit != 'true'
env:
APK_CACHE: ${{ steps.screenshot-apk.outputs.path }}
run: |
set -euo pipefail
mkdir -p "$(dirname "$APK_CACHE")"
find "$(dirname "$APK_CACHE")" -name '*.apk' -mtime +14 -delete
cp apps/mobile/android/app/build/outputs/apk/release/app-release.apk "$APK_CACHE.partial"
mv "$APK_CACHE.partial" "$APK_CACHE"
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
key: ${{ steps.screenshot-apk.outputs.cache-primary-key }}
- name: Enable KVM
run: |
@ -155,3 +151,12 @@ jobs:
MOBILE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
MOBILE_SHA: ${{ github.sha }}
run: bash scripts/publish-mobile-screenshot-gallery.sh
- name: Upload screenshots and diagnostics
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: mobile-android-screenshots-${{ github.run_id }}-${{ github.run_attempt }}
path: test-report/mobile-screenshots
if-no-files-found: error
retention-days: 7

View File

@ -25,8 +25,8 @@ on:
required: false
type: boolean
default: false
hand_off_report:
description: Leave the report on the runner host for the trusted publish workflow
upload_artifacts:
description: Retain the report and diagnostics as GitHub artifacts
required: false
type: boolean
default: false
@ -54,7 +54,7 @@ jobs:
playwright:
name: Web E2E
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 60
timeout-minutes: 30
concurrency:
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
cancel-in-progress: false
@ -83,12 +83,16 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Clear stale Vite dependency cache
run: rm -rf apps/web/node_modules/.vite node_modules/.vite
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Chromium
timeout-minutes: 5
# Browsers persist in ~/.cache/ms-playwright on the runner host; no GitHub cache.
# Skip --with-deps: evo Linux runners keep Chromium's OS libraries installed,
# and apt-get can stall until the job timeout on persistent self-hosted machines.
run: pnpm --filter @rakazo/web exec playwright install chromium
@ -145,7 +149,7 @@ jobs:
if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure')
run: pnpm --filter @rakazo/www e2e
- name: Record Playwright result
if: always() && inputs.hand_off_report
if: always() && inputs.upload_artifacts
env:
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
WWW_PLAYWRIGHT_RESULT: ${{ steps.www_playwright_tests.outcome }}
@ -158,28 +162,19 @@ jobs:
else
printf 'failure\n' > test-report/e2e/outcome.txt
fi
- name: Hand off Playwright report on the runner host
if: always() && inputs.hand_off_report
- name: Upload Playwright artifacts
if: always() && inputs.upload_artifacts
continue-on-error: true
# Never GitHub artifact storage: the publish workflow runs on the same evo host
# and reads this archive as untrusted input.
env:
HANDOFF_ARCHIVE: ${{ github.run_id }}-${{ github.run_attempt }}.zip
run: |
set -euo pipefail
handoff="$HOME/.cache/evoo-ci/playwright-handoff"
mkdir -p "$handoff"
python3 - "$handoff/$HANDOFF_ARCHIVE.partial" playwright-report playwright-report-www test-report/e2e <<'PY'
import os, sys, zipfile
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as archive:
for root in sys.argv[2:]:
for directory, _, files in os.walk(root):
for name in files:
file = os.path.join(directory, name)
if os.path.isfile(file) and not os.path.islink(file):
archive.write(file)
PY
mv "$handoff/$HANDOFF_ARCHIVE.partial" "$handoff/$HANDOFF_ARCHIVE"
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
path: |
playwright-report
playwright-report-www
apps/web/test-results
test-report/e2e
retention-days: 7
if-no-files-found: warn
- name: Publish Playwright visual report
if: always() && inputs.publish_report
env:

View File

@ -37,17 +37,16 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Read Playwright job result
id: playwright
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
shell: bash
run: |
# Read the attempt that finished: a re-run already in progress has no conclusion yet.
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/attempts/${RUN_ATTEMPT}/jobs?per_page=100" \
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?per_page=100" \
--jq '[.jobs[] | select(.name == "Web E2E / Web E2E") | .conclusion] | unique | if length == 1 then .[0] else error("expected exactly one Playwright job") end')"
case "$result" in
success|failure) ;;
@ -76,31 +75,38 @@ jobs:
echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}."
fi
echo "number=$number" >> "$GITHUB_OUTPUT"
- name: Take over Playwright report from the runner host
- name: Check for Playwright artifact
id: artifact
env:
HANDOFF_ARCHIVE: ${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}.zip
ARTIFACT_NAME: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }}
RUN_ID: ${{ github.event.workflow_run.id }}
shell: bash
run: |
# The Web E2E job left this archive on the same evo host; GitHub artifact storage is never used.
handoff="$HOME/.cache/evoo-ci/playwright-handoff"
mkdir -p "$handoff"
find "$handoff" -type f -mtime +1 -delete
if [[ -f "$handoff/$HANDOFF_ARCHIVE" ]]; then
mv "$handoff/$HANDOFF_ARCHIVE" "$RUNNER_TEMP/playwright-artifacts.zip"
artifacts="$(gh api --method GET \
"repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?name=${ARTIFACT_NAME}")"
count="$(jq --arg name "$ARTIFACT_NAME" \
'[.artifacts[] | select(.name == $name and .expired == false)] | length' \
<<<"$artifacts")"
if [[ "$count" == "1" ]]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
elif [[ "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
echo "::warning::The timed-out Playwright job ended before it handed off its report. Publishing an empty gallery."
echo "id=$(jq -r --arg name "$ARTIFACT_NAME" '.artifacts[] | select(.name == $name and .expired == false) | .id' <<<"$artifacts")" >> "$GITHUB_OUTPUT"
elif [[ "$count" == "0" && "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
echo "::warning::The timed-out Playwright job ended before it uploaded an artifact. Publishing an empty gallery."
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "Missing Playwright report $handoff/$HANDOFF_ARCHIVE on this runner host." >&2
echo "Expected exactly one unexpired Playwright artifact; found $count." >&2
exit 1
fi
- name: Extract Playwright report
- name: Download Playwright artifacts
if: steps.artifact.outputs.exists == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_ID: ${{ steps.artifact.outputs.id }}
shell: bash
run: |
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/playwright-artifacts.zip"
# Keep untrusted files outside the checkout, dependencies, configuration,
# and generated dashboard consumed by the comment step.
python3 scripts/extract-playwright-artifacts.py \

View File

@ -3,26 +3,9 @@ name: publish-server-image
# Release tags publish the source-addressed image used by in-app updates plus the human-readable
# semver tags. Main publishes `edge`. Pull requests build with a read-only token and an isolated
# cache, so untrusted code can validate the images without receiving package-publish authority.
# Self-hosted evo runners on one host share a Docker daemon and often ~/.docker; parallel matrix
# jobs must use DOCKER_CONFIG under RUNNER_TEMP plus a per-job buildx builder name wired into
# build-push-action, or setup-buildx --use races and one job's post cleanup stops another's BuildKit.
# `ci-<run>-<image>-<arch>` handoff tags are extra aliases on the per-arch digest; GHCR does not
# expose a safe tag-only delete API (DELETE /v2/.../manifests/<tag> is unsupported or digest-scoped),
# so they are left in place and do not affect edge/sha-* manifests assembled in publish.
on:
push:
branches: [main]
paths:
- ".dockerignore"
- ".github/workflows/publish-server-image.yml"
- "apps/**"
- "infra/compose/**"
- "infra/sandboxes/computer/**"
- "infra/updater/**"
- "packages/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
tags: ["v*"]
pull_request:
paths:
@ -48,51 +31,10 @@ env:
DOCKER_BUILD_RECORD_UPLOAD: false
concurrency:
group: publish-server-image-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: publish-server-image-${{ github.ref }}
cancel-in-progress: false
jobs:
changes:
name: Detect image publish inputs
runs-on: [self-hosted, evoo-ci]
outputs:
publish: ${{ steps.decide.outputs.publish }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
persist-credentials: false
fetch-depth: 0
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
id: filter
with:
filters: |
publish:
- '.dockerignore'
- '.github/workflows/publish-server-image.yml'
- 'apps/**'
- 'infra/compose/**'
- 'infra/sandboxes/computer/**'
- 'infra/updater/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- name: Decide whether release jobs should run
id: decide
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.filter.outputs.publish }}" == "true" ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "publish=false" >> "$GITHUB_OUTPUT"
fi
installer-smoke:
name: Compose installer smokes
if: github.event_name == 'pull_request'
@ -130,16 +72,7 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ci-pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Resolve the lowercase image namespace
id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
@ -149,37 +82,28 @@ jobs:
with:
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- name: Locate the main build cache on this runner host
id: cache
# Build caches stay on the evo host; GitHub's cache service is never used.
run: echo "dir=$HOME/.cache/evoo-ci/buildx/${{ matrix.name }}-amd64" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
builder: ${{ steps.buildx.outputs.name }}
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
# Pull requests only read the cache main writes.
cache-from: type=local,src=${{ steps.cache.outputs.dir }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
provenance: false
sbom: false
build:
name: Build ${{ matrix.name }} ${{ matrix.arch }}
needs: changes
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
if: github.event_name != 'pull_request'
# Each architecture builds natively on its own runner and pushes by digest only.
# amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx,
# and QEMU emulation took hours for the desktop-sized computer image.
# See docs/self-host.md (Published images and tags).
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
concurrency:
group: publish-server-image-build-${{ github.ref }}-${{ matrix.name }}-${{ matrix.arch }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
permissions:
contents: read
packages: write
@ -206,16 +130,7 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}-${{ matrix.arch }}
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
@ -232,14 +147,9 @@ jobs:
# Labels only; the tagged manifest is assembled in the publish job.
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- name: Locate the build cache on this runner host
id: cache
# Build caches stay on the evo host; GitHub's cache service is never used.
run: echo "dir=$HOME/.cache/evoo-ci/buildx/${{ matrix.name }}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT"
- id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
builder: ${{ steps.buildx.outputs.name }}
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/${{ matrix.arch }}
@ -248,18 +158,10 @@ jobs:
outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true
# GET /health can report the exact source commit without a deployment-supplied override.
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=local,src=${{ steps.cache.outputs.dir }}
cache-to: type=local,mode=max,dest=${{ steps.cache.outputs.dir }}-new
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-${{ matrix.arch }}
provenance: mode=max
sbom: true
- name: Replace the build cache
# A local cache only grows; swapping in the fresh export drops layers this build no longer uses.
env:
CACHE_DIR: ${{ steps.cache.outputs.dir }}
run: |
set -euo pipefail
rm -rf "$CACHE_DIR"
mv "$CACHE_DIR-new" "$CACHE_DIR"
- name: Tag digest for publish job handoff
env:
DIGEST: ${{ steps.build.outputs.digest }}
@ -272,13 +174,10 @@ jobs:
publish:
name: Publish ${{ matrix.name }} image
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
needs: [changes, build]
if: github.event_name != 'pull_request'
needs: build
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 15
concurrency:
group: publish-server-image-publish-${{ github.ref == 'refs/heads/main' && 'main' || github.ref }}-${{ matrix.name }}
cancel-in-progress: false
permissions:
contents: read
packages: write
@ -292,16 +191,7 @@ jobs:
- name: updater
- name: computer
steps:
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-publish-${{ matrix.name }}
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
@ -390,3 +280,13 @@ jobs:
subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true
- name: Remove CI handoff tags
continue-on-error: true
env:
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
IMAGE_NAME: ${{ matrix.name }}
run: |
set -euo pipefail
for arch in amd64; do
docker buildx imagetools rm "${IMAGE}:ci-${GITHUB_RUN_ID}-${IMAGE_NAME}-${arch}"
done

View File

@ -18,8 +18,6 @@ jobs:
validate:
name: Validate stable release
runs-on: [self-hosted, evoo-ci, Linux]
permissions:
contents: write
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
@ -97,32 +95,17 @@ jobs:
set -euo pipefail
include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'
if [[ "$WINDOWS_SIGNING" == "true" ]]; then
# Internal runners only: Windows waits for a self-hosted Windows runner, never a GitHub-hosted one.
include="$(jq -c '. + [{"runner":["self-hosted","evoo-ci","Windows"],"artifact":"windows"}]' <<<"$include")"
# No self-hosted Windows runner; keep GitHub-hosted only when signing credentials exist.
include="$(jq -c '. + [{"runner":"windows-2022","artifact":"windows"}]' <<<"$include")"
fi
echo "include=$include" >> "$GITHUB_OUTPUT"
echo "windows=$WINDOWS_SIGNING" >> "$GITHUB_OUTPUT"
- name: Open the draft release the platform builds fill
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
shell: bash
run: |
set -euo pipefail
# Builds hand their files to this draft, never to GitHub Actions artifact storage.
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
gh release delete "$RELEASE_TAG" --yes
fi
gh release create "$RELEASE_TAG" --draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
build:
name: Build signed ${{ matrix.artifact }} artifacts
needs: validate
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: write
strategy:
fail-fast: false
matrix:
@ -261,27 +244,20 @@ jobs:
test -f apps/desktop/out/latest-linux.yml
grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml
- name: Hand installers and updater metadata to the draft release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
files=(
- name: Retain installers and updater metadata
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }}
path: |
apps/desktop/out/*.AppImage
apps/desktop/out/*.blockmap
apps/desktop/out/*.dmg
apps/desktop/out/*.exe
apps/desktop/out/latest*.yml
apps/desktop/out/*.zip
)
if [[ ${#files[@]} -eq 0 ]]; then
echo "The build produced no release files." >&2
exit 1
fi
bash scripts/upload-release-assets.sh "${files[@]}"
if-no-files-found: error
compression-level: 0
retention-days: 7
publish:
name: Attest and publish complete release
@ -299,11 +275,11 @@ jobs:
fetch-depth: 0
persist-credentials: false
ref: ${{ needs.validate.outputs.sha }}
- name: Collect every platform from the draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release download "$RELEASE_TAG" --dir release-artifacts
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: desktop-*-${{ needs.validate.outputs.sha }}
path: release-artifacts
merge-multiple: true
- name: Verify the complete stable update feed
env:
RELEASE_SHA: ${{ needs.validate.outputs.sha }}
@ -347,34 +323,26 @@ jobs:
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: release-artifacts/*
- name: Add checksums to the draft
- name: Create draft and upload every platform
id: create_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release upload "$RELEASE_TAG" release-artifacts/SHA256SUMS --clobber
run: >-
gh release create "$RELEASE_TAG" release-artifacts/*
--draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
- name: Publish the completed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release edit "$RELEASE_TAG" --draft=false --latest
discard:
name: Remove an incomplete draft
needs: [validate, build, publish]
if: failure() && needs.validate.result == 'success'
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 5
permissions:
contents: write
steps:
- name: Delete the draft release
- name: Remove an incomplete draft
if: failure() && steps.create_release.outcome != 'skipped'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
shell: bash
run: |
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .draft 2>/dev/null)" == "true" ]]; then
gh release delete "$RELEASE_TAG" --yes
fi

View File

@ -19,6 +19,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:topology
- name: Replay computer tools against real Chromium without inference

View File

@ -11,7 +11,6 @@
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
- The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push.
- Work autonomously. Make routine calls yourself, such as opening or reopening a pull request, linking CI screenshots, or fixing gaps you found that belong to the task, then verify and report the outcome. Ask the maintainer only before irreversible actions or for product decisions without a sensible default.
- CI runs only on the self-hosted `evoo-ci` runners and never consumes GitHub Actions resources: no GitHub-hosted runners, no Actions artifact storage (`upload-artifact`, `download-artifact`), and no GitHub cache service (`actions/cache`, setup-node `cache:`, buildx `type=gha`). Keep caches and job handoffs on the runner host; `infra/updater/src/github-actions-budget.test.ts` enforces this.
- After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
- PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author.
- Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown.

View File

@ -1,114 +0,0 @@
# Project: Dual-Surface-View in evOo (`apps/web`)
## Architecture
evOo (`apps/web`) organizes the workspace into two primary surfaces:
1. **Mission Stream**: The chat, agent transcript (`data-testid="transcript"`), run steps (`data-testid="run-steps"`), and composer inside `<main>`.
2. **Living Canvas**: The computer surface (`data-testid="computer-preview"`), workspace files explorer (`WorkspaceFilesView`), code inspector, in-app browser, and artifact viewer inside `<aside data-testid="side-panel">`.
In desktop viewports (>= 768px `md`), these two surfaces operate as equal, first-class split surfaces within `Shell.tsx`:
- Positioned side-by-side without modal dialog occlusion.
- Separated by an interactive `DualSurfaceDivider` supporting fluid pointer drag-resizing, double-click snap to 50/50 balance, and keyboard arrow adjustments.
- Controlled via quick-switch header preset controls: Focus Stream (100/0), Dual-Surface Split (50/50), Focus Canvas (0/100), and a side-swapping toggle (Stream-first vs. Canvas-first).
- Configured by persisted preferences (`localStorage` via `dual-surface-pref.ts`) with safe defaults and bounded numeric clamping.
- Responsive degradation (< 768px `md`): Automatically transitions to stacked/drawer/single-surface mode without mutating desktop split ratio preferences.
- Monochrome design system adhering to `@rakazo/ui-tokens` with zero hardcoded hex or arbitrary CSS values.
- Internationalized using LinguiJS with 100% German (`de`) translation coverage.
```
+-----------------------------------------------------------------------------------+
| Top Navigation Bar: [Bots Toggle] ... [Preset: 100/0 | 50/50 | 0/100] [Swap] ... |
+----------------------------------------------------+---+--------------------------+
| Surface A (e.g. Mission Stream / Chat) | D | Surface B (Living Canvas)|
| | I | |
| - Transcript (`data-testid="transcript"`) | V | - Computer Preview |
| - Run steps (`data-testid="run-steps"`) | I | - Workspace Explorer |
| - Composer | D | - Code Inspector |
| | E | (`data-testid="side-panel")|
| | R | |
+----------------------------------------------------+---+--------------------------+
```
## Feature Inventory
| # | Feature | Description | Milestone | Source |
|---|---------|-------------|-----------|--------|
| 1 | Preferences Storage & Schema | `dual-surface-pref.ts` managing `splitRatio` (0.20-0.80, default 0.50), `layoutMode` (`split`, `stream`, `canvas`), `orientation` (`stream-first`, `canvas-first`) with localStorage read/write and bounds clamping | M1 | ORIGINAL_REQUEST §R3 |
| 2 | Layout Hook & State Coordination | `useDualSurfaceLayout.ts` React hook handling layout state, preset toggles, orientation flip, and media query synchronization | M1 | ORIGINAL_REQUEST §R1, R3 |
| 3 | Fluid Drag-Resize & Snap Divider | `DualSurfaceDivider.tsx` + `useDualSurfaceResize.ts` with pointer capture, hover/active semantic tokens, bounds clamping, double-click 50/50 snap, and ARIA separator attributes | M2 | ORIGINAL_REQUEST §R2, R4 |
| 4 | Header Preset Controls & Orientation Swap | `DualSurfaceHeaderControls.tsx` with quick-switch presets (100/0, 50/50, 0/100) and Swap Orientation toggle, styled with semantic tokens and full accessibility | M2 | ORIGINAL_REQUEST §R1, R2 |
| 5 | Shell Flex Integration & Surface Swap | Coordinated flex container in `Shell.tsx` hosting `<main>` and `<aside data-testid="side-panel">`, supporting dynamic order swapping while preserving existing test IDs | M3 | ORIGINAL_REQUEST §R1, R2, R4 |
| 6 | Responsive Degradation (< md) | Seamless fallback on viewports < 768px to stacked/single-surface navigation without corrupting persisted desktop preferences | M3 | ORIGINAL_REQUEST §R3 |
| 7 | Design System & Token Compliance | Exclusively semantic tokens from `@rakazo/ui-tokens` (`bg-border`, `hover:bg-accent`, `active:bg-primary`, `ring-ring`), monochrome styling, no hardcoded hex/arbitrary css | M3 | ORIGINAL_REQUEST §R4 |
| 8 | Localization (German Translations) | Lingui message extraction, full German translations in `messages.po`, clean `intl:extract` and `intl:compile` | M3 | ORIGINAL_REQUEST §Acceptance Criteria |
| 9 | E2E Testing & Adversarial Hardening | Comprehensive test suite (Tiers 1-4) and adversarial hardening (Tier 5), 100% green tests (78/78 suites baseline + new tests) and 0 TS errors | M4 | ORIGINAL_REQUEST §Acceptance Criteria |
## Milestones
| # | Name | Scope | Dependencies | Status |
|---|------|-------|-------------|--------|
| M1 | Preferences, Layout Hook & State Management | `dual-surface-pref.ts`, `useDualSurfaceLayout.ts`, unit tests | none | DONE |
| M2 | Interactive Divider & Header Preset Controls | `DualSurfaceDivider.tsx`, `useDualSurfaceResize.ts`, `dual-surface-header-controls.tsx`, unit/DOM tests | M1 | DONE |
| M3 | Shell Integration, Responsive Degradation & Localization | `Shell.tsx` layout refactoring, responsive fallback, German translations in `messages.po`, preserve test IDs | M2 | DONE |
| M4 | E2E Test Suite (Tiers 1-4) & Adversarial Hardening (Tier 5) | Comprehensive E2E test suite execution, 100% pass, forensic integrity audit | M3, E2E Track | IN_PROGRESS |
## Interface Contracts
### `dual-surface-pref.ts`
```typescript
export type DualSurfaceLayoutMode = "split" | "stream" | "canvas";
export type DualSurfaceOrientation = "stream-first" | "canvas-first";
export interface DualSurfacePreferences {
splitRatio: number; // 0.20 to 0.80, default 0.50
layoutMode: DualSurfaceLayoutMode;
orientation: DualSurfaceOrientation;
}
export function readDualSurfacePreferences(): DualSurfacePreferences;
export function writeDualSurfacePreferences(prefs: Partial<DualSurfacePreferences>): void;
export function clampSplitRatio(ratio: number): number;
```
### `useDualSurfaceLayout.ts`
```typescript
export interface DualSurfaceLayoutState {
splitRatio: number;
layoutMode: DualSurfaceLayoutMode;
orientation: DualSurfaceOrientation;
isMobile: boolean; // < 768px (md)
setSplitRatio: (ratio: number) => void;
setLayoutMode: (mode: DualSurfaceLayoutMode) => void;
setOrientation: (orientation: DualSurfaceOrientation) => void;
toggleOrientation: () => void;
resetSplit: () => void; // Snaps to 0.50
}
```
### `DualSurfaceDivider.tsx`
```typescript
export interface DualSurfaceDividerProps {
splitRatio: number;
onResize: (newRatio: number) => void;
onReset: () => void; // Double-click snap
disabled?: boolean;
}
```
### `DualSurfaceHeaderControls.tsx`
```typescript
export interface DualSurfaceHeaderControlsProps {
layoutMode: DualSurfaceLayoutMode;
orientation: DualSurfaceOrientation;
onSelectMode: (mode: DualSurfaceLayoutMode) => void;
onToggleOrientation: () => void;
disabled?: boolean;
}
```
## Code Layout
- `apps/web/src/lib/dual-surface-pref.ts`: Preference reading, writing, and boundary clamping.
- `apps/web/src/lib/dual-surface-pref.test.ts`: Unit tests for storage and mathematical boundaries.
- `apps/web/src/hooks/useDualSurfaceLayout.ts`: Layout state and responsive lifecycle management.
- `apps/web/src/hooks/useDualSurfaceResize.ts`: Pointer capture and keyboard navigation hook for divider.
- `apps/web/src/components/DualSurfaceDivider.tsx`: Accessible interactive divider component.
- `apps/web/src/pages/shell/dual-surface-header-controls.tsx`: Quick-switch header presets and swap buttons.
- `apps/web/src/pages/Shell.tsx`: Integration into application root layout.
- `apps/web/src/locales/de/messages.po`: German translation catalog entries.
- `apps/web/src/tests/dual-surface-view.test.tsx`: Component integration and DOM interaction tests.

View File

@ -68,7 +68,7 @@ docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
export KOKORO_BASE_URL=http://127.0.0.1:8880
```
German voices come from a local Piper server (`PIPER_BASE_URL`). Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
## Tech debt (brand / packages)

View File

@ -1,135 +0,0 @@
# E2E Test Infrastructure: evOo Voice-First Autonomous Agent OS (Dual Track)
## 1. Test Philosophy & Principles
- **Opaque-Box & Requirement-Driven**: Derived strictly from user specifications in `ORIGINAL_REQUEST.md` (§R1, §R2, §R3) and architecture specifications in `orchestrator_2/PROJECT.md` (§F1–§F12).
- **Interface Contract Verification**: Validates concrete contracts for `StreamingVoiceFrame`, `StreamingVoiceClient`, `ActionQueueStep`, `DynamicActionQueue`, and `RoutineNotificationPolicy`.
- **Offline & Deterministic Execution**: Zero external cloud or hosted vendor dependencies; uses simulated Web Audio, deterministic PCM frame generators, and pure React 19 testing harnesses.
- **Strict Linting & Design System Compliance**: Conforms strictly to `@rakazo/ui-tokens` monochrome tokens, zero arbitrary hex colors, top-level `import type`, and clean Biome formatting.
---
## 2. Test Architecture & Runner Commands
### Execution Commands
1. **Run All E2E OS Test Suites (139 tests)**:
```bash
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts apps/web/src/tests/e2e-autonomous-agency-queues.test.ts apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx apps/web/src/tests/e2e-cross-feature-workflows.test.ts
```
2. **Run Individual Feature Suites**:
- Voice Foundation (F1–F4):
```bash
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts
```
- Autonomous Agency & Queues (F5–F8):
```bash
pnpm vitest run apps/web/src/tests/e2e-autonomous-agency-queues.test.ts
```
- Minimalist Tactile UX & Onboarding (F9–F12):
```bash
pnpm vitest run apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx
```
- Cross-Feature & Real-World Workflows (T3 & T4):
```bash
pnpm vitest run apps/web/src/tests/e2e-cross-feature-workflows.test.ts
```
3. **TypeScript Compilation Check**:
```bash
pnpm --filter @rakazo/web check
```
4. **Code Quality & Linter**:
```bash
pnpm biome check apps/web/src/tests/e2e-*.ts* apps/web/src/tests/fixtures/*.ts
```
---
## 3. Feature Inventory & Test Coverage Matrix
| # | Feature | Scope & Requirement | Test File | T1 | T2 | T3 | T4 | Total Tests |
|---|---------|---------------------|-----------|:--:|:--:|:--:|:--:|:-----------:|
| **F1** | Streaming Audio Wire & Low-Latency VAD | ORIGINAL_REQUEST §R1 (<120ms framing, PCM16, VAD) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F2** | Full-Duplex Audio & Barge-In Interruption | ORIGINAL_REQUEST §R1 (Continuous AEC, barge-in mute) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F3** | Incremental Utterance Streamer & Invite Strip | ORIGINAL_REQUEST §R1 (Clause pipelining, no invite delays) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F4** | Cross-Platform Earcons & Mobile Audio | ORIGINAL_REQUEST §R1 (Tactile Web Audio & mobile cues) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F5** | Optimistic Input Queue & Zero UI Freeze | ORIGINAL_REQUEST §R2 (Non-blocking composer, optimistic FIFO) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F6** | Mid-Step Tool Cancellation & Active Steering | ORIGINAL_REQUEST §R2 (Mid-execution abort, active steering) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F7** | Outcome-Driven Action Queue State Machine | ORIGINAL_REQUEST §R2 (Dynamic action queue, outcomes) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F8** | Quiet Background Routines & Delta Evaluation | ORIGINAL_REQUEST §R2 (on_change delta hash gating, no nag) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F9** | Tactile Minimalist Step Execution | ORIGINAL_REQUEST §R3 (Strip chrome bloat, folding step) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **F10** | 30-Second Rapid Onboarding | ORIGINAL_REQUEST §R3 (1-click bootstrap, 3 action sparks) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **F11** | Monochrome Semantic Token Hygiene | ORIGINAL_REQUEST §R3 (@rakazo/ui-tokens, zero rogue hex) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **F12** | Living Canvas Progressive Disclosure | ORIGINAL_REQUEST §R3 (Dynamic canvas, preserve test IDs) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **T3** | Cross-Feature Interactions | Pairwise combinations across R1, R2, R3 | `e2e-cross-feature-workflows.test.ts` | - | - | 12 | - | 12 |
| **T4** | Real-World Application Workflows | Realistic end-to-end operational scenarios | `e2e-cross-feature-workflows.test.ts` | - | - | - | 7 | 7 |
| **Total** | **All Features F1–F12 across T1–T4** | **100% Comprehensive Coverage** | **4 Suites + 3 Fixtures** | **60** | **60** | **12** | **7** | **139** |
---
## 4. Test Tiers Breakdown
### Tier 1: Feature Coverage (60 Tests: 5 Tests × 12 Features)
- **F1**: 80ms PCM16 frame chunking (1280 samples at 16kHz); VAD normalized RMS energy; <120ms cutoff threshold eliminating 850ms silence floor; monotonic timestamp increment; streaming client transcript & playback chunk callbacks.
- **F2**: Continuous microphone capture with acoustic echo cancellation (`echoCancellation: true`); user speech energy detection while bot speaking triggers barge-in; immediate playback audio mute; backend abort propagation (`abort('barge_in')`); state transition from speaking to listening.
- **F3**: Incremental clause segmentation in 4–6 word chunks; time-to-first-sound pipelining (<300ms TTFS target); stripping robotic invite prompts ("What would you like to do next?"); code fence description transformation per `speakable()`; natural punctuation boundary clause splits.
- **F4**: Core tactile earcons (`listening-start`, `listening-stop`, `speaking-start`, `speaking-done`); Web Audio procedural oscillator profiles; master audio bus ducking (0.28 ducking ratio); cross-platform mobile earcon mapping; state machine audio cue triggers (`interact`, `attention`).
- **F5**: Submitting message while another in-flight enqueues without UI freeze; temporary optimistic ID assignment (`temp-...`) and timestamp; composer text clearing immediately; Stop button unblocked and clickable; sequential FIFO drainage.
- **F6**: Ingesting steering during active tool call dispatches mid-step cancellation signal; tool abort controller receives `abort('steering_redirection')`; aborted tool output discarded from history; agent pivots immediately to steering intent; steering message marked claimed.
- **F7**: `DynamicActionQueue` initialization; multi-step prompt synthesis into `ActionQueueStep` items; queue advancement starts pending step with `startedAt`; completing step sets `completedAt` and advances; `cancelActive()` marks step cancelled.
- **F8**: `RoutineNotificationPolicy` with `"on_change"` evaluates output delta hash; identical output hash suppresses notification (quiet run); distinct output hash triggers notification; policy `"always"` notifies unconditionally; policy `"never"` suppresses all notifications.
- **F9**: Minimalist folding step button renders with `data-testid="run-steps"`; displays clean `# step` counter without progress track bloat; clicking fold button toggles `aria-expanded`; step tool name and summary rendered when expanded; keyboard Enter/Space toggles fold.
- **F10**: 1-click bootstrap creates initial agent workspace without 11-step questionnaire; auto-detects system timezone via `Intl.DateTimeFormat`; auto-detects system language via `navigator.language`; surfaces 3 tactile starter sparks; selecting spark starts session (<30s value).
- **F11**: Theme tokens definition from `@rakazo/ui-tokens`; zero hardcoded product hex colors; zero arbitrary Tailwind brackets `[var(--...)]`; accessible focus rings (`focus-visible:ring-ring`); status indicators use semantic tokens (`text-destructive`, `text-muted-foreground`).
- **F12**: Living Canvas pane renders with `data-testid="side-panel"`; Mission Stream renders with `data-testid="mission-stream-pane"` and `data-testid="transcript"`; preserves critical test IDs (`computer-preview`, `dual-surface-divider`, `dual-surface-header-controls`); progressive disclosure on artifact creation; tab switching between Files, Sandbox, Browser.
### Tier 2: Boundary & Corner Cases (60 Tests: 5 Tests × 12 Features)
- **F1**: Empty audio buffer (`Int16Array(0)`) handled with RMS 0 and no NaN errors; pure silence buffer maintains energy 0 and triggers silence cutoff without hang; clipping values clamped to Int16 limits (-32768 to 32767); out-of-order timestamp detection and jitter buffer tolerance; network drop triggers client reconnect and state preservation.
- **F2**: Microphone permission denied handled with graceful error state; interruption when audio playback is already idle is safe no-op; micro-burst transients (<50ms noise pop) filtered by VAD debounce; rapid alternating speaker turns (ping-pong barge-in); concurrent interrupt and incoming playback chunk race condition resolves in favor of user interrupt.
- **F3**: Single-word answers streamed without minimum character delay stalling; code-only block output sanitized without synthetic audio artifacts; emoji-only message stream strips emojis and does not stall TTS engine; micro-fragment token arrivals buffered into complete words; unexpected LLM stream termination cleanly finalizes active clause.
- **F4**: Rapid consecutive sound cue triggers (<10ms) queue smoothly without audio glitches; muted/silent mode suppresses oscillator output while executing state transition; AudioContext suspended state auto-resumes on first user gesture; unknown sound cue name falls back gracefully without throwing exceptions; cross-platform volume normalization preserves peak headroom.
- **F5**: Rapid typing burst (10 rapid inputs within 100ms) enqueues all 10 without dropping; bounded queue size enforces max pending limit with graceful error; network failure while message in flight triggers failed state with retry option without losing text; empty/whitespace-only messages rejected before enqueueing; duplicate messages submitted in rapid succession deduplicated if flagged.
- **F6**: Race condition: tool finishes at the exact moment abort signal fires resolves cleanly; tool execution timeout triggers automated mid-step cancellation with `timeout` reason; multiple rapid contradictory steering messages resolves to most recent intent; tool that throws exception upon abort caught gracefully without crashing runtime; external system side-effect fence prevents state pollution on aborted tools.
- **F7**: Synthesizing empty or meaningless input results in clean no-op; `advance()` called on an empty queue or completed queue is safe no-op; failed step status prevents automatic advance unless retry policy allows; step status state machine strictly validates allowable transitions; queue serialization and deserialization preserves complete step fidelity.
- **F8**: First execution (`prevOutputHash === ''`) with `"on_change"` triggers initial notification; policy `"on_failure"` triggers notification only when routine status is `"failed"`; whitespace/formatting variations evaluated cleanly against normalized hash; high frequency routine executions do not spam notifications if output remains constant; routine execution failure self-heals without crashing scheduler.
- **F9**: Empty steps list renders cleanly without error or broken layout; extreme step count (50+ tool calls) remains folded by default without layout overflow; failed step renders clean status badge without noisy error stacks unless expanded; absence of chrome bloat (removal of animated tracks, live timers `· 1:24`, uppercase callouts `AUFGABE`); rapid folding/unfolding toggles do not oscillate or corrupt DOM state.
- **F10**: Missing or invalid timezone fallback safely resolves to UTC without throwing error; missing or invalid locale fallback safely resolves to `en`; re-entering onboarding while workspace already exists redirects to active space; starter spark with special characters sanitized safely; offline onboarding gracefully saves initial draft locally without blocking UI.
- **F11**: Semantic contrast in monochrome mode preserves readable text tokens; disabled component states apply semantic opacity `disabled:opacity-50` rather than arbitrary grey; high contrast mode support: borders preserve visibility via semantic `border-border`; dynamic theme switching updates token class mappings deterministically; interactive hover states use semantic hover tokens (`hover:bg-accent`).
- **F12**: Closing Living Canvas collapses aside pane without unmounting workspace state; rapid switching between canvas tabs maintains individual surface state; resizing viewport below `md` breakpoint gracefully transitions Dual-Surface to responsive layout; expanding viewport back above `md` restores Dual-Surface split without reset; concurrent artifact creation from multiple sub-agents surfaces items progressively without flicker.
### Tier 3: Cross-Feature Interactions (12 Tests: Pairwise Coverage)
- **Pair 1**: Voice barge-in interrupt while action queue is actively running step (aborts active step, pauses execution, pivots queue).
- **Pair 2**: Optimistic queue burst while streaming audio TTS is playing (synthesizes follow-ups without audio dropout).
- **Pair 3**: Mid-step tool cancellation triggered via voice interruption event (voice audio energy spike propagates to in-flight tool abort controller).
- **Pair 4**: Quiet background routine fires during active voice session (audio ducking remains unaffected; output unchanged suppresses chat/audio notification).
- **Pair 5**: 30-second rapid onboarding directly initiates full-duplex voice stage (first-time user goes from 1-click bootstrap straight to hands-free voice dialogue).
- **Pair 6**: Action queue synthesis dynamically surfaces Living Canvas for artifact inspection (multi-step queue opens canvas on artifact creation).
- **Pair 7**: Tactile folding step reflects mid-step tool cancellation without UI glitch (shows `[cancelled]` step folded cleanly).
- **Pair 8**: Voice streaming client reconnects after temporary network drop while action queue advances to next step.
- **Pair 9**: Audio earcon triggers on optimistic queue drain and background delta detection (`interact` on drain, `attention` on delta).
- **Pair 10**: Living Canvas orientation swap while action queue runs background steps (DOM flex order reverses while steps advance uninterrupted).
- **Pair 11**: Voice interrupt resets incremental utterance clause pipelining (discards queued clauses immediately).
- **Pair 12**: Delta routine evaluation suppresses notification while user is typing in optimistic composer (zero disruption to active composer focus).
### Tier 4: Real-World Application Scenarios (7 Comprehensive Workflows)
- **Scenario 1: Ambient Hands-Free Meeting & Live Action Queue**: User dictates three multi-step action items in voice mode -> VAD frames audio -> parsed into Dynamic Action Queue -> steps advance deterministically -> earcon signals completion.
- **Scenario 2: Conversational Barge-In & Mid-Step Tool Cancellation**: Agent executing long command -> user speaks barge-in redirect -> playback instantly muted -> in-flight tool aborted -> queue pivoted to new intent.
- **Scenario 3: High-Frequency Multi-Prompt Burst & Optimistic Ordering**: User types 3 follow-up messages rapidly during elevated latency -> optimistic queue renders all 3 immediately with temporary IDs -> requests drain in order -> zero UI freeze.
- **Scenario 4: Quiet Background Routine with Delta Detection & No Nagging**: Scheduled routine runs periodic cycles -> hash unchanged -> zero notifications -> on cycle with detected anomaly, delta triggers notification -> user alerted only when critical.
- **Scenario 5: Rapid 30-Second Zero-Questionnaire Onboarding to Live Execution**: New user lands -> system auto-detects timezone and locale -> user clicks starter spark "Research competitor architecture" -> workspace boots instantly -> agent begins execution within 30 seconds.
- **Scenario 6: Tactile Dual-Surface Progressive Disclosure & Step Folding Workflow**: User asks agent to inspect repo -> agent executes 12 tool steps -> steps fold into clean `# steps` button -> agent writes file -> Living Canvas smoothly unfolds without modal occlusion.
- **Scenario 7: Network Loss & Reconnection Resilience during Full-Duplex Voice Session**: Voice stream active -> network packet loss occurs -> streaming client detects connection drop -> buffers outgoing frames -> reconnects -> flushes buffer and resumes conversation without data loss.
---
## 5. Artifact Index & Directory Layout
```
apps/web/src/tests/
├── e2e-voice-first-foundation.test.ts # 40 tests (Features F1-F4, Tiers 1-2)
├── e2e-autonomous-agency-queues.test.ts # 40 tests (Features F5-F8, Tiers 1-2)
├── e2e-minimalist-tactile-ux.test.tsx # 40 tests (Features F9-F12, Tiers 1-2)
├── e2e-cross-feature-workflows.test.ts # 19 tests (Tier 3 Pairwise & Tier 4 Scenarios)
└── fixtures/
├── voice-contracts.ts # F1-F4 contracts & reference VAD/framing models
├── agency-contracts.ts # F5-F8 contracts, Action Queue & Routine policy models
└── ux-contracts.ts # F9-F12 contracts, environment detection & starter sparks
```

View File

@ -1,123 +0,0 @@
# TEST_READY: evOo Voice-First Autonomous Agent Operating System
## Status
- **Test Suite Status**: READY
- **Author**: `test_writer_r2_1` (Dual-Track E2E Testing Specialist)
- **Timestamp**: 2026-10-12T13:05:00Z
- **Target Milestones**: M1, M2, M3, M4, M5 (Features F1–F12 across Requirements R1, R2, R3)
- **Test Framework**: Vitest 4.1.11 with `// @vitest-environment jsdom` & Pure React 19 harnesses
---
## 1. Authored Test Suites Summary
| # | Test File Path | Scope & Feature Focus | Tiers Covered | Total Test Cases | Passing Baseline |
|---|----------------|-----------------------|:-------------:|:----------------:|:----------------:|
| 1 | `apps/web/src/tests/e2e-voice-first-foundation.test.ts` | Streaming Audio Wire (<120ms framing, PCM16, VAD), Full-Duplex Audio & Barge-In Interruption, Incremental Utterance Streamer (<300ms TTFS, invite strip), Tactile Monochrome Earcons | Tier 1, Tier 2 (F1–F4) | 40 | ✅ 40 / 40 Passed |
| 2 | `apps/web/src/tests/e2e-autonomous-agency-queues.test.ts` | Optimistic Input Queue (Zero UI Freeze, unblocked stop), Mid-Step Tool Cancellation & Active Steering, Dynamic Action Queue State Machine, Quiet Background Routines & Delta Evaluation (`on_change`) | Tier 1, Tier 2 (F5–F8) | 40 | ✅ 40 / 40 Passed |
| 3 | `apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx` | Tactile Minimalist Step Execution (strip JobProgressCard bloat, folding `# step`), 30-Second Rapid Onboarding (zero questionnaires, 3 action sparks), Monochrome Token Hygiene (@rakazo/ui-tokens), Living Canvas Progressive Disclosure | Tier 1, Tier 2 (F9–F12) | 40 | ✅ 40 / 40 Passed |
| 4 | `apps/web/src/tests/e2e-cross-feature-workflows.test.ts` | 12 Pairwise Cross-Feature Interactions & 7 Realistic End-to-End Workflows (Hands-free meeting, voice barge-in tool abort, high-frequency prompt burst, quiet background routines, 30s onboarding, progressive disclosure, voice network resilience) | Tier 3, Tier 4 | 19 | ✅ 19 / 19 Passed |
| **Total** | **4 Comprehensive Test Suites + 3 Fixtures** | **Full Feature Inventory (F1 to F12) Coverage** | **Tiers 1–4** | **139 tests** | **✅ 139 / 139 (100% Pass)** |
---
## 2. Requirement & Feature Coverage Matrix
| Feature | Source Requirement | Primary Test File | Tiers | Pass / Fail |
|---------|-------------------|-------------------|:-----:|:-----------:|
| **F1: Streaming Audio Wire & Low-Latency VAD** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F2: Full-Duplex Audio & Barge-In Interruption** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F3: Incremental Utterance Streamer & Invite Strip** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F4: Cross-Platform Earcons & Mobile Audio** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F5: Optimistic Input Queue & Zero UI Freeze** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F6: Mid-Step Tool Cancellation & Active Steering** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F7: Outcome-Driven Action Queue State Machine** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F8: Quiet Background Routines & Delta Evaluation** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F9: Tactile Minimalist Step Execution** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F10: 30-Second Rapid Onboarding** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F11: Monochrome Semantic Token Hygiene** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F12: Living Canvas Progressive Disclosure** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **T3: Pairwise Cross-Feature Interactions** | Cross-Module Interactions | `e2e-cross-feature-workflows.test.ts` | Tier 3 | ✅ PASS (12/12) |
| **T4: Real-World Application Scenarios** | Operational Workflows | `e2e-cross-feature-workflows.test.ts` | Tier 4 | ✅ PASS (7/7) |
---
## 3. Tier Breakdown & Test Cases
### Tier 1: Feature Coverage (60 Tests)
- **F1**: 80ms PCM16 frame chunking (1280 samples at 16kHz); VAD normalized RMS energy; <120ms cutoff threshold eliminating 850ms silence floor; monotonic timestamp increment; streaming client transcript & playback chunk callbacks.
- **F2**: Continuous microphone capture with acoustic echo cancellation (`echoCancellation: true`); user speech energy detection while bot speaking triggers barge-in; immediate playback audio mute; backend abort propagation (`abort('barge_in')`); state transition from speaking to listening.
- **F3**: Incremental clause segmentation in 4–6 word chunks; time-to-first-sound pipelining (<300ms TTFS target); stripping robotic invite prompts ("What would you like to do next?"); code fence description transformation per `speakable()`; natural punctuation boundary clause splits.
- **F4**: Core tactile earcons (`listening-start`, `listening-stop`, `speaking-start`, `speaking-done`); Web Audio procedural oscillator profiles; master audio bus ducking (0.28 ducking ratio); cross-platform mobile earcon mapping; state machine audio cue triggers (`interact`, `attention`).
- **F5**: Submitting message while another in-flight enqueues without UI freeze; temporary optimistic ID assignment (`temp-...`) and timestamp; composer text clearing immediately; Stop button unblocked and clickable; sequential FIFO drainage.
- **F6**: Ingesting steering during active tool call dispatches mid-step cancellation signal; tool abort controller receives `abort('steering_redirection')`; aborted tool output discarded from history; agent pivots immediately to steering intent; steering message marked claimed.
- **F7**: `DynamicActionQueue` initialization; multi-step prompt synthesis into `ActionQueueStep` items; queue advancement starts pending step with `startedAt`; completing step sets `completedAt` and advances; `cancelActive()` marks step cancelled.
- **F8**: `RoutineNotificationPolicy` with `"on_change"` evaluates output delta hash; identical output hash suppresses notification (quiet run); distinct output hash triggers notification; policy `"always"` notifies unconditionally; policy `"never"` suppresses all notifications.
- **F9**: Minimalist folding step button renders with `data-testid="run-steps"`; displays clean `# step` counter without progress track bloat; clicking fold button toggles `aria-expanded`; step tool name and summary rendered when expanded; keyboard Enter/Space toggles fold.
- **F10**: 1-click bootstrap creates initial agent workspace without 11-step questionnaire; auto-detects system timezone via `Intl.DateTimeFormat`; auto-detects system language via `navigator.language`; surfaces 3 tactile starter sparks; selecting spark starts session (<30s value).
- **F11**: Theme tokens definition from `@rakazo/ui-tokens`; zero hardcoded product hex colors; zero arbitrary Tailwind brackets `[var(--...)]`; accessible focus rings (`focus-visible:ring-ring`); status indicators use semantic tokens (`text-destructive`, `text-muted-foreground`).
- **F12**: Living Canvas pane renders with `data-testid="side-panel"`; Mission Stream renders with `data-testid="mission-stream-pane"` and `data-testid="transcript"`; preserves critical test IDs (`computer-preview`, `dual-surface-divider`, `dual-surface-header-controls`); progressive disclosure on artifact creation; tab switching between Files, Sandbox, Browser.
### Tier 2: Boundary & Corner Cases (60 Tests)
- **F1**: Empty audio buffer (`Int16Array(0)`) handled with RMS 0 and no NaN errors; pure silence buffer maintains energy 0 and triggers silence cutoff without hang; clipping values clamped to Int16 limits (-32768 to 32767); out-of-order timestamp detection and jitter buffer tolerance; network drop triggers client reconnect and state preservation.
- **F2**: Microphone permission denied handled with graceful error state; interruption when audio playback is already idle is safe no-op; micro-burst transients (<50ms noise pop) filtered by VAD debounce; rapid alternating speaker turns (ping-pong barge-in); concurrent interrupt and incoming playback chunk race condition resolves in favor of user interrupt.
- **F3**: Single-word answers streamed without minimum character delay stalling; code-only block output sanitized without synthetic audio artifacts; emoji-only message stream strips emojis and does not stall TTS engine; micro-fragment token arrivals buffered into complete words; unexpected LLM stream termination cleanly finalizes active clause.
- **F4**: Rapid consecutive sound cue triggers (<10ms) queue smoothly without audio glitches; muted/silent mode suppresses oscillator output while executing state transition; AudioContext suspended state auto-resumes on first user gesture; unknown sound cue name falls back gracefully without throwing exceptions; cross-platform volume normalization preserves peak headroom.
- **F5**: Rapid typing burst (10 rapid inputs within 100ms) enqueues all 10 without dropping; bounded queue size enforces max pending limit with graceful error; network failure while message in flight triggers failed state with retry option without losing text; empty/whitespace-only messages rejected before enqueueing; duplicate messages submitted in rapid succession deduplicated if flagged.
- **F6**: Race condition: tool finishes at the exact moment abort signal fires resolves cleanly; tool execution timeout triggers automated mid-step cancellation with `timeout` reason; multiple rapid contradictory steering messages resolves to most recent intent; tool that throws exception upon abort caught gracefully without crashing runtime; external system side-effect fence prevents state pollution on aborted tools.
- **F7**: Synthesizing empty or meaningless input results in clean no-op; `advance()` called on an empty queue or completed queue is safe no-op; failed step status prevents automatic advance unless retry policy allows; step status state machine strictly validates allowable transitions; queue serialization and deserialization preserves complete step fidelity.
- **F8**: First execution (`prevOutputHash === ''`) with `"on_change"` triggers initial notification; policy `"on_failure"` triggers notification only when routine status is `"failed"`; whitespace/formatting variations evaluated cleanly against normalized hash; high frequency routine executions do not spam notifications if output remains constant; routine execution failure self-heals without crashing scheduler.
- **F9**: Empty steps list renders cleanly without error or broken layout; extreme step count (50+ tool calls) remains folded by default without layout overflow; failed step renders clean status badge without noisy error stacks unless expanded; absence of chrome bloat (removal of animated tracks, live timers `· 1:24`, uppercase callouts `AUFGABE`); rapid folding/unfolding toggles do not oscillate or corrupt DOM state.
- **F10**: Missing or invalid timezone fallback safely resolves to UTC without throwing error; missing or invalid locale fallback safely resolves to `en`; re-entering onboarding while workspace already exists redirects to active space; starter spark with special characters sanitized safely; offline onboarding gracefully saves initial draft locally without blocking UI.
- **F11**: Semantic contrast in monochrome mode preserves readable text tokens; disabled component states apply semantic opacity `disabled:opacity-50` rather than arbitrary grey; high contrast mode support: borders preserve visibility via semantic `border-border`; dynamic theme switching updates token class mappings deterministically; interactive hover states use semantic hover tokens (`hover:bg-accent`).
- **F12**: Closing Living Canvas collapses aside pane without unmounting workspace state; rapid switching between canvas tabs maintains individual surface state; resizing viewport below `md` breakpoint gracefully transitions Dual-Surface to responsive layout; expanding viewport back above `md` restores Dual-Surface split without reset; concurrent artifact creation from multiple sub-agents surfaces items progressively without flicker.
### Tier 3: Cross-Feature Interactions (12 Tests)
- Pair 1: Voice barge-in interrupt while action queue is actively running step.
- Pair 2: Optimistic queue burst while streaming audio TTS is playing.
- Pair 3: Mid-step tool cancellation triggered via voice interruption event.
- Pair 4: Quiet background routine fires during active voice session.
- Pair 5: 30-second rapid onboarding directly initiates full-duplex voice stage.
- Pair 6: Action queue synthesis dynamically surfaces Living Canvas for artifact inspection.
- Pair 7: Tactile folding step reflects mid-step tool cancellation without UI glitch.
- Pair 8: Voice streaming client reconnects after temporary network drop while action queue advances.
- Pair 9: Audio earcon triggers on optimistic queue drain and background delta detection.
- Pair 10: Living Canvas orientation swap while action queue runs background steps.
- Pair 11: Voice interrupt resets incremental utterance clause pipelining.
- Pair 12: Delta routine evaluation suppresses notification while user is typing in optimistic composer.
### Tier 4: Real-World Application Scenarios (7 Comprehensive Workflows)
- Scenario 1: Ambient Hands-Free Meeting & Live Action Queue.
- Scenario 2: Conversational Barge-In & Mid-Step Tool Cancellation.
- Scenario 3: High-Frequency Multi-Prompt Burst & Optimistic Ordering.
- Scenario 4: Quiet Background Routine with Delta Detection & No Nagging.
- Scenario 5: Rapid 30-Second Zero-Questionnaire Onboarding to Live Execution.
- Scenario 6: Tactile Dual-Surface Progressive Disclosure & Step Folding Workflow.
- Scenario 7: Network Loss & Reconnection Resilience during Full-Duplex Voice Session.
---
## 4. Verification Method
Run the following command in the repository root to execute the complete E2E test suite:
```bash
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts apps/web/src/tests/e2e-autonomous-agency-queues.test.ts apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx apps/web/src/tests/e2e-cross-feature-workflows.test.ts
```
Output:
```
Test Files 4 passed (4)
Tests 139 passed (139)
Duration ~3.0s
```
TypeScript Check:
```bash
pnpm --filter @rakazo/web check
```
Biome Lint & Format Check:
```bash
pnpm biome check apps/web/src/tests/e2e-*.ts* apps/web/src/tests/fixtures/*.ts
```

View File

@ -26,13 +26,9 @@ function savedSkill(name: string, source = "user") {
}
function setup(rows: ReturnType<typeof savedSkill>[] = []) {
type Where = { id?: string; spaceId?: string; userId: string; source?: string; name?: unknown };
type Where = { id?: string; spaceId: string; userId: string; source?: string };
const matches = (row: ReturnType<typeof savedSkill>, where: Where) =>
Object.entries(where).every(([key, value]) =>
key === "name" && typeof value === "object" && value && "equals" in value
? row.name.toLowerCase() === String(value.equals).toLowerCase()
: key === "NOT" || row[key as keyof typeof row] === value,
);
Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === value);
const agentSkill = {
findMany: vi.fn(async ({ where }: { where: Where }) =>
rows.filter((row) => matches(row, where)),
@ -59,19 +55,15 @@ function setup(rows: ReturnType<typeof savedSkill>[] = []) {
};
}
/** Only the entries competing for the name "Interrogate"; other builtins are irrelevant here. */
const interrogate = <T extends { name: string }>(skills: T[]) =>
skills.filter((skill) => skill.name.trim().toLowerCase() === "interrogate");
describe("built-in skill precedence in the API", () => {
it.each(["interrogate", " Interrogate "])(
"keeps %j listed, readable and mutable",
async (name) => {
const { service } = setup([savedSkill(name)]);
expect(interrogate(await service.list(actor))).toEqual([
await expect(service.list(actor)).resolves.toEqual([
expect.objectContaining({ id: "saved-1", name, readOnly: false }),
]);
expect(interrogate(await service.listWithContent(actor))).toEqual([
await expect(service.listWithContent(actor)).resolves.toEqual([
expect.objectContaining({ id: "saved-1", content: expect.stringContaining("Saved steps") }),
]);
await expect(service.get(actor, { name: " INTERROGATE " })).resolves.toMatchObject({
@ -105,23 +97,7 @@ describe("built-in skill precedence in the API", () => {
expect(agentSkill.deleteMany).not.toHaveBeenCalled();
});
it("lists the user's skills from other spaces too, the current space winning a clash", async () => {
const { service } = setup([
{ ...savedSkill("Launch"), id: "home-1", spaceId: "home" },
{ ...savedSkill("launch"), id: "here-1" },
{ ...savedSkill("Weekly review"), id: "home-2", spaceId: "home" },
]);
const own = (await service.list(actor)).filter((skill) => skill.source === "user");
expect(own.map((skill) => skill.id).sort()).toEqual(["here-1", "home-2"]);
await expect(service.get(actor, { name: "Weekly review" })).resolves.toMatchObject({
id: "home-2",
});
await expect(
service.create(actor, { name: "weekly REVIEW", description: "x" }),
).rejects.toThrow("already exists");
});
it.each([{ userId: "other-user" }])(
it.each([{ spaceId: "other-space" }, { userId: "other-user" }])(
"does not let foreign skills shadow the builtin: %j",
async (foreignOwner) => {
const { service } = setup([{ ...savedSkill(" Interrogate "), ...foreignOwner }]);
@ -129,7 +105,7 @@ describe("built-in skill precedence in the API", () => {
id: "builtin:Interrogate",
readOnly: true,
});
expect(interrogate(await service.list(actor))).toEqual([
await expect(service.list(actor)).resolves.toEqual([
expect.objectContaining({ id: "builtin:Interrogate" }),
]);
await expect(service.get(actor, { skillId: "saved-1" })).rejects.toThrow();

View File

@ -7,7 +7,6 @@ import {
isSkillReadOnly,
mergeBuiltinSkills,
parseSkillMd,
preferSpaceSkills,
type SkillSource,
} from "@rakazo/core";
import { IsolationError, type PrismaClient } from "@rakazo/db";
@ -122,7 +121,11 @@ export function resolveSkillContent(input: {
export function createAgentSkillsService(prisma: PrismaClient) {
async function owned(actor: Actor, skillId: string) {
const row = await prisma.agentSkill.findFirst({
where: { id: skillId, userId: actor.userId },
where: {
id: skillId,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!row) throw new IsolationError();
return row;
@ -131,24 +134,20 @@ export function createAgentSkillsService(prisma: PrismaClient) {
return {
async list(actor: Actor): Promise<Omit<AgentSkill, "content">[]> {
const rows = await prisma.agentSkill.findMany({
where: { userId: actor.userId },
where: { spaceId: actor.spaceId, userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return mergeBuiltinSkills(
builtinCatalog(),
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
).map(({ content: _content, ...entry }) => entry);
return mergeBuiltinSkills(builtinCatalog(), rows.map(mapAgentSkill)).map(
({ content: _content, ...entry }) => entry,
);
},
async listWithContent(actor: Actor): Promise<AgentSkill[]> {
const rows = await prisma.agentSkill.findMany({
where: { userId: actor.userId },
where: { spaceId: actor.spaceId, userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return mergeBuiltinSkills(
builtinCatalog(),
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
);
return mergeBuiltinSkills(builtinCatalog(), rows.map(mapAgentSkill));
},
async get(actor: Actor, input: { skillId?: string; name?: string }): Promise<AgentSkill> {
@ -162,10 +161,13 @@ export function createAgentSkillsService(prisma: PrismaClient) {
}
const name = input.name?.trim() ?? "";
const rows = await prisma.agentSkill.findMany({
where: { userId: actor.userId },
where: {
spaceId: actor.spaceId,
userId: actor.userId,
},
orderBy: [{ name: "asc" }, { id: "asc" }],
});
const row = findSkillByName(preferSpaceSkills(rows, actor.spaceId), name);
const row = findSkillByName(rows, name);
if (row) return mapAgentSkill(row);
const builtin = findSkillByName(builtinCatalog(), name);
if (!builtin) throw new IsolationError();
@ -179,6 +181,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
const resolved = resolveSkillContent(input);
const clash = await prisma.agentSkill.findFirst({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { equals: resolved.name, mode: "insensitive" },
},
@ -231,6 +234,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
if (!findSkillByName([existing], resolved.name)) {
const clash = await prisma.agentSkill.findFirst({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { equals: resolved.name, mode: "insensitive" },
NOT: { id: existing.id },
@ -246,7 +250,12 @@ export function createAgentSkillsService(prisma: PrismaClient) {
// Mutate only owner-scoped user rows (never builtin/plugin), even if source was tampered.
try {
const updated = await prisma.agentSkill.updateMany({
where: { id: existing.id, userId: actor.userId, source: "user" },
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
data: {
name: resolved.name,
description: resolved.description,
@ -266,7 +275,11 @@ export function createAgentSkillsService(prisma: PrismaClient) {
throw error;
}
const row = await prisma.agentSkill.findFirst({
where: { id: existing.id, userId: actor.userId },
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!row) throw new IsolationError();
return mapAgentSkill(row);
@ -278,7 +291,12 @@ export function createAgentSkillsService(prisma: PrismaClient) {
throw new ORPCError("BAD_REQUEST", { message: "Builtin and plugin skills are read-only." });
}
const deleted = await prisma.agentSkill.deleteMany({
where: { id: existing.id, userId: actor.userId, source: "user" },
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
});
if (deleted.count !== 1) throw new IsolationError();
return { ok: true };

View File

@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { ORPCError, onError, ValidationError } from "@orpc/server";
import { ORPCError, onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import type {
AgentRuntime,
@ -29,14 +29,12 @@ import {
createMediaRuntime,
createMessagingContextLoader,
createMessagingTeamChatSender,
createPostgresReconciliationLeadership,
createProfileConsolidationScheduler,
createRunExecutor,
createRunSandbox,
createRunSecretWriter,
createWebProvider,
destroyBot,
destroySpaceSandboxes,
EmailEmulator,
EncryptedSecretStore,
ExpoPushSender,
@ -85,7 +83,6 @@ import {
parsePositiveInteger,
provisionMessagingIdentity,
requireMembership,
spaceIdsForSandboxTeardownAfterAccountDelete,
} from "@rakazo/db";
import type { Logger } from "@rakazo/logging";
import {
@ -361,45 +358,30 @@ export async function createApp(
"https://172.*:*",
"http://*.local:*",
"https://*.local:*",
...(env.extraTrustedOrigins ?? []),
...(process.env.EXTRA_TRUSTED_ORIGINS?.split(",").map((s) => s.trim()) ?? []),
],
beforeDeleteUser: async (userId) => {
const bots = await prisma.bot.findMany({
where: { userId },
select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true },
});
const lifecycleDeps = {
prisma,
sandbox,
home,
jobs,
artifacts,
dataDir: env.dataDir,
};
const deleteContext = {
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
userId,
signal: new AbortController().signal,
};
await Promise.all(
bots.map((bot) =>
destroyBot(
lifecycleDeps,
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
bot,
{ ...deleteContext, spaceId: bot.spaceId, botId: bot.id },
{
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
spaceId: bot.spaceId,
userId,
botId: bot.id,
signal: new AbortController().signal,
},
{ deleteMemories: true },
),
),
);
const candidateSpaceIds = [...new Set(bots.map((bot) => bot.spaceId))];
const spaceIds = await spaceIdsForSandboxTeardownAfterAccountDelete(prisma, {
userId,
spaceIds: candidateSpaceIds,
});
for (const spaceId of spaceIds) {
await destroySpaceSandboxes(lifecycleDeps, { spaceId }, { ...deleteContext, spaceId });
}
await removePiUserSessions(env.dataDir, userId);
},
});
@ -475,30 +457,26 @@ export async function createApp(
cloudAgent,
media,
});
let graphileWorkerHost: GraphileJobWorkerHost | undefined;
if (inMemoryJobs) {
await inMemoryJobs.start(jobHandlers);
} else if (jobPool) {
graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: parsePositiveInteger(process.env.GRAPHILE_WORKER_CONCURRENCY, 4),
const graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: 4,
pollInterval: 500,
});
await graphileWorkerHost.start(jobHandlers);
}
const reconciler =
inMemoryJobs || !prismaOverride
? createJobReconciler({
prisma,
jobs,
events,
leadership: jobPool ? createPostgresReconciliationLeadership(jobPool) : undefined,
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
reconcileMediaJobs: () => media.reconcile(),
})
: undefined;
const reconciler = inMemoryJobs
? createJobReconciler({
prisma,
jobs,
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
reconcileMediaJobs: () => media.reconcile(),
})
: undefined;
reconciler?.start();
const router = createRouter({
@ -617,7 +595,7 @@ export async function createApp(
actor,
signal: c.req.raw.signal,
userAgent: c.req.header("user-agent"),
webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin, env.extraTrustedOrigins),
webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin),
},
});
if (matched) return c.newResponse(response.body, response);
@ -1011,7 +989,6 @@ export async function createApp(
await teamChatBridge?.stop();
await email?.drain?.();
await reconciler?.stop();
await graphileWorkerHost?.stop();
await jobs.close();
await realtime.close();
await connector.stop();
@ -1027,7 +1004,6 @@ export async function createApp(
function isTrustedOrigin(origin: string, env: AppEnv) {
if (!origin) return true;
if (origin === env.webOrigin || origin === env.apiUrl || origin === env.authUrl) return true;
if (env.extraTrustedOrigins?.includes(origin)) return true;
if (origin.startsWith("rakazo://") || origin.startsWith("exp://")) return true;
try {
const host = new URL(origin).hostname;
@ -1055,18 +1031,7 @@ function sessionHeaders(request: Request) {
* "fetch failed" and keep the host and errno one level down.
*/
export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void {
if (error instanceof ORPCError) return;
const where = `rpc ${path.join("/")} failed`;
if (error instanceof ORPCError) {
// A handler result that breaks its output contract is a server bug, not a chosen error.
if (error.code !== "INTERNAL_SERVER_ERROR" || !(error.cause instanceof ValidationError)) return;
const issues = error.cause.issues.map((issue) => ({
path: (issue.path ?? [])
.map((segment) => (typeof segment === "object" ? segment.key : segment))
.join("."),
problem: issue.message,
}));
getLogger().error(where, error, { issues });
return;
}
getLogger().error(where, error);
}

View File

@ -175,7 +175,7 @@ describe("upload timeouts", () => {
it("gives the server time for large uploads but keeps the header timeout", () => {
const server = createServer(API_SERVER_OPTIONS);
expect(server.requestTimeout).toBe(2 * 60 * 60 * 1000);
expect(server.requestTimeout).toBe(30 * 60 * 1000);
expect(server.headersTimeout).toBe(createServer().headersTimeout);
});

View File

@ -1,8 +1,8 @@
import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { storeArtifactStream } from "@rakazo/adapters";
import { artifactMaxBytes, storeArtifactStream } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { artifactMaxBytes, attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
import { attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { createGroupRepos, createRepos, IsolationError, requireMembership } from "@rakazo/db";
import type { Context, Hono } from "hono";
@ -11,11 +11,11 @@ import { cancelBody } from "./http-body.js";
export const ARTIFACT_HTTP_PATH = "/api/artifacts";
/**
* Node's default 5-minute requestTimeout cuts off a large upload on a slow uplink; 2 hours fits a
* 5 GiB repository ZIP at about 6 Mbit/s. headersTimeout keeps its default, and the per-upload
* stall timeout stops a slow-drip client from holding a connection that long.
* Node's default 5-minute requestTimeout cuts off a 500 MiB video upload on any uplink slower than
* about 14 Mbit/s; 30 minutes fits it at about 2.5 Mbit/s. headersTimeout keeps its default, and
* the per-upload stall timeout stops a slow-drip client from holding a connection that long.
*/
export const API_SERVER_OPTIONS = { requestTimeout: 2 * 60 * 60 * 1000 };
export const API_SERVER_OPTIONS = { requestTimeout: 30 * 60 * 1000 };
/** An upload that sends no bytes for this long is aborted and its partial file deleted. */
export const ARTIFACT_UPLOAD_STALL_TIMEOUT_MS = 60_000;

View File

@ -20,7 +20,6 @@ export interface AppEnv {
authSecret: string;
authUrl: string;
webOrigin: string;
extraTrustedOrigins: string[];
privacyPolicyUrl?: string;
apiUrl: string;
apiHost: string;
@ -110,10 +109,6 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
authSecret,
authUrl: source.BETTER_AUTH_URL ?? source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
webOrigin: source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
extraTrustedOrigins: (source.EXTRA_TRUSTED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
privacyPolicyUrl: optional(source.PRIVACY_POLICY_URL),
apiUrl: source.API_URL ?? "http://127.0.0.1:3100",
apiHost: source.API_HOST ?? "127.0.0.1",

View File

@ -213,19 +213,13 @@ export function buildInboxItems(input: {
const botSelect = { id: true, name: true, color: true } as const;
/**
* The other spaces whose items the inbox shows. The home space's main conversation collects every
* space the user belongs to, except spaces being deleted; a project's main conversation keeps to
* its own project.
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
* being deleted.
*/
async function otherInboxSpaces(
prisma: PrismaClient,
actor: Actor,
): Promise<Map<string, InboxSpace>> {
const current = await prisma.space.findUnique({
where: { id: actor.spaceId },
select: { isDefault: true },
});
if (!current?.isDefault) return new Map();
const memberships = await prisma.spaceMember.findMany({
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
select: { space: { select: { id: true, name: true } } },

View File

@ -1,4 +1,4 @@
import { ORPCError, ValidationError } from "@orpc/server";
import { ORPCError } from "@orpc/server";
import { createLogger, createTestSink, installLogger } from "@rakazo/logging";
import { afterEach, describe, expect, it } from "vitest";
import { logUnexpectedRpcError } from "./app.js";
@ -20,28 +20,6 @@ describe("logUnexpectedRpcError", () => {
expect(sink.events).toEqual([]);
});
it("names the procedure and the fields when a result breaks its output contract", () => {
const sink = createTestSink();
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));
const error = new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Output validation failed",
cause: new ValidationError({
message: "Output validation failed",
issues: [{ message: "Expected string", path: ["items", 0, { key: "title" }] }],
data: { items: [{ title: null, body: "private" }] },
}),
});
logUnexpectedRpcError(error, ["inbox", "list"]);
expect(sink.events).toHaveLength(1);
expect(sink.events[0]).toMatchObject({
message: "rpc inbox/list failed",
issues: [{ path: "items.0.title", problem: "Expected string" }],
});
expect(JSON.stringify(sink.events[0])).not.toContain("private");
});
it("names the procedure and every cause behind an opaque failure", () => {
const sink = createTestSink();
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));

View File

@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
closingNote,
focusCard,
focusFactValue,
intakeContext,
intakeStep,
nextIntakeStep,
@ -15,13 +14,6 @@ const plain = (text: string) => text.replace(/\s/g, " ");
const off = { morning: null, evening: null, weekly: null };
describe("onboarding steps", () => {
it("writes focus facts in the user's locale", () => {
expect(focusFactValue("day", "en-US")).toBe("Wants help with day-to-day work first");
expect(focusFactValue("day", "de-DE")).toBe(
"Möchte zuerst bei der täglichen Arbeit unterstützt werden",
);
});
it("asks in order and offers notifications only to a device that can still turn push on", () => {
expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal");
expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm");

View File

@ -43,39 +43,13 @@ interface IntakeStep {
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
}
export type FocusOptionId = "day" | "inbox" | "research" | "crew";
export interface FocusOption {
id: FocusOptionId;
id: string;
letter: string;
label: string;
apps: string[];
}
const FOCUS_FACTS = {
en: {
day: "Wants help with day-to-day work first",
inbox: "Wants help with inbox and email first",
research: "Wants help with research and writing first",
crew: "Wants a coordinated crew of specialist bots",
},
de: {
day: "Möchte zuerst bei der täglichen Arbeit unterstützt werden",
inbox: "Möchte zuerst bei Posteingang und E-Mail unterstützt werden",
research: "Möchte zuerst bei Recherche und Schreiben unterstützt werden",
crew: "Möchte eine koordinierte Crew aus Spezialisten-Bots",
},
} as const satisfies Record<string, Record<FocusOptionId, string>>;
/** Profile fact text for a focus answer, in the user's locale (defaults to English). */
function isFocusOptionId(id: string): id is FocusOptionId {
return FOCUS_OPTIONS.some((option) => option.id === id);
}
export function focusFactValue(focusId: string, locale: string): string {
if (!isFocusOptionId(focusId)) return focusId;
const key: keyof typeof FOCUS_FACTS = locale.toLowerCase().startsWith("de") ? "de" : "en";
return FOCUS_FACTS[key][focusId];
/** Stored as a `goals` fact, so it stays a plain English statement. */
fact: string;
}
export const FOCUS_OPTIONS: FocusOption[] = [
@ -84,24 +58,28 @@ export const FOCUS_OPTIONS: FocusOption[] = [
letter: "A",
label: "Day-to-day work",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants help with day-to-day work first",
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
apps: ["gmail", "googlecalendar", "slack"],
fact: "Wants help with inbox and email first",
},
{
id: "research",
letter: "C",
label: "Research & writing",
apps: ["hackernews", "notion", "googledocs"],
fact: "Wants help with research and writing first",
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants a coordinated crew of specialist bots",
},
];
@ -139,16 +117,9 @@ export const INTAKE_STEPS: IntakeStep[] = [
save: async ({ prisma, tx, userId }, answer) => {
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
if (!option) return;
const profile = await tx.userProfile.findUnique({
where: { userId },
select: { locale: true, timeZone: true },
});
const locale = intakeContext(profile, new Date()).locale;
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
await updateUserProfile(prisma, userId, {
addFacts: [
{ category: "goals", value: focusFactValue(option.id, locale), source: "onboarding" },
],
addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }],
});
},
},

View File

@ -1,43 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listHostDirectories } from "./project-directories.js";
describe("listHostDirectories", () => {
it("lists directories in the user home or projects directory by default", async () => {
const listing = await listHostDirectories();
expect(listing.currentPath).toBeTruthy();
expect(Array.isArray(listing.directories)).toBe(true);
expect(Array.isArray(listing.commonPaths)).toBe(true);
expect(listing.commonPaths.some((p) => p.name.includes("Home") || p.name === "Projects")).toBe(
true,
);
});
it("lists specific existing directory and excludes hidden folders and node_modules", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "evoo-test-dirs-"));
try {
await fs.mkdir(path.join(tmpDir, "visible-folder-a"));
await fs.mkdir(path.join(tmpDir, "visible-folder-b"));
await fs.mkdir(path.join(tmpDir, ".hidden-folder"));
await fs.mkdir(path.join(tmpDir, "node_modules"));
await fs.writeFile(path.join(tmpDir, "some-file.txt"), "hello");
const listing = await listHostDirectories(tmpDir);
expect(listing.currentPath).toBe(await fs.realpath(tmpDir));
expect(listing.directories.map((d) => d.name)).toEqual([
"visible-folder-a",
"visible-folder-b",
]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("falls back safely when given an invalid path", async () => {
const listing = await listHostDirectories("/nonexistent-path-12345/abcdef");
expect(listing.currentPath).toBe(os.homedir());
expect(Array.isArray(listing.directories)).toBe(true);
});
});

View File

@ -1,95 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export interface HostDirectoryListing {
currentPath: string;
parentPath: string | null;
commonPaths: Array<{ name: string; path: string }>;
directories: Array<{ name: string; path: string }>;
}
export async function listHostDirectories(targetPath?: string): Promise<HostDirectoryListing> {
const home = os.homedir();
const raw = targetPath?.trim();
let requestedPath = home;
if (raw) {
if (raw === "~") {
requestedPath = home;
} else if (raw.startsWith("~/")) {
requestedPath = path.join(home, raw.slice(2));
} else {
requestedPath = path.resolve(raw);
}
} else {
// Default: prioritize ~/Projects if it exists, otherwise home directory
const projectsCandidate = path.join(home, "Projects");
try {
const stat = await fs.stat(projectsCandidate);
if (stat.isDirectory()) {
requestedPath = projectsCandidate;
}
} catch {
requestedPath = home;
}
}
let currentPath: string;
try {
currentPath = await fs.realpath(requestedPath);
const stat = await fs.stat(currentPath);
if (!stat.isDirectory()) {
currentPath = home;
}
} catch {
currentPath = home;
}
const parsed = path.parse(currentPath);
const parentPath = currentPath === parsed.root ? null : path.dirname(currentPath);
const commonCandidates = [
{ name: "Projects", path: path.join(home, "Projects") },
{ name: "Home (~)", path: home },
{ name: "Desktop", path: path.join(home, "Desktop") },
{ name: "Documents", path: path.join(home, "Documents") },
{ name: "Downloads", path: path.join(home, "Downloads") },
];
const commonPaths: Array<{ name: string; path: string }> = [];
for (const candidate of commonCandidates) {
try {
const s = await fs.stat(candidate.path);
if (s.isDirectory()) {
commonPaths.push(candidate);
}
} catch {
// not present on host
}
}
const directories: Array<{ name: string; path: string }> = [];
try {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Skip hidden folders (.git, .Trash, etc.) and node_modules
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
directories.push({
name: entry.name,
path: path.join(currentPath, entry.name),
});
}
directories.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
} catch {
// Unreadable directory returns empty list
}
return {
currentPath,
parentPath,
commonPaths,
directories,
};
}

View File

@ -1,114 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { listUserGithubRepos } from "./project-github.js";
vi.mock("node:child_process", () => ({
execFile: vi.fn((_cmd, _args, callback) => {
callback(new Error("gh not installed"));
}),
}));
describe("listUserGithubRepos", () => {
it("queries repositories and filters by query", async () => {
const mockPrisma = {
capabilityInstall: {
findMany: vi.fn().mockResolvedValue([
{
id: "cap-1",
kind: "mcp",
config: { preset: "github" },
secretId: "sec-1",
},
]),
},
secret: {
findUnique: vi.fn().mockResolvedValue({
id: "sec-1",
ciphertext: "fake-ciphertext",
}),
},
};
const mockSecrets = {
load: vi.fn().mockReturnValue("fake-token"),
};
// Mock fetch for GitHub API
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
if (url.includes("/user/repos")) {
return {
ok: true,
json: async () => [
{
name: "evOo-local",
full_name: "testorg/evOo-local",
html_url: "https://github.com/testorg/evOo-local",
description: "AI Operating System",
private: false,
updated_at: "2026-10-12T00:00:00Z",
},
{
name: "secret-vault",
full_name: "testorg/secret-vault",
html_url: "https://github.com/testorg/secret-vault",
description: "Private keys",
private: true,
updated_at: "2026-10-11T00:00:00Z",
},
],
} as unknown as Response;
}
if (url.includes("/user")) {
return {
ok: true,
json: async () => ({ login: "testuser" }),
} as unknown as Response;
}
return { ok: false } as unknown as Response;
});
try {
const all = await listUserGithubRepos(mockPrisma as any, undefined, mockSecrets);
expect(all.connected).toBe(true);
expect(all.username).toBe("testuser");
expect(all.repos).toHaveLength(2);
expect(all.repos[0].name).toBe("evOo-local");
const filtered = await listUserGithubRepos(
mockPrisma as any,
{ query: "vault" },
mockSecrets,
);
expect(filtered.repos).toHaveLength(1);
expect(filtered.repos[0].name).toBe("secret-vault");
expect(filtered.repos[0].isPrivate).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
it("handles unauthenticated state gracefully", async () => {
const mockPrisma = {
capabilityInstall: {
findMany: vi.fn().mockResolvedValue([]),
},
secret: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const origToken = process.env.GITHUB_TOKEN;
const origGhToken = process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GH_TOKEN;
try {
const res = await listUserGithubRepos(mockPrisma as any, { query: "test" });
expect(res.connected !== undefined).toBe(true);
expect(Array.isArray(res.repos)).toBe(true);
} finally {
if (origToken) process.env.GITHUB_TOKEN = origToken;
if (origGhToken) process.env.GH_TOKEN = origGhToken;
}
});
});

View File

@ -1,200 +0,0 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import type { PrismaClient } from "@rakazo/db";
const execFileAsync = promisify(execFile);
export interface GithubRepoItem {
name: string;
fullName: string;
url: string;
description?: string | null;
isPrivate: boolean;
updatedAt?: string;
}
export interface ListGithubReposOutput {
connected: boolean;
username?: string;
repos: GithubRepoItem[];
}
export async function listUserGithubRepos(
prisma: Pick<PrismaClient, "capabilityInstall" | "secret">,
input?: { query?: string; limit?: number },
secrets?: Pick<EncryptedSecretStore, "load">,
): Promise<ListGithubReposOutput> {
let token: string | null = null;
// 1. Check CapabilityInstall in database if secret store is provided
if (secrets) {
try {
const installs = await prisma.capabilityInstall.findMany({
where: { kind: "mcp" },
});
for (const inst of installs) {
const cfg = inst.config as Record<string, unknown> | null;
if (cfg?.preset === "github" && inst.secretId) {
const secret = await prisma.secret.findUnique({
where: { id: inst.secretId },
select: { id: true, ciphertext: true },
});
if (secret) {
try {
const loaded = secrets.load(secret.ciphertext, secret.id);
if (loaded?.trim()) {
token = loaded.trim();
break;
}
} catch {
// decryption error fallback
}
}
}
}
} catch {
// Database lookup error fallback
}
}
// 2. Check environment
if (!token) {
token = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || null;
}
// 3. Check local gh CLI auth token
if (!token) {
try {
const { stdout } = await execFileAsync("gh", ["auth", "token"], { timeout: 3000 });
if (stdout.trim()) {
token = stdout.trim();
}
} catch {
// gh CLI not available or not logged in
}
}
// If token is available, query GitHub REST API
if (token) {
try {
let username: string | undefined;
const userRes = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "evOo-App",
Accept: "application/vnd.github+json",
},
});
if (userRes.ok) {
const userData = (await userRes.json()) as { login?: string };
username = userData.login;
}
const reposRes = await fetch(
"https://api.github.com/user/repos?sort=pushed&per_page=100&type=all",
{
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "evOo-App",
Accept: "application/vnd.github+json",
},
},
);
if (reposRes.ok) {
const rawRepos = (await reposRes.json()) as Array<{
name: string;
full_name: string;
html_url: string;
description?: string | null;
private?: boolean;
pushed_at?: string;
updated_at?: string;
}>;
let items: GithubRepoItem[] = rawRepos.map((r) => ({
name: r.name,
fullName: r.full_name,
url: r.html_url,
description: r.description ?? null,
isPrivate: Boolean(r.private),
updatedAt: r.pushed_at || r.updated_at,
}));
if (input?.query?.trim()) {
const q = input.query.trim().toLowerCase();
items = items.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.fullName.toLowerCase().includes(q) ||
(r.description && r.description.toLowerCase().includes(q)),
);
}
const limit = input?.limit ?? 50;
return {
connected: true,
username,
repos: items.slice(0, limit),
};
}
} catch {
// Fall through to gh repo list fallback
}
}
// Fallback: try gh repo list directly
try {
const { stdout } = await execFileAsync(
"gh",
[
"repo",
"list",
"--limit",
"100",
"--json",
"nameWithOwner,name,url,description,isPrivate,pushedAt",
],
{ timeout: 5000 },
);
const parsed = JSON.parse(stdout) as Array<{
nameWithOwner: string;
name: string;
url: string;
description?: string;
isPrivate?: boolean;
pushedAt?: string;
}>;
let items: GithubRepoItem[] = parsed.map((r) => ({
name: r.name,
fullName: r.nameWithOwner,
url: r.url,
description: r.description || null,
isPrivate: Boolean(r.isPrivate),
updatedAt: r.pushedAt,
}));
if (input?.query?.trim()) {
const q = input.query.trim().toLowerCase();
items = items.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.fullName.toLowerCase().includes(q) ||
(r.description && r.description.toLowerCase().includes(q)),
);
}
const limit = input?.limit ?? 50;
return {
connected: true,
repos: items.slice(0, limit),
};
} catch {
return {
connected: false,
repos: [],
};
}
}

View File

@ -45,12 +45,10 @@ import {
clearInactiveUserComputerControl,
computerSupportsUpdate,
computerUpdateView,
createBrowserProvider,
createVoiceProvider,
deploymentAutoReviewDefault,
destroyBot,
displayBotWorkspacePath,
engageBrowserCowork,
enqueueTakeoverContinuation,
expireComputerControl,
filterTeamWorkspaceEntries,
@ -75,7 +73,6 @@ import {
queueComputerUpdate,
RECOMMENDED_MODEL,
readProjectWorkspaceFile,
recordBrowserUserAction,
releaseComputerExecutionLease,
replaceComputer,
resolveAutoReviewChecker,
@ -114,15 +111,11 @@ import {
AttachmentValidationError,
containsSecret,
expandSkillReferencesInPrompt,
GITHUB_MCP_SOURCE,
hasMixedOneShotSchedule,
isOneShotRoutineCrons,
isProjectColor,
isProjectIcon,
isValidAvatarValue,
nextCronDateAcrossStrict,
} from "@rakazo/core";
import { readProjectSummary } from "@rakazo/core/node/project-summary";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import {
BotSectionNameConflictError,
@ -135,7 +128,6 @@ import {
createProjectGoalRepos,
createRepos,
createSpaceForMember,
DuplicateSpaceNameError,
deleteEmptySpaceForMember,
deleteUnreferencedCredentialSecret,
ensureMainConversation,
@ -172,9 +164,7 @@ import {
savePushSubscription,
selectSpaceModelPreference,
selectSpaceVoicePreference,
syncProjectMembers,
updateNotificationSettings,
updateSpaceForOwner,
wireProjectWorkspace,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
@ -241,9 +231,7 @@ import {
onboardingScanStatus,
startOnboardingScan,
} from "./onboarding-scan.js";
import { listHostDirectories } from "./project-directories.js";
import { listUserGithubRepos } from "./project-github.js";
import { listUserRuns } from "./runs.js";
import { listSpaceRuns } from "./runs.js";
import { addScreenProxyCapability } from "./screen-proxy.js";
import { querySpaceSearch } from "./search.js";
import { withSerializableRetry } from "./serializable-retry.js";
@ -271,10 +259,7 @@ import {
followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage,
removeQueuedMessage,
resolveThreadTarget,
resumeThreadRuns,
sendQueuedThreadMessagesNow,
sendThreadMessage,
setThreadUnreadState,
stopThreadRuns,
@ -722,12 +707,6 @@ export function createRouter(deps: RouterDeps) {
spaceNavigationDto(deps, context.actor, repos, groupRepos),
),
create: authed.spaces.create.handler(async ({ context, input }) => {
if (input.icon !== undefined && !isProjectIcon(input.icon)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project icon" });
}
if (input.color !== undefined && !isProjectColor(input.color)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project color" });
}
// A linked folder lives on the host, and the host is the deployment owner's machine.
if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
@ -750,9 +729,6 @@ export function createRouter(deps: RouterDeps) {
currentSpaceId: context.actor.spaceId,
userId: context.actor.userId,
name: input.name,
icon: input.icon,
color: input.color,
gitRemoteUrl: input.gitRemoteUrl,
});
} catch (error) {
if (error instanceof SpaceLimitError || error instanceof InvalidSpaceNameError) {
@ -760,54 +736,9 @@ export function createRouter(deps: RouterDeps) {
}
throw error;
}
const wiredWorkspace = workspacePath
const workspace = workspacePath
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
: null;
if (input.gitRemoteUrl && !wiredWorkspace?.git?.remoteUrl) {
await deps.prisma.space.update({
where: { id: space.id },
data: { gitRemoteUrl: input.gitRemoteUrl },
});
}
const workspace =
wiredWorkspace ??
(input.gitRemoteUrl
? {
path: null,
git: {
remoteUrl: input.gitRemoteUrl,
defaultBranch: "main",
headCommit: null,
dirty: false,
dirtyPaths: [],
},
}
: null);
// Without a stated goal, the linked folder's README or manifest says what this is.
const goal =
input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null);
if (goal) {
const existingGoal = await deps.prisma.memoryDocument.findFirst({
where: { spaceId: space.id, scope: "user", path: "PROJECT_GOAL.md" },
select: { id: true },
});
if (existingGoal) {
await deps.prisma.memoryDocument.update({
where: { id: existingGoal.id },
data: { content: goal },
});
} else {
await deps.prisma.memoryDocument.create({
data: {
spaceId: space.id,
userId: context.actor.userId,
scope: "user",
path: "PROJECT_GOAL.md",
content: goal,
},
});
}
}
// A project starts as a conversation with its own main agent.
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
const spaceActor = await requireMembership(deps.prisma, context.actor.userId, space.id);
@ -818,14 +749,10 @@ export function createRouter(deps: RouterDeps) {
return {
id: space.id,
name: space.name,
icon: input.icon ?? null,
color: input.color ?? null,
isDefault: false,
hasContent: false,
canDelete: true,
canEdit: true,
workspace,
goal,
bots: bots.map(spaceBotDto),
groups: groups.map(spaceGroupDto),
externalConversations: [],
@ -846,81 +773,6 @@ export function createRouter(deps: RouterDeps) {
spaceId: conversation.spaceId,
};
}),
update: authed.spaces.update.handler(async ({ context, input }) => {
if (input.icon !== undefined && input.icon !== null && !isProjectIcon(input.icon)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project icon" });
}
if (input.color !== undefined && input.color !== null && !isProjectColor(input.color)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project color" });
}
if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
}
let workspacePath: string | null | undefined;
if (input.workspacePath) {
try {
workspacePath = await resolveProjectWorkspaceDirectory(input.workspacePath);
} catch (error) {
if (error instanceof InvalidProjectWorkspacePathError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
} else if (input.workspacePath === null) {
workspacePath = null;
}
try {
await updateSpaceForOwner(deps.prisma, {
userId: context.actor.userId,
spaceId: input.spaceId,
name: input.name,
icon: input.icon,
color: input.color,
workspacePath,
gitRemoteUrl: input.gitRemoteUrl,
});
if (workspacePath) {
await wireProjectWorkspace(deps.prisma, { spaceId: input.spaceId, workspacePath });
}
} catch (error) {
if (error instanceof SpaceNotFoundError) {
throw new ORPCError("NOT_FOUND", { message: error.message });
}
if (error instanceof IsolationError) throw new ORPCError("FORBIDDEN");
if (error instanceof InvalidSpaceNameError || error instanceof DuplicateSpaceNameError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
if (input.goal !== undefined) {
const existingGoal = await deps.prisma.memoryDocument.findFirst({
where: { spaceId: input.spaceId, scope: "user", path: "PROJECT_GOAL.md" },
});
if (input.goal?.trim()) {
if (existingGoal) {
await deps.prisma.memoryDocument.update({
where: { id: existingGoal.id },
data: { content: input.goal.trim() },
});
} else {
await deps.prisma.memoryDocument.create({
data: {
spaceId: input.spaceId,
userId: context.actor.userId,
scope: "user",
path: "PROJECT_GOAL.md",
content: input.goal.trim(),
},
});
}
} else if (existingGoal) {
await deps.prisma.memoryDocument.delete({ where: { id: existingGoal.id } });
}
}
return { ok: true as const };
}),
remove: authed.spaces.remove.handler(async ({ context, input }) => {
let claimId: string | null = null;
let claimActive = false;
@ -1080,15 +932,6 @@ export function createRouter(deps: RouterDeps) {
if (claimRenewal) clearInterval(claimRenewal);
}
}),
listDirectories: authed.spaces.listDirectories.handler(async ({ context, input }) => {
if (!context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
}
return listHostDirectories(input.path);
}),
listGithubRepos: authed.spaces.listGithubRepos.handler(async ({ input }) =>
listUserGithubRepos(deps.prisma, input, deps.secrets),
),
},
bootstrap: authed.bootstrap.handler(async ({ context, input }) => {
const actor = context.actor;
@ -1124,7 +967,7 @@ export function createRouter(deps: RouterDeps) {
try {
const { AppBootstrapSchema } = await import("@rakazo/contracts");
AppBootstrapSchema.parse(output);
} catch (_e) {}
} catch (_e: any) {}
return output;
}),
@ -1664,10 +1507,6 @@ export function createRouter(deps: RouterDeps) {
input.name,
);
}
await syncProjectMembers(deps.prisma, {
userId: context.actor.userId,
libraryBotId: existing.id,
});
const bots = await repos.listBots(context.actor);
const bot = bots.find((b) => b.id === input.botId);
if (!bot) throw new IsolationError();
@ -2065,42 +1904,6 @@ export function createRouter(deps: RouterDeps) {
await stopThreadRuns(deps, context.actor, target);
return { ok: true as const };
}),
resume: authed.threads.resume.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return resumeThreadRuns(deps, context.actor, target);
}),
sendNow: authed.threads.sendNow.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return sendQueuedThreadMessagesNow(deps, context.actor, target, {
messageId: input.messageId,
});
}),
removeQueuedMessage: authed.threads.removeQueuedMessage.handler(
async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return removeQueuedMessage(
{ prisma: deps.prisma, events: deps.events },
context.actor,
target,
input.messageId,
);
},
),
clear: authed.threads.clear.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
const contextBotId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
@ -2504,33 +2307,18 @@ export function createRouter(deps: RouterDeps) {
select: { botId: true, status: true },
})
: null;
const leaseRunWaits =
const waitingForTakeover =
executionRun?.botId === bot.id &&
(executionRun.status === "waiting_takeover" ||
bot.computer.controlRunId === executionLease?.runId);
const leaseBusy = executionBlocksUserTakeover({
hasLease: Boolean(executionLease),
leaseExpiresAt: executionLease?.expiresAt,
runStatus: executionRun?.status,
});
// A run whose setup failed while it waited keeps waiting_takeover after its execution
// lease is released, or behind an older run's inactive lease. Bind it anyway, or handing
// control back never resumes it.
const leaselessWaitingRun =
leaseRunWaits || leaseBusy
? null
: await deps.prisma.run.findFirst({
where: {
botId: bot.id,
spaceId: context.actor.spaceId,
status: "waiting_takeover",
},
orderBy: { createdAt: "desc" },
select: { id: true },
});
const waitingRunId = leaseRunWaits ? executionLease?.runId : leaselessWaitingRun?.id;
const waitingForTakeover = leaseRunWaits || Boolean(leaselessWaitingRun);
if (leaseBusy && !waitingForTakeover) {
if (
executionBlocksUserTakeover({
hasLease: Boolean(executionLease),
leaseExpiresAt: executionLease?.expiresAt,
runStatus: executionRun?.status,
takeoverRequested: waitingForTakeover,
})
) {
throw new ORPCError("CONFLICT", { message: "Stop the bot first" });
}
// Keep an inactive lease as a fencing tombstone. The next run reclaims it
@ -2551,7 +2339,7 @@ export function createRouter(deps: RouterDeps) {
controlLeaseId: leaseId,
controlLeaseExpiresAt: expiresAt,
controlBotId: bot.id,
controlRunId: waitingForTakeover ? waitingRunId : null,
controlRunId: waitingForTakeover ? executionLease?.runId : null,
state: "running",
},
});
@ -2894,77 +2682,6 @@ export function createRouter(deps: RouterDeps) {
}
return { ok: true as const };
}),
browserChromeState: authed.computer.browserChromeState.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "state", context);
}),
browserNavigate: authed.computer.browserNavigate.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
const browser = createBrowserProvider(undefined, { sandbox: deps.sandbox });
const result = await browser.navigate(
toComputerRef(bot.computer),
{ url: input.url },
computerContext(context.actor, bot.id, "browser.navigate"),
);
if (result.fallback) {
throw new ORPCError("CONFLICT", { message: result.error ?? "Browser unavailable" });
}
await noteBrowserNavigate(deps, context.actor, bot, input.url);
return readBrowserChrome(deps, bot, "state", context);
}),
browserBack: authed.computer.browserBack.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "back", context);
}),
browserForward: authed.computer.browserForward.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "forward", context);
}),
browserReload: authed.computer.browserReload.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "reload", context);
}),
browserCoworkEngage: authed.computer.browserCoworkEngage.handler(
async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer) throw new IsolationError();
const engaged = await engageBrowserCowork(
deps,
context.actor,
bot,
computerContext(context.actor, bot.id, "browser.cowork"),
);
return engaged;
},
),
browserUserAction: authed.computer.browserUserAction.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
const threadId = bot.thread?.id;
if (!threadId) throw new IsolationError();
await recordBrowserUserAction(deps, context.actor, {
botId: bot.id,
threadId,
kind: input.kind,
detail: input.detail,
passwordField: input.passwordField,
});
return { ok: true as const };
}),
},
memory: {
list: authed.memory.list.handler(async ({ context, input }) => {
@ -3424,9 +3141,8 @@ export function createRouter(deps: RouterDeps) {
},
capabilities: {
list: authed.capabilities.list.handler(async ({ context }) => {
// Tool sources and plugins follow the user into every space.
const rows = await deps.prisma.capabilityInstall.findMany({
where: { userId: context.actor.userId },
where: { spaceId: context.actor.spaceId, userId: context.actor.userId },
});
return rows.map((row) => ({
id: row.id,
@ -3491,10 +3207,6 @@ export function createRouter(deps: RouterDeps) {
source = "https://treg.to/mcp/";
config = { ...config, preset: "treg", auth: { type: "bearer" } };
}
if (config.preset === "github") {
source = GITHUB_MCP_SOURCE;
config = { preset: "github", auth: { type: "bearer" } };
}
const verified = await verifyMcpInstall({
source,
config,
@ -3585,7 +3297,11 @@ export function createRouter(deps: RouterDeps) {
remove: authed.capabilities.remove.handler(async ({ context, input }) => {
await deps.prisma.$transaction(async (tx) => {
const existing = await tx.capabilityInstall.findFirst({
where: { id: input.id, userId: context.actor.userId },
where: {
id: input.id,
spaceId: context.actor.spaceId,
userId: context.actor.userId,
},
});
if (!existing) return;
await tx.capabilityInstall.delete({ where: { id: existing.id } });
@ -3595,7 +3311,11 @@ export function createRouter(deps: RouterDeps) {
});
if (shared === 0) {
await tx.secret.deleteMany({
where: { id: existing.secretId, userId: context.actor.userId },
where: {
id: existing.secretId,
spaceId: context.actor.spaceId,
userId: context.actor.userId,
},
});
}
}
@ -3781,7 +3501,11 @@ export function createRouter(deps: RouterDeps) {
});
} else if (clearing && existing.secretId) {
await tx.secret.deleteMany({
where: { id: existing.secretId, userId: context.actor.userId },
where: {
id: existing.secretId,
spaceId: context.actor.spaceId,
userId: context.actor.userId,
},
});
}
return updated;
@ -5435,7 +5159,7 @@ export function createRouter(deps: RouterDeps) {
},
runs: {
list: authed.runs.list.handler(async ({ context, input }) => ({
runs: await listUserRuns(deps.prisma, context.actor, input.filter),
runs: await listSpaceRuns(deps.prisma, context.actor, input.filter),
})),
},
tasks: {
@ -5631,8 +5355,6 @@ async function spaceNavigationDto(
space: {
select: {
name: true,
icon: true,
color: true,
isDefault: true,
deletingAt: true,
workspacePath: true,
@ -5660,7 +5382,6 @@ async function spaceNavigationDto(
externalConversations,
contentBots,
contentGroups,
goalDocuments,
] = await Promise.all([
repos.listBots(actor),
groupRepos.listGroups(actor),
@ -5674,29 +5395,19 @@ async function spaceNavigationDto(
// The user's own main agent of a project is part of the project, not content to clear.
deps.prisma.bot.findMany({
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true, userId: true },
distinct: ["spaceId", "userId"],
select: { spaceId: true },
distinct: ["spaceId"],
}),
deps.prisma.chatGroup.findMany({
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true, userId: true },
distinct: ["spaceId", "userId"],
}),
deps.prisma.memoryDocument.findMany({
where: { spaceId: { in: spaceIds }, scope: "user", path: "PROJECT_GOAL.md" },
select: { spaceId: true, content: true },
select: { spaceId: true },
distinct: ["spaceId"],
}),
]);
const spacesWithContent = new Set([
...contentBots.map((row) => row.spaceId),
...contentGroups.map((row) => row.spaceId),
]);
const spacesBlockedByOtherMember = new Set(
[...contentBots, ...contentGroups]
.filter((row) => row.userId !== actor.userId)
.map((row) => row.spaceId),
);
const goalBySpaceId = new Map(goalDocuments.map((doc) => [doc.spaceId, doc.content]));
const currentMembership = memberships.find((membership) => membership.spaceId === actor.spaceId);
if (!currentMembership) throw new IsolationError();
const botsBySpace = partitionBySpace([...currentBots, ...inactiveBots]);
@ -5724,20 +5435,16 @@ async function spaceNavigationDto(
return {
id: membership.spaceId,
name: membership.space.name,
icon: membership.space.icon,
color: membership.space.color,
isDefault: membership.space.isDefault,
hasContent: spacesWithContent.has(membership.spaceId),
canDelete:
membership.role === "owner" &&
!membership.space.isDefault &&
memberships.length > 1 &&
!spacesBlockedByOtherMember.has(membership.spaceId) &&
!spacesWithContent.has(membership.spaceId) &&
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
canEdit: membership.role === "owner",
// A linked folder is on the owner's host; other members see neither its path nor remote.
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
goal: goalBySpaceId.get(membership.spaceId) ?? null,
bots: spaceBots.map(spaceBotDto),
groups: spaceGroups.map(spaceGroupDto),
externalConversations: externalConversations.filter(
@ -6135,55 +5842,6 @@ async function persistModelCredential(
return modelCredentialDto(cred, input.plaintext);
}
async function readBrowserChrome(
deps: RouterDeps,
bot: {
id: string;
computer: { homeKey: string; kind: string; providerRef: string | null } | null;
},
command: "state" | "back" | "forward" | "reload",
context: { actor: Actor },
) {
if (!bot.computer?.providerRef) {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
const browser = createBrowserProvider(undefined, { sandbox: deps.sandbox });
const chrome = browser as InstanceType<typeof import("@rakazo/adapters").ComputerBrowserProvider>;
const result = await chrome.chrome(
toComputerRef(bot.computer),
command,
computerContext(context.actor, bot.id, `browser.${command}`),
);
if (!result.ok) {
throw new ORPCError("CONFLICT", {
message: "error" in result && result.error ? result.error : "Browser unavailable",
});
}
return {
url: result.url,
title: result.title,
loading: result.loading,
canGoBack: result.canGoBack,
canGoForward: result.canGoForward,
};
}
async function noteBrowserNavigate(
deps: RouterDeps,
actor: Actor,
bot: { id: string; thread: { id: string } | null },
url: string,
) {
const threadId = bot.thread?.id;
if (!threadId) return;
await recordBrowserUserAction(deps, actor, {
botId: bot.id,
threadId,
kind: "navigate",
detail: url,
});
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw signal.reason ?? new Error("Request cancelled");
}

View File

@ -49,19 +49,15 @@ export function activityNotificationsEnabled(
return groupId !== null || notifyOnFinish;
}
/**
* The user's runs across every space they belong to, so a chat waiting on them in another
* project still shows up wherever they look.
*/
export async function listUserRuns(
export async function listSpaceRuns(
prisma: PrismaClient,
actor: Actor,
filter: "active" | "recent",
): Promise<RunActivityRow[]> {
const rows = await prisma.run.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
space: { memberships: { some: { userId: actor.userId } } },
bot: { archivedAt: null },
...(filter === "active"
? { status: { in: [...ACTIVE_RUN_STATUSES] } }
@ -92,7 +88,6 @@ export async function listUserRuns(
groupId: row.thread.groupId,
groupName: row.thread.group?.name ?? null,
threadId: row.threadId,
spaceId: row.spaceId,
status: row.status as RunActivityRow["status"],
trigger: row.trigger as RunActivityRow["trigger"],
notificationsEnabled: activityNotificationsEnabled(row.thread.groupId, row.bot.notifyOnFinish),

View File

@ -1,7 +1,6 @@
import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts";
import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
import { Prisma, type PrismaClient } from "@rakazo/db";
const SEARCH_LIMIT = 25;
/** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */
@ -65,65 +64,19 @@ export async function querySpaceSearch(
bucket.push(hit);
}
const [bots, groups, artifacts, groupArtifacts, routines] = await Promise.all([
prisma.bot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ title: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
],
},
take: CONVERSATION_HIT_LIMIT,
}),
prisma.chatGroup.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { contains: query, mode: "insensitive" },
},
take: CONVERSATION_HIT_LIMIT,
}),
prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: null,
botId: { not: null },
name: { contains: query, mode: "insensitive" },
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: { not: null },
name: { contains: query, mode: "insensitive" },
},
include: { group: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
prisma.routine.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ prompt: { contains: query, mode: "insensitive" } },
],
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
]);
const bots = await prisma.bot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ title: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
],
},
take: CONVERSATION_HIT_LIMIT,
});
for (const bot of bots) {
pushInto(
conversationHits,
@ -138,6 +91,14 @@ export async function querySpaceSearch(
);
}
const groups = await prisma.chatGroup.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { contains: query, mode: "insensitive" },
},
take: CONVERSATION_HIT_LIMIT,
});
for (const group of groups) {
pushInto(
conversationHits,
@ -153,72 +114,27 @@ export async function querySpaceSearch(
}
const contentBudget = Math.max(0, SEARCH_LIMIT - conversationHits.length);
const pattern = `%${query}%`;
const [botArtifactMessages, groupArtifactMessages, messageRows, groupMessageRows] =
await Promise.all([
findArtifactMessages(
prisma,
actor,
artifacts.flatMap((artifact) =>
artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [],
),
"bot",
),
findArtifactMessages(
prisma,
actor,
groupArtifacts.flatMap((artifact) =>
artifact.groupId && artifact.group
? [{ id: artifact.id, targetId: artifact.groupId }]
: [],
),
"group",
),
prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
botId: string;
botName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN bots b ON b.id = t."botId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND b."archivedAt" IS NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`,
prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
groupId: string;
groupName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN chat_groups g ON g.id = t."groupId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."groupId" IS NOT NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`,
]);
const artifacts = await prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: null,
botId: { not: null },
name: { contains: query, mode: "insensitive" },
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
});
const botArtifactMessages = await findArtifactMessages(
prisma,
actor,
artifacts.flatMap((artifact) =>
artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [],
),
"bot",
);
for (const artifact of artifacts) {
if (!artifact.botId || !artifact.bot) continue;
const message = botArtifactMessages.get(artifact.id);
@ -239,6 +155,24 @@ export async function querySpaceSearch(
);
}
const groupArtifacts = await prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: { not: null },
name: { contains: query, mode: "insensitive" },
},
include: { group: { select: { name: true } } },
take: SEARCH_LIMIT,
});
const groupArtifactMessages = await findArtifactMessages(
prisma,
actor,
groupArtifacts.flatMap((artifact) =>
artifact.groupId && artifact.group ? [{ id: artifact.id, targetId: artifact.groupId }] : [],
),
"group",
);
for (const artifact of groupArtifacts) {
if (!artifact.groupId || !artifact.group) continue;
const message = groupArtifactMessages.get(artifact.id);
@ -259,6 +193,19 @@ export async function querySpaceSearch(
);
}
const routines = await prisma.routine.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ prompt: { contains: query, mode: "insensitive" } },
],
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
});
for (const routine of routines) {
pushInto(
contentHits,
@ -274,6 +221,29 @@ export async function querySpaceSearch(
);
}
const pattern = `%${query}%`;
const messageRows = await prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
botId: string;
botName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN bots b ON b.id = t."botId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND b."archivedAt" IS NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`;
for (const row of messageRows) {
pushMessageHits(row.blocks as MessageBlock[], {
botId: row.botId,
@ -285,6 +255,28 @@ export async function querySpaceSearch(
});
}
const groupMessageRows = await prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
groupId: string;
groupName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN chat_groups g ON g.id = t."groupId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."groupId" IS NOT NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`;
for (const row of groupMessageRows) {
pushMessageHits(row.blocks as MessageBlock[], {
groupId: row.groupId,

View File

@ -88,77 +88,4 @@ describe("model team chat engagement judge", () => {
expect(instructions).toContain("(Europe/Berlin, the user's time zone)");
expect(instructions).toContain("low-cost engagement judge");
});
it("prefers the space fast model tier when mapped and no explicit judge override is set", async () => {
let selectedModel: { provider: string; id: string; apiKey?: string } | undefined;
const runtime = {
run: vi.fn((request: { model: { provider: string; id: string; apiKey?: string } }) => {
selectedModel = request.model;
return (async function* () {
yield { type: "done" as const, text: '{"act":true,"reason":"Direct mention."}' };
})();
}),
} as unknown as AgentRuntime;
const prisma = {
deploymentSettings: { findUnique: vi.fn(async () => null) },
spaceModelPreference: { findFirst: vi.fn(async () => null) },
spaceModelTier: {
findUnique: vi.fn(async () => ({
tier: "fast",
credentialId: "cred-fast",
modelId: "gemma4:latest",
credential: {
id: "cred-fast",
userId: "user-1",
provider: "openai-compatible",
secretId: "sec-fast",
preferences: [{ isDefault: false, modelId: null }],
},
})),
},
secret: {
findFirst: vi.fn(async () => ({ id: "sec-fast", ciphertext: "cipher-fast" })),
},
userProfile: { findUnique: vi.fn(async () => ({ timeZone: "UTC" })) },
} as unknown as PrismaClient;
const secrets = {
load: vi.fn(() =>
JSON.stringify({
kind: "openai_compatible",
apiKey: "local-key",
baseUrl: "http://127.0.0.1:11434/v1",
}),
),
} as unknown as EncryptedSecretStore;
const judge = new ModelTeamChatEngagementJudge({
prisma,
runtime,
secrets,
deploymentProvider: "openrouter",
deploymentModel: "expensive-default",
});
const decision = await judge.decide({
bot: {
id: "bot-1",
spaceId: "space-1",
userId: "user-1",
name: "Arthur",
modelProvider: "anthropic",
modelId: "claude-opus",
},
channelId: "C1",
rules: "",
messages: [],
});
expect(decision).toEqual({ act: true, reason: "Direct mention.", askedByEventId: undefined });
expect(selectedModel).toMatchObject({
provider: "openai-compatible",
id: "gemma4:latest",
apiKey: "local-key",
baseUrl: "http://127.0.0.1:11434/v1",
});
});
});

View File

@ -1,15 +1,14 @@
import { randomUUID } from "node:crypto";
import type { AgentModelOAuthCredential, AgentRuntime } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import {
type EncryptedSecretStore,
formatCurrentTimeInstruction,
loadUserTimeZone,
resolveModelAuth,
serializeModelSecret,
toOAuthCredential,
} from "@rakazo/adapters";
import type { PrismaClient } from "@rakazo/db";
import { findDefaultModelCredential, findModelCredential, findSpaceModelTier } from "@rakazo/db";
import { findDefaultModelCredential, findModelCredential, type PrismaClient } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
const MAX_RULES_CHARS = 4_000;
@ -192,39 +191,27 @@ export class ModelTeamChatEngagementJudge implements TeamChatEngagementJudge {
};
};
} | null> {
const settings = await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const scope = { userId: bot.userId, spaceId: bot.spaceId };
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
const fastTier =
!explicitJudgeOverride && this.deps.prisma.spaceModelTier
? await findSpaceModelTier(this.deps.prisma, scope, "fast")
: null;
const defaultCredential = fastTier
? null
: await findDefaultModelCredential(this.deps.prisma, scope);
const defaultCredential = await findDefaultModelCredential(this.deps.prisma, scope);
const requestedProvider = this.deps.providerOverride ?? bot.modelProvider;
const requestedModel = this.deps.modelOverride ?? bot.modelId;
const overrideCredential =
!fastTier && requestedProvider
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
: null;
const overrideCredential = requestedProvider
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
: null;
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
const useOverride = Boolean(
requestedProvider && requestedModel && (explicitJudgeOverride || overrideCredential),
);
const credential =
fastTier?.credential ?? (useOverride ? overrideCredential : defaultCredential);
const settings = fastTier
? null
: await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const credential = useOverride ? overrideCredential : defaultCredential;
const provider =
fastTier?.credential.provider ??
(useOverride ? requestedProvider : null) ??
credential?.provider ??
settings?.defaultModelProvider ??
this.deps.deploymentProvider;
const modelId =
fastTier?.modelId ??
(useOverride ? requestedModel : null) ??
credential?.defaultModel ??
settings?.defaultModelId ??

View File

@ -2,17 +2,15 @@ import type { SandboxProvider } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import type { ThreadTarget } from "./thread-target.js";
import {
cancelSupersededQueuedRuns,
followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage,
resolveGroupFollowUpBotId,
resumeThreadRuns,
sendQueuedThreadMessagesNow,
sendThreadMessage,
stopThreadRuns,
type ThreadTarget,
threadHead,
threadSnapshot,
} from "./thread-target.js";
@ -213,13 +211,7 @@ describe("threadSnapshot", () => {
expect.objectContaining({
where: expect.objectContaining({
type: {
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
"agent.tool.completed",
],
in: ["thread.progress", "thread.subagent", "agent.tool.called", "agent.tool.completed"],
},
}),
}),
@ -238,69 +230,6 @@ describe("threadSnapshot", () => {
]);
});
it("does not reload live narration that was already posted as a message", async () => {
const run = {
id: "run-1",
botId: "bot-1",
threadId: "thread-1",
taskId: "task-1",
status: "running",
trigger: "user",
modelProvider: null,
modelId: null,
error: null,
startedAt: null,
completedAt: null,
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const at = new Date("2026-08-23T00:00:00.000Z");
const event = (seq: number, type: string, payload: Record<string, unknown>) => ({
id: `event-${seq}`,
threadId: "thread-1",
botId: "bot-1",
seq,
type,
runId: "run-1",
payload,
createdAt: at,
});
const tx = {
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 6 }),
findMany: vi.fn().mockResolvedValue([
event(4, "thread.progress", { text: "Opening the page." }),
event(5, "thread.message.created", {
messageId: "message-1",
role: "bot",
blocks: [{ kind: "text", text: "Opening the page." }],
}),
event(6, "thread.progress", { text: "Writing the summary." }),
]),
},
run: { findFirst: botRunFindFirst([run]) },
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
} as unknown as PrismaClient;
const target = {
kind: "bot",
botId: "bot-1",
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const snapshot = await threadSnapshot({ prisma }, target);
expect(snapshot.messages).toEqual([
expect.objectContaining({
id: "progress:run-1",
blocks: [{ kind: "progress", text: "Writing the summary." }],
}),
]);
});
it("returns the latest failed run so the client can show its error", async () => {
const run = {
id: "run-failed",
@ -1032,13 +961,10 @@ function groupTarget() {
}
describe("sendThreadMessage", () => {
it("steers a waiting-input run instead of refusing the message", async () => {
it("rejects a new bot message while a run is waiting on input", async () => {
const tx = {
thread: {
update: vi
.fn()
.mockResolvedValueOnce({ nextMessageSeq: 2 })
.mockResolvedValueOnce({ nextEventSeq: 3 }),
update: vi.fn().mockResolvedValue({ nextMessageSeq: 2 }),
},
message: {
create: vi.fn().mockResolvedValue({
@ -1046,7 +972,7 @@ describe("sendThreadMessage", () => {
threadId: "thread-1",
seq: 1,
role: "user",
blocks: [{ kind: "text", text: "more context" }],
blocks: [{ kind: "text", text: "hi" }],
botId: null,
replyToMessageId: null,
runId: null,
@ -1058,13 +984,9 @@ describe("sendThreadMessage", () => {
findMany: vi
.fn()
.mockResolvedValue([{ id: "run-waiting", taskId: "task-1", status: "waiting_input" }]),
findUnique: vi.fn().mockResolvedValue({
status: "waiting_input",
startedAt: new Date(),
}),
},
steeringMessage: { create: vi.fn() },
event: { create: vi.fn().mockResolvedValue({ seq: 2 }) },
event: { create: vi.fn() },
task: { create: vi.fn() },
};
const prisma = {
@ -1078,37 +1000,26 @@ describe("sendThreadMessage", () => {
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const enqueue = vi.fn().mockResolvedValue(undefined);
await expect(
sendThreadMessage(
{
prisma,
events: { notify: vi.fn().mockResolvedValue(undefined) } as never,
jobs: { enqueue } as never,
events: { notify: vi.fn() } as never,
jobs: { enqueue: vi.fn() } as never,
},
actor,
target,
{
text: "more context",
text: "hi",
clientNonce: "nonce-1",
},
),
).resolves.toMatchObject({
runId: "run-waiting",
taskId: "task-1",
seq: 1,
runIds: ["run-waiting"],
).rejects.toMatchObject({
code: "CONFLICT",
message: "Answer the pending ask first.",
});
expect(tx.steeringMessage.create).toHaveBeenCalledWith({
data: {
messageId: "msg-1",
botId: "bot-1",
userId: "user-1",
runId: "run-waiting",
},
});
expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ name: "run.continue" }));
expect(tx.steeringMessage.create).not.toHaveBeenCalled();
});
it("steers a waiting-takeover run instead of refusing the message", async () => {
@ -2120,192 +2031,4 @@ describe("stopThreadRuns", () => {
expect.objectContaining({ runId: "run-b" }),
);
});
it("sendQueuedThreadMessagesNow cancels the active run, preserves pending steering, and enqueues an immediate continuation run", async () => {
const queuedCreatedAt = new Date("2026-01-01T10:00:05.000Z");
const transaction = {
$queryRaw: vi.fn(),
steeringMessage: {
findMany: vi.fn().mockResolvedValue([
{
id: "steer-1",
botId: "bot-a",
userId: "user-1",
messageId: "msg-queued-1",
claimedAt: null,
message: {
id: "msg-queued-1",
blocks: [{ kind: "text", text: "Send this right now" }],
seq: 2,
createdAt: queuedCreatedAt,
},
},
]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
run: {
updateManyAndReturn: vi.fn().mockResolvedValue([{ id: "run-old", taskId: "task-old" }]),
create: vi.fn().mockResolvedValue({ id: "run-new", status: "queued" }),
},
task: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue({ id: "task-new" }),
},
message: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
computerExecutionLease: {
findMany: vi.fn().mockResolvedValue([]),
},
computer: {
findMany: vi.fn().mockResolvedValue([]),
},
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 42 }),
},
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof transaction) => unknown) =>
callback(transaction),
),
computerExecutionLease: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
computer: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
event: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
} as unknown as PrismaClient;
const jobs = {
enqueue: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn().mockResolvedValue(undefined),
};
const events = {
notify: vi.fn().mockResolvedValue(undefined),
};
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
const target = {
kind: "bot",
botId: "bot-a",
threadId: "thread-1",
bot: {} as never,
} satisfies ThreadTarget;
const result = await sendQueuedThreadMessagesNow(
{
prisma,
sandbox: {} as SandboxProvider,
events: events as never,
jobs: jobs as never,
},
actor,
target,
{ messageId: "msg-queued-1" },
);
expect(result).toEqual({ ok: true, runIds: ["run-new"] });
expect(transaction.run.updateManyAndReturn).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
threadId: "thread-1",
botId: { in: ["bot-a"] },
}),
data: expect.objectContaining({ status: "cancelled" }),
}),
);
expect(transaction.steeringMessage.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["steer-1"] } },
data: { runId: "run-new", claimedAt: null },
});
expect(jobs.cancel).toHaveBeenCalledWith("run:run-old");
expect(jobs.enqueue).toHaveBeenCalledWith({
name: "run.continue",
payload: { runId: "run-new" },
replaceKey: "run:run-new",
});
expect(events.notify).toHaveBeenCalledWith("thread-1", 42);
});
});
describe("resumeThreadRuns", () => {
it("re-queues the latest cancelled or failed run without creating a duplicate message", async () => {
const transaction = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: "run-cancelled",
taskId: "task-cancelled",
botId: "bot-a",
status: "cancelled",
createdAt: new Date("2026-04-20T10:00:00.000Z"),
completedAt: new Date("2026-04-20T10:02:00.000Z"),
},
]),
update: vi.fn().mockResolvedValue({}),
},
task: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 19 }),
},
};
const prisma = {
$transaction: vi.fn(async (fn: (tx: typeof transaction) => Promise<unknown>) =>
fn(transaction),
),
} as unknown as PrismaClient;
const jobs = {
enqueue: vi.fn().mockResolvedValue(undefined),
};
const events = {
notify: vi.fn().mockResolvedValue(undefined),
};
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
const target = {
kind: "bot",
botId: "bot-a",
threadId: "thread-1",
bot: {} as never,
} satisfies ThreadTarget;
const result = await resumeThreadRuns(
{
prisma,
events: events as never,
jobs: jobs as never,
},
actor,
target,
);
expect(result).toEqual({ ok: true, runIds: ["run-cancelled"] });
expect(transaction.task.updateMany).toHaveBeenCalledWith({
where: { id: "task-cancelled" },
data: { status: "queued" },
});
expect(transaction.run.update).toHaveBeenCalledWith({
where: { id: "run-cancelled" },
data: {
status: "queued",
error: null,
completedAt: null,
leaseOwner: null,
leaseExpiresAt: null,
},
});
expect(jobs.enqueue).toHaveBeenCalledWith({
name: "run.continue",
payload: { runId: "run-cancelled" },
replaceKey: "run:run-cancelled",
});
expect(events.notify).toHaveBeenCalledWith("thread-1", 19);
});
});

View File

@ -1,10 +1,5 @@
import { ORPCError } from "@orpc/server";
import {
type JobPublisher,
runContinueJob,
runJobKey,
type SandboxProvider,
} from "@rakazo/adapter-kit";
import { type JobPublisher, runContinueJob, type SandboxProvider } from "@rakazo/adapter-kit";
import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@rakazo/adapters";
import {
type Actor,
@ -165,15 +160,9 @@ function quoteAppearsInBlocks(quote: string, blocks: MessageBlock[]): boolean {
}
const THREAD_MESSAGE_PAGE_SIZE = 100;
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_input", "waiting_takeover"]);
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_takeover"]);
const STEERABLE_RUN_STATUSES = new Set([
"queued",
"leased",
"running",
"waiting_input",
"waiting_takeover",
]);
const STEERABLE_RUN_STATUSES = new Set(["queued", "leased", "running", "waiting_takeover"]);
type MentionTargetInput = string | { kind: "bot" | "group" | "routine" | "connector"; id: string };
@ -502,7 +491,6 @@ export async function threadSnapshot(
runId: currentRun.id,
type: {
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
@ -568,7 +556,6 @@ export async function threadSnapshot(
runId: { in: activeRuns.map((run) => run.id) },
type: {
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
@ -644,12 +631,8 @@ function messagesWithLiveEvents(
persisted: ThreadSnapshot["messages"],
liveEvents: Parameters<typeof projectMessages>[0],
) {
// Message events only clear the live text they promoted; the persisted rows already carry them.
const live = projectMessages(liveEvents).filter((message) => {
if (
message.id.startsWith("progress:") &&
message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")
) {
if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) {
return true;
}
if (!message.id.startsWith("subagent:")) return false;
@ -1156,50 +1139,63 @@ export async function reactToThreadMessage(
});
}
async function snapshotCancelledRunTeardown(tx: Prisma.TransactionClient, runIds: string[]) {
// Snapshot teardown coordinates before commit. Once cancellation becomes
// visible, a worker can release its lease / execution columns immediately; a
// later lookup would then miss the sandbox work this request must stop.
// Team ownership lives on ComputerExecutionLease; Computer.executionRunId is
// only a legacy secondary path and is not written by current acquisition.
const leases = runIds.length
? await tx.computerExecutionLease.findMany({
where: { runId: { in: runIds } },
select: { computerId: true, botId: true, runId: true, fence: true },
})
: [];
const leaseComputerIds = [...new Set(leases.map((lease) => lease.computerId))];
const computers = runIds.length
? await tx.computer.findMany({
where:
leaseComputerIds.length > 0
? {
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: runIds } }],
}
: { executionRunId: { in: runIds } },
select: {
id: true,
homeKey: true,
kind: true,
providerRef: true,
executionBotId: true,
executionRunId: true,
},
})
: [];
return { runIds, computers, leases };
}
async function teardownCancelledRuns(
export async function stopThreadRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
snapshot: Awaited<ReturnType<typeof snapshotCancelledRunTeardown>>,
target: ThreadTarget,
) {
const { runIds, computers, leases } = snapshot;
if (runIds.length === 0) return;
const { runIds, computers, leases } = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: new Date() },
select: { id: true },
});
const ids = cancelled.map((run) => run.id);
await tx.steeringMessage.deleteMany({
where: {
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
message: { threadId: target.threadId },
},
});
// Snapshot teardown coordinates before commit. Once cancellation becomes
// visible, a worker can release its lease / execution columns immediately; a
// later lookup would then miss the sandbox work this request must stop.
// Team ownership lives on ComputerExecutionLease; Computer.executionRunId is
// only a legacy secondary path and is not written by current acquisition.
const leases = ids.length
? await tx.computerExecutionLease.findMany({
where: { runId: { in: ids } },
select: { computerId: true, botId: true, runId: true, fence: true },
})
: [];
const leaseComputerIds = [...new Set(leases.map((lease) => lease.computerId))];
const computers = ids.length
? await tx.computer.findMany({
where:
leaseComputerIds.length > 0
? {
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: ids } }],
}
: { executionRunId: { in: ids } },
select: {
id: true,
homeKey: true,
kind: true,
providerRef: true,
executionBotId: true,
executionRunId: true,
},
})
: [];
return { runIds: ids, computers, leases };
});
// Keep the DB lease until after teardown so a replacement run cannot claim the
// screen while we still need the cancelled run's screenLeaseId to release it.
const computerById = new Map(computers.map((computer) => [computer.id, computer]));
@ -1277,381 +1273,6 @@ async function teardownCancelledRuns(
});
}
export async function stopThreadRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
target: ThreadTarget,
) {
const snapshot = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: new Date() },
select: { id: true },
});
const ids = cancelled.map((run) => run.id);
await tx.steeringMessage.deleteMany({
where: {
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
message: { threadId: target.threadId },
},
});
return snapshotCancelledRunTeardown(tx, ids);
});
await teardownCancelledRuns(deps, actor, snapshot);
}
/**
* Resume the latest stopped (`cancelled`) or interrupted (`failed`) run in the thread
* without creating a duplicate user message row.
*/
export async function resumeThreadRuns(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const committed = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true, status: true },
});
if (activeRuns.length > 0) {
return {
runs: activeRuns,
eventSeq: null as number | null,
};
}
const recentTerminals = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 20,
select: {
id: true,
botId: true,
taskId: true,
status: true,
createdAt: true,
completedAt: true,
},
});
const latestTerminal = pickLatestTerminalRun(recentTerminals);
if (
!latestTerminal ||
(latestTerminal.status !== "cancelled" && latestTerminal.status !== "failed")
) {
return {
runs: [] as Array<{ id: string; status: string }>,
eventSeq: null as number | null,
};
}
await tx.task.updateMany({
where: { id: latestTerminal.taskId },
data: { status: "queued" },
});
await tx.run.update({
where: { id: latestTerminal.id },
data: {
status: "queued",
error: null,
completedAt: null,
leaseOwner: null,
leaseExpiresAt: null,
},
});
if (target.kind === "group") {
await touchGroupUpdatedAt(tx, target.groupId);
}
const latestEvent = await tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return {
runs: [{ id: latestTerminal.id, status: "queued" }],
eventSeq: latestEvent?.seq ?? null,
};
});
if (committed.eventSeq !== null) {
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread resume realtime notification", error);
});
}
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
return {
ok: true as const,
runIds: committed.runs.map((run) => run.id),
};
}
/**
* Promote queued follow-up messages immediately: cancel any currently active runs
* holding up the queue without discarding unclaimed steering messages, create a
* new continuation run for the pending steering messages, and enqueue it right away.
*/
export async function sendQueuedThreadMessagesNow(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
input?: { messageId?: string },
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const committed = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const pendingSteering = await tx.steeringMessage.findMany({
where: {
botId: { in: botIds },
claimedAt: null,
message: { threadId: target.threadId },
},
include: {
message: { select: { id: true, blocks: true, seq: true, createdAt: true } },
},
orderBy: [{ message: { seq: "asc" } }, { id: "asc" }],
});
const pendingByBotId = new Map<string, typeof pendingSteering>();
for (const item of pendingSteering) {
const list = pendingByBotId.get(item.botId) ?? [];
list.push(item);
pendingByBotId.set(item.botId, list);
}
if (pendingByBotId.size === 0 && input?.messageId) {
const targetMsg = await tx.message.findFirst({
where: { id: input.messageId, threadId: target.threadId, role: "user" },
select: { id: true, blocks: true, seq: true, createdAt: true },
});
if (targetMsg) {
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
createdAt: { lt: targetMsg.createdAt },
},
select: { id: true, botId: true },
});
const fallbackBotId =
activeRuns[0]?.botId ??
(target.kind === "bot" ? target.botId : groupDefaultBotId(target));
if (fallbackBotId && activeRuns.length > 0) {
const steering = await tx.steeringMessage.create({
data: {
messageId: targetMsg.id,
botId: fallbackBotId,
userId: actor.userId,
runId: activeRuns[0]?.id ?? null,
},
});
pendingByBotId.set(fallbackBotId, [{ ...steering, message: targetMsg }]);
}
}
}
if (pendingByBotId.size === 0) {
return {
teardown: { runIds: [] as string[], computers: [], leases: [] },
runs: [] as Array<{ id: string; status: string }>,
eventSeq: null as number | null,
};
}
const affectedBotIds = [...pendingByBotId.keys()];
const now = new Date();
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
botId: { in: affectedBotIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: now },
select: { id: true, taskId: true },
});
const cancelledRunIds = cancelled.map((run) => run.id);
if (cancelled.length > 0) {
await tx.task.updateMany({
where: { id: { in: cancelled.map((run) => run.taskId) } },
data: { status: "cancelled" },
});
await tx.steeringMessage.deleteMany({
where: {
runId: { in: cancelledRunIds },
claimedAt: { not: null },
},
});
}
const teardown = await snapshotCancelledRunTeardown(tx, cancelledRunIds);
const createdRuns: Array<{ id: string; status: string }> = [];
for (const [botId, items] of pendingByBotId) {
const last = items.at(-1)!;
const task = await tx.task.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
userId: items[0]!.userId,
prompt: "Respond to the user's steering context.",
status: "queued",
},
});
const runCreatedAt = new Date(Math.max(Date.now(), last.message.createdAt.getTime() + 1));
const run = await tx.run.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
taskId: task.id,
userId: items[0]!.userId,
status: "queued",
trigger: "follow_up",
sourceMessageId: last.message.id,
createdAt: runCreatedAt,
},
select: { id: true, status: true },
});
await tx.steeringMessage.updateMany({
where: { id: { in: items.map((item) => item.id) } },
data: { runId: run.id, claimedAt: null },
});
await tx.message.updateMany({
where: { id: { in: items.map((item) => item.message.id) } },
data: { runId: run.id },
});
createdRuns.push(run);
}
if (target.kind === "group") {
await touchGroupUpdatedAt(tx, target.groupId);
}
const latestEvent = await tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return {
teardown,
runs: createdRuns,
eventSeq: latestEvent?.seq ?? null,
};
});
if (committed.teardown.runIds.length > 0) {
await Promise.all([
teardownCancelledRuns(deps, actor, committed.teardown),
...committed.teardown.runIds.map((runId) =>
deps.jobs.cancel(runJobKey(runId)).catch(() => undefined),
),
]);
}
if (committed.runs.length > 0) {
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
if (committed.eventSeq != null) {
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread sendNow realtime notification", error);
});
}
}
return {
ok: true as const,
runIds: committed.runs.map((run) => run.id),
};
}
export async function removeQueuedMessage(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
},
actor: Actor,
target: ThreadTarget,
messageId: string,
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const result = await deps.prisma.$transaction(async (tx) => {
// Verify the message exists and belongs to this thread
const message = await tx.message.findFirst({
where: {
id: messageId,
threadId: target.threadId,
role: "user",
},
});
if (!message) {
return { removed: false as const };
}
// Check if this message is queued (has an unclaimed steering message)
const steering = await tx.steeringMessage.findFirst({
where: {
messageId: messageId,
botId: { in: botIds },
claimedAt: null,
},
});
if (!steering) {
// No queued steering entry — nothing to remove
return { removed: false as const };
}
// Delete the steering message (unclaims it from the queue)
await tx.steeringMessage.delete({
where: { id: steering.id },
});
// Also clean up the user message if it's exclusively queued
// (no run attached to it)
if (!message.runId) {
await tx.message.delete({
where: { id: messageId },
});
}
return { removed: true as const };
});
if (result.removed) {
// Notify subscribers so the UI updates
const latest = await deps.prisma.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
await deps.events.notify(target.threadId, latest?.seq ?? 0).catch(() => undefined);
}
return { ok: true as const, removed: result.removed };
}
export async function setThreadUnreadState(
prisma: PrismaClient,
actor: Actor,

View File

@ -39,7 +39,7 @@ describe("toVoiceStatus", () => {
});
describe("voice HTTP routes", () => {
it("rejects unauthenticated speak, transcribe, and transcribe-stream", async () => {
it("rejects unauthenticated speak and transcribe", async () => {
const app = new Hono();
mountVoiceHttpRoutes(app, {} as VoiceDeps, async () => null);
const speak = await app.request("/api/voice/speak", {
@ -52,20 +52,13 @@ describe("voice HTTP routes", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }),
});
const transcribeStream = await app.request("/api/voice/transcribe-stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }),
});
expect(speak.status).toBe(401);
expect(transcribe.status).toBe(401);
expect(transcribeStream.status).toBe(401);
});
it.each([
["/api/voice/speak", MAX_SPEAK_REQUEST_BYTES],
["/api/voice/transcribe", MAX_TRANSCRIBE_REQUEST_BYTES],
["/api/voice/transcribe-stream", MAX_TRANSCRIBE_REQUEST_BYTES],
])(
"rejects a declared oversized body on %s without waiting for cancellation",
async (path, max) => {
@ -119,22 +112,6 @@ describe("voice HTTP routes", () => {
expect(response.status).toBe(413);
expect(cancel).toHaveBeenCalledOnce();
});
it("rejects empty recording on transcribe-stream", async () => {
const app = new Hono();
mountVoiceHttpRoutes(
app,
{} as VoiceDeps,
async () => ({ userId: "user", spaceId: "space" }) as Actor,
);
const response = await app.request("/api/voice/transcribe-stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "", mimeType: "audio/webm" }),
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Recording is empty." });
});
});
const actor = { userId: "user-1", spaceId: "space-1" } as Actor;

View File

@ -25,7 +25,7 @@ import {
selectSpaceVoicePreference,
} from "@rakazo/db";
import type { Context, Hono } from "hono";
import { cancelBody, readBoundedBody } from "./http-body.js";
import { readBoundedBody } from "./http-body.js";
import { withSerializableRetry } from "./serializable-retry.js";
export interface VoiceDeps {
@ -147,11 +147,7 @@ export async function persistVoiceCredential(
let voiceId = input.voiceId?.trim() ?? "";
if (!voiceId) {
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
const germanVoice =
input.provider === "kokoro"
? voices.find((v) => v.id === "df_eva" || /^d[fm]_/i.test(v.id))
: undefined;
voiceId = germanVoice?.id ?? voices[0]?.id ?? "";
voiceId = voices[0]?.id ?? "";
}
const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
const cred = await withSerializableRetry(() =>
@ -380,113 +376,6 @@ export function mountVoiceHttpRoutes(
return voiceHttpError(c, error);
}
});
app.post("/api/voice/transcribe-stream", async (c) => {
const actor = await authenticate(c);
if (!actor) return c.json({ error: "Unauthorized" }, 401);
const contentType = c.req.header("content-type") ?? "";
const isJson =
contentType.includes("application/json") ||
(!contentType.startsWith("audio/") && !contentType.includes("octet-stream"));
let audio: Uint8Array;
let mimeType = "audio/webm";
if (isJson) {
const raw = await readBoundedBody(c.req.raw, MAX_TRANSCRIBE_REQUEST_BYTES);
if (raw === null) return c.json({ error: "Request body is too large." }, 413);
const body = parseVoiceRequestBody(raw);
const audioBase64 = String((body as { audioBase64?: unknown }).audioBase64 ?? "");
try {
audio = decodeAudioBase64(audioBase64);
mimeType = String((body as { mimeType?: unknown }).mimeType ?? "audio/webm");
} catch (error) {
return voiceHttpError(c, error);
}
} else {
const rawBytes = await readBoundedBytes(c.req.raw, MAX_TRANSCRIBE_REQUEST_BYTES);
if (rawBytes === null) return c.json({ error: "Request body is too large." }, 413);
if (rawBytes.byteLength === 0) {
return voiceHttpError(c, new ORPCError("BAD_REQUEST", { message: "Recording is empty." }));
}
audio = rawBytes;
mimeType = contentType.split(";")[0]?.trim() || "audio/webm";
}
try {
const result = await transcribeVoice(deps, actor, {
audio,
mimeType,
signal: c.req.raw.signal,
});
const accept = c.req.header("accept") ?? "";
if (accept.includes("application/json") && !accept.includes("text/event-stream")) {
return c.json({ text: result.text, isFinal: true });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ text: result.text, delta: result.text, isFinal: true })}\n\n`,
),
);
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
controller.close();
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
} catch (error) {
return voiceHttpError(c, error);
}
});
}
async function readBoundedBytes(request: Request, maxBytes: number): Promise<Uint8Array | null> {
const contentLengthHeader = request.headers.get("content-length");
if (contentLengthHeader !== null) {
const contentLength = Number(contentLengthHeader);
if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > maxBytes) {
cancelBody(request.body);
return null;
}
}
if (!request.body) return new Uint8Array(0);
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
cancelBody(reader);
return null;
}
chunks.push(value);
}
const combined = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.byteLength;
}
return combined;
} finally {
reader.releaseLock();
}
}
function parseVoiceRequestBody(raw: string): Record<string, unknown> {

View File

@ -25,12 +25,4 @@ describe("requestWebOrigin", () => {
"https://evoo.example",
);
});
it("accepts origins listed in extraTrustedOrigins", () => {
expect(
requestWebOrigin("https://host.example.ts.net:8443", configured, [
"https://host.example.ts.net:8443",
]),
).toBe("https://host.example.ts.net:8443");
});
});

View File

@ -14,20 +14,15 @@ export function isLoopbackHost(host: string): boolean {
}
/** The web origin that browser-facing URLs (OAuth returns, screen links) should
* point at for this request. A browser opened over a loopback, private-network,
* or explicitly trusted origin gets its own origin back so a phone on the LAN
* or Tailscale is not sent to 127.0.0.1; any other caller gets WEB_ORIGIN. */
export function requestWebOrigin(
origin: string | undefined,
webOrigin: string,
extraTrustedOrigins: readonly string[] = [],
): string {
* point at for this request. A browser opened over a loopback or private-network
* address gets its own origin back so a phone on the LAN is not sent to
* 127.0.0.1; any other caller gets the configured WEB_ORIGIN. */
export function requestWebOrigin(origin: string | undefined, webOrigin: string): string {
if (!origin) return webOrigin;
try {
const url = new URL(origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return webOrigin;
if (url.origin === new URL(webOrigin).origin) return url.origin;
if (extraTrustedOrigins.includes(url.origin)) return url.origin;
return isPrivateOrLocalHost(url.hostname) ? url.origin : webOrigin;
} catch {
return webOrigin;

View File

@ -66,9 +66,9 @@ describe("desktop release workflow", () => {
`include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'`,
);
expect(workflow).toMatch(
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":\["self-hosted","evoo-ci","Windows"\],"artifact":"windows"\}/,
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":"windows-2022","artifact":"windows"\}/,
);
expect(workflow).not.toMatch(/windows-\d|windows-latest/);
expect(workflow.split("windows-2022")).toHaveLength(2);
expect(workflow).toContain(`WINDOWS_BUILT: ${expression("needs.validate.outputs.windows")}`);
expect(workflow).toContain('if [[ "$WINDOWS_BUILT" == "true" ]]; then');
expect(workflow).toContain("feeds+=(release-artifacts/latest.yml)");
@ -94,11 +94,4 @@ describe("desktop release workflow", () => {
expect(workflow).toContain("--draft --generate-notes");
expect(workflow).toContain("--draft=false --latest");
});
it("hands platform builds to the draft release, never to Actions artifact storage", () => {
expect(workflow).not.toMatch(/actions\/(?:upload|download)-artifact@/);
expect(workflow).toContain("bash scripts/upload-release-assets.sh");
expect(workflow).toContain('gh release download "$RELEASE_TAG" --dir release-artifacts');
expect(workflow).toContain("needs: [validate, build, publish]");
});
});

View File

@ -3,15 +3,8 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { Alert, Pressable, ScrollView, Text, TextInput } from "react-native";
import { BotMemberPicker } from "../components/bot-member-picker";
import {
type MobileBot,
type MobileGroup,
type MobileSpace,
type MobileSpaceNavigation,
rpc,
} from "../lib/api";
import { type MobileBot, type MobileGroup, rpc } from "../lib/api";
import { useI18n } from "../lib/i18n";
import { canDeleteInboxSpace, removeInboxSpace } from "../lib/inbox-spaces";
import { useMobileTokens } from "../lib/native";
export default function GroupSettingsScreen() {
@ -20,16 +13,12 @@ export default function GroupSettingsScreen() {
const router = useRouter();
const { groupId } = useLocalSearchParams<{ groupId: string }>();
const [group, setGroup] = useState<MobileGroup | null>(null);
const [projectSpace, setProjectSpace] = useState<MobileSpace | null>(null);
const [bots, setBots] = useState<MobileBot[]>([]);
const [name, setName] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const isMainConversation = group?.kind === "main";
const isProjectMain = isMainConversation && projectSpace !== null && !projectSpace.isDefault;
useEffect(() => {
if (!groupId) return;
void Promise.all([
@ -37,19 +26,11 @@ export default function GroupSettingsScreen() {
(groups) => groups.find((row) => row.id === groupId) ?? null,
),
rpc<MobileBot[]>("bots/list"),
rpc<MobileSpaceNavigation>("spaces/list").catch(() => null),
])
.then(([nextGroup, nextBots, navigation]) => {
.then(([nextGroup, nextBots]) => {
if (!nextGroup) throw new Error(t("Group not found"));
setGroup(nextGroup);
const space =
navigation?.spaces.find(
(item) => item.id === nextGroup.spaceId || item.id === navigation.current.id,
) ?? null;
setProjectSpace(space);
setName(
nextGroup.kind === "main" && space && !space.isDefault ? space.name : nextGroup.name,
);
setName(nextGroup.name);
setSelected(nextGroup.members.map((member) => member.botId));
setBots(nextBots.filter((bot) => !bot.archivedAt));
})
@ -61,20 +42,10 @@ export default function GroupSettingsScreen() {
setPending(true);
setError(null);
try {
const trimmed = name.trim();
if (isProjectMain && projectSpace) {
if (trimmed && trimmed !== projectSpace.name) {
await rpc("spaces/update", { spaceId: projectSpace.id, name: trimmed });
}
router.back();
return;
}
const input: { groupId: string; name?: string; botIds?: string[] } = { groupId };
if (trimmed !== group.name) input.name = trimmed;
if (!isMainConversation) {
const memberIds = group.members.map((member) => member.botId).join(",");
if (selected.join(",") !== memberIds) input.botIds = selected;
}
if (name.trim() !== group.name) input.name = name.trim();
const memberIds = group.members.map((member) => member.botId).join(",");
if (selected.join(",") !== memberIds) input.botIds = selected;
if (input.name || input.botIds) await rpc("groups/update", input);
router.back();
} catch (err) {
@ -86,29 +57,6 @@ export default function GroupSettingsScreen() {
function remove() {
if (!groupId || !group) return;
if (isProjectMain && projectSpace) {
Alert.alert(
t("Delete {name}?", { name: projectSpace.name }),
t("This removes the empty space for everyone."),
[
{ text: t("Cancel"), style: "cancel" },
{
text: t("Delete"),
style: "destructive",
onPress: () =>
void removeInboxSpace(projectSpace.id, async () => {})
.then(() => router.replace("/"))
.catch((err) =>
Alert.alert(
t("Could not delete space"),
err instanceof Error ? err.message : t("Try again."),
),
),
},
],
);
return;
}
Alert.alert(group.name, t("Delete this group? Bots and their solo threads are kept."), [
{ text: t("Cancel"), style: "cancel" },
{
@ -127,12 +75,6 @@ export default function GroupSettingsScreen() {
]);
}
const saveDisabled =
!name.trim() ||
pending ||
(!isMainConversation &&
(selected.length < GROUP_MEMBER_MIN || selected.length > GROUP_MEMBER_MAX));
return (
<>
<Stack.Screen options={{ title: t("Group settings") }} />
@ -155,27 +97,34 @@ export default function GroupSettingsScreen() {
fontSize: 16,
}}
/>
{!isMainConversation ? (
<>
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
{t("Members")}
</Text>
<BotMemberPicker
bots={bots}
selected={selected}
onChange={setSelected}
disabled={pending}
/>
</>
) : null}
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
{t("Members")}
</Text>
<BotMemberPicker
bots={bots}
selected={selected}
onChange={setSelected}
disabled={pending}
/>
{error ? <Text style={{ color: tokens.destructive, marginTop: 12 }}>{error}</Text> : null}
<Pressable
onPress={() => void save()}
disabled={saveDisabled}
disabled={
!name.trim() ||
selected.length < GROUP_MEMBER_MIN ||
selected.length > GROUP_MEMBER_MAX ||
pending
}
style={{
marginTop: 24,
backgroundColor: tokens.primary,
opacity: saveDisabled ? 0.5 : 1,
opacity:
!name.trim() ||
selected.length < GROUP_MEMBER_MIN ||
selected.length > GROUP_MEMBER_MAX ||
pending
? 0.5
: 1,
borderRadius: 11,
padding: 14,
alignItems: "center",
@ -185,35 +134,19 @@ export default function GroupSettingsScreen() {
{pending ? t("Saving…") : t("Save")}
</Text>
</Pressable>
{isProjectMain && projectSpace && canDeleteInboxSpace(projectSpace) ? (
<Pressable
onPress={remove}
style={{
marginTop: 16,
borderRadius: 11,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete space")}</Text>
</Pressable>
) : !isMainConversation ? (
<Pressable
onPress={remove}
style={{
marginTop: 16,
borderRadius: 11,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
</Pressable>
) : null}
<Pressable
onPress={remove}
style={{
marginTop: 16,
borderRadius: 11,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
</Pressable>
</ScrollView>
</>
);

View File

@ -6,7 +6,6 @@ import {
type SpaceBot,
type SpaceGroup,
} from "@rakazo/contracts";
import { projectLook, runChatTitle } from "@rakazo/core";
import { botColors } from "@rakazo/ui-tokens";
import { Redirect, useFocusEffect, useRouter } from "expo-router";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -45,10 +44,9 @@ import {
rpc,
selectedSpaceId,
selectInitialSpace,
selectLibrarySpace,
selectSpace,
} from "../lib/api";
import { mobileShellIconSizes, mobileTokens, resolveMobileAppearance } from "../lib/appearance";
import { mobileTokens, resolveMobileAppearance } from "../lib/appearance";
import { autopilotSummary } from "../lib/autopilot";
import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt";
import { t, useI18n } from "../lib/i18n";
@ -412,7 +410,6 @@ export default function Home() {
return;
creatingBotRef.current = true;
try {
await selectLibrarySpace();
// Authoritative roster so a slow home fetch does not treat later bots as first.
// A failed list is unknown — use the delayed path rather than assuming first.
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
@ -464,7 +461,7 @@ export default function Home() {
<NativeSymbol
ios={activityMode ? "bell.fill" : "bell"}
android={activityMode ? "notifications" : "notifications-outline"}
size={mobileShellIconSizes.md}
size={17}
color={activityMode ? tokens.primaryForeground : tokens.foreground}
/>
</CircleButton>
@ -478,7 +475,7 @@ export default function Home() {
})
}
>
<NativeSymbol ios="magnifyingglass" android="search" size={mobileShellIconSizes.md} />
<NativeSymbol ios="magnifyingglass" android="search" size={17} />
</CircleButton>
<CircleButton
accessibilityLabel={t("Create")}
@ -492,7 +489,7 @@ export default function Home() {
]);
}}
>
<NativeSymbol ios="plus" android="add" size={mobileShellIconSizes.lg} />
<NativeSymbol ios="plus" android="add" size={18} />
</CircleButton>
</View>
</View>
@ -597,25 +594,10 @@ export default function Home() {
}}
disabled={spaceBusy}
onPress={() => {
if (!item.space) return;
const targetSpace = item.space;
const main = !targetSpace.isDefault
? targetSpace.groups.find((group) => group.kind === "main")
: undefined;
if (main) {
void openMobileSpace(targetSpace.id, () =>
router.push({
pathname: "/group-thread",
params: { groupId: main.id, name: targetSpace.name },
}),
);
return;
}
void chooseInboxSpace(targetSpace.id);
if (item.space) void chooseInboxSpace(item.space.id);
}}
style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
>
<ProjectTile space={item.space} />
<Text style={styles.spaceTitle}>{item.title}</Text>
</Pressable>
{canDeleteInboxSpace(item.space) ? (
@ -628,11 +610,7 @@ export default function Home() {
}}
style={({ pressed }) => [styles.spaceActions, pressed && styles.rowPressed]}
>
<NativeSymbol
ios="ellipsis"
android="ellipsis-horizontal"
size={mobileShellIconSizes.lg}
/>
<NativeSymbol ios="ellipsis" android="ellipsis-horizontal" size={20} />
</Pressable>
) : null}
</View>
@ -730,17 +708,16 @@ function ActivitySection({
const { t } = useI18n();
const router = useRouter();
const botsById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot])), [bots]);
const openRun = (run: RunActivityRow) =>
void openMobileSpace(run.spaceId, () => {
if (run.groupId) {
router.push({
pathname: "/group-thread",
params: { groupId: run.groupId, name: run.groupName ?? t("Group") },
});
return;
}
router.push({ pathname: "/thread", params: { botId: run.botId, name: run.botName } });
});
const openRun = (run: RunActivityRow) => {
if (run.groupId) {
router.push({
pathname: "/group-thread",
params: { groupId: run.groupId, name: run.groupName ?? t("Group") },
});
return;
}
router.push({ pathname: "/thread", params: { botId: run.botId, name: run.botName } });
};
return (
<View style={styles.activitySection}>
@ -846,7 +823,7 @@ function ActivityRow({
bot?: MobileBot;
onPress: () => void;
}) {
const title = runChatTitle(run);
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
const status = activityStatusLabel(run.status);
const preview = run.promptSnippet ? `${run.promptSnippet} · ${status}` : status;
return (
@ -924,16 +901,6 @@ function ConversationRow({
);
}
function ProjectTile({ space }: { space: Pick<InboxSpace, "icon" | "color"> }) {
const styles = useThemedStyles(createHomeStyles);
const { icon, color } = projectLook(space);
return (
<View style={[styles.projectTile, { backgroundColor: color.hex }]}>
<NativeSymbol ios={icon.ios} android={icon.android as never} size={15} color={color.ink} />
</View>
);
}
function CircleButton({
children,
onPress,
@ -1091,9 +1058,9 @@ function createHomeStyles() {
gap: 12,
},
circleButton: {
width: 44,
height: 44,
borderRadius: 22,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: native.fillPressed,
alignItems: "center",
justifyContent: "center",
@ -1219,15 +1186,6 @@ function createHomeStyles() {
spaceSelect: {
flex: 1,
minHeight: 44,
flexDirection: "row",
alignItems: "center",
gap: 10,
},
projectTile: {
width: 28,
height: 28,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
},
spaceTitle: {

View File

@ -5,10 +5,7 @@ import {
CONNECTION_CATALOG_PAGE_SIZE,
EMPTY_PLUGIN_CATALOG_MESSAGE,
filterConnectionCatalogItems,
GITHUB_MCP_SOURCE,
GITHUB_TOKEN_URL,
humanizeToolName,
isGithubInstall,
} from "@rakazo/core";
import { useEffect, useMemo, useRef, useState } from "react";
import {
@ -87,11 +84,6 @@ export default function Integrations() {
const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(CONNECTION_CATALOG_PAGE_SIZE);
const [sources, setSources] = useState<CapabilityInstall[]>([]);
const github = sources.find(isGithubInstall);
const customSources = sources.filter((source) => !isGithubInstall(source));
const [githubOpen, setGithubOpen] = useState(false);
const [githubToken, setGithubToken] = useState("");
const [githubError, setGithubError] = useState<string | null>(null);
const [sourceKind, setSourceKind] = useState<SourceKind | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [name, setName] = useState("");
@ -396,27 +388,6 @@ export default function Integrations() {
}
}
async function connectGithub() {
setPending("github");
setGithubError(null);
try {
await rpc("capabilities/install", {
kind: "mcp",
name: "GitHub",
source: GITHUB_MCP_SOURCE,
credential: githubToken.trim(),
config: { preset: "github" },
});
setGithubToken("");
setGithubOpen(false);
await refresh();
} catch (reason) {
setGithubError(reason instanceof Error ? reason.message : t("Could not connect GitHub"));
} finally {
setPending(null);
}
}
async function removeSource(source: CapabilityInstall) {
setPending(source.id);
setSourceError(null);
@ -621,63 +592,6 @@ export default function Integrations() {
renderDetail(detailItem)
) : (
<>
<View testID="github-connect" style={styles.card}>
<View style={styles.detailTitleRow}>
<ConnectorLogo label="GitHub" styles={styles} />
<Text style={[styles.title, styles.grow]}>GitHub</Text>
{github ? (
<Pressable
accessibilityRole="button"
disabled={pending === github.id}
onPress={() => void removeSource(github)}
>
<Text style={styles.remove}>{t("Disconnect")}</Text>
</Pressable>
) : githubOpen ? null : (
<Pressable
accessibilityRole="button"
onPress={() => setGithubOpen(true)}
style={styles.smallButton}
>
<Text style={styles.buttonLabel}>{t("Connect")}</Text>
</Pressable>
)}
</View>
{!github && githubOpen ? (
<>
<Pressable
accessibilityRole="link"
onPress={() => void Linking.openURL(GITHUB_TOKEN_URL)}
style={styles.cardButton}
>
<Text style={styles.buttonLabel}>{t("Create token on GitHub")}</Text>
</Pressable>
<TextInput
value={githubToken}
onChangeText={setGithubToken}
accessibilityLabel={t("GitHub token")}
placeholder="ghp_…"
placeholderTextColor={native.tertiaryLabel}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
style={styles.input}
/>
<Pressable
accessibilityRole="button"
disabled={pending === "github" || !githubToken.trim()}
onPress={() => void connectGithub()}
style={styles.cardButton}
>
<Text style={styles.buttonLabel}>
{pending === "github" ? t("Connecting…") : t("Connect")}
</Text>
</Pressable>
</>
) : null}
{githubError ? <Text style={styles.error}>{githubError}</Text> : null}
</View>
{!catalogReady ? <ActivityIndicator color={native.fillPressed} /> : null}
{catalogReady && catalog.length === 0 ? (
@ -868,10 +782,10 @@ export default function Integrations() {
) : null}
<Text style={styles.section}>{t("Tool sources")}</Text>
{customSources.length === 0 ? (
{sources.length === 0 ? (
<Text style={styles.secondary}>{t("No custom sources installed.")}</Text>
) : null}
{customSources.map((source) => (
{sources.map((source) => (
<View key={source.id} style={styles.row}>
<View style={styles.grow}>
<Text style={styles.title}>{source.name}</Text>

View File

@ -9,7 +9,7 @@ import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Pressable, ScrollView, Text, TextInput } from "react-native";
import { ComputerModePicker } from "../components/computer-mode-picker";
import { type MobileBot, rpc, selectLibrarySpace } from "../lib/api";
import { type MobileBot, rpc } from "../lib/api";
import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
@ -42,7 +42,6 @@ export default function NewBot() {
setPending(true);
setError(null);
try {
await selectLibrarySpace();
// Failed list is unknown — delay focus rather than treating the bot as first.
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
const isFirstBot = existing !== null && existing.length === 0;

View File

@ -6,21 +6,23 @@ import type {
Connection,
ConnectionCatalogItem,
MessageBlock,
MessageReaction,
Routine,
} from "@rakazo/contracts";
import { AUTOPILOT_LIMITS, canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
import type { AutopilotUntilChoice, ComposerMention, SlashActionId } from "@rakazo/core";
import {
AUTOPILOT_LIMITS,
canReactToThreadMessage,
MESSAGE_REACTIONS,
type MessageReaction,
} from "@rakazo/contracts";
import type { AutopilotUntilChoice } from "@rakazo/core";
import {
abortableDelay,
attachmentsForThread,
autopilotUntil,
buildComposerMentionOptions,
clearComposerDraft,
type ComposerMention,
cloudAgentHttpsUrl,
composerDraftThreadKey,
deriveWorkspaceFocus,
foldRunNarration,
isApprovalAskBlock,
isRoutineEventMessage,
isRunTerminalEvent,
@ -29,15 +31,13 @@ import {
liveScreenRunId,
mentionChipKey,
projectMessageReactions,
queuedFollowUps,
readComposerDraft,
resolveComposerSendPlan,
SLASH_ACTIONS,
type SlashActionId,
selectedAskActionLabel,
serializeComposerPrompt,
truncateSlashDescription,
userVisibleMessages,
writeComposerDraft,
} from "@rakazo/core";
import * as Clipboard from "expo-clipboard";
import { useFocusEffect, useLocalSearchParams, useNavigation, useRouter } from "expo-router";
@ -77,7 +77,6 @@ import {
} from "../components/markdown-artifact-preview";
import { NativeSymbol } from "../components/native-symbol";
import { RoutineEventLine } from "../components/routine-event-line";
import { RunSteps } from "../components/run-steps";
import { ThreadCard } from "../components/thread-card";
import type {
MobileBot,
@ -102,7 +101,7 @@ import {
shouldApplyMobileThreadRefresh,
subscribeThread,
} from "../lib/api";
import { mobileShellIconSizes, mobileTokens } from "../lib/appearance";
import { mobileTokens } from "../lib/appearance";
import { type MobileArtifactTarget, openMobileArtifact } from "../lib/artifact-open";
import { confirmDeleteBot } from "../lib/bot-lifecycle";
import { cancelFocusPrompt, focusPromptThreadActive } from "../lib/focus-prompt";
@ -288,8 +287,6 @@ function Thread() {
const mentionBotsAppliedGeneration = useRef(0);
const readVisibleTarget = useRef<string | null>(null);
const threadKey = groupId ?? botId;
const draftKey = composerDraftThreadKey({ botId, groupId });
const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]);
const [threadScrollState, setThreadScrollState] = useState<ThreadScrollState>(() =>
scrollBehavior.current.state(),
);
@ -311,7 +308,7 @@ function Thread() {
/** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
const [screenRunId, setScreenRunId] = useState<string | null>(null);
const activeThreadId = useRef<string | undefined>(undefined);
const [draft, setDraft] = useState(() => initialDraft.text);
const [draft, setDraft] = useState("");
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [slashQuery, setSlashQuery] = useState<string | null>(null);
const [agentSkills, setAgentSkills] = useState<AgentSkillCatalogEntry[]>([]);
@ -326,17 +323,12 @@ function Thread() {
connectionId?: string;
}>
>([]);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>(
() => initialDraft.selectedMentions,
);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(
() => initialDraft.selectedSkill,
);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [replyTarget, setReplyTarget] = useState<MobileMessage | null>(null);
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const [stopped, setStopped] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [markdownPreview, setMarkdownPreview] = useState<MarkdownArtifactPreviewTarget | null>(
@ -351,17 +343,7 @@ function Thread() {
),
[snap?.messages],
);
const runNarration = useMemo(
() => foldRunNarration(reactionView.visibleMessages),
[reactionView.visibleMessages],
);
const visibleMessages = useMemo(
() => reactionView.visibleMessages.filter((message) => !runNarration.hidden.has(message.id)),
[reactionView.visibleMessages, runNarration],
);
const [taskPillExpanded, setTaskPillExpanded] = useState(false);
const [dismissedPlanKey, setDismissedPlanKey] = useState<string | null>(null);
const [taskPillSubStepsOpen, setTaskPillSubStepsOpen] = useState(false);
const visibleMessages = reactionView.visibleMessages;
const latestMessageId = visibleMessages.at(-1)?.id ?? null;
const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey);
const composerMentionTargets = useMemo(
@ -440,106 +422,6 @@ function Thread() {
});
}, [inGroup, snap?.activeRuns, snap?.members, snap?.run]);
const working = inGroup ? workingGroupBots.length > 0 : isWorkingStatus(currentBotStatus);
const activeMobilePlan = useMemo(() => {
const msgs = reactionView.visibleMessages;
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i]?.role === "user") {
lastUserIdx = i;
break;
}
}
let planIdx = -1;
let planBlock: Extract<MessageBlock, { kind: "plan" }> | null = null;
for (let i = msgs.length - 1; i > lastUserIdx; i--) {
const m = msgs[i];
if (!m) continue;
for (let b = m.blocks.length - 1; b >= 0; b--) {
const block = m.blocks[b];
if (block?.kind === "plan") {
planBlock = block;
planIdx = i;
break;
}
}
if (planBlock) break;
}
if (!planBlock) {
for (let i = lastUserIdx - 1; i >= 0; i--) {
const m = msgs[i];
if (!m) continue;
for (let b = m.blocks.length - 1; b >= 0; b--) {
const block = m.blocks[b];
if (block?.kind === "plan" && block.status !== "completed") {
planBlock = block;
planIdx = i;
break;
}
}
if (planBlock) break;
}
}
if (!planBlock) return null;
const absorbedIds = new Set<string>();
const stepIds = new Set<string>();
const steps: MobileMessage[] = [];
for (let i = Math.max(0, lastUserIdx + 1, planIdx); i < msgs.length; i++) {
const m = msgs[i];
if (!m) continue;
const folded = runNarration.steps.get(m.id);
if (folded && folded.length > 0) {
absorbedIds.add(m.id);
for (const s of folded) {
if (!stepIds.has(s.id)) {
stepIds.add(s.id);
steps.push(s);
}
}
}
}
const doneCount = planBlock.steps.filter(
(s) => s.status === "completed" || s.status === "skipped",
).length;
const isCompleted =
planBlock.status === "completed" ||
(planBlock.steps.length > 0 && doneCount === planBlock.steps.length && !working);
return {
block: planBlock,
steps,
absorbedIds,
doneCount,
isCompleted,
completionKey: `${planBlock.planId}:${isCompleted ? "done" : "active"}`,
};
}, [reactionView.visibleMessages, runNarration.steps, working]);
useEffect(() => {
if (!activeMobilePlan?.isCompleted) {
setDismissedPlanKey(null);
return;
}
const key = activeMobilePlan.completionKey;
const collapseTimer = setTimeout(() => setTaskPillExpanded(false), 1800);
const hideTimer = setTimeout(() => setDismissedPlanKey(key), 3500);
return () => {
clearTimeout(collapseTimer);
clearTimeout(hideTimer);
};
}, [activeMobilePlan?.completionKey, activeMobilePlan?.isCompleted]);
const activeRunCreatedAt = useMemo(() => {
const candidates = snap?.activeRuns ?? (snap?.run ? [snap.run] : []);
return candidates.find((run) => isWorkingStatus(run.status))?.createdAt;
}, [snap?.activeRuns, snap?.run]);
const queuedIds = useMemo(
() =>
new Set(
(working
? queuedFollowUps(snap?.messages ?? [], activeRunCreatedAt, reactionView.reactions)
: []
).map((message) => message.id),
),
[activeRunCreatedAt, reactionView.reactions, snap?.messages, working],
);
useEffect(() => {
void rpc<AgentSkillCatalogEntry[]>("agentSkills/list")
@ -703,7 +585,7 @@ function Thread() {
<NativeSymbol
ios="gearshape"
android="settings-outline"
size={mobileShellIconSizes.lg}
size={21}
color={tokens.foreground}
/>
</Pressable>
@ -712,7 +594,7 @@ function Thread() {
<NativeSymbol
ios="ellipsis"
android="ellipsis-horizontal"
size={mobileShellIconSizes.lg}
size={21}
color={tokens.foreground}
/>
</Pressable>
@ -1111,31 +993,16 @@ function Thread() {
);
}, [botId, groupId, messageId]);
const loadedDraftKeyRef = useRef(draftKey);
useLayoutEffect(() => {
if (loadedDraftKeyRef.current !== draftKey) {
loadedDraftKeyRef.current = draftKey;
const stored = readComposerDraft(draftKey);
setDraft(stored.text);
setSelectedSkill(stored.selectedSkill);
setSelectedMentions(stored.selectedMentions);
return;
}
writeComposerDraft(draftKey, {
text: draft,
selectedSkill,
selectedMentions,
});
}, [draft, draftKey, selectedMentions, selectedSkill]);
useEffect(() => {
setPendingAttachments((current) => attachmentsForThread(current, threadKey));
setDraft("");
setMentionQuery(null);
setSlashQuery(null);
setSelectedSkill(null);
setSelectedMentions([]);
setReplyTarget(null);
setAttachmentNotice(null);
setError(null);
setStopped(false);
}, [threadKey]);
function updateDraft(value: string) {
@ -1177,11 +1044,6 @@ function Thread() {
function runSlashAction(action: SlashActionId) {
setDraft("");
setSlashQuery(null);
writeComposerDraft(draftKey, {
text: "",
selectedSkill,
selectedMentions,
});
if (action === "chat-settings") {
if (inGroup && groupId) {
router.push({ pathname: "/group-settings", params: { groupId } });
@ -1207,10 +1069,6 @@ function Thread() {
const initialGroupTarget = groupId;
if ((!initialBotTarget && !initialGroupTarget) || sending) return;
const originThreadKey = initialGroupTarget ?? initialBotTarget;
const originDraftKey = composerDraftThreadKey({
botId: initialBotTarget,
groupId: initialGroupTarget,
});
const attachments = attachmentsForThread(pendingAttachments, originThreadKey);
const plan = resolveComposerSendPlan({
text: serializeComposerPromptText(),
@ -1230,7 +1088,6 @@ function Thread() {
if (initialBotTarget) cancelFocusPrompt(initialBotTarget);
};
setSending(true);
setStopped(false);
setError(null);
try {
if (plan.shouldRunRoutines) {
@ -1245,7 +1102,6 @@ function Thread() {
);
}
const clearOriginComposer = () => {
clearComposerDraft(originDraftKey);
setPendingAttachments((current) =>
current.filter((attachment) => attachment.threadKey !== originThreadKey),
);
@ -1340,16 +1196,12 @@ function Thread() {
const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sending) return;
setSending(true);
setStopped(false);
setError(null);
try {
await rpc(
"threads/stop",
targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },
);
if (isCurrentTarget(targetBotId, targetGroupId)) {
setStopped(true);
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Failed to stop work"));
@ -1369,80 +1221,6 @@ function Thread() {
}
}
async function resume() {
const targetBotId = botId;
const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sending) return;
setSending(true);
setStopped(false);
setError(null);
try {
await rpc(
"threads/resume",
targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },
);
if (isCurrentTarget(targetBotId, targetGroupId)) {
await refresh();
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Could not continue"));
}
} finally {
setSending(false);
}
}
const [sendingNowId, setSendingNowId] = useState<string | null>(null);
async function sendQueuedNow(message: MobileMessage) {
const targetBotId = botId;
const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sendingNowId) return;
setSendingNowId(message.id);
setError(null);
try {
await rpc("threads/sendNow", {
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
messageId: message.id,
});
if (isCurrentTarget(targetBotId, targetGroupId)) {
await refresh();
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Failed to send message"));
}
} finally {
setSendingNowId(null);
}
}
const [removingQueuedId, setRemovingQueuedId] = useState<string | null>(null);
async function removeQueuedNow(message: MobileMessage) {
const targetBotId = botId;
const targetGroupId = groupId;
const messageId = message.id.startsWith("temp-") ? undefined : message.id;
if (!messageId || (!targetBotId && !targetGroupId) || removingQueuedId) return;
setRemovingQueuedId(message.id);
setError(null);
try {
await rpc("threads/removeQueuedMessage", {
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
messageId,
});
if (isCurrentTarget(targetBotId, targetGroupId)) {
await refresh();
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Failed to remove message"));
}
} finally {
setRemovingQueuedId(null);
}
}
const answerMessage = useCallback(
async (message: MobileMessage, answer: string) => {
const targetBotId = botId;
@ -1526,10 +1304,6 @@ function Thread() {
const targetGroupId = groupId;
if (!targetBotId && !targetGroupId) return;
const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! };
const targetDraftKey = composerDraftThreadKey({
botId: targetBotId,
groupId: targetGroupId,
});
const until = autopilotUntil(choice, new Date());
void rpc("autopilot/start", {
...target,
@ -1537,12 +1311,8 @@ function Thread() {
...(until ? { until: until.toISOString() } : {}),
})
.then(async () => {
const existing = readComposerDraft(targetDraftKey);
writeComposerDraft(targetDraftKey, { ...existing, text: "" });
if (isCurrentTarget(targetBotId, targetGroupId)) {
setDraft("");
await refresh();
}
setDraft("");
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
})
.catch((err) =>
Alert.alert(t("Autopilot"), err instanceof Error ? err.message : t("Try again.")),
@ -1714,7 +1484,6 @@ function Thread() {
function renderMessageRow(message: MobileMessage, options?: { enableJump?: boolean }) {
const actionProps = messageActionProps(message);
const messageReactions = reactionView.reactions.get(message.id);
const foldedSteps = runNarration.steps.get(message.id);
const activityBotId =
!inGroup && message.role === "bot" && message.id.startsWith("progress:")
? (message.botId ?? botId)
@ -1774,9 +1543,6 @@ function Thread() {
flexShrink: 1,
}}
>
{foldedSteps && !activeMobilePlan?.absorbedIds.has(message.id) ? (
<RunSteps steps={foldedSteps} />
) : null}
<Pressable accessible={false} onLongPress={actionProps.onLongPress}>
<MessageBubble
botId={botId ?? snap?.members?.[0]?.botId ?? ""}
@ -1797,55 +1563,6 @@ function Thread() {
actionProps={actionProps}
/>
</Pressable>
{queuedIds.has(message.id) ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: 8,
marginTop: 4,
}}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 12 }}>{t("Queued")}</Text>
<Pressable
accessibilityRole="button"
disabled={sendingNowId === message.id}
hitSlop={6}
onPress={() => void sendQueuedNow(message)}
>
<Text
style={{
color: tokens.foreground,
fontSize: 12,
fontWeight: "600",
opacity: sendingNowId === message.id ? 0.5 : 1,
}}
>
{t("Send now")}
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
disabled={removingQueuedId === message.id}
hitSlop={6}
onPress={() => void removeQueuedNow(message)}
>
<Text
style={{
color:
removingQueuedId === message.id ? tokens.mutedForeground : tokens.destructive,
fontSize: 12,
fontWeight: "600",
opacity: removingQueuedId === message.id ? 0.5 : 1,
}}
numberOfLines={1}
>
{t("Remove")}
</Text>
</Pressable>
</View>
) : null}
{messageReactions ? (
<View
style={{
@ -1956,70 +1673,7 @@ function Thread() {
>
{error ? <Text style={{ color: tokens.mutedForeground, marginTop: 12 }}>{error}</Text> : null}
{runError ? (
<View
style={{
marginTop: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<Text style={{ color: tokens.destructive, flex: 1 }}>{runError}</Text>
{!working ? (
<Pressable
accessibilityRole="button"
testID="thread-resume-button"
disabled={sending}
onPress={() => void resume()}
style={{
borderRadius: 999,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 12,
paddingVertical: 6,
opacity: sending ? 0.5 : 1,
}}
>
<Text style={{ color: tokens.foreground, fontSize: 13, fontWeight: "600" }}>
{t("Resume")}
</Text>
</Pressable>
) : null}
</View>
) : stopped && !working ? (
<View
testID="thread-stopped-banner"
style={{
marginTop: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<Text style={{ color: tokens.mutedForeground, flex: 1 }}>{t("Stopped")}</Text>
<Pressable
accessibilityRole="button"
testID="thread-resume-button"
disabled={sending}
onPress={() => void resume()}
style={{
borderRadius: 999,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 12,
paddingVertical: 6,
opacity: sending ? 0.5 : 1,
}}
>
<Text style={{ color: tokens.foreground, fontSize: 13, fontWeight: "600" }}>
{t("Resume")}
</Text>
</Pressable>
</View>
<Text style={{ color: tokens.destructive, marginTop: 12 }}>{runError}</Text>
) : null}
<View style={{ flex: 1, position: "relative" }}>
{showPinnedPage ? (
@ -2360,191 +2014,6 @@ function Thread() {
))}
</View>
) : null}
{activeMobilePlan &&
!(activeMobilePlan.isCompleted && dismissedPlanKey === activeMobilePlan.completionKey) ? (
<View
testID="composer-task-pill"
style={{
marginTop: 12,
borderRadius: 20,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 14,
paddingTop: 8,
paddingBottom: 10,
gap: 8,
}}
>
<Pressable
testID="composer-task-pill-toggle"
accessibilityRole="button"
accessibilityState={{ expanded: taskPillExpanded }}
onPress={() => setTaskPillExpanded((current) => !current)}
style={{ gap: 6 }}
>
<View
style={{
width: 32,
height: 4,
borderRadius: 2,
backgroundColor: tokens.border,
alignSelf: "center",
}}
/>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
<Text
style={{
color: activeMobilePlan.isCompleted ? tokens.success : tokens.foreground,
fontSize: 14,
fontWeight: "700",
}}
>
{activeMobilePlan.isCompleted ? "✓" : "▸"}
</Text>
<Text
numberOfLines={1}
style={{
color: tokens.foreground,
fontSize: 13.5,
fontWeight: "600",
flex: 1,
}}
>
{activeMobilePlan.isCompleted
? activeMobilePlan.block.title
: ((
activeMobilePlan.block.steps.find((s) => s.status === "in_progress") ??
activeMobilePlan.block.steps.find((s) => s.status === "pending")
)?.label ?? activeMobilePlan.block.title)}
</Text>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
<Text
style={{
color: activeMobilePlan.isCompleted ? tokens.success : tokens.mutedForeground,
fontSize: 12.5,
fontWeight: "500",
}}
>
{activeMobilePlan.isCompleted
? t("Completed")
: `${activeMobilePlan.doneCount}/${activeMobilePlan.block.steps.length}`}
</Text>
<NativeSymbol
ios={taskPillExpanded ? "chevron.down" : "chevron.up"}
android={taskPillExpanded ? "chevron-down" : "chevron-up"}
size={14}
color={tokens.mutedForeground}
/>
</View>
</View>
</Pressable>
{taskPillExpanded ? (
<View
style={{
borderTopWidth: 1,
borderTopColor: tokens.border,
paddingTop: 10,
gap: 6,
}}
>
<Text
style={{
color: tokens.cardForeground,
fontSize: 14,
fontWeight: "600",
}}
>
{activeMobilePlan.block.title}
</Text>
{activeMobilePlan.block.steps.map((step) => {
const done = step.status === "completed";
const active = step.status === "in_progress";
const skipped = step.status === "skipped";
const glyph = done ? "✓" : active ? "▸" : skipped ? "–" : "○";
return (
<View
key={step.id}
style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}
>
<Text
style={{
color: done
? tokens.success
: active
? tokens.foreground
: tokens.mutedForeground,
fontSize: 13.5,
fontWeight: active ? "700" : "400",
width: 16,
}}
>
{glyph}
</Text>
<View style={{ flex: 1 }}>
<Text
style={{
color: done || skipped ? tokens.mutedForeground : tokens.foreground,
fontSize: 13.5,
fontWeight: active ? "600" : "400",
textDecorationLine: done || skipped ? "line-through" : "none",
}}
>
{step.label}
</Text>
{step.note ? (
<Text style={{ color: tokens.mutedForeground, fontSize: 12 }}>
{step.note}
</Text>
) : null}
</View>
</View>
);
})}
{activeMobilePlan.steps.length > 0 ? (
<View
style={{
borderTopWidth: 1,
borderTopColor: tokens.border,
paddingTop: 6,
marginTop: 2,
gap: 4,
}}
>
<Pressable
accessibilityRole="button"
onPress={() => setTaskPillSubStepsOpen((current) => !current)}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 12.5 }}>
{t("{count} steps", { count: activeMobilePlan.steps.length })}{" "}
{taskPillSubStepsOpen ? "▴" : "▾"}
</Text>
</Pressable>
{taskPillSubStepsOpen
? activeMobilePlan.steps.map((s) => (
<Text
key={s.id}
style={{ color: tokens.mutedForeground, fontSize: 12.5 }}
>
{blockText(s)}
</Text>
))
: null}
</View>
) : null}
</View>
) : null}
</View>
) : null}
<View
style={{
flexDirection: "row",
@ -3048,105 +2517,6 @@ const MessageBubble = memo(function MessageBubble({
</View>
);
}
const plans = message.blocks.filter(
(block): block is Extract<MessageBlock, { kind: "plan" }> => block.kind === "plan",
);
if (plans.length) {
return (
<View style={{ gap: 8, width: "100%" }}>
{plans.map((plan) => {
const doneCount = plan.steps.filter(
(s) => s.status === "completed" || s.status === "skipped",
).length;
const isCompleted = plan.status === "completed";
return (
<View
key={plan.planId}
testID="plan-card"
style={{
width: "90%",
borderRadius: 18,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 16,
paddingVertical: 14,
gap: 10,
}}
>
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 8 }}>
<Text
style={{
color: tokens.cardForeground,
fontSize: 15,
fontWeight: "600",
flexShrink: 1,
}}
>
{plan.title}
</Text>
<Text
style={{
color: isCompleted ? tokens.success : tokens.mutedForeground,
fontSize: 13,
fontWeight: "500",
}}
>
{isCompleted ? t("Completed") : `${doneCount}/${plan.steps.length}`}
</Text>
</View>
<View style={{ gap: 6 }}>
{plan.steps.map((step) => {
const done = step.status === "completed";
const active = step.status === "in_progress";
const skipped = step.status === "skipped";
const glyph = done ? "✓" : active ? "▸" : skipped ? "–" : "○";
return (
<View
key={step.id}
style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}
>
<Text
style={{
color: done
? tokens.success
: active
? tokens.foreground
: tokens.mutedForeground,
fontSize: 14,
fontWeight: active ? "700" : "400",
width: 16,
}}
>
{glyph}
</Text>
<View style={{ flex: 1 }}>
<Text
style={{
color: done || skipped ? tokens.mutedForeground : tokens.foreground,
fontSize: 14,
fontWeight: active ? "600" : "400",
textDecorationLine: done || skipped ? "line-through" : "none",
}}
>
{step.label}
</Text>
{step.note ? (
<Text style={{ color: tokens.mutedForeground, fontSize: 12.5 }}>
{step.note}
</Text>
) : null}
</View>
</View>
);
})}
</View>
</View>
);
})}
</View>
);
}
if (isRoutineEventMessage(message.blocks)) {
return (
<View style={{ width: "100%" }}>

View File

@ -3,7 +3,6 @@ import {
buildOoRig,
createOoLife,
OO_LIST_MOMENTS,
OO_RIG_FACE_VIEWBOX,
OO_RIG_INSET,
OO_RIG_SIZE,
OO_STILL_TIME,
@ -27,7 +26,7 @@ import Svg, {
} from "react-native-svg";
const VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`;
const FACE_VIEWBOX = OO_RIG_FACE_VIEWBOX;
const FACE_VIEWBOX = "47 62 112 112";
/** Native frames redraw the SVG tree, so they run at half the display rate. */
const FRAME_SECONDS = 1 / 30;
/** Above this size an Oo always moves; smaller ones only for list moments. */

View File

@ -1,52 +0,0 @@
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import type { MobileMessage } from "../lib/api";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
import { NativeSymbol } from "./native-symbol";
/** A run's earlier step messages, folded behind one line above its latest message. */
export function RunSteps({ steps }: { steps: readonly MobileMessage[] }) {
const tokens = useMobileTokens();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const label = steps.length === 1 ? t("1 step") : t("{count} steps", { count: steps.length });
return (
<View style={{ marginBottom: 6 }}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: open }}
onPress={() => setOpen((current) => !current)}
hitSlop={8}
style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start" }}
>
<NativeSymbol
ios={open ? "chevron.down" : "chevron.right"}
android={open ? "chevron-down" : "chevron-forward"}
size={12}
color={tokens.mutedForeground}
/>
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5 }}>{label}</Text>
</Pressable>
{open ? (
<View
style={{
marginTop: 4,
gap: 6,
borderLeftWidth: 1,
borderLeftColor: tokens.border,
paddingLeft: 10,
}}
>
{steps.map((step) => (
<Text key={step.id} style={{ color: tokens.mutedForeground, fontSize: 14 }}>
{step.blocks
.flatMap((block) => (block.kind === "text" ? [block.text] : []))
.join("\n")}
</Text>
))}
</View>
) : null}
</View>
);
}

View File

@ -144,16 +144,6 @@ export function selectedSpaceId(): string | null {
return cachedSpaceId || null;
}
/**
* Agents live in the home library, where a project's coordinator recruits them from; select it
* before creating one so the new agent and its chat share the request scope.
*/
export async function selectLibrarySpace(): Promise<void> {
const navigation = await rpc<MobileSpaceNavigation>("spaces/list");
const home = navigation.spaces.find((space) => space.isDefault)?.id;
if (home && home !== cachedSpaceId) await selectSpace(home);
}
/** Keep requests usable after the server deleted the selected Space but native
* storage could not replace it. Neutralize any same-endpoint rollback before
* writing the replacement selection so a cleanup failure cannot leave the new
@ -784,7 +774,7 @@ export type MobileGroup = Pick<
| "updatedAt"
| "members"
> &
Partial<Pick<Group, "spaceId" | "kind">>;
Partial<Pick<Group, "spaceId">>;
export type MobileSpace = Space;
export type MobileSpaceNavigation = SpaceNavigation;
@ -797,14 +787,8 @@ export type MobileSnapshot = {
cursor?: number;
messages: MobileMessage[];
olderCursor: number | null;
run: {
id: string;
botId?: string;
status: string;
error?: string | null;
createdAt?: string;
} | null;
activeRuns?: Array<{ id: string; botId?: string; status: string; createdAt?: string }>;
run: { id: string; botId?: string; status: string; error?: string | null } | null;
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
members?: MobileGroup["members"];
computer?: {
kind?: string;

View File

@ -60,12 +60,6 @@ export function mobileTokens(
return tokensForAppearance(resolveMobileAppearance(preference, scheme));
}
/** Header and inbox nav glyphs — aligned with web `--icon-*` scale (~25% larger). */
export const mobileShellIconSizes = {
md: 22,
lg: 26,
} as const;
export function subscribeAppearance(listener: () => void): () => void {
listeners.add(listener);
return () => {

View File

@ -147,18 +147,12 @@ describe("spaceInboxItems", () => {
expect(canDeleteInboxSpace(space({ canDelete }))).toBe(false);
});
it("offers deletion for an explicitly authorized non-default space", () => {
it("offers deletion for an explicitly authorized empty non-default space", () => {
expect(canDeleteInboxSpace(space())).toBe(true);
expect(canDeleteInboxSpace(space({ hasContent: true, canDelete: true }))).toBe(true);
});
it("deduplicates same-named projects into a single project entry", () => {
const first = space({ id: "space-work-1", name: "Work", hasContent: false });
const second = space({ id: "space-work-2", name: " work ", hasContent: true, bots: [bot] });
const items = spaceInboxItems([first, second]);
const headings = items.filter((item) => item.type === "heading" && item.space);
expect(headings).toHaveLength(1);
expect(headings[0]).toMatchObject({ key: "space-work-2", title: " work " });
it("hides deletion while a space still has content", () => {
expect(canDeleteInboxSpace(space({ hasContent: true }))).toBe(false);
});
});

View File

@ -12,7 +12,7 @@ import {
export type InboxSpace = Pick<
MobileSpace,
"id" | "name" | "isDefault" | "hasContent" | "canDelete" | "botSections" | "icon" | "color"
"id" | "name" | "isDefault" | "hasContent" | "canDelete" | "botSections"
> & {
bots: (MobileBot | SpaceBot)[];
groups: (MobileGroup | SpaceGroup)[];
@ -24,47 +24,22 @@ export type InboxSpaceItem =
| { type: "heading"; key: string; title: string; space?: InboxSpace };
export function canDeleteInboxSpace(
space: Pick<InboxSpace, "isDefault" | "canDelete"> & Partial<Pick<InboxSpace, "hasContent">>,
space: Pick<InboxSpace, "isDefault" | "hasContent" | "canDelete">,
) {
return !space.isDefault && space.canDelete === true;
return !space.isDefault && !space.hasContent && space.canDelete === true;
}
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
const seenProjectNames = new Set<string>();
const canonicalProjectIds = new Set<string>();
const orderedProjects = [...spaces]
.filter((space) => !space.isDefault)
.sort((left, right) => Number(Boolean(right.hasContent)) - Number(Boolean(left.hasContent)));
for (const space of orderedProjects) {
const normalized = space.name.trim().toLowerCase();
if (normalized && seenProjectNames.has(normalized)) continue;
if (normalized) seenProjectNames.add(normalized);
canonicalProjectIds.add(space.id);
}
const visibleSpaces = spaces.filter(
(space) => space.isDefault || canonicalProjectIds.has(space.id),
);
return visibleSpaces.flatMap((space): InboxSpaceItem[] => {
const normalizedSpaceName = space.name.trim().toLowerCase();
return spaces.flatMap((space): InboxSpaceItem[] => {
const chats = [
// A main bot is reached through its space's main conversation, not listed again.
...space.bots
.filter((chat) => chat.kind !== "main")
.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
...space.groups
.filter((chat) => {
const normalizedGroupName = chat.name.trim().toLowerCase();
if (!space.isDefault && normalizedGroupName === normalizedSpaceName) return false;
if (space.isDefault && chat.kind !== "main" && seenProjectNames.has(normalizedGroupName))
return false;
return true;
})
.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
];
const items: InboxSpaceItem[] = [];
if (visibleSpaces.length > 1 || !space.isDefault || chats.length === 0) {
if (spaces.length > 1 || !space.isDefault || chats.length === 0) {
items.push({ type: "heading", key: space.id, title: space.name, space });
}
for (const group of groupBotsForSidebar(chats, space.botSections)) {

View File

@ -1,8 +1,4 @@
export const DE_MESSAGES: Record<string, string> = {
"Connecting…": "Verbinde…",
"GitHub token": "GitHub-Token",
"Create token on GitHub": "Token bei GitHub erstellen",
"Could not connect GitHub": "GitHub konnte nicht verbunden werden",
"1 step": "1 Schritt",
"1 tool": "1 Tool",
"@{name}": "@{name}",
@ -261,9 +257,6 @@ export const DE_MESSAGES: Record<string, string> = {
Eyeshadow: "Lidschatten",
Failed: "Fehlgeschlagen",
"Failed to refresh": "Aktualisieren fehlgeschlagen",
Resume: "Fortsetzen",
Stopped: "Gestoppt",
"Failed to remove message": "Nachricht konnte nicht entfernt werden",
"Failed to send message": "Nachricht konnte nicht gesendet werden",
"Failed to stop work": "Arbeit konnte nicht gestoppt werden",
"Fewer {label}": "Weniger {label}",
@ -500,7 +493,6 @@ export const DE_MESSAGES: Record<string, string> = {
"Section name": "Abschnittsname",
Send: "Senden",
"Send answer": "Antwort senden",
"Send now": "Jetzt gleich senden",
"Send reset link": "Link zum Zurücksetzen senden",
"Sending…": "Wird gesendet…",
Server: "Server",

View File

@ -1,8 +1,4 @@
export const RU_MESSAGES: Record<string, string> = {
"Connecting…": "Подключение…",
"GitHub token": "Токен GitHub",
"Create token on GitHub": "Создать токен на GitHub",
"Could not connect GitHub": "Не удалось подключить GitHub",
"Update your server to use AI data sharing in this mobile version.":
"Обновите сервер, чтобы использовать обмен данными с ИИ в этой версии мобильного приложения.",
"Ask the server owner to configure this provider.":
@ -274,9 +270,6 @@ export const RU_MESSAGES: Record<string, string> = {
"Executor token": "Токен Executor",
Failed: "Ошибка",
"Failed to refresh": "Не удалось обновить",
Resume: "Продолжить",
Stopped: "Остановлено",
"Failed to remove message": "Не удалось удалить сообщение",
"Failed to send message": "Не удалось отправить сообщение",
"Failed to stop work": "Не удалось остановить работу",
File: "Файл",
@ -432,7 +425,6 @@ export const RU_MESSAGES: Record<string, string> = {
Send: "Отправить",
"Send reset link": "Отправить ссылку для сброса",
"Send answer": "Отправить ответ",
"Send now": "Отправить сейчас",
"Sending…": "Отправка…",
Server: "Сервер",
"Server URL": "URL-адрес сервера",

View File

@ -1,8 +1,4 @@
export const ZH_MESSAGES: Record<string, string> = {
"Connecting…": "正在连接…",
"GitHub token": "GitHub 令牌",
"Create token on GitHub": "在 GitHub 上创建令牌",
"Could not connect GitHub": "无法连接 GitHub",
"Update your server to use AI data sharing in this mobile version.":
"请更新服务器,以便在此移动版本中使用 AI 数据共享功能。",
"Release computer": "释放电脑",
@ -259,9 +255,6 @@ export const ZH_MESSAGES: Record<string, string> = {
"Executor token": "Executor 令牌",
Failed: "失败",
"Failed to refresh": "刷新失败",
Resume: "继续",
Stopped: "已停止",
"Failed to remove message": "删除消息失败",
"Failed to send message": "发送消息失败",
"Failed to stop work": "停止工作失败",
File: "文件",
@ -413,7 +406,6 @@ export const ZH_MESSAGES: Record<string, string> = {
Send: "发送",
"Send reset link": "发送重置链接",
"Send answer": "发送回答",
"Send now": "立即发送",
"Sending…": "正在发送…",
Server: "服务器",
"Server URL": "服务器地址",

View File

@ -1,69 +0,0 @@
import type { OoSoundCue, SoundDriver } from "@rakazo/core";
import { CUE_RECIPES, PcmWavSynthesizer } from "@rakazo/core";
import { File, Paths } from "expo-file-system";
export class ExpoMobileSoundDriver implements SoundDriver {
private cacheDir = `${Paths.cache}/earcons`;
private cachedUris = new Map<OoSoundCue, string>();
isSupported(): boolean {
return true;
}
async play(cue: OoSoundCue): Promise<void> {
if (!CUE_RECIPES[cue]) return;
// Check if HTML5 audio is present (mobile web fallback / test environment)
const AudioCtor = (globalThis as { Audio?: typeof Audio }).Audio;
if (typeof AudioCtor === "function" && typeof window !== "undefined") {
const wav = PcmWavSynthesizer.renderCueWav(cue);
const blob = new Blob([wav as Uint8Array<ArrayBuffer>], { type: "audio/wav" });
const url = URL.createObjectURL(blob);
try {
const audio = new AudioCtor(url);
await audio.play().catch(() => undefined);
} finally {
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
return;
}
// Native Expo Audio playback
try {
const { createAudioPlayer, setAudioModeAsync } = await import("expo-audio");
await setAudioModeAsync({
playsInSilentMode: true,
interruptionMode: "mixWithOthers",
shouldPlayInBackground: false,
});
let uri = this.cachedUris.get(cue);
if (!uri) {
const wav = PcmWavSynthesizer.renderCueWav(cue);
const file = new File(this.cacheDir, `earcon-${cue}.wav`);
file.create({ overwrite: true });
file.write(this.bytesToBase64(wav), { encoding: "base64" });
uri = file.uri;
this.cachedUris.set(cue, uri);
}
const player = createAudioPlayer({ uri });
player.play();
setTimeout(() => player.release(), 2000);
} catch {
// Best-effort degradation
}
}
private bytesToBase64(bytes: Uint8Array): string {
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
}
export const mobileSound = new ExpoMobileSoundDriver();

View File

@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureApiRequestContext, currentApiBase, rpc } from "./api";
import {
MAX_VOICE_AUDIO_BYTES,
playSpeech,
playMpeg,
speakText,
speakUtterance,
VOICE_RESPONSE_TIMEOUT_MS,
@ -92,7 +92,7 @@ describe("mobile speech", () => {
}
vi.stubGlobal("Audio", ImmediateAudio);
await expect(playSpeech(new Uint8Array([1, 2, 3]))).resolves.toBeUndefined();
await expect(playMpeg(new Uint8Array([1, 2, 3]))).resolves.toBeUndefined();
});
it("rejects oversized audio without waiting for response cancellation", async () => {

View File

@ -35,7 +35,7 @@ export async function speakText(text: string, opts: SpeechOptions = {}): Promise
);
if (!prepared.ready) return false;
for (const utterance of prepared.utterances) {
await playSpeech(await renderUtterance(utterance, opts, requestContext));
await playMpeg(await renderUtterance(utterance, opts, requestContext));
}
return true;
}
@ -84,13 +84,7 @@ async function renderUtterance(
}
}
/** Kokoro answers with MP3, Piper (German voices) with WAV. */
function clipFormat(bytes: Uint8Array): { type: string; extension: string } {
const riff = bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46;
return riff ? { type: "audio/wav", extension: "wav" } : { type: "audio/mpeg", extension: "mp3" };
}
export async function playSpeech(bytes: Uint8Array): Promise<void> {
export async function playMpeg(bytes: Uint8Array): Promise<void> {
const AudioCtor = (globalThis as { Audio?: typeof Audio }).Audio;
if (typeof AudioCtor === "function") {
await playWithHtmlAudio(AudioCtor, bytes);
@ -100,7 +94,7 @@ export async function playSpeech(bytes: Uint8Array): Promise<void> {
}
async function playWithHtmlAudio(AudioCtor: typeof Audio, bytes: Uint8Array): Promise<void> {
const blob = new Blob([new Uint8Array(bytes)], { type: clipFormat(bytes).type });
const blob = new Blob([new Uint8Array(bytes)], { type: "audio/mpeg" });
const url = URL.createObjectURL(blob);
try {
const audio = new AudioCtor(url);
@ -138,7 +132,7 @@ async function playWithNativeAudio(bytes: Uint8Array): Promise<void> {
interruptionMode: "mixWithOthers",
shouldPlayInBackground: false,
});
const file = new File(Paths.cache, `rakazo-voice-${Date.now()}.${clipFormat(bytes).extension}`);
const file = new File(Paths.cache, `rakazo-voice-${Date.now()}.mp3`);
file.create({ overwrite: true });
file.write(bytesToBase64(bytes), { encoding: "base64" });
const player = createAudioPlayer({ uri: file.uri });

View File

@ -17,35 +17,35 @@
"test:e2e": "maestro test .maestro/smoke.yaml"
},
"dependencies": {
"@expo/metro-runtime": "57.0.17",
"@expo/ui": "57.0.26",
"@expo/metro-runtime": "57.0.16",
"@expo/ui": "57.0.21",
"@rakazo/chat-ui": "workspace:*",
"@rakazo/contracts": "workspace:*",
"@rakazo/core": "workspace:*",
"@rakazo/ui-tokens": "workspace:*",
"@react-native-vector-icons/ionicons": "13.1.4",
"@react-native-vector-icons/material-design-icons": "13.1.4",
"expo": "57.0.31",
"expo": "57.0.26",
"expo-application": "57.0.3",
"expo-audio": "57.0.5",
"expo-clipboard": "57.0.2",
"expo-constants": "57.0.22",
"expo-constants": "57.0.20",
"expo-document-picker": "57.0.3",
"expo-file-system": "57.0.7",
"expo-font": "57.0.4",
"expo-image-picker": "57.0.20",
"expo-keep-awake": "57.0.2",
"expo-linking": "57.0.13",
"expo-linking": "57.0.11",
"expo-localization": "57.0.2",
"expo-modules-core": "57.0.24",
"expo-notifications": "57.0.24",
"expo-router": "57.0.29",
"expo-modules-core": "57.0.20",
"expo-notifications": "57.0.21",
"expo-router": "57.0.24",
"expo-secure-store": "57.0.4",
"expo-sharing": "57.0.24",
"expo-splash-screen": "57.0.10",
"expo-sharing": "57.0.22",
"expo-splash-screen": "57.0.9",
"expo-status-bar": "57.0.1",
"expo-symbols": "57.0.3",
"expo-updates": "57.0.26",
"expo-updates": "57.0.24",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",

View File

@ -31,7 +31,7 @@ test("the bot's screen opens beside the chat while it works and steps back after
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
await expect(page.getByTestId("oo-computer-browser-viewport").locator("iframe")).toHaveAttribute(
await expect(page.getByTestId("computer-preview").locator("iframe")).toHaveAttribute(
"src",
SCREEN_URL,
);

View File

@ -26,28 +26,16 @@ test("the bot's cursor glides to where it clicks on its live screen", async ({
);
await page.getByTitle("Agent computer").click();
const preview = page.getByTestId("oo-computer-browser-viewport");
const preview = page.getByTestId("computer-preview");
await expect(preview.locator("iframe")).toHaveAttribute("src", SCREEN_URL);
const cursor = preview.getByTestId("agent-cursor");
await expect(cursor).toHaveCSS("opacity", "0");
// The click and typing last about a second, so record every action rather than racing a poll.
await cursor.evaluate((node) => {
const seen: string[] = [];
(window as unknown as { cursorActions: string[] }).cursorActions = seen;
new MutationObserver(() => seen.push(node.getAttribute("data-action") ?? "")).observe(node, {
attributeFilter: ["data-action"],
});
});
// The scripted runtime clicks the screen center (640, 400) and types.
await rpc(page, "threads/send", { botId, text: "use your screen and type cursor-demo" });
await expect(cursor).toHaveCSS("opacity", "1");
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { cursorActions: string[] }).cursorActions),
)
.toEqual(expect.arrayContaining(["click", "type"]));
await expect(cursor).toHaveAttribute("data-action", /click|type/);
const layer = (await preview.getByTestId("agent-cursor-layer").boundingBox())!;
await expect
.poll(async () => {

View File

@ -12,12 +12,14 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI
await composer.fill("write notes/result.txt and attach it to the thread");
await page.keyboard.press("Enter");
const fileCard = page.getByRole("button", { name: "Preview result.txt" });
// Scope to ArtifactFileCard text (name + mime); sidebar bot status can also
// include the prompt path notes/result.txt once status syncs immediately.
const fileCard = page.getByRole("button", { name: /result\.txt text\/plain/ });
await expect(fileCard).toBeVisible({ timeout: 30_000 });
await captureScreenshot(page, testInfo, "current-file-card");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Download result.txt" }).click();
await fileCard.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("result.txt");
});

View File

@ -114,12 +114,12 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
await captureScreenshot(page, testInfo, "27a-bot-settings-model");
await page.getByRole("button", { name: "Show computer" }).click();
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveAttribute("data-panel", /^(computer|browser)$/);
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(page.getByRole("button", { name: "Show settings" })).toBeVisible();
// Overlay may flash during boot or never appear (already ready/asleep/stopped). Assert panel
// chrome, then wait until any overlay has cleared — avoid Locator.or() strict-mode multi-hits.
const bootOverlay = page.getByText(/Booting up .* computer/);
await expect(sidePanel.getByTestId("oo-computer-browser-viewport")).toBeVisible();
await expect(sidePanel.getByTestId("computer-preview")).toBeVisible();
await expect(bootOverlay).toBeHidden();
await expect(sidePanel.getByText("Teach a task")).toHaveCount(0);
await expect(sidePanel.getByTestId("teach-start-button")).toHaveCount(0);

View File

@ -307,10 +307,6 @@ test("group chats share every context-menu action", async ({ page }, testInfo) =
await group.click({ button: "right" });
await captureScreenshot(page, testInfo, "group-context-menu-mobile");
await page.keyboard.press("Escape");
if ((await page.getByRole("menu").count()) > 0) {
await page.keyboard.press("Escape");
}
await expect(page.getByRole("menu")).toHaveCount(0);
await page.setViewportSize({ width: 1280, height: 800 });
await group.click({ button: "right" });

View File

@ -1,128 +1,91 @@
import { expect, type Locator, type Page, test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { captureScreenshot } from "./helpers";
const fixture = "/e2e/fixtures/chart-links.html";
/** Browser errors per page, so a chart that never renders fails with its cause. */
const browserErrors = new WeakMap<Page, string[]>();
test.beforeEach(({ page }) => {
const errors: string[] = [];
browserErrors.set(page, errors);
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("response", (response) => {
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`);
});
test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
test.setTimeout(120_000);
// ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below.
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`);
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 });
for (const inspect of ["0", "1"]) {
for (const href of [
'javascript:void(document.body.dataset.plotExecuted="yes")',
' \u0000\u001fJaVa\tScRi\nPt:void(document.body.dataset.plotExecuted="yes")',
'javascript:%76oid(document.body.dataset.plotExecuted="yes")',
"data:text/html,<script>document.body.dataset.plotExecuted='yes'</script>",
"file:///example.txt",
]) {
await page.goto(`${fixture}?${new URLSearchParams({ href, inspect })}`);
const circle = page.locator("svg circle").first();
// Every navigation renders Plot again, which takes seconds on a loaded CI runner.
await expect(circle).toBeVisible({ timeout: 30_000 });
const assertInert = async () => {
const links = page.locator("svg a");
expect(await links.count()).toBeGreaterThan(0);
expect(
await links.evaluateAll((anchors) =>
anchors.every(
(anchor) =>
!anchor.hasAttribute("href") &&
!anchor.hasAttributeNS("http://www.w3.org/1999/xlink", "href"),
),
),
).toBe(true);
await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed");
};
await assertInert();
await circle.click();
// Hover inspection still works, without reintroducing unsafe links.
if (inspect === "1") await expect(page.locator('[aria-label="tip"]')).toBeVisible();
await assertInert();
await page.getByRole("button", { name: "Expand", exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.locator("svg circle")).toBeVisible({ timeout: 30_000 });
await dialog.locator("svg circle").click();
await assertInert();
await expect(page).toHaveURL(`${fixture}?${new URLSearchParams({ href, inspect })}`);
await page.getByRole("button", { name: "Close chart" }).click();
}
}
await captureScreenshot(page, testInfo, "chart-with-inert-link");
});
/** Waits for the plotted dot, failing fast with the chart error and browser errors instead. */
async function expectChart(page: Page, scope: Locator | Page = page, reloadAttempt = 0) {
const circle = scope.locator("svg circle").first();
const failure = scope.getByText("Chart failed to render", { exact: false }).first();
try {
// Every navigation renders Plot again, which takes seconds on a loaded CI runner.
await expect(circle.or(failure).first()).toBeVisible({ timeout: 90_000 });
if (await failure.isVisible()) throw new Error(await failure.innerText());
} catch (error) {
if (reloadAttempt === 0 && scope === page) {
await page.reload();
return expectChart(page, scope, 1);
test("chart links retain supported URLs and browser encoding semantics", async ({ page }) => {
test.setTimeout(120_000);
await page.route("https://example.test/**", (route) =>
route.fulfill({ body: "Chart destination" }),
);
await page.route("**/e2e/fixtures/java*", (route) =>
route.fulfill({ body: "Chart destination" }),
);
// Each test gets a fresh browser context, so this one pays the cold Plot import too.
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details" })}`);
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 });
for (const href of [
"https://example.test/chart?a=1&b=2",
"mailto:chart@example.test",
"tel:+15555550123",
"#details",
"java&#x73;cript:void(0)",
"javascript%3Avoid(0)",
"java%73cript:void(0)",
]) {
await page.goto(`${fixture}?${new URLSearchParams({ href })}`);
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 30_000 });
const link = page.locator("svg a").first();
await expect(link).toBeAttached();
expect(
await link.evaluate((anchor) =>
anchor.getAttributeNS("http://www.w3.org/1999/xlink", "href"),
),
).toBe(href);
const destination = new URL(href, page.url());
expect(destination.protocol).not.toBe("javascript:");
if (destination.protocol === "http:" || destination.protocol === "https:") {
await link.locator("circle").click();
await expect(page).toHaveURL(destination.href);
await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed");
}
const errors = browserErrors.get(page) ?? [];
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nBrowser errors:\n${errors.join("\n") || "none"}`,
);
}
return circle;
}
test.describe
.serial("chart links", () => {
test("cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
test.setTimeout(120_000);
// ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below.
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`);
await expectChart(page);
for (const inspect of ["0", "1"]) {
for (const href of [
'javascript:void(document.body.dataset.plotExecuted="yes")',
' \u0000\u001fJaVa\tScRi\nPt:void(document.body.dataset.plotExecuted="yes")',
'javascript:%76oid(document.body.dataset.plotExecuted="yes")',
"data:text/html,<script>document.body.dataset.plotExecuted='yes'</script>",
"file:///example.txt",
]) {
await page.goto(`${fixture}?${new URLSearchParams({ href, inspect })}`);
const circle = await expectChart(page);
const assertInert = async () => {
const links = page.locator("svg a");
expect(await links.count()).toBeGreaterThan(0);
expect(
await links.evaluateAll((anchors) =>
anchors.every(
(anchor) =>
!anchor.hasAttribute("href") &&
!anchor.hasAttributeNS("http://www.w3.org/1999/xlink", "href"),
),
),
).toBe(true);
await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed");
};
await assertInert();
await circle.click();
// Hover inspection still works, without reintroducing unsafe links.
if (inspect === "1") await expect(page.locator('[aria-label="tip"]')).toBeVisible();
await assertInert();
await page.getByRole("button", { name: "Expand", exact: true }).click();
const dialog = page.getByRole("dialog");
await expectChart(page, dialog);
await dialog.locator("svg circle").click();
await assertInert();
await expect(page).toHaveURL(`${fixture}?${new URLSearchParams({ href, inspect })}`);
await page.getByRole("button", { name: "Close chart" }).click();
}
}
await captureScreenshot(page, testInfo, "chart-with-inert-link");
});
test("retain supported URLs and browser encoding semantics", async ({ page }) => {
test.setTimeout(120_000);
// Warm Plot before request interception so lazy import and fixture modules are not racing routes.
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details" })}`);
await expectChart(page);
await page.route("https://example.test/**", (route) =>
route.fulfill({ body: "Chart destination" }),
);
await page.route("**/e2e/fixtures/java*", (route) =>
route.fulfill({ body: "Chart destination" }),
);
for (const href of [
"https://example.test/chart?a=1&b=2",
"mailto:chart@example.test",
"tel:+15555550123",
"#details",
"java&#x73;cript:void(0)",
"javascript%3Avoid(0)",
"java%73cript:void(0)",
]) {
await page.goto(`${fixture}?${new URLSearchParams({ href })}`);
await expectChart(page);
const link = page.locator("svg a").first();
await expect(link).toBeAttached();
expect(
await link.evaluate((anchor) =>
anchor.getAttributeNS("http://www.w3.org/1999/xlink", "href"),
),
).toBe(href);
const destination = new URL(href, page.url());
expect(destination.protocol).not.toBe("javascript:");
if (destination.protocol === "http:" || destination.protocol === "https:") {
await link.locator("circle").click();
await expect(page).toHaveURL(destination.href);
await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed");
}
}
});
});
});

View File

@ -1,53 +0,0 @@
import { expect, test } from "@playwright/test";
import { completeOnboarding, routePatchRpc, signup, waitForChatComposer } from "./helpers";
const viewports = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 390, height: 844 },
];
for (const viewport of viewports) {
test(`queued run shows waiting status (${viewport.name})`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const stamp = Date.now();
await signup(page, `queued-${viewport.name}-${stamp}@rakazo.test`, "password12", "Queue");
await completeOnboarding(page);
await waitForChatComposer(page);
const botId = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1)!;
let holdQueued = false;
await routePatchRpc(page, "threads/get", (json) => {
if (!holdQueued) return json;
const run = {
id: "run-queued-e2e",
botId,
threadId: String(json.threadId ?? "thread-e2e"),
taskId: "task-queued-e2e",
status: "queued",
trigger: "user",
routineId: null,
modelProvider: null,
modelId: null,
error: null,
startedAt: null,
completedAt: null,
createdAt: new Date().toISOString(),
};
return { ...json, run, activeRuns: [run] };
});
await page.route("**/rpc/threads/send", async (route) => {
holdQueued = true;
await route.continue();
});
const composer = page.getByRole("combobox", { name: /Message|Nachricht/i });
await composer.fill("Hallo");
await composer.press("Enter");
await expect(
page.getByRole("status").filter({ hasText: /Queued|Wartet|Working|Arbeitet/i }),
).toBeVisible({
timeout: 20_000,
});
await expect(page.getByRole("button", { name: /Stop|Stopp/i })).toBeVisible();
});
}

View File

@ -39,9 +39,9 @@ test("screen connection failures stay visible and can be retried", async ({ page
);
await page.getByTitle("Agent computer").click();
const preview = page.getByTestId("oo-computer-browser-viewport");
const preview = page.getByTestId("computer-preview");
await expect(preview.getByRole("alert")).toContainText("temporarily busy");
await expect(page.getByTestId("computer-open")).toHaveCount(0);
await expect(preview.getByTestId("computer-preview-open")).toHaveCount(0);
await captureScreenshot(page, testInfo, "computer-screen-connection-error");
failScreen = false;
@ -50,14 +50,14 @@ test("screen connection failures stay visible and can be retried", async ({ page
await expect(preview.getByRole("alert")).toHaveCount(0);
failScreen = true;
await page.getByTestId("computer-open").click();
await preview.hover();
await preview.getByTestId("computer-preview-open").click();
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
const fullScreen = page.getByTestId("computer-viewport");
await expect(fullScreen.getByRole("alert")).toContainText("temporarily busy");
await expect(page.getByRole("alert")).toContainText("temporarily busy");
await captureScreenshot(page, testInfo, "computer-full-screen-connection-error");
failScreen = false;
await fullScreen.getByRole("button", { name: "Retry screen" }).click();
await page.getByRole("button", { name: "Retry screen" }).click();
await expect(page.locator('iframe[title="Bot screen"]')).toHaveAttribute("src", screenUrl);
await expect(page.getByRole("alert")).toHaveCount(0);
});

View File

@ -59,8 +59,9 @@ test("computer maintenance shows durable background progress and failure recover
});
});
await page.getByTitle("Agent computer").click();
await expect(page.getByTestId("oo-computer-browser-viewport")).toBeVisible();
await page.getByTestId("computer-open").click();
await expect(page.getByTestId("computer-preview")).toBeVisible();
await page.getByTestId("computer-preview").hover();
await page.getByTestId("computer-preview-open").click();
await page.getByTestId("computer-more-button").click();
await page.getByRole("menuitem", { name: "Update computer", exact: true }).click();
const dialog = page.getByTestId("computer-update-dialog");

View File

@ -1,46 +0,0 @@
import { expect, test } from "@playwright/test";
import { completeOnboarding, projectRow, routePatchRpc, rpc, signup } from "./helpers";
test("empty project space offers Start before the main conversation exists", async ({ page }) => {
const stamp = Date.now();
await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project");
await completeOnboarding(page);
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Empty project" });
// spaces/create provisions a main conversation server-side; strip it from bootstrap so the
// shell shows the pre-start empty project UI.
await routePatchRpc(page, "bootstrap", (json) => {
const groups = (json.groups as Array<{ spaceId: string }>).filter(
(group) => group.spaceId !== side.id,
);
const bots = (json.bots as Array<{ spaceId: string }>).filter((bot) => bot.spaceId !== side.id);
const spaces = (
json.spaces as Array<{
id: string;
bots: unknown[];
groups: unknown[];
hasContent: boolean;
}>
).map((space) =>
space.id === side.id ? { ...space, bots: [], groups: [], hasContent: false } : space,
);
return { ...json, groups, bots, spaces };
});
await page.evaluate((spaceId) => {
window.localStorage.setItem("rakazo:space-id", spaceId);
}, side.id);
await page.goto("/app", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
});
const mainStart = page.getByTestId("project-start-main");
await expect(mainStart).toBeVisible({ timeout: 30_000 });
await expect(mainStart).toContainText(/Start|Starten/i);
// The sidebar lists the project as one row; opening it starts the main conversation.
const projekte = page.locator(".sidebar-projekte");
await expect(projectRow(projekte, "Empty project")).toBeVisible({ timeout: 30_000 });
});

View File

@ -1,104 +0,0 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
test("onboarding names the first Oo and shows it in chat", async ({ page }) => {
const stamp = Date.now();
await signup(page, `name-oo-${stamp}@rakazo.test`, "password12", "Namer");
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
await page.getByTestId("onboarding-oo-name-input").fill("Luna");
await page
.getByTestId("onboarding-name-step")
.getByRole("button", { name: /^(Continue|Weiter)$/i })
.click();
await page.getByRole("button", { name: /^(Skip|Überspringen ·)/i }).click();
await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click();
await expect(page).toHaveURL(/\/app\/g\/[^/]+/, { timeout: 30_000 });
await expect(
page.getByTestId("bot-settings-trigger").getByText("Luna", { exact: true }),
).toBeVisible();
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Luna");
});
test("German UI keeps Deutsch on language skip label", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true });
localStorage.setItem("rakazo.uiLocale", "de");
});
const stamp = Date.now();
await signup(page, `de-skip-${stamp}@rakazo.test`, "password12", "Deutsch");
await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click();
await expect(
page.getByRole("heading", { name: /Wähle deine Sprache|Choose your language/i }),
).toBeVisible();
await expect(page.getByRole("button", { name: /Überspringen · Deutsch/i })).toBeVisible();
});
test("German voice step lists German voices after choosing Deutsch", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true });
localStorage.setItem("rakazo.uiLocale", "de");
});
const stamp = Date.now();
await signup(page, `de-voice-${stamp}@rakazo.test`, "password12", "Stimme");
await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click();
await page.getByRole("button", { name: "Deutsch", exact: true }).click();
await expect(page.getByText(/Loading voices|Stimmen werden geladen/i)).toHaveCount(0, {
timeout: 30_000,
});
await expect(page.getByTestId("onboarding-voice-df_eva")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("onboarding-voice-dm_thorsten")).toBeVisible();
await expect(page.getByTestId("onboarding-voice-af_alloy")).toHaveCount(0);
await expect(page.getByTestId("onboarding-voice-dm_thorsten")).toHaveAttribute(
"aria-pressed",
"true",
);
});
test("main conversation does not show a group subtitle for one Oo", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true });
localStorage.setItem("rakazo.uiLocale", "de");
});
const stamp = Date.now();
await signup(page, `main-1-1-${stamp}@rakazo.test`, "password12", "Solo");
await completeOnboarding(page, undefined, { mainConversation: true });
await expect(page.getByTestId("bot-settings-trigger")).not.toContainText("Gruppenunterhaltung");
await expect(page.getByTestId("oo-group-stage")).toHaveCount(0);
});
test.describe("mobile shell", () => {
test.use({
hasTouch: true,
isMobile: true,
viewport: { width: 390, height: 844 },
});
test("Escape closes the mobile navigation drawer", async ({ page }) => {
const stamp = Date.now();
await signup(page, `mobile-esc-${stamp}@rakazo.test`, "password12", "Escape");
await completeOnboarding(page);
await page.getByRole("button", { name: "Open navigation" }).click();
await expect(page.getByRole("button", { name: "Close navigation" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Close navigation" })).toHaveCount(0);
await expect(page.getByTestId("open-navigation")).toBeFocused();
});
});
test("main conversation opens its lead's computer", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `main-computer-${stamp}@rakazo.test`, "password12", "Screen");
await completeOnboarding(page, undefined, { mainConversation: true });
await expect(page).toHaveURL(/\/app\/g\//);
// The lead's computer opens beside the main conversation without leaving it.
await page.getByTitle("Agent computer").click();
await expect(page).toHaveURL(/\/app\/g\//);
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel.getByTestId("oo-computer-browser-viewport")).toBeVisible();
await captureScreenshot(page, testInfo, "main-conversation-computer");
});

View File

@ -1,30 +0,0 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers";
test("GitHub connects from a token alone and disconnects again", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `github-connect-${stamp}@rakazo.test`, "password12", "GitHub Connect");
await completeOnboarding(page);
await openUserSettings(page, "integrations");
const github = page.getByTestId("github-connect");
await expect(github).toBeVisible();
await github.getByRole("button", { name: "Connect", exact: true }).click();
await expect(github.getByRole("link", { name: "Create token on GitHub" })).toHaveAttribute(
"href",
/^https:\/\/github\.com\/settings\/tokens\/new\?description=evOo&scopes=repo/,
);
const token = github.getByLabel("GitHub token");
await token.fill("fake-github-token-value");
await captureScreenshot(page, testInfo, "github-connect");
await token.press("Enter");
await expect(github.getByText("Connected", { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(token).toBeHidden();
await captureScreenshot(page, testInfo, "github-connected");
await page.reload({ waitUntil: "domcontentloaded" });
await openUserSettings(page, "integrations");
await expect(github.getByText("Connected", { exact: true })).toBeVisible({ timeout: 30_000 });
await github.getByRole("button", { name: "Disconnect" }).click();
await expect(github.getByRole("button", { name: "Connect", exact: true })).toBeVisible();
});

View File

@ -80,7 +80,8 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
expect(mainBox).not.toBeNull();
expect(panelBox).not.toBeNull();
expect((mainBox?.x ?? 0) + (mainBox?.width ?? 0)).toBeLessThanOrEqual(panelBox?.x ?? 0);
await sidePanel.getByTestId("computer-open").click();
await sidePanel.getByTestId("computer-preview").hover();
await sidePanel.getByTestId("computer-preview-open").click();
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
await expect(page.getByRole("button", { name: "Skip", exact: true }).last()).toBeVisible();
await expect(page.getByRole("button", { name: "I’m done", exact: true }).last()).toBeVisible();
@ -106,7 +107,8 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
}
await page.getByTitle("Agent computer").click();
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
const openComputer = sidePanel.getByTestId("computer-open");
await sidePanel.getByTestId("computer-preview").hover();
const openComputer = sidePanel.getByTestId("computer-preview-open");
await expect(openComputer).toBeVisible({ timeout: 30_000 });
await openComputer.click();
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
@ -117,31 +119,22 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
});
await captureScreenshot(page, testInfo, "09a-computer-takeover-skipped");
const botId = activeBotId(page);
await rpc(page, "routines/create", {
botId,
name: "Monday briefing",
prompt: "write a file in your home called notes/result.txt that says routine-ok",
crons: ["0 9 * * *"],
timezone: "UTC",
active: true,
notify: true,
webhookEnabled: false,
githubEnabled: false,
messageProvider: null,
});
if ((await sidePanel.getAttribute("data-panel")) === "computer") {
await page.getByTitle("Agent computer").click();
}
const routinesListed = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/list") && response.ok(),
await page.getByRole("button", { name: "Create Routine" }).click();
await page.locator("label:has-text('Name') input").fill("Monday briefing");
await page
.locator("label:has-text('Instruction') textarea")
.fill("write a file in your home called notes/result.txt that says routine-ok");
await page.getByRole("button", { name: "Add trigger" }).click();
await page.getByRole("menuitem", { name: "On a schedule" }).hover();
await page.getByRole("menuitem", { name: "Every day", exact: true }).click();
const savedRoutine = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
);
await page.getByTitle("Agent computer").click();
await routinesListed;
await sidePanel.locator('[data-tab="overview"]').click();
await expect(
sidePanel.getByTestId("routine-list-row").getByText("Monday briefing"),
).toBeVisible();
await page.getByRole("button", { name: "Save" }).click();
await savedRoutine;
await expect(page.getByRole("button", { name: "Save" })).toBeEnabled();
await page.getByRole("button", { name: "Back" }).click();
await expect(page.getByText("Monday briefing")).toBeVisible();
await captureScreenshot(page, testInfo, "10-routine-created");
await openUserSettings(page, "integrations");
@ -150,8 +143,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
await expect(featured).toContainText(
/Gmail[\s\S]*Google Calendar[\s\S]*Google Drive[\s\S]*Slack[\s\S]*Notion/,
);
// The catalog tile and the built-in token connection.
await expect(page.getByText("GitHub", { exact: true })).toHaveCount(2);
await expect(page.getByText("GitHub", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Add Executor", exact: true })).toBeHidden();
await expect(page.getByRole("button", { name: "Add Treg", exact: true })).toBeHidden();
await expect(page.getByRole("button", { name: "Add MCP server", exact: true })).toBeHidden();
@ -315,7 +307,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
await page.getByTitle("Agent computer").click();
}
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
const gear = page.getByRole("button", { name: "Show settings" }).first();
const gear = page.getByRole("button", { name: "Show settings" });
await gear.click();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export" }).click();

View File

@ -63,10 +63,6 @@ test("create group from + and see two bots in one transcript", async ({ page },
const researcherId = await createBot(page, "Researcher");
const writerId = await createBot(page, "Research Writer");
// Replies read themselves aloud by default; keep that off so only the Speak click below talks.
for (const botId of [researcherId, writerId]) {
await rpc(page, "bots/update", { botId, autoSpeak: false });
}
await openNewGroup(page);
await page.locator("label:has-text('Name') input").fill("Draft team");
@ -173,26 +169,18 @@ test("create group from + and see two bots in one transcript", async ({ page },
await expect(desktopSettings.getByRole("button", { name: "Save", exact: true })).toBeEnabled();
await page.unroute("**/rpc/groups/update");
await desktopSettings.getByRole("button", { name: "Save", exact: true }).click();
// The panel closes once the save lands; switching chats before that races the layout change.
await expect(desktopSettings).toHaveAttribute("data-panel", "closed");
await page.getByRole("combobox", { name: "Message" }).fill("@Researcher unfinished draft");
await projekte
.getByRole("button", { name: /^Review team/ })
.first()
.click();
await expect(page).toHaveURL(new RegExp(`/app/g/${reviewGroup.id}`));
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue("");
// Each chat keeps its own unsent draft.
await projekte
.getByRole("button", { name: /^Draft team/ })
.first()
.click();
await expect(page).toHaveURL(new RegExp(`/app/g/${draftGroupId}`));
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue(
"@Researcher unfinished draft",
);
await page.getByRole("combobox", { name: "Message" }).fill("");
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue("");
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("@Res");

View File

@ -1,5 +1,4 @@
import { expect, type Locator, type Page, type Route, type TestInfo } from "@playwright/test";
import type { Routine } from "@rakazo/contracts";
type RpcEnvelope = { json?: Record<string, unknown> };
@ -12,11 +11,6 @@ export function unterhaltungenOnly(page: Page) {
return page.locator(".sidebar-unterhaltungen").locator(":scope > *:not(.sidebar-projekte)");
}
/** A project's row in the sidebar; it opens the project's main conversation. */
export function projectRow(scope: Page | Locator, name: string) {
return scope.getByTestId(/^project-row-/).filter({ hasText: name });
}
export function isRealSandboxProvider(provider = process.env.SANDBOX_PROVIDER) {
return provider === "e2b" || provider === "daytona" || provider === "box";
}
@ -32,43 +26,6 @@ export function activeBotId(page: Page) {
return id;
}
export async function createTestRoutine(
page: Page,
botId: string,
overrides: {
name?: string;
prompt?: string;
crons?: string[];
timezone?: string;
active?: boolean;
webhookEnabled?: boolean;
githubEnabled?: boolean;
messageProvider?: string | null;
} = {},
) {
return rpc<Routine>(page, "routines/create", {
botId,
name: "Test routine",
prompt: "Run the check-in",
crons: ["0 9 * * *"],
timezone: "UTC",
active: true,
notify: true,
webhookEnabled: false,
githubEnabled: false,
messageProvider: null,
...overrides,
});
}
/** Opens the side panel on the agent's overview, where its routines are listed. */
export async function openAgentOverview(page: Page, title = "Agent computer") {
const overview = page.getByTestId("side-panel").locator('[data-tab="overview"]');
// The header button toggles the panel, so only press it while the panel is closed.
if (!(await overview.isVisible())) await page.getByTitle(title).click();
await overview.click();
}
export async function rpc<T>(page: Page, procedure: string, body: unknown): Promise<T> {
const spaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const response = await page.request.post(`/rpc/${procedure}`, {
@ -130,16 +87,6 @@ async function visible(locator: Locator, timeout: number) {
return locator.isVisible({ timeout }).catch(() => false);
}
const onboardingContinueButton = /^(Continue|Weiter)$/i;
const onboardingSkipButton = /^(Skip|Überspringen)$/i;
const onboardingLanguageSkipButton = /^(Skip|Überspringen) ·/i;
/** After the look picker, skip the optional Oo naming step (keeps the default name). */
export async function skipOnboardingNameStep(page: Page) {
await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
await page.getByRole("button", { name: onboardingSkipButton }).click();
}
/**
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
* onboarding question pending (the composer works regardless). Unless `mainConversation` is
@ -148,29 +95,23 @@ export async function skipOnboardingNameStep(page: Page) {
export async function completeOnboarding(
page: Page,
testInfo?: TestInfo,
options: { mainConversation?: boolean; ooName?: string } = {},
options: { mainConversation?: boolean } = {},
) {
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
if (page.url().includes("/onboarding")) {
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|name|language|voice|integrations|scan|bot)$/,
/^(emoji|language|voice|integrations|scan|bot)$/,
{ timeout: 30_000 },
);
}
const design = page.getByRole("heading", { name: /Design your Oo|Gestalte dein Oo/i });
const naming = page.getByTestId("onboarding-name-step");
const language = page.getByRole("heading", {
name: /Choose your language|Wähle deine Sprache/i,
});
const voice = page.getByRole("heading", { name: /Pick a voice|Wähle eine Stimme/i });
const integrations = page.getByRole("heading", {
name: /Server integrations|Server-Integrationen/i,
});
const expectedOoName = options.ooName ?? "Chief";
const chief = page.getByText(expectedOoName).first();
const design = page.getByRole("heading", { name: /Design your Oo/i });
const language = page.getByRole("heading", { name: /Choose your language/i });
const voice = page.getByRole("heading", { name: /Pick a voice/i });
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
const chief = page.getByText("Chief").first();
// Scripted CI has needsModel false, so a fresh owner starts on integrations.
// A model-gated run still opens the look picker first.
@ -178,30 +119,23 @@ export async function completeOnboarding(
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
if (await design.isVisible()) {
await page.getByRole("button", { name: onboardingContinueButton }).click();
await expect(naming).toBeVisible();
if (options.ooName) {
await page.getByTestId("onboarding-oo-name-input").fill(options.ooName);
await naming.getByRole("button", { name: onboardingContinueButton }).click();
} else {
await page.getByRole("button", { name: onboardingSkipButton }).click();
}
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(language).toBeVisible();
await page.getByRole("button", { name: onboardingLanguageSkipButton }).click();
await page.getByRole("button", { name: /Skip/i }).click();
await expect(voice).toBeVisible();
await page.getByRole("button", { name: onboardingSkipButton }).click();
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
if (await visible(integrations, 8_000)) {
if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps");
await page.getByRole("button", { name: onboardingSkipButton }).click();
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
// then lands in the main conversation.
const skipScan = page
.locator('[data-onboarding-step="scan"]')
.getByRole("button", { name: onboardingSkipButton });
.getByRole("button", { name: "Skip", exact: true });
await expect(async () => {
if (await skipScan.isVisible()) await skipScan.click();
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
@ -237,17 +171,15 @@ export async function signup(
) {
await expect(async () => {
await page.goto("/sign-up", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: /Create your evOo|Dein evOo erstellen/i }),
).toBeVisible({
await expect(page.getByRole("heading", { name: "Create your evOo" })).toBeVisible({
timeout: 5_000,
});
}).toPass({ timeout: 45_000 });
if (testInfo) await captureScreenshot(page, testInfo, "01-sign-up");
await page.getByPlaceholder(/Your name|Dein Name/i).fill(name);
await page.getByPlaceholder(/Your email address|Deine E-Mail-Adresse/i).fill(email);
await page.getByPlaceholder(/^Password$|^Passwort$/i).fill(password);
await page.getByRole("button", { name: /Create account|Konto erstellen/i }).click();
await page.getByPlaceholder("Your name").fill(name);
await page.getByPlaceholder("Your email address").fill(email);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("button", { name: "Create account" }).click();
}
export async function captureScreenshot(page: Page, testInfo: TestInfo, name: string) {
@ -326,15 +258,9 @@ export async function openUserSettings(
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
});
const openNav = page.getByTestId("open-navigation");
if (await openNav.isVisible().catch(() => false)) {
await openNav.click();
}
const menuTrigger = page.getByTestId("user-menu-trigger");
await expect(menuTrigger).toBeVisible({ timeout: 30_000 });
await menuTrigger.scrollIntoViewIfNeeded();
await menuTrigger.click();
await page.getByRole("button", { name: /^Settings$|^Einstellungen$/i }).click();
await expect(page.getByTestId("user-menu-trigger")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("user-menu-trigger").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
const settings = page.getByTestId("user-settings");
await expect(settings).toBeVisible();
if (section && section !== "general") {
@ -346,9 +272,9 @@ export async function openUserSettings(
/** Wait until the shell thread is loaded and the message composer is interactive. */
export async function waitForChatComposer(page: Page) {
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
timeout: 30_000,
});
await expect(page.getByRole("combobox", { name: /^Message$|^Nachricht$/i })).toBeVisible();
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
}
/** Create a named bot via RPC for test setup (skips the + picker). */
@ -369,54 +295,3 @@ export async function createNamedBot(
await waitForChatComposer(page);
return bot.id;
}
export const ALLOWED_CONSOLE_ERRORS = [
/Failed to load resource.*404/i,
/Failed to load resource.*401/i,
/favicon/i,
/ERR_ABORTED/i,
/Content Security Policy/i,
/net::ERR_/i,
/React Router Future Flag/i,
/DevTools failed to load SourceMap/i,
];
/** Collect unexpected console errors and page errors for the rest of the test. */
export function attachConsoleGuard(page: Page) {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() !== "error") return;
const text = msg.text();
if (ALLOWED_CONSOLE_ERRORS.some((pattern) => pattern.test(text))) return;
errors.push(text);
});
page.on("pageerror", (err) => errors.push(err.message));
return errors;
}
/** True when the document does not scroll horizontally (±1px tolerance). */
export async function assertNoHorizontalOverflow(page: Page) {
const ok = await page.evaluate(() => {
const doc = document.documentElement;
return doc.scrollWidth <= doc.clientWidth + 1;
});
expect(ok, `horizontal overflow on ${page.url()}`).toBe(true);
}
const MOBILE_TOUCH_MIN = 44;
/**
* Primary chrome controls must meet 44×44px on touch viewports, either by box size or `rk-touch-target`.
*/
export async function assertPrimaryTouchTargets(page: Page, selectors: string[]) {
for (const selector of selectors) {
const locator = page.locator(selector).first();
await expect(locator, selector).toBeVisible();
const ok = await locator.evaluate((node, min) => {
const rect = node.getBoundingClientRect();
if (rect.width >= min && rect.height >= min) return true;
return node.classList.contains("rk-touch-target");
}, MOBILE_TOUCH_MIN);
expect(ok, `${selector} touch target`).toBe(true);
}
}

View File

@ -1,14 +1,7 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts";
import {
captureScreenshot,
completeOnboarding,
openAgentOverview,
openMainBotThread,
rpc,
signup,
} from "./helpers";
import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers";
/** The nav button reads "Today" or "Today, N need you". */
function openHeute(page: Page) {
@ -25,10 +18,7 @@ test("Heute shows native brief instead of Mission Control iframe", async ({ page
await completeOnboarding(page);
await openHeute(page);
await expect(page).toHaveURL(/\/app\/heute$/);
await page.reload();
await expect(page.getByTestId("heute-brief")).toBeVisible();
await expect(page).toHaveURL(/\/app\/heute$/);
await expect(page).toHaveURL(/\/app\/[^/]+$/);
const brief = page.getByTestId("heute-brief");
await expect(brief).toBeVisible();
@ -91,21 +81,12 @@ test("Heute shows the coach's brief, habits and goals, checks habits off and set
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days");
await captureScreenshot(page, testInfo, "heute-coach");
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/app\/heute$/);
await expect(page.getByTestId("heute-brief")).toBeVisible();
await page.reload();
await openHeute(page);
await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked();
// Check-in times live behind one compact control.
const rhythm = page.getByTestId("heute-rhythm");
await expect(page.getByTestId("heute-coach-brief")).toBeVisible();
await rhythm.scrollIntoViewIfNeeded();
await expect(rhythm).toBeVisible({ timeout: 30_000 });
const rhythmLoaded = page.waitForResponse(
(response) => response.url().includes("/rpc/coach/rhythm/get") && response.ok(),
);
await rhythm.click();
await rhythmLoaded;
await page.getByTestId("heute-rhythm").click();
const panel = page.getByTestId("heute-rhythm-panel");
await expect(panel).toBeVisible();
const morning = panel.getByTestId("heute-rhythm-morning");
@ -149,7 +130,7 @@ test("Heute shows the coach's brief, habits and goals, checks habits off and set
// The main bot's routine list names the check-ins by their key, in the UI language.
await openMainBotThread(page);
await openAgentOverview(page);
await page.getByTitle("Agent computer").click();
await expect(page.getByText("Morning brief", { exact: true })).toBeVisible();
await expect(page.getByText("Weekly review", { exact: true })).toBeVisible();
});

View File

@ -15,9 +15,8 @@ test("the in-app browser previews a page and follows it back to the browser", as
}),
);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Browser" }).click();
const browser = page.getByTestId("in-app-browser");
await browser.getByTestId("in-app-browser-mode-local").click();
const address = browser.getByRole("textbox", { name: "Address" });
await expect(address).toBeFocused();
@ -39,11 +38,10 @@ test("the in-app browser previews a page and follows it back to the browser", as
await browser.getByRole("button", { name: "Open in browser" }).click();
expect((await popup).url()).toBe("https://preview.example/app");
await page.getByTestId("side-panel").getByRole("button", { name: "Close panel" }).click();
await browser.getByRole("button", { name: "Close panel" }).click();
await expect(browser).toHaveCount(0);
// The last address comes back when the browser reopens.
await page.getByTitle("Agent computer").click();
await page.getByTestId("in-app-browser-mode-local").click();
await page.getByRole("button", { name: "Browser" }).click();
await expect(page.getByTestId("in-app-browser-frame")).toHaveAttribute(
"src",
"https://preview.example/app",
@ -113,9 +111,8 @@ test("on desktop the page is a native view glued to the panel", async ({ page })
() => (window as unknown as { __browserCalls: Array<[string, unknown?]> }).__browserCalls,
);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Browser" }).click();
const browser = page.getByTestId("in-app-browser");
await browser.getByTestId("in-app-browser-mode-local").click();
await browser.getByRole("textbox", { name: "Address" }).fill("localhost:5173");
await browser.getByRole("textbox", { name: "Address" }).press("Enter");
await expect.poll(calls).toContainEqual(["navigate", "http://localhost:5173/"]);
@ -157,6 +154,6 @@ test("on desktop the page is a native view glued to the panel", async ({ page })
await page.keyboard.press("Escape");
await expect.poll(async () => (await calls()).at(-1)?.[0]).toBe("show");
await page.getByTestId("side-panel").getByRole("button", { name: "Close panel" }).click();
await browser.getByRole("button", { name: "Close panel" }).click();
await expect.poll(async () => (await calls()).at(-1)?.[0]).toBe("hide");
});

View File

@ -1,80 +0,0 @@
import { expect, test } from "@playwright/test";
import {
attachConsoleGuard,
completeOnboarding,
openUserSettings,
rpc,
signup,
waitForChatComposer,
} from "./helpers";
const SIGNED_IN_ROUTES = (groupId: string, botId: string) =>
["/app/heute", `/app/g/${groupId}`, `/app/${botId}`, "/onboarding"] as const;
const SETTINGS_SECTIONS = [
"general",
"integrations",
"models",
"memory",
"voice",
"notifications",
"usage",
"computer",
"updates",
"privacy",
] as const;
async function exerciseSignedInRoutes(page: import("@playwright/test").Page, errors: string[]) {
const stamp = Date.now();
await signup(page, `console-j-${stamp}@rakazo.test`, "password12", "Console QA");
await completeOnboarding(page, undefined, { mainConversation: true });
await waitForChatComposer(page);
const groupId = page.url().match(/\/app\/g\/([^/]+)/)?.[1];
const bots = await rpc<Array<{ id: string }>>(page, "bots/list", {});
const botId = bots[0]?.id;
expect(groupId).toBeTruthy();
expect(botId).toBeTruthy();
for (const route of SIGNED_IN_ROUTES(groupId!, botId!)) {
await page.goto(route, { waitUntil: "domcontentloaded" });
if (route !== "/onboarding") {
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
});
}
expect(errors, `after ${route}:\n${errors.join("\n")}`).toEqual([]);
}
const settings = await openUserSettings(page);
for (const section of SETTINGS_SECTIONS) {
const nav = settings.getByTestId(`settings-nav-${section}`);
if (section !== "general") {
if (!(await nav.isVisible().catch(() => false))) continue;
await nav.click();
}
expect(errors, `after settings ${section}:\n${errors.join("\n")}`).toEqual([]);
}
}
test.describe("desktop journeys", () => {
test.use({ viewport: { width: 1440, height: 900 } });
test("signed-in route matrix has no unexpected console errors", async ({ page }) => {
const errors = attachConsoleGuard(page);
await exerciseSignedInRoutes(page, errors);
});
});
test.describe("mobile journeys", () => {
test.use({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
});
test("signed-in route matrix has no unexpected console errors", async ({ page }) => {
const errors = attachConsoleGuard(page);
await exerciseSignedInRoutes(page, errors);
});
});

View File

@ -26,8 +26,7 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
await expect(mainEntry).toBeVisible();
// The main bot is reached through the main conversation, not listed again.
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
await expect(page.getByTestId("oo-group-stage")).toHaveCount(0);
await expect(page.getByTestId("bot-settings-trigger")).toBeVisible();
await expect(page.getByTestId("oo-group-stage")).toBeVisible();
await captureScreenshot(page, testInfo, "main-conversation-landing");
const snapshot = () => rpc<Snapshot>(page, "threads/get", { groupId });

View File

@ -73,40 +73,10 @@ test("an image in the main conversation opens and downloads from the artifact ro
await expect(thumbnail).toHaveAttribute("src", /^\/api\/artifacts\/[^/?]+$/);
await thumbnail.click();
const dialog = page.getByRole("dialog", { name: "Pixel" });
const dialog = page.getByRole("dialog", { name: "pixel.png" });
const download = dialog.getByRole("link", { name: "Download pixel.png" });
await expect(download).toHaveAttribute("href", /\/api\/artifacts\/[^/?]+\?download=1$/);
const saved = page.waitForEvent("download");
await download.click();
expect((await saved).suggestedFilename()).toBe("pixel.png");
});
test("a repository ZIP above the inline cap streams up as a file", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `zip-upload-${stamp}@rakazo.test`, "password12", "Zip Upload");
await completeOnboarding(page);
const input = page.locator('input[type="file"]');
await expect(input).toHaveAttribute("accept", /application\/zip/);
const upload = page.waitForResponse(
(response) =>
response.request().method() === "POST" && /\/api\/artifacts\?/.test(response.url()),
);
// Windows reports ZIPs under the legacy type; 12 MiB is above the 10 MiB inline cap.
await input.setInputFiles({
name: "repo.zip",
mimeType: "application/x-zip-compressed",
buffer: Buffer.alloc(12 * 1024 * 1024, 1),
});
await expect(page.getByRole("button", { name: "Remove repo.zip" })).toBeVisible();
await page.getByRole("combobox", { name: /Message/ }).fill("here is the repo");
await page.keyboard.press("Enter");
const response = await upload;
expect(response.status()).toBe(201);
expect(response.request().headers()["content-type"]).toBe("application/zip");
// Exact: the agent's reply can quote the file name too.
await expect(page.getByTestId("transcript").getByText("repo.zip", { exact: true })).toBeVisible({
timeout: 30_000,
});
await captureScreenshot(page, testInfo, "zip-upload");
});

View File

@ -63,7 +63,7 @@ async function expectRailAtRest(page: Page, row: Locator) {
await expect(row.getByTestId("message-hover-time")).toHaveCSS("opacity", "0");
}
test("message hover shows below-bubble actions; reply links to parent", async ({
test("message hover shows beside-bubble actions; reply links to parent", async ({
page,
}, testInfo) => {
const stamp = Date.now();
@ -92,13 +92,7 @@ test("message hover shows below-bubble actions; reply links to parent", async ({
);
const botText = page.getByText(/i handled: hover-bot-/);
await expect(transcript.getByText(/i handled: hover-bot-/)).toBeVisible({ timeout: 20_000 });
// Pin the row by id: once the run settles, long replies fold their details away.
const botMessageId = await transcript
.locator(`[data-message-id]`)
.filter({ has: botText })
.first()
.getAttribute("data-message-id");
const botRow = transcript.locator(`[data-message-id="${botMessageId}"]`);
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
await expect(botRow).toBeVisible();
await expectRailAtRest(page, botRow);
await captureScreenshot(page, testInfo, "message-actions-rest-desktop");
@ -116,7 +110,6 @@ test("message hover shows below-bubble actions; reply links to parent", async ({
).toBeLessThan(2);
await expect(botToolbar.getByRole("button", { name: "Reply" })).toBeVisible();
await expect(botToolbar.getByRole("button", { name: "Copy" })).toBeVisible();
await expect(botToolbar.getByRole("button", { name: "More" })).toBeVisible();
// Measure a unique visible bubble, so an oversized wrapper cannot hide a gap.
const botBubble = botRow.getByTestId("message-bot-bubble").first();
@ -125,12 +118,16 @@ test("message hover shows below-bubble actions; reply links to parent", async ({
const railBox = await botRail.boundingBox();
const bubbleBox = await botBubble.boundingBox();
if (!railBox || !bubbleBox) return null;
const railMid = railBox.y + railBox.height / 2;
const bubbleMid = bubbleBox.y + bubbleBox.height / 2;
return {
below: railBox.y >= bubbleBox.y + bubbleBox.height - 4,
alignedStart: Math.abs(railBox.x - bubbleBox.x) < 8,
beside: railBox.x >= bubbleBox.x + bubbleBox.width - 2,
flush: railBox.x - (bubbleBox.x + bubbleBox.width) < 8,
centered: Math.abs(railMid - bubbleMid) <= 12,
notBelow: railBox.y + railBox.height <= bubbleBox.y + bubbleBox.height + 12,
};
})
.toEqual({ below: true, alignedStart: true });
.toEqual({ beside: true, flush: true, centered: true, notBelow: true });
await captureScreenshot(page, testInfo, "message-bot-actions-desktop");
await expectRailAtRest(page, botRow);
@ -145,7 +142,6 @@ test("message hover shows below-bubble actions; reply links to parent", async ({
const rail = await revealHoverRail(parentRow);
const toolbar = parentRow.getByTestId("message-hover-actions");
await expect(toolbar.getByRole("button", { name: "Reply" })).toBeVisible();
await expect(toolbar.getByRole("button", { name: "Copy" })).toBeVisible();
await expect(toolbar.getByRole("button", { name: "More" })).toBeVisible();
const react = toolbar.getByRole("button", { name: "React" });
await expect(react).toBeVisible();
@ -160,19 +156,23 @@ test("message hover shows below-bubble actions; reply links to parent", async ({
})
.toBe(true);
// User bubble (right): icons sit underneath the bubble, aligned to the end.
// User bubble (right): icons sit to the left, vertically centered — not under the bubble.
const frame = parentRow.getByTestId("message-bubble-frame");
await expect
.poll(async () => {
const railBox = await rail.boundingBox();
const frameBox = await frame.boundingBox();
if (!railBox || !frameBox) return null;
const railMid = railBox.y + railBox.height / 2;
const frameMid = frameBox.y + frameBox.height / 2;
return {
below: railBox.y >= frameBox.y - 4,
alignedEnd: Math.abs(railBox.x + railBox.width - (frameBox.x + frameBox.width)) < 8,
beside: railBox.x + railBox.width <= frameBox.x + 2,
flush: frameBox.x - (railBox.x + railBox.width) < 8,
centered: Math.abs(railMid - frameMid) <= 12,
notBelow: railBox.y >= frameBox.y - 12,
};
})
.toEqual({ below: true, alignedEnd: true });
.toEqual({ beside: true, flush: true, centered: true, notBelow: true });
// User bubble is a muted elevated surface (not cream invert / bright pill).
const userSurface = parentRow.getByTestId("message-user-bubble");
@ -181,7 +181,7 @@ test("message hover shows below-bubble actions; reply links to parent", async ({
.poll(async () => userSurface.evaluate((el) => getComputedStyle(el).backgroundColor))
.not.toMatch(/^rgb\(241,\s*241,\s*239\)$/); // not cream primary foreground
// Long user bubble: rail stays below the bubble, aligned to the right edge.
// Long user bubble: rail stays ~6px beside the bubble edge, not the full row width.
const longText = `hover-long-${stamp}-${"x".repeat(220)}`;
await sendComposerMessage(page, composer, longText);
const longRow = transcript
@ -196,7 +196,7 @@ test("message hover shows below-bubble actions; reply links to parent", async ({
const railBox = await longRail.boundingBox();
const frameBox = await longFrame.boundingBox();
if (!railBox || !frameBox) return null;
return Math.abs(railBox.x + railBox.width - (frameBox.x + frameBox.width));
return Math.abs(frameBox.x - (railBox.x + railBox.width));
})
.toBeLessThan(8);
@ -318,23 +318,16 @@ test("reply preview jumps to parent outside the loaded page", async ({ page }) =
await sendComposerMessage(page, composer, parentText);
const transcript = page.getByTestId("transcript");
const parentRow = transcript
.locator(`[data-message-id]:not([data-message-id^="temp-"])`)
.filter({ hasText: parentText })
.first();
const parentRow = transcript.locator(`[data-message-id]`).filter({ hasText: parentText }).first();
await expect(parentRow).toBeVisible({ timeout: 20_000 });
const parentId = await parentRow.getAttribute("data-message-id");
expect(parentId).toBeTruthy();
expect(parentId).not.toMatch(/^temp-/);
await parentRow.hover();
await parentRow.getByRole("button", { name: "Reply" }).click();
await sendComposerMessage(page, composer, replyText);
const replyRow = transcript
.locator(`[data-message-id]:not([data-message-id^="temp-"])`)
.filter({ hasText: replyText })
.first();
const replyRow = transcript.locator(`[data-message-id]`).filter({ hasText: replyText }).first();
await expect(replyRow).toBeVisible({ timeout: 20_000 });
await expect(replyRow.getByTestId("reply-parent-preview")).toContainText(parentText);
@ -367,12 +360,9 @@ test("reply preview jumps to parent outside the loaded page", async ({ page }) =
const response = await route.fetch();
const body = (await response.json()) as Parameters<typeof stripParent>[0];
stripParent(body);
const headers = { ...response.headers() };
delete headers["content-length"];
await route.fulfill({
status: response.status(),
headers,
contentType: "application/json",
headers: response.headers(),
body: JSON.stringify(body),
});
} catch (error) {

View File

@ -69,7 +69,7 @@ test("selecting a text span quotes it into a reply", async ({ page }, testInfo)
await composer.fill(sourceText);
await composer.press("Enter");
const sourceRow = transcript
.locator("[data-message-id]:not([data-message-id^='temp-'])")
.locator("[data-message-id]")
.filter({ has: page.getByTestId("message-user-bubble") })
.filter({ hasText: `quote-source-${stamp}` })
.first();

View File

@ -28,9 +28,6 @@ test("phone widths read text at 16px and up while desktop keeps its sizes", asyn
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => fontSize(smallText)).toBeCloseTo(16, 1);
expect(await fontSize(composer)).toBeGreaterThanOrEqual(16);
const userText = page.getByTestId("message-user-bubble").last();
const replyText = reply.locator(".rk-chat-markdown p").first();
expect(await fontSize(replyText)).toBe(await fontSize(userText));
await page.emulateMedia({ colorScheme: "dark" });
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");

View File

@ -1,54 +0,0 @@
import { expect, test } from "@playwright/test";
import {
assertNoHorizontalOverflow,
assertPrimaryTouchTargets,
completeOnboarding,
openUserSettings,
rpc,
signup,
waitForChatComposer,
} from "./helpers";
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test("main routes avoid horizontal overflow on a phone viewport", async ({ page }) => {
const stamp = Date.now();
await signup(page, `mobile-layout-${stamp}@rakazo.test`, "password12", "Mobile Layout");
await completeOnboarding(page, undefined, { mainConversation: true });
await waitForChatComposer(page);
const groupMatch = page.url().match(/\/app\/g\/([^/]+)/);
const groupId = groupMatch?.[1];
const bots = await rpc<Array<{ id: string }>>(page, "bots/list", {});
const botId = bots[0]?.id;
expect(groupId).toBeTruthy();
expect(botId).toBeTruthy();
const routes = [`/app/heute`, `/app/g/${groupId}`, `/app/${botId}`];
for (const route of routes) {
await page.goto(route, { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
});
await assertNoHorizontalOverflow(page);
}
const settings = await openUserSettings(page);
for (const section of ["models", "memory", "voice", "notifications", "privacy"] as const) {
await settings.getByTestId(`settings-nav-${section}`).click();
await assertNoHorizontalOverflow(page);
}
});
test("primary shell controls meet touch target guidance", async ({ page }) => {
const stamp = Date.now();
await signup(page, `mobile-touch-${stamp}@rakazo.test`, "password12", "Mobile Touch");
await completeOnboarding(page);
await waitForChatComposer(page);
await assertPrimaryTouchTargets(page, [
"[data-testid='user-menu-trigger']",
"[data-testid='create-menu-trigger']",
"[data-testid='open-navigation']",
]);
});

View File

@ -20,9 +20,7 @@ test("the app reloads itself once when a module request aborts while booting", a
await page.goto("/sign-in", { waitUntil: "commit" });
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible({
timeout: 60_000,
});
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible();
expect(counter.aborted).toBe(1);
expect(await page.evaluate(() => sessionStorage.getItem("rakazo:boot-reload"))).toBeNull();
});
@ -32,9 +30,7 @@ test("a lazy route recovers when its module request aborts", async ({ page }) =>
await page.goto("/sign-in");
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible({
timeout: 60_000,
});
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible();
expect(counter.aborted).toBe(1);
expect(
await page.evaluate(() => sessionStorage.getItem("rakazo:module-reload:pages/Auth")),

View File

@ -11,7 +11,7 @@ test("focus choice suggests apps and preserves a completed connection", async ({
const stamp = Date.now();
await signup(page, `onboarding-${stamp}@rakazo.test`, "password12", "Robin");
// The main bot greets the user in the main conversation, where onboarding lands.
await completeOnboarding(page, undefined, { mainConversation: true, ooName: "Robin" });
await completeOnboarding(page, undefined, { mainConversation: true });
await expect(
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
@ -31,11 +31,12 @@ test("focus choice suggests apps and preserves a completed connection", async ({
await captureScreenshot(page, testInfo, "choice-card-onboarding");
await page.getByTestId("focus-choice-day").click();
// The focus step must not rename the bot: onboarding keeps the chosen name.
// The focus step suggests apps but must not rename the bot: the name the
// user chose during creation ("Chief") is preserved.
await expect(
page.getByTestId("bot-settings-trigger").getByText("Robin", { exact: true }),
page.getByTestId("bot-settings-trigger").getByText("Chief", { exact: true }),
).toBeVisible();
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Robin");
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
// The app cards speak for themselves; onboarding adds no text around them.
await expect(page.getByText(/^Got it/)).toHaveCount(0);
@ -102,7 +103,7 @@ test("focus choice suggests apps and preserves a completed connection", async ({
test("choice refresh failures leave options available for retry", async ({ page }) => {
await signup(page, `choice-refresh-${Date.now()}@rakazo.test`, "password12", "Choice Retry");
await completeOnboarding(page, undefined, { mainConversation: true, ooName: "Robin" });
await completeOnboarding(page, undefined, { mainConversation: true });
const choice = page.getByTestId("focus-choice-day");
await expect(choice).toBeEnabled();
// Keep the existing choice rendered while its save succeeds and navigation refresh fails.

View File

@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot, signup, skipOnboardingNameStep } from "./helpers";
import { captureScreenshot, signup } from "./helpers";
test.beforeEach(async ({ page }) => {
await page.route("**/rpc/integrationSetup/get", (route) =>
@ -49,12 +49,11 @@ test("emoji-first onboarding: shape + color selection with live preview", async
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
// Click Continue → naming, then language.
// Click Continue → goes to the language step
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
await skipOnboardingNameStep(page);
// Step 2: Language selection
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
@ -97,7 +96,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => {
.getByRole("button", { name: /Continue/i })
.first()
.click();
await skipOnboardingNameStep(page);
// Language step — click Skip, then skip voice.
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();

View File

@ -1,30 +0,0 @@
import { expect, test } from "@playwright/test";
import { completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test("German UI localizes goal placeholder and question in intake", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true });
localStorage.setItem("rakazo.uiLocale", "de");
});
const stamp = Date.now();
await signup(page, `intake-de-${stamp}@rakazo.test`, "password12", "Deutsch Intake");
await completeOnboarding(page, undefined, { mainConversation: true });
await rpc(page, "profile/update", { locale: "de-DE", language: "de" });
await page.getByTestId("focus-choice-day").click();
const goal = page.locator('[data-testid="choice-card"][data-step="goal"]');
await expect(goal).toContainText("Was möchtest du in den nächsten Wochen erledigt bekommen?");
await expect(
goal.getByRole("textbox", {
name: "Was möchtest du in den nächsten Wochen erledigt bekommen?",
}),
).toHaveAttribute("placeholder", "Dein Ziel");
const settings = await openUserSettings(page);
const profile = settings.getByTestId("user-profile-settings");
await expect(profile.getByTestId("user-facts-goals")).toContainText(
"Möchte zuerst bei der täglichen Arbeit unterstützt werden",
);
});

View File

@ -1,6 +1,6 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { captureScreenshot, signup, skipOnboardingNameStep } from "./helpers";
import { captureScreenshot, signup } from "./helpers";
test.beforeEach(async ({ page }) => {
await page.route("**/rpc/integrationSetup/get", (route) =>
@ -77,6 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page }
.getByRole("button", { name: /Continue/i })
.first()
.click();
await skipOnboardingNameStep(page);
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
});

View File

@ -1,39 +0,0 @@
import { expect, test } from "@playwright/test";
import { signup } from "./helpers";
test.describe("desktop", () => {
test.use({ viewport: { width: 1440, height: 900 } });
test("reload during onboarding restores Oo look and step", async ({ page }) => {
const stamp = Date.now();
await signup(page, `onboard-reload-${stamp}@rakazo.test`, "password12", "Reload");
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
await page.getByTestId("onboarding-oo-name-input").fill("Nova");
await page.reload();
await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
await expect(page.getByTestId("onboarding-oo-name-input")).toHaveValue("Nova");
await expect(page.locator('[data-onboarding-step="name"]')).toBeVisible();
});
});
test.describe("mobile", () => {
test.use({
hasTouch: true,
isMobile: true,
viewport: { width: 390, height: 844 },
});
test("reload during look step keeps category progress", async ({ page }) => {
const stamp = Date.now();
await signup(page, `onboard-reload-m-${stamp}@rakazo.test`, "password12", "Mobile");
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await expect(page.locator('[data-onboarding-step="emoji"]')).toBeVisible();
await page.reload();
await expect(page.locator('[data-onboarding-step="emoji"]')).toBeVisible();
await expect(
page.getByRole("heading", { name: /Design your Oo|Gestalte dein Oo/i }),
).toBeVisible();
});
});

View File

@ -5,10 +5,6 @@ for (const theme of ["light", "dark"] as const) {
test(`Oo looks render every species with the Oo eyes (${theme})`, async ({ page }, testInfo) => {
await page.goto(`/e2e/fixtures/oo-gallery.html?theme=${theme}`);
const gallery = page.getByTestId("oo-gallery");
await expect(gallery).toBeVisible({ timeout: 60_000 });
await expect(gallery.locator("svg[data-oo-species]").first()).toBeVisible({
timeout: 90_000,
});
await expect(gallery.locator("svg[data-oo-species]")).toHaveCount(12);
for (const species of ["cat", "dog", "bunny", "bear", "robot", "ghost", "frog", "penguin"]) {
await expect(gallery.locator(`svg[data-oo-species="${species}"]`).first()).toBeVisible();
@ -27,10 +23,7 @@ for (const theme of ["light", "dark"] as const) {
test("Oo keeps blinking and moving while visible", async ({ page }) => {
await page.goto("/e2e/fixtures/avatar-motion.html");
const avatar = page.locator(".rakazo-bot-avatar");
await expect(avatar).toBeVisible();
const rig = avatar.locator('[data-part="rig"]');
await expect(rig).toBeVisible();
const rig = page.locator('.rakazo-bot-avatar svg[data-oo-species] [data-part="rig"]');
const first = await rig.getAttribute("transform");
await expect.poll(() => rig.getAttribute("transform")).not.toBe(first);
});

View File

@ -1,30 +0,0 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
test("a 1:1 chat keeps name and status in the header instead of a banner", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await signup(page, `oo-header-${Date.now()}@rakazo.test`, "password12", "Header QA");
await completeOnboarding(page);
const composer = page.getByRole("combobox", { name: /^Message/ });
await composer.fill("Please review `shared/PROJECT_CHECKPOINT_WRAPUP.md`.");
await composer.press("Enter");
await expect(
page
.getByTestId("transcript")
.locator("[data-message-id]")
.filter({ hasText: "done. i handled:" }),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("oo-stage")).toHaveCount(0);
await expect(page.getByTestId("bot-settings-trigger")).toBeVisible();
await page.emulateMedia({ colorScheme: "light" });
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await captureScreenshot(page, testInfo, "oo-chat-header-light");
await page.emulateMedia({ colorScheme: "dark" });
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await captureScreenshot(page, testInfo, "oo-chat-header-dark");
});

View File

@ -1,88 +0,0 @@
import { expect, test } from "@playwright/test";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
const SCREEN_URL = "https://screen.example/embed.html";
test("Oo browser shows live screen, cursor, cowork click, and user context", async ({
page,
}, testInfo) => {
await signup(page, `oo-browser-${Date.now()}@rakazo.test`, "password12", "Browser Cowork");
await completeOnboarding(page);
const botId = activeBotId(page);
await rpc(page, "computer/boot", { botId });
await page.route("https://screen.example/**", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><title>Chrome</title><body style="margin:0;background:#111;height:100vh"></body>`,
}),
);
await page.route("**/rpc/computer/screenUrl", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { url: SCREEN_URL } }),
}),
);
let coworkInput = 0;
await page.route("**/rpc/computer/browserCoworkEngage", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
json: {
leaseId: "lease-test",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
interrupted: true,
},
}),
}),
);
await page.route("**/rpc/computer/input", (route) => {
coworkInput += 1;
return route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { ok: true } }),
});
});
await page.route("**/rpc/computer/browserChromeState", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
json: {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
},
}),
}),
);
let userActionDetail = "";
await page.route("**/rpc/computer/browserUserAction", (route) => {
const body = route.request().postDataJSON() as { json?: { detail?: string } };
userActionDetail = body.json?.detail ?? "";
return route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { ok: true } }),
});
});
await page.getByTitle("Agent computer").click();
const browser = page.getByTestId("in-app-browser");
await expect(browser.getByTestId("oo-computer-browser-screen")).toHaveAttribute(
"src",
SCREEN_URL,
);
await rpc(page, "threads/send", { botId, text: "use your screen and click center" });
const cursor = browser.getByTestId("agent-cursor");
await expect(cursor).toHaveCSS("opacity", "1", { timeout: 15_000 });
await browser.getByTestId("browser-cowork-overlay").click({ position: { x: 120, y: 80 } });
await expect.poll(() => coworkInput).toBeGreaterThan(0);
await expect.poll(() => userActionDetail).toContain("(");
await captureScreenshot(page, testInfo, "oo-computer-browser");
});

View File

@ -18,8 +18,7 @@ test("live page helper preserves identity, masks passwords, and reports partial
res.setHeader("content-type", "text/html");
res.end(`<!doctype html><title>Fixture</title><p>Readable article content</p>
<label>Name <input id="name"></label><input aria-label="Password" type="password" value="fake-test-password">
<select id="plan" aria-label="Plan"><option value="free">Free</option><option value="pro">Pro Plan</option></select>
<button id="save" onmouseenter="this.dataset.hovered='1'" onclick="this.dataset.clicks=Number(this.dataset.clicks||0)+1">Save</button>
<button id="save" onclick="this.dataset.clicks=Number(this.dataset.clicks||0)+1">Save</button>
<label><input type="checkbox">Agree</label><button disabled>Disabled</button>
<script>window.__rakazoPageBrowser={snapshot:()=>({title:'spoofed'})};</script>`);
});
@ -84,18 +83,9 @@ test("live page helper preserves identity, masks passwords, and reports partial
const refreshed = await command("snapshot");
expect(ref(refreshed, "Replacement")).not.toBe(ref(stale, "Save"));
expect(
await command("act", {
actions: [
{ kind: "click", ref: ref(refreshed, "Agree") },
{ kind: "select", ref: ref(refreshed, "Plan"), text: "Pro Plan" },
{ kind: "press", ref: ref(refreshed, "Name"), key: "Enter" },
{ kind: "scroll", direction: "down", amount: 2 },
{ kind: "wait", ms: 10 },
],
}),
).toMatchObject({ ok: true, completed: 5 });
await command("act", { actions: [{ kind: "click", ref: ref(refreshed, "Agree") }] }),
).toMatchObject({ ok: true, completed: 1 });
await expect(page.getByRole("checkbox")).toBeChecked();
await expect(page.locator("#plan")).toHaveValue("pro");
expect(
await command("navigate", { url: `http://127.0.0.1:${address.port}/next` }),
).toMatchObject({ ok: true, title: "Fixture" });

View File

@ -1,77 +0,0 @@
import { expect, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
projectRow,
rpc,
signup,
waitForChatComposer,
} from "./helpers";
test("a project is one sidebar row with its own look, and its coordinator lists the delegated threads", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-coordinator-${stamp}@rakazo.test`, "password12", "Coordinator");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Garden");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await waitForChatComposer(page);
const row = projectRow(projekte, "Garden");
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toHaveAttribute("aria-current", "page");
await expect(row.locator("[data-project-icon]")).toHaveAttribute("data-project-icon", "folder");
// Name, icon and color change from the row's menu.
await row.click({ button: "right" });
await page.getByRole("menuitem", { name: "Customize" }).click();
const look = page.getByTestId("project-look-dialog");
await expect(look).toBeVisible();
await look.getByLabel("Name").fill("Garten");
await look.getByRole("radio", { name: "green" }).click();
await look.getByTestId("project-icon-leaf").click();
await captureScreenshot(page, testInfo, "project-look");
await look.getByRole("button", { name: "Save", exact: true }).click();
await expect(look).toBeHidden();
const renamed = projectRow(projekte, "Garten");
await expect(renamed.locator("[data-project-icon]")).toHaveAttribute("data-project-icon", "leaf");
await expect(renamed.locator("[data-project-icon]")).toHaveAttribute(
"data-project-color",
"green",
);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(renamed.locator("[data-project-icon]")).toHaveAttribute(
"data-project-icon",
"leaf",
{ timeout: 30_000 },
);
// The coordinator hands work to a specialist; the finished thread shows in its overview.
await waitForChatComposer(page);
await rpc(page, "bots/create", {
name: "Gardener",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("delegate to the bot named Gardener: plan the spring beds");
await composer.press("Enter");
const card = page.getByTestId("transcript").getByTestId("delegated-task-card");
await expect(card).toHaveAttribute("data-status", "done", { timeout: 60_000 });
const overview = page.getByTestId("project-overview");
const done = overview.getByRole("button", { name: /Done/ });
await expect(done).toBeVisible({ timeout: 30_000 });
await done.click();
await expect(overview.getByText("Gardener").first()).toBeVisible();
await captureScreenshot(page, testInfo, "project-overview");
});

View File

@ -1,12 +1,9 @@
import { expect, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
projectRow,
rpc,
signup,
waitForChatComposer,
} from "./helpers";
import { expect, type Locator, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
function projectSpace(projekte: Locator, name: string) {
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
}
test("a new project opens straight into the conversation with its own main agent", async ({
page,
@ -29,7 +26,9 @@ test("a new project opens straight into the conversation with its own main agent
await waitForChatComposer(page);
// The project is one conversation, led by a main agent of its own.
const projectEntry = projectRow(projekte, "Launch");
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
name: "Chief, main conversation",
});
await expect(projectEntry).toBeVisible({ timeout: 30_000 });
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
@ -45,20 +44,12 @@ test("a new project opens straight into the conversation with its own main agent
await captureScreenshot(page, testInfo, "project-main-conversation");
});
test("a project's main agent recruits a library agent into the project conversation", async ({
test("a project's main agent hands work to the project's specialist", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-delegate-${stamp}@rakazo.test`, "password12", "Project Delegate");
await completeOnboarding(page);
// The agent is created once, in the home library.
const designer = await rpc<{ id: string }>(page, "bots/create", {
name: "Designer",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
@ -69,6 +60,13 @@ test("a project's main agent recruits a library agent into the project conversat
await waitForChatComposer(page);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
const designer = await rpc<{ id: string }>(page, "bots/create", {
name: "Designer",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("delegate to the bot named Designer: sketch a landing page");
await composer.press("Enter");
@ -81,14 +79,9 @@ test("a project's main agent recruits a library agent into the project conversat
await expect(
transcript.getByText("closing the task: sketch a landing page.").first(),
).toBeVisible({ timeout: 60_000 });
// The project's own Designer joined the conversation; the library agent stayed home.
const projectBots = await rpc<Array<{ id: string; name: string }>>(page, "bots/list", {});
const member = projectBots.find((bot) => bot.name === "Designer");
expect(member?.id).toBeTruthy();
expect(member?.id).not.toBe(designer.id);
const snapshot = await rpc<{ members?: Array<{ botId: string }> }>(page, "threads/get", {
groupId,
});
expect(snapshot.members?.map((entry) => entry.botId)).toContain(member?.id);
expect(snapshot.members?.map((member) => member.botId)).toContain(designer.id);
await captureScreenshot(page, testInfo, "project-main-agent-delegates");
});

View File

@ -1,66 +0,0 @@
import { expect, test } from "@playwright/test";
import {
attachConsoleGuard,
completeOnboarding,
rpc,
signup,
waitForChatComposer,
} from "./helpers";
const mobile = { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true };
test.describe("signed out deep links", () => {
test.use(mobile);
for (const { path, expectUrl, expectText } of [
{ path: "/", expectUrl: /\/($|\?)/, expectText: /evOo|Willkommen|Welcome/i },
{ path: "/sign-in", expectUrl: /\/sign-in/, expectText: /Sign in to evOo|Bei evOo anmelden/i },
{ path: "/sign-up", expectUrl: /\/sign-up/, expectText: /Create account|Konto erstellen/i },
{ path: "/forgot-password", expectUrl: /\/forgot-password/, expectText: /password|Passwort/i },
{ path: "/onboarding", expectUrl: /\/sign-in/, expectText: /Sign in|Anmelden/i },
{ path: "/app/heute", expectUrl: /\/sign-in/, expectText: /Sign in|Anmelden/i },
{ path: "/app/g/example-group", expectUrl: /\/sign-in/, expectText: /Sign in|Anmelden/i },
{ path: "/app/example-bot", expectUrl: /\/sign-in/, expectText: /Sign in|Anmelden/i },
]) {
test(`load ${path} without console errors`, async ({ page }) => {
const errors = attachConsoleGuard(page);
await page.goto(path, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(expectUrl);
await expect(page.locator("body")).toContainText(expectText);
expect(errors, errors.join("\n")).toEqual([]);
});
}
});
test.describe("signed in reload matrix", () => {
test.use(mobile);
test("core app routes reload cleanly", async ({ page }) => {
const errors = attachConsoleGuard(page);
const stamp = Date.now();
await signup(page, `deeplink-${stamp}@rakazo.test`, "password12", "Deep Link");
await completeOnboarding(page, undefined, { mainConversation: true });
await waitForChatComposer(page);
const groupId = page.url().match(/\/app\/g\/([^/]+)/)?.[1];
const bots = await rpc<Array<{ id: string }>>(page, "bots/list", null);
const botId = bots[0]?.id;
expect(groupId).toBeTruthy();
expect(botId).toBeTruthy();
const routes = [`/app/heute`, `/app/g/${groupId}`, `/app/${botId}`, `/onboarding`];
for (const route of routes) {
await page.goto(route, { waitUntil: "domcontentloaded" });
if (route === "/onboarding") {
await expect(page).toHaveURL(/\/(app|onboarding)/);
} else {
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
});
}
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.locator("body")).not.toBeEmpty();
expect(errors, errors.join("\n")).toEqual([]);
}
});
});

View File

@ -5,8 +5,6 @@ import {
captureScreenshot,
completeOnboarding,
createNamedBot,
createTestRoutine,
openAgentOverview,
rpc,
signup,
} from "./helpers";
@ -31,10 +29,8 @@ test("routine active switch keeps its thumb inside the track", async ({ page },
const stamp = Date.now();
await signup(page, `routine-toggle-${stamp}@rakazo.test`, "password12", "Routine Toggle");
await completeOnboarding(page);
const botId = activeBotId(page);
await createTestRoutine(page, botId, { name: "Toggle styling" });
await openAgentOverview(page);
await page.getByRole("button", { name: /Toggle styling/ }).click();
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Create Routine" }).click();
const toggle = page.getByRole("switch", { name: "Active" });
const thumb = toggle.locator("span");
@ -89,7 +85,7 @@ test("routine editing updates in place, preserves timezone, and deletion persist
expect(created.nextRunAt).not.toBeNull();
expect(localSchedule(created.nextRunAt!, created.timezone)).toMatchObject({ hour: 9, minute: 0 });
await page.reload();
await openAgentOverview(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: /Tokyo check-in/ }).click();
await page.locator("label:has-text('Name') input").fill("Weekday check-in");
@ -133,31 +129,27 @@ test("routine editing updates in place, preserves timezone, and deletion persist
expect(await rpc<Routine[]>(page, "routines/list", { botId })).toEqual([]);
await page.reload();
await openAgentOverview(page);
await page.getByTitle("Agent computer").click();
await expect(updatedButton).toHaveCount(0);
});
test("invalid advanced cron is rejected without creating a routine", async ({ page }) => {
test("invalid advanced cron is rejected without creating a routine", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `routine-invalid-${stamp}@rakazo.test`, "password12", "Invalid Routine");
await completeOnboarding(page);
const botId = activeBotId(page);
await expect(
rpc<Routine>(page, "routines/create", {
botId,
name: "Broken schedule",
prompt: "This should never run",
crons: ["61 25 * * *"],
timezone: "UTC",
active: true,
notify: true,
webhookEnabled: false,
githubEnabled: false,
messageProvider: null,
}),
).rejects.toThrow();
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Create Routine" }).click();
await page.locator("label:has-text('Name') input").fill("Broken schedule");
await page.locator("label:has-text('Instruction') textarea").fill("This should never run");
await addScheduleTrigger(page, "Advanced...");
await page.getByLabel("Cron expression").fill("61 25 * * *");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("Enter a valid cron expression.");
expect(await rpc<Routine[]>(page, "routines/list", { botId })).toEqual([]);
await captureScreenshot(page, testInfo, "invalid-cron-rejected");
});
test("a successful routine create is not reported as failed when refresh fails", async ({
@ -168,30 +160,26 @@ test("a successful routine create is not reported as failed when refresh fails",
await completeOnboarding(page);
const botId = activeBotId(page);
const created = await createTestRoutine(page, botId, {
name: "Persisted routine",
prompt: "Run once each morning",
});
await openAgentOverview(page);
await page.getByRole("button", { name: /Persisted routine/ }).click();
await page
.locator("label:has-text('Instruction') textarea")
.fill("Run once each morning, revised");
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Create Routine" }).click();
await page.locator("label:has-text('Name') input").fill("Persisted routine");
await page.locator("label:has-text('Instruction') textarea").fill("Run once each morning");
await addScheduleTrigger(page, "Every day");
await page.route(
"**/rpc/routines/list",
(route) => route.fulfill({ status: 500, body: "refresh failed" }),
{ times: 1 },
);
const updateResponse = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/update") && response.ok(),
const createResponse = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
);
const failedRefresh = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/list") && response.status() === 500,
);
await page.getByRole("button", { name: "Save", exact: true }).click();
await updateResponse;
await createResponse;
await failedRefresh;
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "routine");
await expect(page.getByRole("alert")).toHaveCount(0);
@ -199,8 +187,7 @@ test("a successful routine create is not reported as failed when refresh fails",
await page.unroute("**/rpc/routines/list");
const routines = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routines).toHaveLength(1);
expect(routines[0]?.id).toBe(created.id);
expect(routines[0]?.prompt).toBe("Run once each morning, revised");
expect(routines[0]?.name).toBe("Persisted routine");
});
test("switching bots while a routine save is pending does not reopen stale state", async ({
@ -240,7 +227,7 @@ test("switching bots while a routine save is pending does not reopen stale state
]);
await page.reload();
await openAgentOverview(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: /First routine/ }).click();
await page.locator("label:has-text('Name') input").fill("First routine updated");
@ -280,7 +267,7 @@ test("switching bots while a routine save is pending does not reopen stale state
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
await expect(page.getByRole("alert")).toHaveCount(0);
await openAgentOverview(page);
await page.getByTitle("Agent computer").click();
await expect(page.getByRole("button", { name: /Second routine/ })).toHaveCount(1);
await expect(page.getByRole("button", { name: /First routine/ })).toHaveCount(0);

View File

@ -1,14 +1,6 @@
import { expect, test } from "@playwright/test";
import type { Routine } from "@rakazo/contracts";
import {
activeBotId,
captureScreenshot,
completeOnboarding,
createTestRoutine,
openAgentOverview,
rpc,
signup,
} from "./helpers";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
test("Slack message trigger uses the mounted messaging provider and persists", async ({
page,
@ -26,21 +18,26 @@ test("Slack message trigger uses the mounted messaging provider and persists", a
await completeOnboarding(page);
const botId = activeBotId(page);
await rpc<Routine>(page, "routines/create", {
botId,
name: "Triage Slack updates",
prompt: "Review the verified message event",
crons: [],
timezone: "UTC",
active: true,
notify: true,
webhookEnabled: false,
githubEnabled: false,
messageProvider: "slack",
});
await openAgentOverview(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Create Routine" }).click();
await page.getByPlaceholder("Name this routine").fill("Triage Slack updates");
await page
.getByPlaceholder("What should this routine do each time it runs?")
.fill("Review the verified message event");
await page.getByRole("button", { name: "Add trigger" }).click();
await page.getByRole("menuitem", { name: "Slack message", exact: true }).click();
const panel = page.getByTestId("side-panel");
await expect(panel.getByText("Slack message", { exact: true })).toBeVisible();
await expect(
panel.getByText("Runs when this bot receives a verified message from this provider."),
).toBeVisible();
const saved = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
);
await page.getByRole("button", { name: "Save", exact: true }).click();
await saved;
const [routine] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routine).toMatchObject({
name: "Triage Slack updates",
@ -60,25 +57,26 @@ test("GitHub event trigger exposes signed delivery settings and persists", async
await completeOnboarding(page);
const botId = activeBotId(page);
await rpc<Routine>(page, "routines/create", {
botId,
name: "Review repository events",
prompt: "Inspect the signed GitHub event",
crons: [],
timezone: "UTC",
active: true,
notify: true,
webhookEnabled: false,
githubEnabled: true,
messageProvider: null,
});
await openAgentOverview(page);
await page.getByRole("button", { name: /Review repository events/ }).click();
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Create Routine" }).click();
await page.getByPlaceholder("Name this routine").fill("Review repository events");
await page
.getByPlaceholder("What should this routine do each time it runs?")
.fill("Inspect the signed GitHub event");
await page.getByRole("button", { name: "Add trigger" }).click();
await page.getByRole("menuitem", { name: "Git event", exact: true }).click();
await expect(
page.getByTestId("side-panel").getByText("Git event", { exact: true }),
).toBeVisible();
await expect(page.getByText(new RegExp(`/api/v1/bots/${botId}/github$`))).toBeVisible();
await expect(page.getByText("X-Hub-Signature-256: sha256=…", { exact: true })).toBeVisible();
const saved = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
);
await page.getByRole("button", { name: "Save", exact: true }).click();
await saved;
const [routine] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routine).toMatchObject({
name: "Review repository events",
@ -104,15 +102,14 @@ test("Korean webhook routine keeps technical field labels in English", async ({
await settings.getByRole("option", { name: "한국어", exact: true }).click();
await page.getByRole("button", { name: "계정 설정 닫기" }).click();
const botId = activeBotId(page);
await createTestRoutine(page, botId, {
name: "한국어 웹훅 확인",
prompt: "웹훅을 확인합니다.",
crons: [],
webhookEnabled: true,
});
await openAgentOverview(page, "Agent 컴퓨터");
await page.getByRole("button", { name: /한국어 웹훅 확인/ }).click();
await page.getByTitle("Agent 컴퓨터").click();
await page.getByRole("button", { name: "자동 실행 만들기" }).click();
await page.getByPlaceholder("이 루틴의 이름을 정하세요").fill("한국어 웹훅 확인");
await page
.getByPlaceholder("이 루틴이 실행될 때마다 무엇을 해야 하나요?")
.fill("웹훅을 확인합니다.");
await page.getByRole("button", { name: "트리거 추가" }).click();
await page.getByRole("menuitem", { name: "웹훅", exact: true }).click();
await expect(page.getByText("웹훅이 실행될 때", { exact: true })).toBeVisible();
await expect(page.getByText("POST 대상")).toBeVisible();
@ -125,17 +122,27 @@ test("routine test-run completes and survives reload", async ({ page }, testInfo
const stamp = Date.now();
await signup(page, `routine-${stamp}@rakazo.test`, "password12", "Routine");
await completeOnboarding(page);
const botId = activeBotId(page);
await openAgentOverview(page);
await page.getByTitle("Agent computer").click();
await expect(page.getByRole("button", { name: "Test run" })).toHaveCount(0);
await createTestRoutine(page, botId, {
name: "Daily verification",
prompt: "write routine-run-now-ok into the durable task result",
crons: ["0 9 * * 1-5"],
});
await page.reload();
await openAgentOverview(page);
await page.getByRole("button", { name: "Create Routine" }).click();
await page.locator("label:has-text('Name') input").fill("Daily verification");
await page
.locator("label:has-text('Instruction') textarea")
.fill("write routine-run-now-ok into the durable task result");
await page.getByRole("button", { name: "Add trigger" }).click();
await page.getByRole("menuitem", { name: "On a schedule" }).hover();
await page.getByRole("menuitem", { name: "Weekdays", exact: true }).click();
await expect(page.getByLabel("How often")).toHaveValue("Weekdays");
await captureScreenshot(page, testInfo, "32-routine-configured");
const saved = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
);
await page.getByRole("button", { name: "Save" }).click();
await saved;
await expect(page.getByRole("button", { name: "Save" })).toBeEnabled();
await page.getByRole("button", { name: "Back" }).click();
const routine = page.getByRole("button", { name: /Daily verification/ });
await expect(routine).toContainText("Weekdays at 9:00 AM");
await captureScreenshot(page, testInfo, "33-routine-scheduled");
@ -148,7 +155,7 @@ test("routine test-run completes and survives reload", async ({ page }, testInfo
await page.reload();
await expect(page.getByText(/routine-run-now-ok/i).first()).toBeVisible();
await openAgentOverview(page);
await page.getByTitle("Agent computer").click();
await expect(page.getByRole("button", { name: /Daily verification/ })).toContainText(
"Weekdays at 9:00 AM",
);

View File

@ -1,50 +0,0 @@
import { expect, test } from "@playwright/test";
import type { Routine } from "@rakazo/contracts";
import {
activeBotId,
completeOnboarding,
createTestRoutine,
openAgentOverview,
rpc,
signup,
} from "./helpers";
test("routines section is hidden until the Oo has one, then list toggle persists", async ({
page,
}) => {
const stamp = Date.now();
await signup(page, `routine-list-${stamp}@rakazo.test`, "password12", "Routine List");
await completeOnboarding(page);
const botId = activeBotId(page);
await openAgentOverview(page);
const panel = page.getByTestId("side-panel");
await expect(panel.getByText("Routines", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("routine-create-button")).toHaveCount(0);
await createTestRoutine(page, botId, { name: "Morning check-in" });
await page.reload();
await openAgentOverview(page);
await expect(panel.getByText("Routines", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Morning check-in/ })).toBeVisible();
const toggle = page.getByTestId("routine-list-active-toggle");
await expect(toggle).toHaveAttribute("aria-checked", "true");
await expect(page.getByText("Every day", { exact: false })).toBeVisible();
const updated = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/update") && response.ok(),
);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", "false");
await updated;
const [routine] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routine?.active).toBe(false);
await expect(page.getByText("Paused", { exact: true })).toBeVisible();
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", "true");
const [reactivated] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(reactivated?.active).toBe(true);
});

View File

@ -1,24 +0,0 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } from "./helpers";
test("a long run shows its answer with the earlier steps folded", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `run-steps-${stamp}@rakazo.test`, "password12", "Steps");
await completeOnboarding(page);
await waitForChatComposer(page);
const composer = page.getByRole("combobox", { name: /Message|Nachricht/i });
await composer.fill("Show your steps");
await composer.press("Enter");
const transcript = page.getByTestId("transcript");
await expect(transcript.getByText("Here is the summary.")).toBeVisible({ timeout: 60_000 });
const steps = transcript.getByTestId("run-steps");
await expect(steps).toHaveCount(1);
await expect(transcript.getByText("Opening the page.")).toHaveCount(0);
await captureScreenshot(page, testInfo, "run-steps-folded");
await steps.getByRole("button", { name: "2 steps" }).click();
await expect(steps.getByText("Opening the page.")).toBeVisible();
await expect(steps.getByText("Reading the article.")).toBeVisible();
await captureScreenshot(page, testInfo, "run-steps-open");
});

Some files were not shown because too many files have changed in this diff Show More