fix(ci): stabilize ci and release workflows
This commit is contained in:
parent
6e8f938da2
commit
9c0b8beb7c
86 changed files with 3663 additions and 344 deletions
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -50,7 +50,9 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
|
@ -59,7 +61,7 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Restore ESLint cache
|
||||
uses: actions/cache@v5
|
||||
|
|
@ -67,9 +69,6 @@ jobs:
|
|||
path: .eslintcache
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.*', 'src/**/*.ts', 'src/**/*.tsx') }}
|
||||
|
||||
- name: Auto-fix import sort (Node version parity)
|
||||
run: npx eslint src/ --fix --no-cache || true
|
||||
|
||||
- name: Validate workspace truth gate
|
||||
run: pnpm check:ci
|
||||
|
||||
|
|
@ -81,7 +80,9 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
|
@ -90,7 +91,7 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test:workspace:ci
|
||||
|
|
@ -108,7 +109,9 @@ jobs:
|
|||
run: git config --global core.longpaths true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
|
@ -117,7 +120,7 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test task change ledger
|
||||
run: pnpm test:task-change-ledger
|
||||
|
|
|
|||
6
.github/workflows/codex-runtime-smoke.yml
vendored
6
.github/workflows/codex-runtime-smoke.yml
vendored
|
|
@ -51,7 +51,9 @@ jobs:
|
|||
run: git config --global core.longpaths true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
|
@ -60,7 +62,7 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile --ignore-scripts
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Smoke Codex app-managed runtime install
|
||||
run: pnpm smoke:codex-runtime-install
|
||||
|
|
|
|||
20
.github/workflows/landing.yml
vendored
20
.github/workflows/landing.yml
vendored
|
|
@ -4,15 +4,15 @@ on:
|
|||
push:
|
||||
branches: [main]
|
||||
paths: [landing/**]
|
||||
pull_request:
|
||||
paths: [landing/**]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
group: landing-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
|
@ -24,6 +24,8 @@ jobs:
|
|||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: landing/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: landing
|
||||
|
|
@ -37,18 +39,24 @@ jobs:
|
|||
NUXT_PUBLIC_GITHUB_REPO: 777genius/agent-teams-ai
|
||||
run: npm run generate:all
|
||||
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/configure-pages@v6
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
path: landing/.output/public
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
|
|
|||
75
.github/workflows/release.yml
vendored
75
.github/workflows/release.yml
vendored
|
|
@ -18,7 +18,9 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
|
@ -27,7 +29,7 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
|
@ -57,7 +59,7 @@ jobs:
|
|||
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: |
|
||||
|
|
@ -67,13 +69,13 @@ jobs:
|
|||
|
||||
prepare-runtime:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
|
|
@ -84,7 +86,12 @@ jobs:
|
|||
--generate-notes \
|
||||
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
|
||||
|
||||
- name: Skip runtime asset preparation for manual builds
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: echo "Runtime asset preparation is only needed for tagged releases."
|
||||
|
||||
- name: Check runtime assets
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
id: runtime-assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -116,10 +123,10 @@ jobs:
|
|||
--method POST \
|
||||
"repos/${SOURCE_REPO}/actions/workflows/release-runtime.yml/dispatches" \
|
||||
-f ref=main \
|
||||
-f inputs[source_ref]="$SOURCE_REF" \
|
||||
-f inputs[runtime_version]="$RUNTIME_VERSION" \
|
||||
-f inputs[target_release_repo]="$GITHUB_REPOSITORY" \
|
||||
-f inputs[target_release_tag]="$TARGET_TAG"
|
||||
-f "inputs[source_ref]=$SOURCE_REF" \
|
||||
-f "inputs[runtime_version]=$RUNTIME_VERSION" \
|
||||
-f "inputs[target_release_repo]=$GITHUB_REPOSITORY" \
|
||||
-f "inputs[target_release_tag]=$TARGET_TAG"
|
||||
|
||||
- name: Wait for runtime assets
|
||||
if: steps.runtime-assets.outputs.missing == '1'
|
||||
|
|
@ -175,7 +182,9 @@ jobs:
|
|||
name: dist
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
|
@ -184,12 +193,12 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Setup Python for node-gyp
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
|
@ -256,6 +265,9 @@ jobs:
|
|||
- name: Validate packaged bundle (macOS ${{ matrix.arch }})
|
||||
run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Agent Teams UI.app" darwin ${{ matrix.arch }}
|
||||
|
||||
- name: Smoke packaged app (macOS ${{ matrix.arch }})
|
||||
run: node ./scripts/electron-builder/smokePackagedApp.cjs "release/mac-${{ matrix.arch }}/Agent Teams UI.app" darwin
|
||||
|
||||
- name: Upload assets to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
|
|
@ -284,7 +296,9 @@ jobs:
|
|||
name: dist
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
|
@ -293,12 +307,12 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Setup Python for node-gyp
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
|
@ -359,6 +373,10 @@ jobs:
|
|||
shell: bash
|
||||
run: node ./scripts/electron-builder/verifyBundle.cjs "release/win-unpacked" win32 x64
|
||||
|
||||
- name: Smoke packaged app (Windows)
|
||||
shell: bash
|
||||
run: node ./scripts/electron-builder/smokePackagedApp.cjs "release/win-unpacked" win32
|
||||
|
||||
- name: Upload assets to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
|
|
@ -388,7 +406,9 @@ jobs:
|
|||
name: dist
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
|
@ -397,17 +417,17 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Setup Python for node-gyp
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Linux packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libarchive-tools rpm
|
||||
sudo apt-get install -y libarchive-tools rpm xvfb
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
|
@ -463,6 +483,9 @@ jobs:
|
|||
- name: Validate packaged bundle (Linux)
|
||||
run: node ./scripts/electron-builder/verifyBundle.cjs "release/linux-unpacked" linux x64
|
||||
|
||||
- name: Smoke packaged app (Linux)
|
||||
run: xvfb-run -a node ./scripts/electron-builder/smokePackagedApp.cjs "release/linux-unpacked" linux
|
||||
|
||||
- name: Upload assets to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
|
|
@ -542,8 +565,8 @@ jobs:
|
|||
|
||||
# Canonical Windows feed
|
||||
download_asset "Claude-Agent-Teams-UI-Setup.exe"
|
||||
WIN_SHA="$(sha512_base64 Claude-Agent-Teams-UI-Setup.exe)"
|
||||
WIN_SIZE="$(file_size Claude-Agent-Teams-UI-Setup.exe)"
|
||||
WIN_SHA="$(sha512_base64 "Claude-Agent-Teams-UI-Setup.exe")"
|
||||
WIN_SIZE="$(file_size "Claude-Agent-Teams-UI-Setup.exe")"
|
||||
cat > latest.yml <<EOF
|
||||
version: ${VERSION}
|
||||
files:
|
||||
|
|
@ -557,8 +580,8 @@ jobs:
|
|||
|
||||
# Canonical Linux feed
|
||||
download_asset "Claude-Agent-Teams-UI.AppImage"
|
||||
LINUX_SHA="$(sha512_base64 Claude-Agent-Teams-UI.AppImage)"
|
||||
LINUX_SIZE="$(file_size Claude-Agent-Teams-UI.AppImage)"
|
||||
LINUX_SHA="$(sha512_base64 "Claude-Agent-Teams-UI.AppImage")"
|
||||
LINUX_SIZE="$(file_size "Claude-Agent-Teams-UI.AppImage")"
|
||||
cat > latest-linux.yml <<EOF
|
||||
version: ${VERSION}
|
||||
files:
|
||||
|
|
@ -576,10 +599,10 @@ jobs:
|
|||
# until we switch to universal packaging or an arch-aware provider.
|
||||
download_asset "Agent.Teams.AI-${VERSION}-arm64-mac.zip"
|
||||
download_asset "Agent.Teams.AI-${VERSION}-arm64.dmg"
|
||||
MAC_ZIP_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64-mac.zip)"
|
||||
MAC_ZIP_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64-mac.zip)"
|
||||
MAC_DMG_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64.dmg)"
|
||||
MAC_DMG_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64.dmg)"
|
||||
MAC_ZIP_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64-mac.zip")"
|
||||
MAC_ZIP_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64-mac.zip")"
|
||||
MAC_DMG_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64.dmg")"
|
||||
MAC_DMG_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64.dmg")"
|
||||
cat > latest-mac.yml <<EOF
|
||||
version: ${VERSION}
|
||||
files:
|
||||
|
|
|
|||
Binary file not shown.
1
.runtime-download/runtime/COMMIT_SHA
Normal file
1
.runtime-download/runtime/COMMIT_SHA
Normal file
|
|
@ -0,0 +1 @@
|
|||
4968c54bb28f62ce55220de3437fa6d610729736
|
||||
1
.runtime-download/runtime/VERSION
Normal file
1
.runtime-download/runtime/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.0.32
|
||||
BIN
.runtime-download/runtime/claude-multimodel
Executable file
BIN
.runtime-download/runtime/claude-multimodel
Executable file
Binary file not shown.
|
|
@ -48,15 +48,24 @@ const rootGuide: DefaultTheme.SidebarItem[] = [
|
|||
items: [
|
||||
{ text: "Runtime setup", link: "/guide/runtime-setup" },
|
||||
{ text: "Agent workflow", link: "/guide/agent-workflow" },
|
||||
{ text: "MCP integration", link: "/guide/mcp-integration" },
|
||||
{ text: "Team brief examples", link: "/guide/team-brief-examples" },
|
||||
{ text: "Git and worktree strategy", link: "/guide/git-worktree-strategy" },
|
||||
{ text: "Code review", link: "/guide/code-review" },
|
||||
{ text: "Troubleshooting", link: "/guide/troubleshooting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Developers",
|
||||
items: [{ text: "Developer hub", link: "/developers/" }]
|
||||
},
|
||||
{
|
||||
text: "Reference",
|
||||
items: [
|
||||
{ text: "Concepts", link: "/reference/concepts" },
|
||||
{ text: "Providers and runtimes", link: "/reference/providers-runtimes" },
|
||||
{ text: "Contributor architecture", link: "/reference/contributor-architecture" },
|
||||
{ text: "Release notes", link: "/reference/release-notes" },
|
||||
{ text: "Privacy and local data", link: "/reference/privacy-local-data" },
|
||||
{ text: "FAQ", link: "/reference/faq" }
|
||||
]
|
||||
|
|
@ -77,15 +86,24 @@ const ruGuide: DefaultTheme.SidebarItem[] = [
|
|||
items: [
|
||||
{ text: "Настройка рантайма", link: "/ru/guide/runtime-setup" },
|
||||
{ text: "Работа агентов", link: "/ru/guide/agent-workflow" },
|
||||
{ text: "MCP integration", link: "/ru/guide/mcp-integration" },
|
||||
{ text: "Team brief examples", link: "/ru/guide/team-brief-examples" },
|
||||
{ text: "Git and worktree strategy", link: "/ru/guide/git-worktree-strategy" },
|
||||
{ text: "Код-ревью", link: "/ru/guide/code-review" },
|
||||
{ text: "Диагностика", link: "/ru/guide/troubleshooting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Разработчикам",
|
||||
items: [{ text: "Хаб разработчика", link: "/ru/developers/" }]
|
||||
},
|
||||
{
|
||||
text: "Справочник",
|
||||
items: [
|
||||
{ text: "Концепции", link: "/ru/reference/concepts" },
|
||||
{ text: "Провайдеры и рантаймы", link: "/ru/reference/providers-runtimes" },
|
||||
{ text: "Архитектура для контрибьюторов", link: "/ru/reference/contributor-architecture" },
|
||||
{ text: "Релизы", link: "/ru/reference/release-notes" },
|
||||
{ text: "Приватность и локальные данные", link: "/ru/reference/privacy-local-data" },
|
||||
{ text: "FAQ", link: "/ru/reference/faq" }
|
||||
]
|
||||
|
|
@ -94,6 +112,7 @@ const ruGuide: DefaultTheme.SidebarItem[] = [
|
|||
|
||||
const rootNav: DefaultTheme.NavItem[] = [
|
||||
{ text: "Guide", link: "/guide/quickstart", activeMatch: "^/guide/(?!troubleshooting(?:/|$))" },
|
||||
{ text: "Developers", link: "/developers/", activeMatch: "^/developers/" },
|
||||
{ text: "Reference", link: "/reference/concepts", activeMatch: "^/reference/" },
|
||||
{
|
||||
text: "Troubleshooting",
|
||||
|
|
@ -109,6 +128,7 @@ const ruNav: DefaultTheme.NavItem[] = [
|
|||
link: "/ru/guide/quickstart",
|
||||
activeMatch: "^/ru/guide/(?!troubleshooting(?:/|$))"
|
||||
},
|
||||
{ text: "Разработчикам", link: "/ru/developers/", activeMatch: "^/ru/developers/" },
|
||||
{ text: "Справочник", link: "/ru/reference/concepts", activeMatch: "^/ru/reference/" },
|
||||
{
|
||||
text: "Диагностика",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const cards = computed(() => {
|
|||
? [
|
||||
{ icon: "◈", title: "Концепции", desc: "Команды, задачи, роли и уровни автономности.", link: "/ru/reference/concepts" },
|
||||
{ icon: "⌁", title: "Рантаймы", desc: "Claude, Codex, OpenCode и multimodel-режим.", link: "/ru/reference/providers-runtimes" },
|
||||
{ icon: "▦", title: "Архитектура", desc: "Feature layout, guardrails и границы runtime/provider.", link: "/ru/reference/contributor-architecture" },
|
||||
{ icon: "⌘", title: "Локальные данные", desc: "Что хранится на машине и что уходит провайдерам.", link: "/ru/reference/privacy-local-data" },
|
||||
{ icon: "?", title: "FAQ", desc: "Короткие ответы на частые вопросы.", link: "/ru/reference/faq" }
|
||||
]
|
||||
|
|
@ -30,6 +31,7 @@ const cards = computed(() => {
|
|||
? [
|
||||
{ icon: "◈", title: "Concepts", desc: "Teams, tasks, roles, and autonomy levels.", link: "/reference/concepts" },
|
||||
{ icon: "⌁", title: "Runtimes", desc: "Claude, Codex, OpenCode, and multimodel mode.", link: "/reference/providers-runtimes" },
|
||||
{ icon: "▦", title: "Architecture", desc: "Feature layout, guardrails, and runtime/provider boundaries.", link: "/reference/contributor-architecture" },
|
||||
{ icon: "⌘", title: "Local data", desc: "What stays on disk and what providers receive.", link: "/reference/privacy-local-data" },
|
||||
{ icon: "?", title: "FAQ", desc: "Short answers to common questions.", link: "/reference/faq" }
|
||||
]
|
||||
|
|
|
|||
67
landing/product-docs/developers/index.md
Normal file
67
landing/product-docs/developers/index.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
title: Developers - Agent Teams Docs
|
||||
description: Contributor and developer entry point for Agent Teams architecture, guardrails, debugging, and MCP extension paths.
|
||||
---
|
||||
|
||||
# Developers
|
||||
|
||||
Use this page when you want to change Agent Teams itself, debug a team launch, or extend a runtime with MCP tools. The links below point to the canonical repo documents so implementation rules stay in one place.
|
||||
|
||||
## Start here
|
||||
|
||||
| Need | Go to |
|
||||
| --- | --- |
|
||||
| Repo overview, scripts, and source setup | [README.md](https://github.com/777genius/agent-teams-ai/blob/main/README.md) |
|
||||
| Working conventions for agents and contributors | [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) |
|
||||
| Hard implementation guardrails | [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) |
|
||||
| Medium and large feature structure | [Feature architecture standard](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) |
|
||||
| Launch, bootstrap, and teammate messaging debugging | [Agent team debugging runbook](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) |
|
||||
| Contribution process | [Contributing guide](https://github.com/777genius/agent-teams-ai/blob/main/.github/CONTRIBUTING.md) |
|
||||
| Release notes / Changelog | [RELEASE.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md) — [CHANGELOG.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md) |
|
||||
|
||||
## Local development path
|
||||
|
||||
Run the desktop Electron app for normal development:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The browser/web path is not a replacement for the desktop runtime. Desktop mode is the supported local path because it includes IPC, terminals, provider auth, team lifecycle handling, launch diagnostics, and the runtime bridges used by real teams.
|
||||
|
||||
## Architecture checkpoints
|
||||
|
||||
Before changing a feature, identify its boundary:
|
||||
|
||||
| Area | Expected home |
|
||||
| --- | --- |
|
||||
| Medium or large product feature | `src/features/<feature-name>/` |
|
||||
| Electron main process orchestration | `src/main/` |
|
||||
| Preload-safe API surface | `src/preload/` |
|
||||
| Renderer UI and app state | `src/renderer/` |
|
||||
| Shared types and pure helpers | `src/shared/` |
|
||||
| Agent Teams board MCP server | `mcp-server/` |
|
||||
| Board data controller | `agent-teams-controller/` |
|
||||
|
||||
Use `src/features/recent-projects` as the reference slice for feature organization. Keep cross-process contracts explicit, and avoid deep imports across feature boundaries.
|
||||
|
||||
## Debugging path
|
||||
|
||||
For launch hangs, OpenCode `registered` / bootstrap-unconfirmed states, missing teammate replies, or suspicious task logs:
|
||||
|
||||
1. Start with the [debugging runbook](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md).
|
||||
2. Inspect the newest artifact pack under `~/.claude/teams/<team>/launch-failure-artifacts/latest.json`.
|
||||
3. Open the artifact `manifest.json` and check `classification`, bootstrap breadcrumbs, launch diagnostics, member spawn statuses, and redacted log tails.
|
||||
4. Clean up only the team, run, pane, or process you can identify as owned by the smoke test or failed launch.
|
||||
|
||||
## MCP development path
|
||||
|
||||
Agent Teams uses a built-in MCP server named `agent-teams` for board operations. User and project MCP servers can add external capabilities for runtimes. See [MCP integration](/guide/mcp-integration) for setup examples, `.mcp.json` structure, and tool registration guidance.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Contributor architecture](/reference/contributor-architecture)
|
||||
- [Runtime setup](/guide/runtime-setup)
|
||||
- [MCP integration](/guide/mcp-integration)
|
||||
- [Troubleshooting](/guide/troubleshooting)
|
||||
|
|
@ -86,6 +86,8 @@ Prioritize these areas when reviewing:
|
|||
- **Parsing and task lifecycle logic** — changes to task references, chunking, or filtering can break message delivery
|
||||
- **Persistence and code review flows** — changes to task storage or review state must stay consistent across IPC layers
|
||||
|
||||
For the canonical feature layout and hard guardrail links, use [Contributor Architecture](/reference/contributor-architecture).
|
||||
|
||||
## Verification
|
||||
|
||||
Prefer focused verification commands. Broad formatting or lint-fix commands should not be used unless the task explicitly intends broad formatting churn.
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ Each team member runs on a provider backend. In the team editor, pick a provider
|
|||
Mixing providers in one team is supported — for example, a Claude lead with OpenCode builders.
|
||||
|
||||
::: info
|
||||
Gemini support is in development and will appear in the provider list when available.
|
||||
Gemini provider support is in development. See [Providers and runtimes](/reference/providers-runtimes) for current provider status.
|
||||
:::
|
||||
|
||||
## Write a good team brief
|
||||
|
|
|
|||
100
landing/product-docs/guide/git-worktree-strategy.md
Normal file
100
landing/product-docs/guide/git-worktree-strategy.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
---
|
||||
title: Git and Worktree Strategy - Agent Teams Docs
|
||||
description: Decide when to use the main worktree, feature branches, or OpenCode worktree isolation for parallel agent work.
|
||||
---
|
||||
|
||||
# Git and Worktree Strategy
|
||||
|
||||
Git gives Agent Teams the strongest review path: narrow diffs, branch visibility, task-scoped changes, and safer parallel work.
|
||||
|
||||
## Choose a strategy
|
||||
|
||||
| Strategy | Use when | Tradeoff |
|
||||
| --- | --- | --- |
|
||||
| Main worktree | Solo work, docs-only edits, or one teammate at a time | Simple, but parallel edits can collide |
|
||||
| Feature branch | One team is working on one coherent change | Clean review target, but teammates still share files |
|
||||
| Worktree isolation | Multiple OpenCode teammates may edit the same repo in parallel | Better isolation, but merge/review needs more discipline |
|
||||
|
||||
Start simple. Add worktree isolation when parallel edits are likely, not because every task needs a separate checkout.
|
||||
|
||||
## When to enable worktree isolation
|
||||
|
||||
Enable it for OpenCode teammates when:
|
||||
|
||||
- two or more teammates may edit the same repository at once
|
||||
- a task may run formatters, code generators, or broad tests
|
||||
- you want each teammate's branch and diff to stay separate
|
||||
- the lead workspace is dirty and should not receive direct edits
|
||||
|
||||
Keep it off when:
|
||||
|
||||
- the task is read-only
|
||||
- one teammate owns all edits
|
||||
- the repo is not Git-tracked
|
||||
- you need a runtime path that does not support this isolation mode
|
||||
|
||||
::: warning
|
||||
Worktree isolation currently applies to OpenCode members and requires a Git-tracked project.
|
||||
:::
|
||||
|
||||
## Branch hygiene
|
||||
|
||||
Before starting parallel work:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
Use a clean branch when possible. If the main worktree already has user changes, tell agents not to revert unrelated files and keep task scope narrow.
|
||||
|
||||
Recommended branch style:
|
||||
|
||||
```text
|
||||
agent/<team-or-task>/<short-purpose>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
agent/docs/mcp-guide
|
||||
agent/review/task-log-filtering
|
||||
agent/ui/code-review-polish
|
||||
```
|
||||
|
||||
## Review flow
|
||||
|
||||
For isolated worktrees, review the teammate's diff before merging or applying changes back to the main workspace.
|
||||
|
||||
1. Confirm the task result comment names changed scope and verification.
|
||||
2. Inspect the task diff in the review UI.
|
||||
3. Ask for changes on the task if the diff touches unrelated files.
|
||||
4. Approve only after tests or manual checks match the task risk.
|
||||
5. Merge or apply changes deliberately.
|
||||
|
||||
Do not auto-merge worktree output just because the task is complete. Completion means the agent believes the work is ready for review.
|
||||
|
||||
## Conflict policy
|
||||
|
||||
Use this policy for parallel teams:
|
||||
|
||||
| Situation | Action |
|
||||
| --- | --- |
|
||||
| Two teammates edit the same file | Pause one task or make one owner responsible for integration |
|
||||
| Generated files changed broadly | Require a comment explaining the generator and command |
|
||||
| Main worktree has unrelated changes | Preserve them and review only task-owned changes |
|
||||
| Worktree branch diverges | Rebase or merge manually after review, not inside a vague agent task |
|
||||
|
||||
## Task prompt example
|
||||
|
||||
```text
|
||||
Implement the settings validation fix in your assigned worktree. Keep edits inside src/features/settings and focused tests. Do not touch provider auth or task storage. Post the test command and result before completing the task.
|
||||
```
|
||||
|
||||
This prompt works because it names the allowed area, sensitive boundaries, and completion evidence.
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Create a team](/guide/create-team)
|
||||
- [Code review](/guide/code-review)
|
||||
- [Team brief examples](/guide/team-brief-examples)
|
||||
|
|
@ -34,7 +34,7 @@ To use agent runtimes, you need access to at least one provider:
|
|||
| OpenCode | API key for a supported backend (e.g. OpenRouter) |
|
||||
|
||||
::: info
|
||||
Gemini provider support is in development. You can prepare access now, but it will not appear in the team editor until it is ready.
|
||||
Gemini provider support is in development. See [Providers and runtimes](/reference/providers-runtimes) for current status across all providers.
|
||||
:::
|
||||
|
||||
For source development, you also need:
|
||||
|
|
|
|||
224
landing/product-docs/guide/mcp-integration.md
Normal file
224
landing/product-docs/guide/mcp-integration.md
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
---
|
||||
title: MCP Integration - Agent Teams Docs
|
||||
description: Configure MCP in Agent Teams for board operations, teammate coordination, external tool servers, and custom tool development.
|
||||
---
|
||||
|
||||
# MCP Integration
|
||||
|
||||
Agent Teams uses MCP in two practical layers:
|
||||
|
||||
| Layer | What it does | Who uses it |
|
||||
| --- | --- | --- |
|
||||
| Built-in board server | Exposes Agent Teams task, message, review, process, runtime, and cross-team tools | Leads and teammates launched by the app |
|
||||
| External MCP servers | Add optional tools such as browser automation, design context, docs search, or company systems | Users and configured runtimes |
|
||||
|
||||
Keep those layers separate. The built-in `agent-teams` MCP server is how agents coordinate inside Agent Teams. External MCP servers are optional runtime tools.
|
||||
|
||||
## How Agent Teams injects MCP
|
||||
|
||||
When the desktop app launches Claude-based team members, it writes a temporary `--mcp-config` JSON file containing the built-in `agent-teams` server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-teams": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/agent-teams-mcp/index.js"],
|
||||
"env": {
|
||||
"AGENT_TEAMS_MCP_CLAUDE_DIR": "/Users/you/.claude"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In development, the command may point at `mcp-server/src/index.ts` through `tsx`. In packaged builds, the app copies the bundled MCP server to a stable app-data path and runs it with Node. The generated file is app-owned and cleaned up best effort.
|
||||
|
||||
User and project MCP servers remain separate. The app reads installed servers from:
|
||||
|
||||
| Scope | Location |
|
||||
| --- | --- |
|
||||
| User | `~/.claude.json` under `mcpServers` |
|
||||
| Local project entry in Claude config | `~/.claude.json` under `projects[projectPath].mcpServers` |
|
||||
| Project | `<project>/.mcp.json` under `mcpServers` |
|
||||
|
||||
Prefer project scope for tools that belong to one repository. Prefer user scope for tools you reuse across unrelated projects.
|
||||
|
||||
## Project `.mcp.json` example
|
||||
|
||||
Place this file at the project root when a team should see the same project-scoped server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"docs-search": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@acme/docs-search-mcp"],
|
||||
"env": {
|
||||
"DOCS_INDEX_PATH": "./docs-index"
|
||||
}
|
||||
},
|
||||
"local-browser": {
|
||||
"command": "node",
|
||||
"args": ["./tools/mcp/browser-server.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep secrets out of committed `.mcp.json` files. Put credentials in your shell, a user-scoped config, or the app's custom MCP install flow if the value must stay local.
|
||||
|
||||
## Board MCP workflow
|
||||
|
||||
Agents should use board MCP tools when the work belongs to a task:
|
||||
|
||||
1. Read the latest task context.
|
||||
2. Start the task only when actually beginning work.
|
||||
3. Add task comments for blockers, plans, and final results.
|
||||
4. Mark the task complete after the result comment is posted.
|
||||
5. Send a short message when a lead or teammate needs to know the result.
|
||||
|
||||
Example agent flow:
|
||||
|
||||
```text
|
||||
task_get -> task_start -> edit/test -> task_add_comment -> task_complete -> message_send
|
||||
```
|
||||
|
||||
Use a direct message for coordination. Use a task comment for durable task history.
|
||||
|
||||
::: tip
|
||||
If the note affects review, verification, changed scope, or a blocker, put it on the task.
|
||||
:::
|
||||
|
||||
## Built-in Agent Teams tools
|
||||
|
||||
The MCP server registers tools from `agent-teams-controller/src/mcpToolCatalog.js`. The registration loop lives in `mcp-server/src/tools/index.ts`, and each group has its own file under `mcp-server/src/tools/`.
|
||||
|
||||
Common operational tools:
|
||||
|
||||
| Tool | Use |
|
||||
| --- | --- |
|
||||
| `task_get` | Read the latest task context, comments, attachments, status, and relations |
|
||||
| `task_start` | Mark a task in progress when work actually begins |
|
||||
| `task_add_comment` | Add blocker notes, verification notes, plans, and final result summaries |
|
||||
| `task_complete` | Complete a task after the final result comment is posted |
|
||||
| `message_send` | Send a visible inbox message to a lead, teammate, or user |
|
||||
| `review_request`, `review_start`, `review_approve`, `review_request_changes` | Move task-scoped review workflows |
|
||||
| `process_register`, `process_list`, `process_stop`, `process_unregister` | Track teammate-owned dev servers, watchers, and other background services |
|
||||
|
||||
Tool names may appear to runtimes with MCP namespace prefixes, for example `mcp__agent-teams__task_get`. The canonical tool name inside the MCP server remains `task_get`.
|
||||
|
||||
## Register a new built-in tool
|
||||
|
||||
For Agent Teams repository work, add built-in board tools through the existing FastMCP structure:
|
||||
|
||||
1. Add the tool implementation to the matching file in `mcp-server/src/tools/`, or create a new group file if the domain is genuinely new.
|
||||
2. Add the tool name to the appropriate group in `agent-teams-controller/src/mcpToolCatalog.js`.
|
||||
3. Wire a new group through `mcp-server/src/tools/index.ts` only when a new domain group is needed.
|
||||
4. Validate input with `zod` and call the controller API instead of reading board files directly.
|
||||
5. Add focused tests in `mcp-server/test/tools.test.ts` or an e2e case when the transport matters.
|
||||
|
||||
Minimal shape:
|
||||
|
||||
```ts
|
||||
server.addTool({
|
||||
name: 'task_example',
|
||||
description: 'Explain what this tool does for agents.',
|
||||
parameters: z.object({
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
taskId: z.string().min(1)
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
const controller = getController(teamName, claudeDir);
|
||||
return jsonTextContent(controller.tasks.getTask(taskId));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Do not create a tool that bypasses controller validation, mutates unrelated team files, or exposes broad filesystem/process access without a narrow task need.
|
||||
|
||||
## External MCP servers
|
||||
|
||||
Use external MCP servers when a teammate needs a durable tool connection, not just one prompt with pasted context.
|
||||
|
||||
Good fits:
|
||||
|
||||
- browser or website testing tools
|
||||
- design or product data tools
|
||||
- internal docs and search systems
|
||||
- issue tracker or support systems
|
||||
- database inspection tools with read-only credentials
|
||||
|
||||
Poor fits:
|
||||
|
||||
- secrets pasted into prompts
|
||||
- one-off files that can be attached directly
|
||||
- tools that mutate production systems without review
|
||||
- broad local filesystem access when a narrower project scope is enough
|
||||
|
||||
## Scopes
|
||||
|
||||
Agent Teams recognizes shared and project-oriented MCP scopes.
|
||||
|
||||
| Scope | Use when |
|
||||
| --- | --- |
|
||||
| User or Global | The same server should be available across projects |
|
||||
| Project or Local | The server belongs to one repository, workspace, or team context |
|
||||
|
||||
Prefer the narrowest scope that still makes the workflow usable. Project-scoped servers are easier to reason about during review because the tool belongs to the project being changed.
|
||||
|
||||
## Setup checklist
|
||||
|
||||
Before assigning a task that depends on an MCP server:
|
||||
|
||||
1. Install or configure the server.
|
||||
2. Confirm it appears in the app's installed MCP list for the intended scope.
|
||||
3. Run diagnostics from the MCP registry or extensions UI when available.
|
||||
4. Start with a low-risk read-only task.
|
||||
5. Mention the expected MCP tool use in the task description or team brief.
|
||||
|
||||
If a server fails diagnostics, fix that first. A better task prompt will not repair a missing command, wrong config path, or rejected credentials.
|
||||
|
||||
## Install a custom server from the app
|
||||
|
||||
The desktop app exposes MCP registry APIs through Electron IPC for search, browse, install, custom install, uninstall, installed-state reading, and diagnostics. Custom installs validate the server name, scope, project path, env var names, and HTTP headers before calling the runtime install path.
|
||||
|
||||
Use custom install when you have an MCP package that is not in the registry yet:
|
||||
|
||||
| Field | Example |
|
||||
| --- | --- |
|
||||
| Server name | `docs-search` |
|
||||
| Scope | `project` for this repository, `user` for all projects |
|
||||
| Type | `stdio` for local commands, `http` or `sse` for remote servers |
|
||||
| Package | `@acme/docs-search-mcp` |
|
||||
| Env | `DOCS_INDEX_PATH=./docs-index` |
|
||||
|
||||
After install, run diagnostics and create a small read-only task to prove the tool surface before assigning larger work.
|
||||
|
||||
## Task example
|
||||
|
||||
```text
|
||||
Audit the docs home page with the browser MCP. Check desktop and mobile widths, capture any layout issue as a task comment, and only edit landing/product-docs files. Run the docs build before completion.
|
||||
```
|
||||
|
||||
This works because it names the tool, the surface, the write boundary, and the verification step.
|
||||
|
||||
## Safety rules
|
||||
|
||||
- Do not give every teammate every MCP server by default.
|
||||
- Keep write-capable tools out of broad teams unless review requires them.
|
||||
- Prefer read-only credentials for inspection tasks.
|
||||
- Put production-impacting tool use behind explicit task comments and review.
|
||||
- Treat MCP diagnostic failures as setup failures, not agent failures.
|
||||
- Avoid committing secrets in `.mcp.json` or prompts.
|
||||
- Use absolute `projectPath` values when installing project-scoped servers through the app.
|
||||
- Do not edit the app-generated `agent-teams-mcp-*.json` files; they are temporary launch artifacts.
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Runtime setup](/guide/runtime-setup)
|
||||
- [Team brief examples](/guide/team-brief-examples)
|
||||
- [Agent workflow](/guide/agent-workflow)
|
||||
- [Developers](/developers/)
|
||||
|
|
@ -15,6 +15,10 @@ Download the latest release for your platform from the <a href="https://github.c
|
|||
The app is free and open source. The agent runtime you choose may still require provider access — see [Installation](/guide/installation) for details.
|
||||
:::
|
||||
|
||||
::: info
|
||||
The desktop app is the primary product. Agent Teams also runs in a browser for development, but the browser path lacks the full desktop IPC, terminal, provider auth, and team lifecycle behavior. Use `pnpm dev` (Electron) for normal development, not the browser/web dev mode.
|
||||
:::
|
||||
|
||||
## 2. Open or create a project
|
||||
|
||||
Launch the app and select the project directory you want agents to work in. Agent Teams reads local project files and runtime/session state so the UI can show tasks, logs, diffs, and teammate activity.
|
||||
|
|
@ -42,7 +46,7 @@ The setup flow auto-detects installed runtimes on your machine. A common first s
|
|||
| OpenCode | Multi-model teams and many provider backends |
|
||||
|
||||
::: info
|
||||
Gemini support is in development and will appear in the runtime list when available.
|
||||
Gemini provider support is in development. See [Providers and runtimes](/reference/providers-runtimes) for current provider status.
|
||||
:::
|
||||
|
||||
See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider.
|
||||
|
|
|
|||
112
landing/product-docs/guide/team-brief-examples.md
Normal file
112
landing/product-docs/guide/team-brief-examples.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
title: Team Brief Examples - Agent Teams Docs
|
||||
description: Practical team brief templates for small fixes, docs work, implementation tasks, reviews, and high-risk areas.
|
||||
---
|
||||
|
||||
# Team Brief Examples
|
||||
|
||||
A good team brief gives the lead enough structure to create small tasks without forcing every implementation detail upfront.
|
||||
|
||||
Use this shape:
|
||||
|
||||
```text
|
||||
Outcome:
|
||||
Scope:
|
||||
Boundaries:
|
||||
Coordination:
|
||||
Verification:
|
||||
Review:
|
||||
```
|
||||
|
||||
## Minimal brief
|
||||
|
||||
Use for small, low-risk work.
|
||||
|
||||
```text
|
||||
Outcome: Improve the quickstart so a new user can launch one team successfully.
|
||||
Scope: Keep edits inside landing/product-docs.
|
||||
Boundaries: Do not rewrite the whole docs structure.
|
||||
Coordination: Create one or two tasks, keep comments on the task.
|
||||
Verification: Run the docs build.
|
||||
Review: Summarize changed pages and any remaining gaps.
|
||||
```
|
||||
|
||||
## Implementation brief
|
||||
|
||||
Use when code changes touch one feature area.
|
||||
|
||||
```text
|
||||
Outcome: Add a focused improvement to task comment filtering.
|
||||
Scope: Work inside the task/comment feature files unless a shared helper is clearly needed.
|
||||
Boundaries: Do not change task storage format or review state semantics.
|
||||
Coordination: Split parser, UI, and tests into separate tasks if they can be reviewed independently.
|
||||
Verification: Run the focused unit tests first, then the feature typecheck if touched.
|
||||
Review: Call out parsing edge cases and any behavior that affects existing task comments.
|
||||
```
|
||||
|
||||
## Docs brief
|
||||
|
||||
Use for documentation and guide work.
|
||||
|
||||
```text
|
||||
Outcome: Draft practical workflow guides from the docs audit.
|
||||
Scope: Add concise VitePress pages under landing/product-docs/guide.
|
||||
Boundaries: Avoid moving existing navigation hubs owned by other tasks.
|
||||
Coordination: Check related docs tasks before editing nav.
|
||||
Verification: Run the VitePress docs build.
|
||||
Review: Include links added to sidebar and any pages intentionally left as drafts.
|
||||
```
|
||||
|
||||
## Review-heavy brief
|
||||
|
||||
Use for risky areas such as IPC, provider auth, persistence, Git, or task lifecycle logic.
|
||||
|
||||
```text
|
||||
Outcome: Fix the launch failure without changing successful launch behavior.
|
||||
Scope: Start from the newest launch-failure artifact and the affected runtime adapter.
|
||||
Boundaries: Do not change provider prompts until setup and runtime evidence are inspected.
|
||||
Coordination: Make one diagnostic task and one fix task if the cause is confirmed.
|
||||
Verification: Run focused tests and one desktop smoke check when practical.
|
||||
Review: Lead must inspect the diff before approval.
|
||||
```
|
||||
|
||||
## Mixed provider brief
|
||||
|
||||
Use when teammates run different provider/model lanes.
|
||||
|
||||
```text
|
||||
Outcome: Implement and review a small feature using separate builder and reviewer lanes.
|
||||
Scope: Builder edits the feature. Reviewer inspects only the task diff and tests.
|
||||
Boundaries: Do not switch model ids mid-task unless launch fails before work begins.
|
||||
Coordination: Builder posts result comment first. Reviewer posts findings as task comments.
|
||||
Verification: Builder runs focused tests. Reviewer checks failure output and changed scope.
|
||||
Review: Lead approves only after reviewer comments are resolved.
|
||||
```
|
||||
|
||||
## What to avoid
|
||||
|
||||
| Weak brief | Better replacement |
|
||||
| --- | --- |
|
||||
| "Improve the app" | Name the workflow, files, and success check |
|
||||
| "Fix all docs" | Pick one guide group and one build command |
|
||||
| "Use the best model" | Name provider/model choices or let the app defaults stand |
|
||||
| "Refactor as needed" | State which modules are allowed to change |
|
||||
| "Make it production ready" | Define review, tests, and rollout checks |
|
||||
|
||||
## Before launch
|
||||
|
||||
Check these points before starting the team:
|
||||
|
||||
1. The brief names a concrete outcome.
|
||||
2. Risk boundaries are explicit.
|
||||
3. The lead can split the work into reviewable tasks.
|
||||
4. Verification commands are included when known.
|
||||
5. Sensitive areas require review before approval.
|
||||
|
||||
If the brief is still broad, launch a solo or small team first and ask it to produce a task plan rather than implementation.
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Create a team](/guide/create-team)
|
||||
- [MCP integration](/guide/mcp-integration)
|
||||
- [Git and worktree strategy](/guide/git-worktree-strategy)
|
||||
|
|
@ -25,6 +25,8 @@ Run the runtime binary in a terminal to verify `PATH` and auth. Example: `claude
|
|||
|
||||
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts.
|
||||
|
||||
Contributor/debugging details live in [Contributor Architecture](/reference/contributor-architecture), which links to the canonical agent team debugging runbook.
|
||||
|
||||
Look at the newest launch failure artifact:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -59,11 +59,10 @@ Agent Teams is a free desktop app for orchestrating AI agent teams. You are not
|
|||
|
||||
## Reference
|
||||
|
||||
Use the reference pages when you need exact terminology, provider behavior, or privacy boundaries.
|
||||
Use the reference pages when you need exact terminology, provider behavior, contributor architecture, or privacy boundaries.
|
||||
|
||||
<DocsCardGrid type="reference" />
|
||||
|
||||
## Product preview
|
||||
|
||||
<ZoomImage src="/screenshots/1.jpg" alt="Agent Teams kanban board" caption="Task status, teammate activity, and review workflow stay visible in one workspace." />
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ Messages are durable local records. Delivery still depends on the selected runti
|
|||
|
||||
An agent block is hidden, agent-only instruction text wrapped with `<info_for_agent>...</info_for_agent>`. The UI strips these blocks from normal human-facing display, but agents and runtime delivery can use them for coordination details.
|
||||
|
||||
The current canonical marker is `info_for_agent`; older documents may still contain legacy agent block formats.
|
||||
The current canonical marker is `info_for_agent`. Older documents may use block formats wrapped with `<opencode_agent_info>` or `[AGENT_BLOCK]` markers — these are legacy patterns and should be migrated to `info_for_agent` when encountered.
|
||||
|
||||
## Context Phase
|
||||
|
||||
|
|
|
|||
54
landing/product-docs/reference/contributor-architecture.md
Normal file
54
landing/product-docs/reference/contributor-architecture.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
title: Contributor Architecture – Agent Teams Docs
|
||||
description: Contributor guide to feature layout, runtime/provider boundaries, hard guardrails, and canonical architecture documents.
|
||||
---
|
||||
|
||||
# Contributor Architecture
|
||||
|
||||
This page is a map for contributors. It points to the canonical repo guidance instead of restating every implementation rule.
|
||||
|
||||
## Canonical sources
|
||||
|
||||
Use these files as the source of truth when changing the app:
|
||||
|
||||
| Need | Canonical source |
|
||||
| --- | --- |
|
||||
| Repo overview and commands | [README.md](https://github.com/777genius/agent-teams-ai/blob/main/README.md) |
|
||||
| Local working conventions | [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) |
|
||||
| Hard guardrails | [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) |
|
||||
| Medium and large feature layout | [docs/FEATURE_ARCHITECTURE_STANDARD.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) |
|
||||
| Agent team launch debugging | [docs/team-management/debugging-agent-teams.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) |
|
||||
|
||||
## Feature layout
|
||||
|
||||
Medium and large features should live under `src/features/<feature-name>/` and follow the feature architecture standard. Keep feature internals behind public entrypoints, and avoid deep imports across feature boundaries.
|
||||
|
||||
For new work, start with the existing `src/features/recent-projects` slice as the local reference implementation. Small fixes can stay close to the existing code path when creating a feature slice would add more structure than value.
|
||||
|
||||
## Runtime and provider boundaries
|
||||
|
||||
Agent Teams owns orchestration: teams, tasks, messages, launch state, review UI, diagnostics, and local persistence.
|
||||
|
||||
The selected runtime/provider path owns model execution, auth, model availability, rate limits, tool semantics, and runtime-specific transcript evidence. Do not make prompts or UI state compensate for missing auth, missing binaries, rejected model ids, or provider outages. For user-facing setup details, see [Providers and Runtimes](/reference/providers-runtimes).
|
||||
|
||||
## Agent team debugging
|
||||
|
||||
For launch hangs, OpenCode `registered` / bootstrap-unconfirmed states, missing teammate replies, or suspicious task logs, start from the dedicated debugging runbook. Inspect the newest launch failure artifact under `~/.claude/teams/<team>/launch-failure-artifacts/latest.json`, then correlate UI state with persisted files and runtime-specific evidence.
|
||||
|
||||
Avoid broad cleanup while debugging. Stop only the process, lane, team, or smoke run you can identify as belonging to the issue.
|
||||
|
||||
## Contributor conventions
|
||||
|
||||
- Use `pnpm dev` for the desktop Electron app during normal development.
|
||||
- Do not use browser dev mode as a substitute for desktop runtime, IPC, terminal, provider auth, or team lifecycle behavior.
|
||||
- Keep Electron main, preload, renderer, shared, and feature responsibilities separate.
|
||||
- Use `wrapAgentBlock(text)` for agent-only blocks instead of manually concatenating markers.
|
||||
- Prefer focused verification. Avoid broad `lint:fix` or formatting churn unless the task is explicitly about formatting.
|
||||
- Treat parsing, task lifecycle, provider/runtime detection, persistence, IPC, Git, and review flows as high-risk areas that need targeted tests or a clear verification path.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Runtime setup](/guide/runtime-setup)
|
||||
- [Troubleshooting](/guide/troubleshooting)
|
||||
- [Code review](/guide/code-review)
|
||||
- [Privacy and local data](/reference/privacy-local-data)
|
||||
|
|
@ -41,7 +41,7 @@ No. Agent Teams is not a cloud code-sync service. Provider-backed model calls ma
|
|||
|
||||
## Where are team files stored?
|
||||
|
||||
Team coordination data is stored locally under `~/.claude/teams/<team>/`, task files under `~/.claude/tasks/<team>/`, and project session data under `~/.claude/projects/<encoded-project>/` when available.
|
||||
Team coordination data is stored locally under `~/.claude/teams/<team>/` (macOS/Linux) or `%APPDATA%\Claude\teams\<team>\` (Windows), task files under `~/.claude/tasks/<team>/` or `%APPDATA%\Claude\tasks\<team>\`, and project session data under `~/.claude/projects/<encoded-project>/` when available.
|
||||
|
||||
## What can leave my machine?
|
||||
|
||||
|
|
|
|||
|
|
@ -22,13 +22,16 @@ The desktop app runs on your machine and reads local project/runtime data to pow
|
|||
|
||||
Important local locations include:
|
||||
|
||||
| Location | Purpose |
|
||||
| --- | --- |
|
||||
| `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state, and review-related team files. |
|
||||
| `~/.claude/tasks/<team>/` | Durable task JSON files for the team board. |
|
||||
| `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files used for session history, context analysis, and transcript-backed UI. |
|
||||
| Platform | Location | Purpose |
|
||||
| --- | --- | --- |
|
||||
| macOS/Linux | `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state, and review-related team files. |
|
||||
| Windows | `%APPDATA%\Claude\teams\<team>\` | Same — team config, member metadata, inboxes, launch state, and diagnostics. |
|
||||
| macOS/Linux | `~/.claude/tasks/<team>/` | Durable task JSON files for the team board. |
|
||||
| Windows | `%APPDATA%\Claude\tasks\<team>\` | Same — durable task JSON files. |
|
||||
| macOS/Linux | `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files used for session history, context analysis, and transcript-backed UI. |
|
||||
| Windows | `%APPDATA%\Claude\projects\<encoded-project>\` | Same — project session files. |
|
||||
|
||||
Exact files can vary by runtime and app version. For launch debugging, the newest evidence is usually under the relevant `~/.claude/teams/<team>/` folder.
|
||||
Exact files can vary by runtime and app version. For launch debugging, the newest evidence is usually under the relevant `~/.claude/teams/<team>/` (or `%APPDATA%\Claude\teams\<team>\`) folder.
|
||||
|
||||
## What can leave your machine
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ Agent Teams keeps orchestration provider-aware but not provider-owned:
|
|||
- model availability, auth, rate limits, and tool behavior remain runtime/provider responsibilities
|
||||
- OpenCode is the broadest routing path when you want one team to use multiple provider/model lanes
|
||||
|
||||
For contributor-facing boundaries and canonical implementation guidance, see [Contributor Architecture](/reference/contributor-architecture).
|
||||
|
||||
Recommended patterns:
|
||||
|
||||
| Pattern | When it helps | Risk |
|
||||
|
|
|
|||
41
landing/product-docs/reference/release-notes.md
Normal file
41
landing/product-docs/reference/release-notes.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
title: Release Notes – Agent Teams Docs
|
||||
description: Release notes and changelog for Agent Teams. Links to the canonical RELEASE.md and CHANGELOG.md for full details.
|
||||
---
|
||||
|
||||
# Release Notes
|
||||
|
||||
Current version: **v1.2.0** (2026-03-31)
|
||||
|
||||
## How releases work
|
||||
|
||||
Agent Teams follows [Semantic Versioning](https://semver.org/). Tags pushed to the repository trigger an automated [release workflow](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md) that builds signed packages for macOS, Windows, and Linux, then publishes them to GitHub Releases.
|
||||
|
||||
## Recent releases
|
||||
|
||||
### v1.2.0 — Agent Graph, per-team tool approval, interactive AskUserQuestion
|
||||
|
||||
Agent Graph with force-directed visualization and kanban task layout, per-team tool approval controls with readable permission prompts, task comment notifications, and interactive AskUserQuestion buttons. Permission system overhaul with Write/Edit/NotebookEdit seeding and MCP tool catalog integration. See [full changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#120---2026-03-31).
|
||||
|
||||
### v1.1.0 — React 19 + Electron 40, user-initiated task starts
|
||||
|
||||
React 19 + Electron 40 migration, user-initiated task starts from the kanban board, auth troubleshooting guide, syntax highlighting for R/Ruby/PHP/SQL, 3x faster transcript search, WSL/Windows path fixes, and XSS vulnerability fix. See [full changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#110---2026-03-25).
|
||||
|
||||
### v1.0.0 — Initial public release
|
||||
|
||||
First stable build: CLI/auth reliability in packaged apps, IPC hardening, cross-platform packaging with signed macOS builds, open-source governance docs (LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY). See [full changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#100---2026-03-23).
|
||||
|
||||
## Canonical sources
|
||||
|
||||
| Document | Description |
|
||||
| --- | --- |
|
||||
| [RELEASE.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md) | Release process, versioning guide, artifact naming, auto-update setup, and release notes template. |
|
||||
| [CHANGELOG.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md) | Full changelog with all versions, features, improvements, and bug fixes from the user perspective. |
|
||||
| [GitHub Releases](https://github.com/777genius/agent-teams-ai/releases) | Downloadable installers for all platforms. |
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Installation](/guide/installation)
|
||||
- [Quickstart](/guide/quickstart)
|
||||
- [Contributor architecture](/reference/contributor-architecture)
|
||||
- [Developers](/developers/)
|
||||
67
landing/product-docs/ru/developers/index.md
Normal file
67
landing/product-docs/ru/developers/index.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
title: Разработчикам - Agent Teams Docs
|
||||
description: Входная страница для contributor docs, архитектуры, guardrails, debugging и MCP extension paths в Agent Teams.
|
||||
---
|
||||
|
||||
# Разработчикам
|
||||
|
||||
Эта страница нужна, когда вы меняете Agent Teams, разбираете зависший запуск команды или расширяете runtime через MCP tools. Ссылки ведут в canonical repo docs, чтобы правила реализации не расходились между страницами.
|
||||
|
||||
## С чего начать
|
||||
|
||||
| Нужно | Открыть |
|
||||
| --- | --- |
|
||||
| Обзор репозитория, scripts и setup из исходников | [README.md](https://github.com/777genius/agent-teams-ai/blob/main/README.md) |
|
||||
| Рабочие правила для агентов и contributors | [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) |
|
||||
| Жёсткие implementation guardrails | [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) |
|
||||
| Структура medium и large features | [Feature architecture standard](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) |
|
||||
| Debugging launch, bootstrap и teammate messaging | [Agent team debugging runbook](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) |
|
||||
| Contribution process | [Contributing guide](https://github.com/777genius/agent-teams-ai/blob/main/.github/CONTRIBUTING.md) |
|
||||
| Релизы / Changelog | [RELEASE.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md) — [CHANGELOG.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md) |
|
||||
|
||||
## Локальный development path
|
||||
|
||||
Для обычной разработки запускайте desktop Electron app:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Browser/web path не заменяет desktop runtime. Desktop mode - поддерживаемый локальный путь, потому что в нём есть IPC, terminals, provider auth, team lifecycle handling, launch diagnostics и runtime bridges, которые используют реальные команды.
|
||||
|
||||
## Architecture checkpoints
|
||||
|
||||
Перед изменением feature определите её границу:
|
||||
|
||||
| Область | Ожидаемое место |
|
||||
| --- | --- |
|
||||
| Medium или large product feature | `src/features/<feature-name>/` |
|
||||
| Electron main process orchestration | `src/main/` |
|
||||
| Preload-safe API surface | `src/preload/` |
|
||||
| Renderer UI и app state | `src/renderer/` |
|
||||
| Shared types и pure helpers | `src/shared/` |
|
||||
| Agent Teams board MCP server | `mcp-server/` |
|
||||
| Board data controller | `agent-teams-controller/` |
|
||||
|
||||
Используйте `src/features/recent-projects` как reference slice для feature organization. Держите cross-process contracts явными и не делайте deep imports через feature boundaries.
|
||||
|
||||
## Debugging path
|
||||
|
||||
Для launch hangs, OpenCode `registered` / bootstrap-unconfirmed states, missing teammate replies или suspicious task logs:
|
||||
|
||||
1. Начните с [debugging runbook](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md).
|
||||
2. Проверьте самый новый artifact pack в `~/.claude/teams/<team>/launch-failure-artifacts/latest.json`.
|
||||
3. Откройте `manifest.json` и посмотрите `classification`, bootstrap breadcrumbs, launch diagnostics, member spawn statuses и redacted log tails.
|
||||
4. Очищайте только team, run, pane или process, который точно принадлежит smoke test или failed launch.
|
||||
|
||||
## MCP development path
|
||||
|
||||
Agent Teams использует встроенный MCP server `agent-teams` для board operations. User и project MCP servers добавляют внешние capabilities для runtimes. См. [MCP integration](/ru/guide/mcp-integration) для setup examples, структуры `.mcp.json` и tool registration guidance.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Contributor architecture](/ru/reference/contributor-architecture)
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup)
|
||||
- [MCP integration](/ru/guide/mcp-integration)
|
||||
- [Диагностика](/ru/guide/troubleshooting)
|
||||
|
|
@ -87,6 +87,8 @@ Team lead — ревьюер по умолчанию. Вы можете наст
|
|||
- **Parsing и task lifecycle logic** — изменения в task references, chunking или filtering могут сломать доставку сообщений
|
||||
- **Persistence и code review flows** — изменения в хранении задач или review state должны оставаться консистентными через IPC layers
|
||||
|
||||
Canonical feature layout и hard guardrail links смотрите в [Архитектуре для контрибьюторов](/ru/reference/contributor-architecture).
|
||||
|
||||
## Верификация
|
||||
|
||||
Лучше запускать focused verification commands. Broad formatting или lint-fix команды не стоит использовать, если задача явно не про форматирование.
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ lang: ru-RU
|
|||
Микс провайдеров в одной команде поддерживается — например, Claude lead с OpenCode builder-ами.
|
||||
|
||||
::: info
|
||||
Поддержка Gemini в разработке и появится в списке провайдеров, когда будет готова.
|
||||
Поддержка провайдера Gemini в разработке. Текущий статус провайдеров смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes).
|
||||
:::
|
||||
|
||||
## Хороший team brief
|
||||
|
|
|
|||
98
landing/product-docs/ru/guide/git-worktree-strategy.md
Normal file
98
landing/product-docs/ru/guide/git-worktree-strategy.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
---
|
||||
title: Git and Worktree Strategy - Agent Teams Docs
|
||||
description: Как выбирать main worktree, feature branches или OpenCode worktree isolation для parallel agent work.
|
||||
---
|
||||
|
||||
# Git and Worktree Strategy
|
||||
|
||||
Git даёт Agent Teams самый сильный review path: narrow diffs, branch visibility, task-scoped changes и более безопасную parallel work.
|
||||
|
||||
## Choose a strategy
|
||||
|
||||
| Strategy | Когда использовать | Tradeoff |
|
||||
| --- | --- | --- |
|
||||
| Main worktree | Solo work, docs-only edits или один teammate за раз | Просто, но parallel edits могут конфликтовать |
|
||||
| Feature branch | Одна team работает над одним coherent change | Чистый review target, но teammates всё ещё делят files |
|
||||
| Worktree isolation | Несколько OpenCode teammates могут параллельно менять один repo | Лучше isolation, но merge/review требует дисциплины |
|
||||
|
||||
Начинайте просто. Включайте worktree isolation, когда parallel edits вероятны, а не потому что каждому task нужен отдельный checkout.
|
||||
|
||||
## When to enable worktree isolation
|
||||
|
||||
Включайте для OpenCode teammates, когда:
|
||||
|
||||
- два или больше teammates могут менять один repository одновременно
|
||||
- task может запускать formatters, code generators или broad tests
|
||||
- нужно держать branch и diff каждого teammate отдельно
|
||||
- lead workspace dirty и не должен получать прямые edits
|
||||
|
||||
Оставляйте выключенным, когда:
|
||||
|
||||
- task read-only
|
||||
- один teammate владеет всеми edits
|
||||
- repo не Git-tracked
|
||||
- нужен runtime path, который не поддерживает этот isolation mode
|
||||
|
||||
::: warning
|
||||
Worktree isolation сейчас применяется к OpenCode members и требует Git-tracked project.
|
||||
:::
|
||||
|
||||
## Branch hygiene
|
||||
|
||||
Перед parallel work:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
По возможности используйте clean branch. Если main worktree уже содержит user changes, скажите agents не revert unrelated files и держать task scope узким.
|
||||
|
||||
Рекомендуемый branch style:
|
||||
|
||||
```text
|
||||
agent/<team-or-task>/<short-purpose>
|
||||
```
|
||||
|
||||
Примеры:
|
||||
|
||||
```text
|
||||
agent/docs/mcp-guide
|
||||
agent/review/task-log-filtering
|
||||
agent/ui/code-review-polish
|
||||
```
|
||||
|
||||
## Review flow
|
||||
|
||||
Для isolated worktrees проверяйте diff teammate до merge или apply в main workspace.
|
||||
|
||||
1. Убедитесь, что task result comment называет changed scope и verification.
|
||||
2. Проверьте task diff в review UI.
|
||||
3. Запросите changes в task, если diff трогает unrelated files.
|
||||
4. Approve только когда tests или manual checks соответствуют risk.
|
||||
5. Merge или apply changes осознанно.
|
||||
|
||||
Не auto-merge worktree output только потому, что task complete. Completion значит, что agent считает работу ready for review.
|
||||
|
||||
## Conflict policy
|
||||
|
||||
| Situation | Action |
|
||||
| --- | --- |
|
||||
| Два teammates меняют один file | Pause one task или назначьте одного owner для integration |
|
||||
| Generated files changed broadly | Требуйте comment с generator и command |
|
||||
| Main worktree имеет unrelated changes | Preserve them и review только task-owned changes |
|
||||
| Worktree branch diverges | Rebase или merge manually после review, не внутри vague agent task |
|
||||
|
||||
## Task prompt example
|
||||
|
||||
```text
|
||||
Implement the settings validation fix in your assigned worktree. Keep edits inside src/features/settings and focused tests. Do not touch provider auth or task storage. Post the test command and result before completing the task.
|
||||
```
|
||||
|
||||
Этот prompt работает, потому что называет allowed area, sensitive boundaries и completion evidence.
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Создание команды](/ru/guide/create-team)
|
||||
- [Код-ревью](/ru/guide/code-review)
|
||||
- [Team brief examples](/ru/guide/team-brief-examples)
|
||||
|
|
@ -35,7 +35,7 @@ Agent Teams распространяется как desktop-приложение
|
|||
| OpenCode | API key для поддерживаемого бэкенда (например, OpenRouter) |
|
||||
|
||||
::: info
|
||||
Поддержка провайдера Gemini в разработке. Вы можете подготовить доступ сейчас, но он не появится в редакторе команды, пока не будет готов.
|
||||
Поддержка провайдера Gemini в разработке. Текущий статус всех провайдеров смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes).
|
||||
:::
|
||||
|
||||
Для запуска из исходников также нужны:
|
||||
|
|
|
|||
101
landing/product-docs/ru/guide/mcp-integration.md
Normal file
101
landing/product-docs/ru/guide/mcp-integration.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
title: MCP Integration - Agent Teams Docs
|
||||
description: Как использовать MCP в Agent Teams для board operations, координации teammates и внешних tool servers.
|
||||
---
|
||||
|
||||
# MCP Integration
|
||||
|
||||
Agent Teams использует MCP двумя практическими способами:
|
||||
|
||||
| Слой | Что делает | Кто использует |
|
||||
| --- | --- | --- |
|
||||
| Board MCP tools | Создают, стартуют, комментируют, завершают и читают tasks | Agents и leads |
|
||||
| External MCP servers | Добавляют инструменты вроде browser, design, docs или company systems | Users и настроенные runtimes |
|
||||
|
||||
Держите эти слои отдельно. Board MCP нужен для координации внутри Agent Teams. External MCP servers - это дополнительные инструменты для runtimes.
|
||||
|
||||
## Board MCP workflow
|
||||
|
||||
Agents должны использовать board MCP tools, когда работа относится к task:
|
||||
|
||||
1. Прочитать свежий task context.
|
||||
2. Стартовать task только когда реально начинают работу.
|
||||
3. Добавлять task comments для blockers, plan и final results.
|
||||
4. Завершать task после result comment.
|
||||
5. Отправлять короткое сообщение, если lead или teammate должен увидеть результат.
|
||||
|
||||
Пример flow:
|
||||
|
||||
```text
|
||||
task_get -> task_start -> edit/test -> task_add_comment -> task_complete -> message_send
|
||||
```
|
||||
|
||||
Direct message подходит для координации. Task comment подходит для durable task history.
|
||||
|
||||
::: tip
|
||||
Если заметка влияет на review, verification, changed scope или blocker, пишите её в task.
|
||||
:::
|
||||
|
||||
## External MCP servers
|
||||
|
||||
Используйте external MCP servers, когда teammate нужен устойчивый tool connection, а не один prompt с pasted context.
|
||||
|
||||
Хорошие случаи:
|
||||
|
||||
- browser или website testing tools
|
||||
- design или product data tools
|
||||
- internal docs и search systems
|
||||
- issue tracker или support systems
|
||||
- database inspection tools с read-only credentials
|
||||
|
||||
Плохие случаи:
|
||||
|
||||
- secrets, вставленные в prompts
|
||||
- one-off files, которые проще attached напрямую
|
||||
- tools, которые меняют production systems без review
|
||||
- широкий local filesystem access, когда достаточно project scope
|
||||
|
||||
## Scopes
|
||||
|
||||
Agent Teams распознаёт shared и project-oriented MCP scopes.
|
||||
|
||||
| Scope | Когда использовать |
|
||||
| --- | --- |
|
||||
| User или Global | Один server нужен в разных projects |
|
||||
| Project или Local | Server относится к одному repository, workspace или team context |
|
||||
|
||||
Выбирайте самый узкий scope, который всё ещё удобен. Project-scoped servers легче проверять на review, потому что tool привязан к изменяемому project.
|
||||
|
||||
## Setup checklist
|
||||
|
||||
Перед task, который зависит от MCP server:
|
||||
|
||||
1. Установите или настройте server.
|
||||
2. Проверьте, что он виден в installed MCP list.
|
||||
3. Запустите diagnostics, если app их предлагает.
|
||||
4. Начните с low-risk read-only task.
|
||||
5. Укажите ожидаемый MCP tool use в task description или team brief.
|
||||
|
||||
Если diagnostics падают, сначала чините setup. Лучший prompt не исправит missing command, неправильный config path или rejected credentials.
|
||||
|
||||
## Task example
|
||||
|
||||
```text
|
||||
Audit the docs home page with the browser MCP. Check desktop and mobile widths, capture any layout issue as a task comment, and only edit landing/product-docs files. Run the docs build before completion.
|
||||
```
|
||||
|
||||
Такой task работает, потому что называет tool, surface, write boundary и verification step.
|
||||
|
||||
## Safety rules
|
||||
|
||||
- Не выдавайте каждому teammate все MCP servers по умолчанию.
|
||||
- Не добавляйте write-capable tools в broad teams без review.
|
||||
- Для inspection tasks предпочитайте read-only credentials.
|
||||
- Production-impacting tool use фиксируйте через explicit task comments и review.
|
||||
- MCP diagnostic failures считайте setup failures, а не agent failures.
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Runtime setup](/ru/guide/runtime-setup)
|
||||
- [Team brief examples](/ru/guide/team-brief-examples)
|
||||
- [Работа агентов](/ru/guide/agent-workflow)
|
||||
|
|
@ -16,6 +16,10 @@ lang: ru-RU
|
|||
Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру — подробности в разделе [Установка](/ru/guide/installation).
|
||||
:::
|
||||
|
||||
::: info
|
||||
Desktop-приложение — основной продукт. Agent Teams также работает в браузере для разработки, но браузерный режим не имеет полного desktop IPC, терминала, provider auth и lifecycle. Для обычной разработки используйте `pnpm dev` (Electron), а не браузерный режим.
|
||||
:::
|
||||
|
||||
## 2. Откройте проект
|
||||
|
||||
Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные файлы проекта и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды.
|
||||
|
|
@ -43,7 +47,7 @@ git status --short
|
|||
| OpenCode | Для multi-model команд и большого числа provider backends |
|
||||
|
||||
::: info
|
||||
Поддержка Gemini в разработке и появится в списке рантаймов, когда будет готова.
|
||||
Поддержка провайдера Gemini в разработке. Текущий статус провайдеров смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes).
|
||||
:::
|
||||
|
||||
Подробная настройка каждого провайдера — в разделе [Настройка рантайма](/ru/guide/runtime-setup).
|
||||
|
|
|
|||
112
landing/product-docs/ru/guide/team-brief-examples.md
Normal file
112
landing/product-docs/ru/guide/team-brief-examples.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
title: Team Brief Examples - Agent Teams Docs
|
||||
description: Практические шаблоны team brief для small fixes, docs work, implementation tasks, review и risky areas.
|
||||
---
|
||||
|
||||
# Team Brief Examples
|
||||
|
||||
Хороший team brief даёт lead достаточно структуры, чтобы создать small tasks, но не требует заранее расписать каждую деталь реализации.
|
||||
|
||||
Используйте форму:
|
||||
|
||||
```text
|
||||
Outcome:
|
||||
Scope:
|
||||
Boundaries:
|
||||
Coordination:
|
||||
Verification:
|
||||
Review:
|
||||
```
|
||||
|
||||
## Minimal brief
|
||||
|
||||
Для маленькой low-risk работы.
|
||||
|
||||
```text
|
||||
Outcome: Improve the quickstart so a new user can launch one team successfully.
|
||||
Scope: Keep edits inside landing/product-docs.
|
||||
Boundaries: Do not rewrite the whole docs structure.
|
||||
Coordination: Create one or two tasks, keep comments on the task.
|
||||
Verification: Run the docs build.
|
||||
Review: Summarize changed pages and any remaining gaps.
|
||||
```
|
||||
|
||||
## Implementation brief
|
||||
|
||||
Для code changes внутри одной feature area.
|
||||
|
||||
```text
|
||||
Outcome: Add a focused improvement to task comment filtering.
|
||||
Scope: Work inside the task/comment feature files unless a shared helper is clearly needed.
|
||||
Boundaries: Do not change task storage format or review state semantics.
|
||||
Coordination: Split parser, UI, and tests into separate tasks if they can be reviewed independently.
|
||||
Verification: Run the focused unit tests first, then the feature typecheck if touched.
|
||||
Review: Call out parsing edge cases and any behavior that affects existing task comments.
|
||||
```
|
||||
|
||||
## Docs brief
|
||||
|
||||
Для documentation и guide work.
|
||||
|
||||
```text
|
||||
Outcome: Draft practical workflow guides from the docs audit.
|
||||
Scope: Add concise VitePress pages under landing/product-docs/guide.
|
||||
Boundaries: Avoid moving existing navigation hubs owned by other tasks.
|
||||
Coordination: Check related docs tasks before editing nav.
|
||||
Verification: Run the VitePress docs build.
|
||||
Review: Include links added to sidebar and any pages intentionally left as drafts.
|
||||
```
|
||||
|
||||
## Review-heavy brief
|
||||
|
||||
Для risky areas: IPC, provider auth, persistence, Git или task lifecycle logic.
|
||||
|
||||
```text
|
||||
Outcome: Fix the launch failure without changing successful launch behavior.
|
||||
Scope: Start from the newest launch-failure artifact and the affected runtime adapter.
|
||||
Boundaries: Do not change provider prompts until setup and runtime evidence are inspected.
|
||||
Coordination: Make one diagnostic task and one fix task if the cause is confirmed.
|
||||
Verification: Run focused tests and one desktop smoke check when practical.
|
||||
Review: Lead must inspect the diff before approval.
|
||||
```
|
||||
|
||||
## Mixed provider brief
|
||||
|
||||
Когда teammates работают на разных provider/model lanes.
|
||||
|
||||
```text
|
||||
Outcome: Implement and review a small feature using separate builder and reviewer lanes.
|
||||
Scope: Builder edits the feature. Reviewer inspects only the task diff and tests.
|
||||
Boundaries: Do not switch model ids mid-task unless launch fails before work begins.
|
||||
Coordination: Builder posts result comment first. Reviewer posts findings as task comments.
|
||||
Verification: Builder runs focused tests. Reviewer checks failure output and changed scope.
|
||||
Review: Lead approves only after reviewer comments are resolved.
|
||||
```
|
||||
|
||||
## What to avoid
|
||||
|
||||
| Weak brief | Better replacement |
|
||||
| --- | --- |
|
||||
| "Improve the app" | Назовите workflow, files и success check |
|
||||
| "Fix all docs" | Выберите одну guide group и build command |
|
||||
| "Use the best model" | Назовите provider/model choices или оставьте app defaults |
|
||||
| "Refactor as needed" | Укажите modules, которые можно менять |
|
||||
| "Make it production ready" | Определите review, tests и rollout checks |
|
||||
|
||||
## Before launch
|
||||
|
||||
Проверьте перед стартом:
|
||||
|
||||
1. Brief называет concrete outcome.
|
||||
2. Risk boundaries explicit.
|
||||
3. Lead может разделить работу на reviewable tasks.
|
||||
4. Verification commands указаны, если известны.
|
||||
5. Sensitive areas требуют review before approval.
|
||||
|
||||
Если brief всё ещё широкий, запустите solo или small team и попросите сначала task plan, а не implementation.
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Создание команды](/ru/guide/create-team)
|
||||
- [MCP integration](/ru/guide/mcp-integration)
|
||||
- [Git and worktree strategy](/ru/guide/git-worktree-strategy)
|
||||
|
|
@ -31,6 +31,8 @@ lang: ru-RU
|
|||
|
||||
Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала inspect artifacts, прежде чем менять team prompts.
|
||||
|
||||
Contributor/debugging details находятся в [Архитектуре для контрибьюторов](/ru/reference/contributor-architecture), где есть ссылка на canonical debugging runbook для agent teams.
|
||||
|
||||
Посмотрите на последний artifact неудачного запуска:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -59,11 +59,10 @@ Agent Teams - бесплатное desktop-приложение для орке
|
|||
|
||||
## Справочник
|
||||
|
||||
Используйте справочник, когда нужны точные термины, поведение провайдеров или границы приватности.
|
||||
Используйте справочник, когда нужны точные термины, поведение провайдеров, contributor architecture или границы приватности.
|
||||
|
||||
<DocsCardGrid type="reference" />
|
||||
|
||||
## Превью продукта
|
||||
|
||||
<ZoomImage src="/screenshots/1.jpg" alt="Канбан-доска Agent Teams" caption="Статусы задач, активность агентов и review workflow видны в одном рабочем пространстве." />
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ Messages - долговечные локальные записи. Но дост
|
|||
|
||||
Agent Block - скрытый agent-only instruction text, обёрнутый в `<info_for_agent>...</info_for_agent>`. UI убирает такие блоки из обычного human-facing display, но agents и runtime delivery могут использовать их для coordination details.
|
||||
|
||||
Текущий canonical marker - `info_for_agent`; в старых документах могут встречаться legacy agent block formats.
|
||||
Текущий canonical marker - `info_for_agent`. В старых документах могут встречаться block formats с маркерами `<opencode_agent_info>` или `[AGENT_BLOCK]` — это устаревшие паттерны, которые стоит заменить на `info_for_agent` при встрече.
|
||||
|
||||
## Context Phase
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
title: Архитектура для контрибьюторов – Документация Agent Teams
|
||||
description: Карта для контрибьюторов по feature layout, runtime/provider boundaries, hard guardrails и canonical architecture docs.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Архитектура для контрибьюторов
|
||||
|
||||
Эта страница - карта для контрибьюторов. Она ведёт к canonical repo guidance и не дублирует все implementation rules.
|
||||
|
||||
## Канонические источники
|
||||
|
||||
Используйте эти файлы как source of truth при изменениях в приложении:
|
||||
|
||||
| Нужно | Канонический источник |
|
||||
| --- | --- |
|
||||
| Обзор репозитория и команды | [README.md](https://github.com/777genius/agent-teams-ai/blob/main/README.md) |
|
||||
| Локальные рабочие conventions | [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) |
|
||||
| Жёсткие guardrails | [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) |
|
||||
| Layout средних и больших features | [docs/FEATURE_ARCHITECTURE_STANDARD.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) |
|
||||
| Диагностика запуска agent teams | [docs/team-management/debugging-agent-teams.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) |
|
||||
|
||||
## Feature layout
|
||||
|
||||
Средние и большие features должны жить в `src/features/<feature-name>/` и следовать feature architecture standard. Держите internals за public entrypoints и не делайте deep imports через границы feature.
|
||||
|
||||
Для новой работы ориентируйтесь на существующий slice `src/features/recent-projects`. Маленькие fixes можно оставлять рядом с текущим code path, если новый feature slice добавит больше структуры, чем пользы.
|
||||
|
||||
## Runtime и provider boundaries
|
||||
|
||||
Agent Teams отвечает за orchestration: teams, tasks, messages, launch state, review UI, diagnostics и local persistence.
|
||||
|
||||
Выбранный runtime/provider path отвечает за model execution, auth, model availability, rate limits, tool semantics и runtime-specific transcript evidence. Не пытайтесь чинить prompts или UI state вместо missing auth, missing binaries, rejected model ids или provider outages. User-facing детали настройки смотрите в [Провайдерах и рантаймах](/ru/reference/providers-runtimes).
|
||||
|
||||
## Диагностика agent teams
|
||||
|
||||
При launch hangs, OpenCode `registered` / bootstrap-unconfirmed states, missing teammate replies или подозрительных task logs начинайте с dedicated debugging runbook. Сначала смотрите newest launch failure artifact в `~/.claude/teams/<team>/launch-failure-artifacts/latest.json`, затем сопоставляйте UI state с persisted files и runtime-specific evidence.
|
||||
|
||||
Не делайте broad cleanup во время диагностики. Останавливайте только process, lane, team или smoke run, который точно относится к проблеме.
|
||||
|
||||
## Contributor conventions
|
||||
|
||||
- Используйте `pnpm dev` для desktop Electron app при обычной разработке.
|
||||
- Не используйте browser dev mode как замену desktop runtime, IPC, terminal, provider auth или team lifecycle behavior.
|
||||
- Разделяйте ответственности Electron main, preload, renderer, shared и features.
|
||||
- Используйте `wrapAgentBlock(text)` для agent-only blocks вместо ручной склейки markers.
|
||||
- Предпочитайте focused verification. Избегайте broad `lint:fix` или formatting churn, если задача не про formatting.
|
||||
- Parsing, task lifecycle, provider/runtime detection, persistence, IPC, Git и review flows считайте high-risk зонами, где нужны targeted tests или clear verification path.
|
||||
|
||||
## Связанные страницы
|
||||
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup)
|
||||
- [Диагностика](/ru/guide/troubleshooting)
|
||||
- [Код-ревью](/ru/guide/code-review)
|
||||
- [Приватность и локальные данные](/ru/reference/privacy-local-data)
|
||||
|
|
@ -47,7 +47,7 @@ opencode --version
|
|||
|
||||
## Где хранятся team files?
|
||||
|
||||
Team coordination data хранится локально в `~/.claude/teams/<team>/`, task files - в `~/.claude/tasks/<team>/`, а project session data - в `~/.claude/projects/<encoded-project>/`, когда она доступна.
|
||||
Team coordination data хранится локально в `~/.claude/teams/<team>/` (macOS/Linux) или `%APPDATA%\Claude\teams\<team>\` (Windows), task files - в `~/.claude/tasks/<team>/` или `%APPDATA%\Claude\tasks\<team>\`, а project session data - в `~/.claude/projects/<encoded-project>/`, когда она доступна.
|
||||
|
||||
## Что может выйти с моей машины?
|
||||
|
||||
|
|
|
|||
|
|
@ -28,13 +28,16 @@ Desktop app работает на вашей машине и читает local
|
|||
|
||||
Важные local locations:
|
||||
|
||||
| Location | Purpose |
|
||||
| --- | --- |
|
||||
| `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state и review-related team files. |
|
||||
| `~/.claude/tasks/<team>/` | Durable task JSON files для team board. |
|
||||
| `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files для session history, context analysis и transcript-backed UI. |
|
||||
| Платформа | Location | Purpose |
|
||||
| --- | --- | --- |
|
||||
| macOS/Linux | `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state и review-related team files. |
|
||||
| Windows | `%APPDATA%\Claude\teams\<team>\` | То же — team config, member metadata, inboxes, launch state и diagnostics. |
|
||||
| macOS/Linux | `~/.claude/tasks/<team>/` | Durable task JSON files для team board. |
|
||||
| Windows | `%APPDATA%\Claude\tasks\<team>\` | То же — durable task JSON files. |
|
||||
| macOS/Linux | `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files для session history, context analysis и transcript-backed UI. |
|
||||
| Windows | `%APPDATA%\Claude\projects\<encoded-project>\` | То же — project session files. |
|
||||
|
||||
Точные файлы зависят от runtime и версии app. Для launch debugging самые свежие evidence обычно лежат в соответствующей папке `~/.claude/teams/<team>/`.
|
||||
Точные файлы зависят от runtime и версии app. Для launch debugging самые свежие evidence обычно лежат в соответствующей папке `~/.claude/teams/<team>/` (или `%APPDATA%\Claude\teams\<team>\`).
|
||||
|
||||
## Что может выйти с машины
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ Agent Teams остаётся provider-aware, но не provider-owned:
|
|||
- model availability, auth, rate limits и tool behavior остаются ответственностью runtime/provider
|
||||
- OpenCode - основной путь, когда одной team нужны разные provider/model lanes
|
||||
|
||||
Contributor-facing границы и canonical implementation guidance смотрите в [Архитектуре для контрибьюторов](/ru/reference/contributor-architecture).
|
||||
|
||||
Рекомендуемые patterns:
|
||||
|
||||
| Pattern | When it helps | Risk |
|
||||
|
|
|
|||
42
landing/product-docs/ru/reference/release-notes.md
Normal file
42
landing/product-docs/ru/reference/release-notes.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
title: Релизы – Документация Agent Teams
|
||||
description: Release notes и changelog для Agent Teams. Ссылки на канонические RELEASE.md и CHANGELOG.md.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Релизы
|
||||
|
||||
Текущая версия: **v1.2.0** (2026-03-31)
|
||||
|
||||
## Как публикуются релизы
|
||||
|
||||
Agent Teams следует [Semantic Versioning](https://semver.org/). Пуш тега в репозиторий запускает автоматический [release workflow](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md), который собирает подписанные пакеты для macOS, Windows и Linux и публикует их в GitHub Releases.
|
||||
|
||||
## Последние релизы
|
||||
|
||||
### v1.2.0 — Agent Graph, per-team tool approval, interactive AskUserQuestion
|
||||
|
||||
Agent Graph с force-directed визуализацией и kanban layout, per-team tool approval controls с понятными permission prompts, уведомления о комментариях к задачам и интерактивные AskUserQuestion кнопки. Permission system overhaul с Write/Edit/NotebookEdit seeding и MCP tool catalog. Полный [changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#120---2026-03-31).
|
||||
|
||||
### v1.1.0 — React 19 + Electron 40, user-initiated task starts
|
||||
|
||||
React 19 + Electron 40 migration, запуск задач пользователем с kanban board, auth troubleshooting guide, подсветка синтаксиса для R/Ruby/PHP/SQL, ускорение поиска транскриптов в 3 раза, исправления WSL/Windows paths и XSS vulnerability. Полный [changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#110---2026-03-25).
|
||||
|
||||
### v1.0.0 — Первый публичный релиз
|
||||
|
||||
Первый стабильный билд: надёжность CLI/auth в packaged apps, IPC hardening, cross-platform packaging с подписанными macOS сборками, open-source governance docs (LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY). Полный [changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#100---2026-03-23).
|
||||
|
||||
## Канонические источники
|
||||
|
||||
| Документ | Описание |
|
||||
| --- | --- |
|
||||
| [RELEASE.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md) | Процесс релиза, версионирование, имена артефактов, auto-update setup и шаблон release notes. |
|
||||
| [CHANGELOG.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md) | Полный changelog со всеми версиями, фичами, улучшениями и исправлениями. |
|
||||
| [GitHub Releases](https://github.com/777genius/agent-teams-ai/releases) | Установочные файлы для всех платформ. |
|
||||
|
||||
## Связанные страницы
|
||||
|
||||
- [Установка](/ru/guide/installation)
|
||||
- [Быстрый старт](/ru/guide/quickstart)
|
||||
- [Архитектура для контрибьюторов](/ru/reference/contributor-architecture)
|
||||
- [Разработчикам](/ru/developers/)
|
||||
|
|
@ -38,6 +38,7 @@
|
|||
"dist:mac:x64": "electron-builder --mac --x64",
|
||||
"dist:win": "electron-builder --win",
|
||||
"dist:linux": "electron-builder --linux",
|
||||
"smoke:packaged": "node ./scripts/electron-builder/smokePackagedApp.cjs",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:workspace": "pnpm typecheck && pnpm --filter agent-teams-mcp typecheck && pnpm --filter agent-teams-mcp typecheck:test",
|
||||
|
|
@ -147,10 +148,12 @@
|
|||
"diff": "^8.0.3",
|
||||
"dompurify": "^3.4.2",
|
||||
"electron-updater": "^6.7.3",
|
||||
"fast-json-stringify": "^6.4.0",
|
||||
"fastify": "^5.8.5",
|
||||
"highlight.js": "^11.11.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"isbinaryfile": "^6.0.0",
|
||||
"json-schema-ref-resolver": "^3.0.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"mermaid": "^11.15.0",
|
||||
|
|
|
|||
|
|
@ -213,6 +213,9 @@ importers:
|
|||
electron-updater:
|
||||
specifier: ^6.7.3
|
||||
version: 6.7.3
|
||||
fast-json-stringify:
|
||||
specifier: ^6.4.0
|
||||
version: 6.4.0
|
||||
fastify:
|
||||
specifier: ^5.8.5
|
||||
version: 5.8.5
|
||||
|
|
@ -225,6 +228,9 @@ importers:
|
|||
isbinaryfile:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
json-schema-ref-resolver:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.4)
|
||||
|
|
@ -6792,8 +6798,8 @@ packages:
|
|||
fast-json-stable-stringify@2.1.0:
|
||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||
|
||||
fast-json-stringify@6.3.0:
|
||||
resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==}
|
||||
fast-json-stringify@6.4.0:
|
||||
resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==}
|
||||
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
|
@ -12337,7 +12343,7 @@ snapshots:
|
|||
|
||||
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||
dependencies:
|
||||
fast-json-stringify: 6.3.0
|
||||
fast-json-stringify: 6.4.0
|
||||
|
||||
'@fastify/forwarded@3.0.1': {}
|
||||
|
||||
|
|
@ -18240,7 +18246,7 @@ snapshots:
|
|||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
|
||||
fast-json-stringify@6.3.0:
|
||||
fast-json-stringify@6.4.0:
|
||||
dependencies:
|
||||
'@fastify/merge-json-schemas': 0.2.1
|
||||
ajv: 8.18.0
|
||||
|
|
@ -18269,7 +18275,7 @@ snapshots:
|
|||
'@fastify/proxy-addr': 5.1.0
|
||||
abstract-logging: 2.0.1
|
||||
avvio: 9.1.0
|
||||
fast-json-stringify: 6.3.0
|
||||
fast-json-stringify: 6.4.0
|
||||
find-my-way: 9.4.0
|
||||
light-my-request: 6.6.0
|
||||
pino: 10.3.1
|
||||
|
|
|
|||
1
resources/runtime/COMMIT_SHA
Normal file
1
resources/runtime/COMMIT_SHA
Normal file
|
|
@ -0,0 +1 @@
|
|||
4968c54bb28f62ce55220de3437fa6d610729736
|
||||
1
resources/runtime/VERSION
Normal file
1
resources/runtime/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.0.32
|
||||
BIN
resources/runtime/claude-multimodel
Executable file
BIN
resources/runtime/claude-multimodel
Executable file
Binary file not shown.
130
scripts/electron-builder/smokePackagedApp.cjs
Normal file
130
scripts/electron-builder/smokePackagedApp.cjs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const STARTUP_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_TIMEOUT_MS ?? 30_000);
|
||||
const POST_STARTUP_STABLE_MS = Number(process.env.PACKAGED_SMOKE_STABLE_MS ?? 8_000);
|
||||
const REQUIRED_LOG_MARKERS = ['renderer did-finish-load'];
|
||||
const FAILURE_PATTERNS = [
|
||||
/Cannot find module/i,
|
||||
/MODULE_NOT_FOUND/i,
|
||||
/Failed to start HTTP server/i,
|
||||
/Unable to set login item/i,
|
||||
/\[DEP0180\]/i,
|
||||
/DeprecationWarning: fs\.Stats constructor is deprecated/i,
|
||||
];
|
||||
|
||||
function fail(message, log = '') {
|
||||
console.error(`[smokePackagedApp] ${message}`);
|
||||
if (log.trim()) {
|
||||
console.error('--- packaged app log ---');
|
||||
console.error(log.trim());
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function findExecutable(bundlePath, platform) {
|
||||
if (platform === 'darwin') {
|
||||
const macOsDir = path.join(bundlePath, 'Contents', 'MacOS');
|
||||
const entries = fs.readdirSync(macOsDir);
|
||||
const executable = entries.find((entry) => {
|
||||
const fullPath = path.join(macOsDir, entry);
|
||||
return fs.statSync(fullPath).isFile() && (fs.statSync(fullPath).mode & 0o111) !== 0;
|
||||
});
|
||||
if (!executable) fail(`No executable found in ${macOsDir}`);
|
||||
return path.join(macOsDir, executable);
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const executable = fs
|
||||
.readdirSync(bundlePath)
|
||||
.find((entry) => entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall'));
|
||||
if (!executable) fail(`No .exe found in ${bundlePath}`);
|
||||
return path.join(bundlePath, executable);
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
const packageJsonPath = path.join(bundlePath, 'resources', 'app.asar.unpacked', 'package.json');
|
||||
const packageJson = fs.existsSync(packageJsonPath)
|
||||
? JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
||||
: {};
|
||||
const preferredNames = [packageJson.name, 'agent-teams-ai', 'Agent Teams UI'].filter(Boolean);
|
||||
for (const name of preferredNames) {
|
||||
const candidate = path.join(bundlePath, name);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const executable = fs.readdirSync(bundlePath).find((entry) => {
|
||||
const fullPath = path.join(bundlePath, entry);
|
||||
return fs.statSync(fullPath).isFile() && (fs.statSync(fullPath).mode & 0o111) !== 0;
|
||||
});
|
||||
if (!executable) fail(`No executable found in ${bundlePath}`);
|
||||
return path.join(bundlePath, executable);
|
||||
}
|
||||
|
||||
fail(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [bundlePathArg, platform] = process.argv.slice(2);
|
||||
if (!bundlePathArg || !platform) {
|
||||
fail('Usage: node ./scripts/electron-builder/smokePackagedApp.cjs <bundlePath> <platform>');
|
||||
}
|
||||
|
||||
const bundlePath = path.resolve(bundlePathArg);
|
||||
const executable = findExecutable(bundlePath, platform);
|
||||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-smoke-'));
|
||||
const args = [`--user-data-dir=${userDataDir}`];
|
||||
const child = spawn(executable, args, {
|
||||
env: {
|
||||
...process.env,
|
||||
AGENT_TEAMS_PACKAGED_SMOKE: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let log = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
log += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
log += chunk.toString();
|
||||
});
|
||||
|
||||
const exitPromise = new Promise((resolve) => {
|
||||
child.on('exit', (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
|
||||
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
|
||||
let startupSeenAt = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (FAILURE_PATTERNS.some((pattern) => pattern.test(log))) {
|
||||
child.kill();
|
||||
fail('Detected startup failure pattern', log);
|
||||
}
|
||||
|
||||
if (startupSeenAt === null && REQUIRED_LOG_MARKERS.every((marker) => log.includes(marker))) {
|
||||
startupSeenAt = Date.now();
|
||||
}
|
||||
|
||||
if (startupSeenAt !== null && Date.now() - startupSeenAt >= POST_STARTUP_STABLE_MS) {
|
||||
child.kill();
|
||||
console.log(`[smokePackagedApp] OK ${platform}: ${bundlePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const exit = await Promise.race([
|
||||
exitPromise,
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), 250)),
|
||||
]);
|
||||
if (exit) {
|
||||
fail(`Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`, log);
|
||||
}
|
||||
}
|
||||
|
||||
child.kill();
|
||||
fail(`Timed out after ${STARTUP_TIMEOUT_MS}ms waiting for packaged startup`, log);
|
||||
}
|
||||
|
||||
main().catch((error) => fail(error?.stack || String(error)));
|
||||
|
|
@ -108,6 +108,25 @@ function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
|||
return <MessageSquareText className={`${className} text-slate-300`} />;
|
||||
}
|
||||
|
||||
function hasOpenCodeRuntimeWarning(preview: MemberLogPreviewMember | undefined): boolean {
|
||||
return (
|
||||
preview?.warnings.some(
|
||||
(warning) =>
|
||||
warning.code === 'opencode_runtime_timeout' ||
|
||||
warning.code === 'opencode_runtime_unavailable' ||
|
||||
warning.code === 'opencode_ambiguous_lane'
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
function hasOpenCodeDeliveryDelayedWarning(preview: MemberLogPreviewMember | undefined): boolean {
|
||||
return preview?.warnings.some((warning) => warning.code === 'opencode_delivery_delayed') === true;
|
||||
}
|
||||
|
||||
function hasOpenCodeEmptyStateWarning(preview: MemberLogPreviewMember | undefined): boolean {
|
||||
return hasOpenCodeDeliveryDelayedWarning(preview) || hasOpenCodeRuntimeWarning(preview);
|
||||
}
|
||||
|
||||
function resolveEmptyText(
|
||||
preview: MemberLogPreviewMember | undefined,
|
||||
loading: boolean,
|
||||
|
|
@ -123,13 +142,10 @@ function resolveEmptyText(
|
|||
if (hasOnlyCodexUnsupportedCoverage) {
|
||||
return 'Unsupported provider';
|
||||
}
|
||||
const hasOpenCodeRuntimeWarning = preview?.warnings.some(
|
||||
(warning) =>
|
||||
warning.code === 'opencode_runtime_timeout' ||
|
||||
warning.code === 'opencode_runtime_unavailable' ||
|
||||
warning.code === 'opencode_ambiguous_lane'
|
||||
);
|
||||
if ((preview?.items.length ?? 0) === 0 && hasOpenCodeRuntimeWarning) {
|
||||
if ((preview?.items.length ?? 0) === 0 && hasOpenCodeDeliveryDelayedWarning(preview)) {
|
||||
return 'OpenCode logs delayed';
|
||||
}
|
||||
if ((preview?.items.length ?? 0) === 0 && hasOpenCodeRuntimeWarning(preview)) {
|
||||
return 'Logs unavailable';
|
||||
}
|
||||
if (loading && !preview) return 'Loading logs';
|
||||
|
|
@ -568,7 +584,8 @@ export const GraphMemberLogPreviewHud = ({
|
|||
: node.label;
|
||||
const preview = previewsByMember.get(normalizeMemberName(memberName));
|
||||
const items = preview?.items ?? [];
|
||||
const isInitialLoading = loading && !preview;
|
||||
const isEmptyLoading =
|
||||
loading && (!preview || (items.length === 0 && hasOpenCodeEmptyStateWarning(preview)));
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -594,7 +611,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
{items.length > 0 ? (
|
||||
items.slice(0, 3).map((item) => renderItem(memberName, item))
|
||||
) : isInitialLoading ? (
|
||||
) : isEmptyLoading ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-0 flex-1 rounded-md text-left text-[11px] text-slate-400/60"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface MemberLogStreamCoverage {
|
|||
export interface MemberLogStreamWarning {
|
||||
code:
|
||||
| 'opencode_ambiguous_lane'
|
||||
| 'opencode_delivery_delayed'
|
||||
| 'opencode_missing_runtime_session'
|
||||
| 'opencode_runtime_unavailable'
|
||||
| 'opencode_runtime_timeout'
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
|||
import { extractMemberLogPreviewItems } from '../../../../core/domain/policies/memberLogPreviewExtractor';
|
||||
|
||||
import { normalizeMemberName } from './memberLogStreamSourceUtils';
|
||||
import {
|
||||
type OpenCodeMemberVisibleActivityEntry,
|
||||
OpenCodeMemberVisibleActivityReader,
|
||||
sanitizeOpenCodeVisibleActivityText,
|
||||
} from './OpenCodeMemberVisibleActivityReader';
|
||||
|
||||
import type { MemberLogPreviewItem, MemberLogStreamWarning } from '../../../../contracts';
|
||||
import type {
|
||||
|
|
@ -32,9 +37,21 @@ const HIDDEN_PREVIEW_BLOCK_TAGS = [
|
|||
const ERROR_RESPONSE_STATES: ReadonlySet<string> = new Set([
|
||||
'permission_blocked',
|
||||
'tool_error',
|
||||
'empty_assistant_turn',
|
||||
'prompt_delivered_no_assistant_message',
|
||||
'session_stale',
|
||||
'session_error',
|
||||
'reconcile_failed',
|
||||
] as const);
|
||||
const OPENCODE_DELIVERY_DELAYED_WARNING: MemberLogStreamWarning = {
|
||||
code: 'opencode_delivery_delayed',
|
||||
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
|
||||
};
|
||||
|
||||
interface LedgerPreviewCandidate {
|
||||
item: MemberLogPreviewItem | null;
|
||||
warning?: MemberLogStreamWarning;
|
||||
}
|
||||
|
||||
interface BinaryResolverLike {
|
||||
resolve(): Promise<string | null>;
|
||||
|
|
@ -73,6 +90,7 @@ class FileOpenCodePromptDeliveryLedgerPreviewReader implements OpenCodePromptDel
|
|||
}
|
||||
|
||||
const DEFAULT_LEDGER_PREVIEW_READER = new FileOpenCodePromptDeliveryLedgerPreviewReader();
|
||||
const DEFAULT_VISIBLE_ACTIVITY_READER = new OpenCodeMemberVisibleActivityReader();
|
||||
|
||||
function classifyOpenCodePreviewError(error: unknown): MemberLogStreamWarning {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -217,34 +235,7 @@ function ledgerStatusText(record: OpenCodePromptDeliveryLedgerRecord): string {
|
|||
}
|
||||
}
|
||||
|
||||
function ledgerErrorTitle(record: OpenCodePromptDeliveryLedgerRecord): string {
|
||||
const reason = record.lastReason?.toLowerCase() ?? '';
|
||||
if (reason.includes('visible_reply')) {
|
||||
return 'Visible reply missing';
|
||||
}
|
||||
switch (record.responseState) {
|
||||
case 'empty_assistant_turn':
|
||||
return 'Empty assistant turn';
|
||||
case 'permission_blocked':
|
||||
return 'Permission blocked';
|
||||
case 'prompt_delivered_no_assistant_message':
|
||||
return 'No assistant reply';
|
||||
case 'responded_non_visible_tool':
|
||||
return 'Visible reply missing';
|
||||
case 'session_stale':
|
||||
return 'OpenCode session stale';
|
||||
case 'tool_error':
|
||||
return 'Tool error';
|
||||
case 'session_error':
|
||||
return 'OpenCode session error';
|
||||
case 'reconcile_failed':
|
||||
return 'OpenCode reconcile failed';
|
||||
default:
|
||||
return 'OpenCode delivery failed';
|
||||
}
|
||||
}
|
||||
|
||||
function ledgerRecordIsError(record: OpenCodePromptDeliveryLedgerRecord): boolean {
|
||||
function ledgerRecordHasDeliveryIssue(record: OpenCodePromptDeliveryLedgerRecord): boolean {
|
||||
return record.status === 'failed_terminal' || ERROR_RESPONSE_STATES.has(record.responseState);
|
||||
}
|
||||
|
||||
|
|
@ -252,15 +243,31 @@ function firstNonEmptyText(values: readonly (string | null | undefined)[]): stri
|
|||
return values.find((value) => typeof value === 'string' && value.trim().length > 0)?.trim() ?? '';
|
||||
}
|
||||
|
||||
function humanizeShortReason(value: string): string {
|
||||
return /^[a-z0-9_]+$/i.test(value) ? value.replace(/_/g, ' ') : value;
|
||||
function ledgerRecordHasObservedEvidence(record: OpenCodePromptDeliveryLedgerRecord): boolean {
|
||||
return (
|
||||
Boolean(record.observedAssistantPreview?.trim()) ||
|
||||
record.observedToolCallNames.length > 0 ||
|
||||
Boolean(record.observedVisibleMessageId?.trim()) ||
|
||||
record.responseState === 'responded_visible_message' ||
|
||||
record.responseState === 'responded_plain_text'
|
||||
);
|
||||
}
|
||||
|
||||
function buildLedgerPreviewWarning(
|
||||
record: OpenCodePromptDeliveryLedgerRecord
|
||||
): MemberLogStreamWarning | undefined {
|
||||
if (!ledgerRecordHasDeliveryIssue(record)) {
|
||||
return undefined;
|
||||
}
|
||||
return OPENCODE_DELIVERY_DELAYED_WARNING;
|
||||
}
|
||||
|
||||
function buildLedgerPreviewItem(
|
||||
record: OpenCodePromptDeliveryLedgerRecord,
|
||||
input: MemberLogPreviewSourceInput
|
||||
): MemberLogPreviewItem | null {
|
||||
): LedgerPreviewCandidate {
|
||||
const timestamp = ledgerRecordTimestampIso(record);
|
||||
const warning = buildLedgerPreviewWarning(record);
|
||||
const sourceBase = {
|
||||
provider: 'opencode_runtime' as const,
|
||||
timestamp,
|
||||
|
|
@ -269,51 +276,35 @@ function buildLedgerPreviewItem(
|
|||
laneId: input.laneId,
|
||||
};
|
||||
|
||||
if (ledgerRecordIsError(record)) {
|
||||
const preview = sanitizeLedgerPreviewText(
|
||||
humanizeShortReason(
|
||||
firstNonEmptyText([
|
||||
record.lastReason,
|
||||
record.diagnostics[0],
|
||||
record.observedAssistantPreview,
|
||||
ledgerStatusText(record),
|
||||
])
|
||||
),
|
||||
input.textLimit
|
||||
);
|
||||
return {
|
||||
...sourceBase,
|
||||
id: `opencode-ledger:${record.id}:error`,
|
||||
kind: 'tool_result',
|
||||
title: ledgerErrorTitle(record),
|
||||
preview,
|
||||
tone: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (record.observedAssistantPreview?.trim()) {
|
||||
return {
|
||||
...sourceBase,
|
||||
id: `opencode-ledger:${record.id}:assistant`,
|
||||
kind: 'text',
|
||||
title:
|
||||
record.responseState === 'responded_visible_message' ||
|
||||
record.responseState === 'responded_plain_text'
|
||||
? 'OpenCode reply'
|
||||
: 'Assistant',
|
||||
preview: sanitizeLedgerPreviewText(record.observedAssistantPreview, input.textLimit),
|
||||
tone: 'neutral',
|
||||
item: {
|
||||
...sourceBase,
|
||||
id: `opencode-ledger:${record.id}:assistant`,
|
||||
kind: 'text',
|
||||
title:
|
||||
record.responseState === 'responded_visible_message' ||
|
||||
record.responseState === 'responded_plain_text'
|
||||
? 'OpenCode reply'
|
||||
: 'Assistant',
|
||||
preview: sanitizeLedgerPreviewText(record.observedAssistantPreview, input.textLimit),
|
||||
tone: 'neutral',
|
||||
},
|
||||
warning,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.observedToolCallNames.length > 0) {
|
||||
return {
|
||||
...sourceBase,
|
||||
id: `opencode-ledger:${record.id}:tools`,
|
||||
kind: 'tool_use',
|
||||
title: 'Tool activity',
|
||||
preview: formatLedgerToolNames(record.observedToolCallNames, input.textLimit),
|
||||
tone: 'neutral',
|
||||
item: {
|
||||
...sourceBase,
|
||||
id: `opencode-ledger:${record.id}:tools`,
|
||||
kind: 'tool_use',
|
||||
title: 'Tool activity',
|
||||
preview: formatLedgerToolNames(record.observedToolCallNames, input.textLimit),
|
||||
tone: 'neutral',
|
||||
},
|
||||
warning,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -322,32 +313,94 @@ function buildLedgerPreviewItem(
|
|||
record.responseState === 'responded_plain_text'
|
||||
) {
|
||||
return {
|
||||
...sourceBase,
|
||||
id: `opencode-ledger:${record.id}:reply`,
|
||||
kind: 'text',
|
||||
title: 'OpenCode reply',
|
||||
preview: sanitizeLedgerPreviewText(ledgerStatusText(record), input.textLimit),
|
||||
tone: 'success',
|
||||
item: {
|
||||
...sourceBase,
|
||||
id: `opencode-ledger:${record.id}:reply`,
|
||||
kind: 'text',
|
||||
title: 'OpenCode reply',
|
||||
preview: sanitizeLedgerPreviewText(ledgerStatusText(record), input.textLimit),
|
||||
tone: 'success',
|
||||
},
|
||||
warning,
|
||||
};
|
||||
}
|
||||
|
||||
if (ledgerRecordHasDeliveryIssue(record) && !ledgerRecordHasObservedEvidence(record)) {
|
||||
return { item: null, warning };
|
||||
}
|
||||
|
||||
const statusText = sanitizeLedgerPreviewText(
|
||||
firstNonEmptyText([record.lastReason, ledgerStatusText(record)]),
|
||||
input.textLimit
|
||||
);
|
||||
return statusText
|
||||
? {
|
||||
...sourceBase,
|
||||
id: `opencode-ledger:${record.id}:status`,
|
||||
kind: 'text',
|
||||
title: 'OpenCode status',
|
||||
preview: statusText,
|
||||
tone:
|
||||
record.status === 'failed_retryable' || record.status === 'retry_scheduled'
|
||||
? 'warning'
|
||||
: 'neutral',
|
||||
item: {
|
||||
...sourceBase,
|
||||
id: `opencode-ledger:${record.id}:status`,
|
||||
kind: 'text',
|
||||
title: 'OpenCode status',
|
||||
preview: statusText,
|
||||
tone:
|
||||
record.status === 'failed_retryable' || record.status === 'retry_scheduled'
|
||||
? 'warning'
|
||||
: 'neutral',
|
||||
},
|
||||
warning,
|
||||
}
|
||||
: null;
|
||||
: { item: null, warning };
|
||||
}
|
||||
|
||||
function dedupeWarnings(warnings: readonly MemberLogStreamWarning[]): MemberLogStreamWarning[] {
|
||||
const seen = new Set<string>();
|
||||
const result: MemberLogStreamWarning[] = [];
|
||||
for (const warning of warnings) {
|
||||
const key = `${warning.code}:${warning.message}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(warning);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function previewItemTimestampMs(item: MemberLogPreviewItem): number {
|
||||
const parsed = Date.parse(item.timestamp);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function comparePreviewItemsNewestFirst(
|
||||
left: MemberLogPreviewItem,
|
||||
right: MemberLogPreviewItem
|
||||
): number {
|
||||
const byTime = previewItemTimestampMs(right) - previewItemTimestampMs(left);
|
||||
return byTime !== 0 ? byTime : right.id.localeCompare(left.id);
|
||||
}
|
||||
|
||||
function dedupePreviewItems(items: readonly MemberLogPreviewItem[]): MemberLogPreviewItem[] {
|
||||
const deduped = new Map<string, MemberLogPreviewItem>();
|
||||
for (const item of items) {
|
||||
if (!deduped.has(item.id)) {
|
||||
deduped.set(item.id, item);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
function buildVisibleActivityPreviewItem(
|
||||
entry: OpenCodeMemberVisibleActivityEntry,
|
||||
input: MemberLogPreviewSourceInput
|
||||
): MemberLogPreviewItem {
|
||||
return {
|
||||
id: `${entry.id}:preview`,
|
||||
kind: 'text',
|
||||
provider: 'opencode_runtime',
|
||||
timestamp: entry.timestamp,
|
||||
title: entry.title,
|
||||
preview: sanitizeOpenCodeVisibleActivityText(entry.text, input.textLimit),
|
||||
tone: entry.title === 'Agent error' ? 'error' : 'neutral',
|
||||
sourceLabel: entry.sourceLabel,
|
||||
laneId: input.laneId,
|
||||
};
|
||||
}
|
||||
|
||||
export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSource {
|
||||
|
|
@ -361,7 +414,8 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
|
|||
constructor(
|
||||
private readonly runtimeBridge: ClaudeMultimodelBridgeService,
|
||||
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver,
|
||||
private readonly ledgerReader: OpenCodePromptDeliveryLedgerPreviewReader = DEFAULT_LEDGER_PREVIEW_READER
|
||||
private readonly ledgerReader: OpenCodePromptDeliveryLedgerPreviewReader = DEFAULT_LEDGER_PREVIEW_READER,
|
||||
private readonly visibleActivityReader: OpenCodeMemberVisibleActivityReader = DEFAULT_VISIBLE_ACTIVITY_READER
|
||||
) {}
|
||||
|
||||
async loadPreview(input: MemberLogPreviewSourceInput): Promise<MemberLogPreviewSourceResult> {
|
||||
|
|
@ -418,11 +472,70 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
|
|||
}
|
||||
|
||||
const ledgerResult = await this.buildLedgerResult(input);
|
||||
const visibleActivityResult = await this.buildVisibleActivityResult(input, ledgerResult);
|
||||
if (visibleActivityResult.status === 'included') {
|
||||
return visibleActivityResult;
|
||||
}
|
||||
if (ledgerResult.status === 'included') {
|
||||
return ledgerResult;
|
||||
return {
|
||||
...ledgerResult,
|
||||
warnings: dedupeWarnings([...ledgerResult.warnings, ...visibleActivityResult.warnings]),
|
||||
};
|
||||
}
|
||||
|
||||
return this.buildTranscriptResult(input, ledgerResult.warnings);
|
||||
return this.buildTranscriptResult(
|
||||
input,
|
||||
dedupeWarnings([...ledgerResult.warnings, ...visibleActivityResult.warnings])
|
||||
);
|
||||
}
|
||||
|
||||
private async buildVisibleActivityResult(
|
||||
input: MemberLogPreviewSourceInput,
|
||||
ledgerResult: MemberLogPreviewSourceResult
|
||||
): Promise<MemberLogPreviewSourceResult> {
|
||||
try {
|
||||
const activityItems = (
|
||||
await this.visibleActivityReader.list({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
forceRefresh: input.forceRefresh,
|
||||
})
|
||||
).map((entry) => buildVisibleActivityPreviewItem(entry, input));
|
||||
const mergedItems = dedupePreviewItems([...activityItems, ...ledgerResult.items]).sort(
|
||||
comparePreviewItemsNewestFirst
|
||||
);
|
||||
const items = mergedItems.slice(0, input.maxItems);
|
||||
const overflowCount = Math.max(
|
||||
0,
|
||||
mergedItems.length - items.length + ledgerResult.overflowCount
|
||||
);
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: items.length > 0 ? 'included' : 'skipped',
|
||||
reason: items.length > 0 ? undefined : 'opencode_visible_activity_empty',
|
||||
items,
|
||||
warnings: [...ledgerResult.warnings],
|
||||
truncated: overflowCount > 0,
|
||||
overflowCount,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'skipped',
|
||||
reason: 'opencode_visible_activity_unavailable',
|
||||
items: [],
|
||||
warnings: [
|
||||
...ledgerResult.warnings,
|
||||
{
|
||||
code: 'opencode_runtime_unavailable',
|
||||
message: `OpenCode visible activity preview is unavailable: ${message}`,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async buildLedgerResult(
|
||||
|
|
@ -440,20 +553,28 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
|
|||
return byTime !== 0 ? byTime : right.id.localeCompare(left.id);
|
||||
});
|
||||
const records = orderedRecords.slice(0, MAX_LEDGER_RECORDS_TO_CONSIDER);
|
||||
const candidates = records
|
||||
.map((record) => buildLedgerPreviewItem(record, input))
|
||||
const candidates = records.map((record) => buildLedgerPreviewItem(record, input));
|
||||
const previewItems = candidates
|
||||
.map((candidate) => candidate.item)
|
||||
.filter((item): item is MemberLogPreviewItem => Boolean(item));
|
||||
const items = candidates.slice(0, input.maxItems);
|
||||
const overflowCount = Math.max(
|
||||
0,
|
||||
Math.max(candidates.length, orderedRecords.length) - items.length
|
||||
const warnings = dedupeWarnings(
|
||||
candidates
|
||||
.map((candidate) => candidate.warning)
|
||||
.filter((warning): warning is MemberLogStreamWarning => Boolean(warning))
|
||||
);
|
||||
const items = previewItems.slice(0, input.maxItems);
|
||||
const overflowCount = Math.max(0, previewItems.length - items.length);
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: items.length > 0 ? 'included' : 'skipped',
|
||||
reason: items.length > 0 ? undefined : 'opencode_delivery_ledger_empty',
|
||||
reason:
|
||||
items.length > 0
|
||||
? undefined
|
||||
: warnings.length > 0
|
||||
? 'opencode_delivery_delayed'
|
||||
: 'opencode_delivery_ledger_empty',
|
||||
items,
|
||||
warnings: [],
|
||||
warnings,
|
||||
truncated: overflowCount > 0,
|
||||
overflowCount,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import {
|
|||
normalizeMemberName,
|
||||
withSegmentSource,
|
||||
} from './memberLogStreamSourceUtils';
|
||||
import {
|
||||
type OpenCodeMemberVisibleActivityEntry,
|
||||
OpenCodeMemberVisibleActivityReader,
|
||||
} from './OpenCodeMemberVisibleActivityReader';
|
||||
|
||||
import type { MemberLogStreamWarning } from '../../../../contracts';
|
||||
import type {
|
||||
|
|
@ -19,26 +23,39 @@ import type {
|
|||
} from '../../../../core/application/ports/MemberLogStreamSource';
|
||||
import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
||||
import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
|
||||
import type { ParsedMessage } from '@main/types';
|
||||
|
||||
interface BinaryResolverLike {
|
||||
resolve(): Promise<string | null>;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 1_500;
|
||||
const DEFAULT_VISIBLE_ACTIVITY_READER = new OpenCodeMemberVisibleActivityReader();
|
||||
|
||||
function classifyOpenCodeError(error: unknown): MemberLogStreamWarning {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes('timed out') || normalized.includes('timeout')) {
|
||||
const record = error && typeof error === 'object' ? (error as Record<string, unknown>) : null;
|
||||
const code = typeof record?.code === 'string' ? record.code : '';
|
||||
const signal = typeof record?.signal === 'string' ? record.signal : '';
|
||||
const killed = record?.killed === true ? 'killed' : '';
|
||||
const normalized = [message, code, signal, killed].join(' ').toLowerCase();
|
||||
if (
|
||||
normalized.includes('timed out') ||
|
||||
normalized.includes('timeout') ||
|
||||
normalized.includes('code 143') ||
|
||||
normalized.includes('signal sigterm') ||
|
||||
normalized.includes('killed')
|
||||
) {
|
||||
return {
|
||||
code: 'opencode_runtime_timeout',
|
||||
message: 'OpenCode runtime transcript timed out; showing other member logs only.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
normalized.includes('--lane') ||
|
||||
normalized.includes('multiple') ||
|
||||
normalized.includes('ambiguous')
|
||||
normalized.includes('ambiguous') ||
|
||||
normalized.includes('without a safe lane') ||
|
||||
normalized.includes('requires --lane') ||
|
||||
(normalized.includes('multiple') && normalized.includes('lane'))
|
||||
) {
|
||||
return {
|
||||
code: 'opencode_ambiguous_lane',
|
||||
|
|
@ -62,7 +79,8 @@ export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource
|
|||
constructor(
|
||||
private readonly runtimeBridge: ClaudeMultimodelBridgeService,
|
||||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder,
|
||||
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver
|
||||
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver,
|
||||
private readonly visibleActivityReader: OpenCodeMemberVisibleActivityReader = DEFAULT_VISIBLE_ACTIVITY_READER
|
||||
) {}
|
||||
|
||||
async load(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult> {
|
||||
|
|
@ -102,8 +120,14 @@ export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource
|
|||
): Promise<MemberLogStreamSourceResult> {
|
||||
const binaryPath = await this.binaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
return this.skipped(
|
||||
'opencode_runtime_unavailable',
|
||||
return this.loadVisibleActivityFallback(
|
||||
input,
|
||||
[
|
||||
{
|
||||
code: 'opencode_runtime_unavailable',
|
||||
message: 'OpenCode runtime bridge is unavailable.',
|
||||
},
|
||||
],
|
||||
'OpenCode runtime bridge is unavailable.'
|
||||
);
|
||||
}
|
||||
|
|
@ -121,38 +145,17 @@ export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource
|
|||
projectedMessages
|
||||
).sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
|
||||
if (parsedMessages.length === 0) {
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'skipped',
|
||||
reason: 'opencode_missing_runtime_session',
|
||||
participants: [],
|
||||
segments: [],
|
||||
warnings: [],
|
||||
};
|
||||
return this.loadVisibleActivityFallback(input, [], 'opencode_missing_runtime_session');
|
||||
}
|
||||
|
||||
const budgeted = applyMemberLogMessageBudget(parsedMessages, input.budget);
|
||||
if (budgeted.messages.length === 0) {
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'skipped',
|
||||
reason: 'opencode_no_renderable_chunks',
|
||||
participants: [],
|
||||
segments: [],
|
||||
warnings: [],
|
||||
};
|
||||
return this.loadVisibleActivityFallback(input, [], 'opencode_no_renderable_chunks');
|
||||
}
|
||||
|
||||
const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages);
|
||||
if (chunks.length === 0) {
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'skipped',
|
||||
reason: 'opencode_no_renderable_chunks',
|
||||
participants: [],
|
||||
segments: [],
|
||||
warnings: [],
|
||||
};
|
||||
return this.loadVisibleActivityFallback(input, [], 'opencode_no_renderable_chunks');
|
||||
}
|
||||
|
||||
const first = budgeted.messages[0];
|
||||
|
|
@ -228,14 +231,143 @@ export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource
|
|||
};
|
||||
} catch (error) {
|
||||
const warning = classifyOpenCodeError(error);
|
||||
return this.skipped(warning.code, warning.message, warning);
|
||||
return this.loadVisibleActivityFallback(input, [warning], warning.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadVisibleActivityFallback(
|
||||
input: MemberLogStreamSourceInput,
|
||||
warnings: readonly MemberLogStreamWarning[],
|
||||
skippedReason: string
|
||||
): Promise<MemberLogStreamSourceResult> {
|
||||
try {
|
||||
const entries = await this.visibleActivityReader.list({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
forceRefresh: input.forceRefresh,
|
||||
});
|
||||
if (entries.length === 0) {
|
||||
return this.skippedFromWarnings(skippedReason, warnings);
|
||||
}
|
||||
|
||||
const messages = entries
|
||||
.map((entry) => toVisibleActivityParsedMessage(entry, input.memberName))
|
||||
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
|
||||
const budgeted = applyMemberLogMessageBudget(messages, input.budget);
|
||||
if (budgeted.messages.length === 0) {
|
||||
return this.skippedFromWarnings(skippedReason, warnings);
|
||||
}
|
||||
|
||||
const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages);
|
||||
if (chunks.length === 0) {
|
||||
return this.skippedFromWarnings(skippedReason, warnings);
|
||||
}
|
||||
|
||||
const first = budgeted.messages[0];
|
||||
const last = budgeted.messages[budgeted.messages.length - 1];
|
||||
if (!first || !last) {
|
||||
return this.skippedFromWarnings(skippedReason, warnings);
|
||||
}
|
||||
|
||||
const participant = buildMemberParticipant(input.memberName);
|
||||
const sessionId = `opencode-visible:${normalizeMemberName(input.memberName)}`;
|
||||
const segment = withSegmentSource(
|
||||
{
|
||||
id: buildSegmentId({
|
||||
provider: this.provider,
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
sessionId,
|
||||
fingerprint: `${sessionId}:${input.laneId ?? ''}:${budgeted.messages.length}`,
|
||||
startTimestamp: first.timestamp.toISOString(),
|
||||
}),
|
||||
participantKey: participant.key,
|
||||
actor: buildMemberActor({
|
||||
memberName: input.memberName,
|
||||
sessionId,
|
||||
role: 'member',
|
||||
}),
|
||||
startTimestamp: first.timestamp.toISOString(),
|
||||
endTimestamp: last.timestamp.toISOString(),
|
||||
chunks,
|
||||
},
|
||||
{
|
||||
provider: this.provider,
|
||||
label: 'OpenCode visible activity',
|
||||
sessionId,
|
||||
...(input.laneId ? { laneId: input.laneId } : {}),
|
||||
messageCount: budgeted.messages.length,
|
||||
truncated:
|
||||
budgeted.droppedMessageCount > 0 ||
|
||||
budgeted.segmentWindowLimited ||
|
||||
budgeted.contentLimited,
|
||||
}
|
||||
);
|
||||
|
||||
const resultWarnings = [...warnings];
|
||||
if (budgeted.segmentWindowLimited) {
|
||||
resultWarnings.push({
|
||||
code: 'segment_message_window_limited',
|
||||
message: 'OpenCode visible activity was trimmed to recent messages.',
|
||||
});
|
||||
}
|
||||
if (budgeted.contentLimited) {
|
||||
resultWarnings.push({
|
||||
code: 'message_content_limited',
|
||||
message: 'Some large OpenCode visible activity content was truncated before rendering.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'included',
|
||||
participants: [participant],
|
||||
segments: [segment],
|
||||
warnings: resultWarnings,
|
||||
metadata: {
|
||||
droppedMessageCount: budgeted.droppedMessageCount,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return this.skippedFromWarnings(skippedReason, [
|
||||
...warnings,
|
||||
{
|
||||
code: 'opencode_runtime_unavailable',
|
||||
message: `OpenCode visible activity fallback is unavailable: ${message}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private skippedFromWarnings(
|
||||
reason: string,
|
||||
warnings: readonly MemberLogStreamWarning[]
|
||||
): MemberLogStreamSourceResult {
|
||||
if (warnings.length === 0) {
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'skipped',
|
||||
reason,
|
||||
participants: [],
|
||||
segments: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
const firstWarning = warnings[0];
|
||||
return this.skipped(
|
||||
firstWarning?.code ?? 'opencode_runtime_unavailable',
|
||||
reason,
|
||||
firstWarning,
|
||||
[...warnings.slice(1)]
|
||||
);
|
||||
}
|
||||
|
||||
private skipped(
|
||||
code: MemberLogStreamWarning['code'],
|
||||
reason: string,
|
||||
warning: MemberLogStreamWarning = { code, message: reason }
|
||||
warning: MemberLogStreamWarning = { code, message: reason },
|
||||
extraWarnings: readonly MemberLogStreamWarning[] = []
|
||||
): MemberLogStreamSourceResult {
|
||||
return {
|
||||
provider: this.provider,
|
||||
|
|
@ -243,7 +375,27 @@ export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource
|
|||
reason,
|
||||
participants: [],
|
||||
segments: [],
|
||||
warnings: [warning],
|
||||
warnings: [warning, ...extraWarnings],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toVisibleActivityParsedMessage(
|
||||
entry: OpenCodeMemberVisibleActivityEntry,
|
||||
memberName: string
|
||||
): ParsedMessage {
|
||||
return {
|
||||
uuid: entry.id,
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
timestamp: new Date(entry.timestamp),
|
||||
role: 'assistant',
|
||||
content: `${entry.title}: ${entry.text}`,
|
||||
isSidechain: true,
|
||||
isMeta: false,
|
||||
sessionId: `opencode-visible:${normalizeMemberName(memberName)}`,
|
||||
agentName: memberName,
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
import { TeamInboxReader } from '@main/services/team/TeamInboxReader';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
|
||||
import { normalizeMemberName, shortHash } from './memberLogStreamSourceUtils';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
const MAX_VISIBLE_ACTIVITY_MESSAGES_TO_CONSIDER = 160;
|
||||
const MAX_VISIBLE_ACTIVITY_ENTRIES = 24;
|
||||
const TEAM_MESSAGES_CACHE_TTL_MS = 1_500;
|
||||
const HIDDEN_ACTIVITY_BLOCK_TAGS = [
|
||||
'info_for_agent',
|
||||
'opencode_runtime_identity',
|
||||
'opencode_app_message_delivery',
|
||||
'system-reminder',
|
||||
] as const;
|
||||
|
||||
export interface OpenCodeVisibleActivityInboxReader {
|
||||
getMessages(teamName: string): Promise<InboxMessage[]>;
|
||||
}
|
||||
|
||||
export interface OpenCodeMemberVisibleActivityEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
title: string;
|
||||
text: string;
|
||||
sourceLabel: string;
|
||||
message: InboxMessage;
|
||||
}
|
||||
|
||||
export class OpenCodeMemberVisibleActivityReader {
|
||||
private readonly teamMessagesCache = new Map<
|
||||
string,
|
||||
{ expiresAt: number; messages: readonly InboxMessage[] }
|
||||
>();
|
||||
private readonly teamMessagesInFlight = new Map<string, Promise<readonly InboxMessage[]>>();
|
||||
|
||||
constructor(
|
||||
private readonly inboxReader: OpenCodeVisibleActivityInboxReader = new TeamInboxReader()
|
||||
) {}
|
||||
|
||||
async list(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
forceRefresh?: boolean;
|
||||
}): Promise<OpenCodeMemberVisibleActivityEntry[]> {
|
||||
const normalizedMemberName = normalizeMemberName(input.memberName);
|
||||
const messages = (await this.getTeamMessages(input.teamName, input.forceRefresh === true))
|
||||
.filter((message) => isVisibleMemberActivityMessage(message, normalizedMemberName))
|
||||
.sort(compareInboxMessagesNewestFirst)
|
||||
.slice(0, MAX_VISIBLE_ACTIVITY_MESSAGES_TO_CONSIDER);
|
||||
|
||||
const deduped = new Map<string, OpenCodeMemberVisibleActivityEntry>();
|
||||
for (const message of messages) {
|
||||
const entry = toVisibleActivityEntry(message);
|
||||
if (!entry || deduped.has(entry.id)) {
|
||||
continue;
|
||||
}
|
||||
deduped.set(entry.id, entry);
|
||||
if (deduped.size >= MAX_VISIBLE_ACTIVITY_ENTRIES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
private async getTeamMessages(
|
||||
teamName: string,
|
||||
forceRefresh: boolean
|
||||
): Promise<readonly InboxMessage[]> {
|
||||
const cacheKey = `${getTeamsBasePath()}::${teamName}`;
|
||||
if (!forceRefresh) {
|
||||
const cached = this.teamMessagesCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.messages;
|
||||
}
|
||||
const inFlight = this.teamMessagesInFlight.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
const promise = this.inboxReader
|
||||
.getMessages(teamName)
|
||||
.then((messages) => {
|
||||
this.teamMessagesCache.set(cacheKey, {
|
||||
expiresAt: Date.now() + TEAM_MESSAGES_CACHE_TTL_MS,
|
||||
messages,
|
||||
});
|
||||
return messages;
|
||||
})
|
||||
.finally(() => {
|
||||
this.teamMessagesInFlight.delete(cacheKey);
|
||||
});
|
||||
this.teamMessagesInFlight.set(cacheKey, promise);
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeOpenCodeVisibleActivityText(value: string, limit?: number): string {
|
||||
const compact = stripAngleTags(removeHiddenActivityBlocks(value))
|
||||
.replace(/\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '$1')
|
||||
.replace(/^\s*>\s?/gm, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!limit || compact.length <= limit) {
|
||||
return compact;
|
||||
}
|
||||
const allowed = Math.max(1, limit - 3);
|
||||
return `${compact.slice(0, allowed)}...`;
|
||||
}
|
||||
|
||||
function isVisibleMemberActivityMessage(
|
||||
message: InboxMessage,
|
||||
normalizedMemberName: string
|
||||
): boolean {
|
||||
if (normalizeMemberName(message.from) !== normalizedMemberName) {
|
||||
return false;
|
||||
}
|
||||
if (!message.timestamp || !Number.isFinite(Date.parse(message.timestamp))) {
|
||||
return false;
|
||||
}
|
||||
const text = message.summary ?? message.text;
|
||||
if (!text || isInboxNoiseMessage(text)) {
|
||||
return false;
|
||||
}
|
||||
return sanitizeOpenCodeVisibleActivityText(text).length > 0;
|
||||
}
|
||||
|
||||
function toVisibleActivityEntry(message: InboxMessage): OpenCodeMemberVisibleActivityEntry | null {
|
||||
const text = sanitizeOpenCodeVisibleActivityText(
|
||||
[message.summary, message.text].filter(Boolean).join('\n\n')
|
||||
);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const id = buildVisibleActivityId(message);
|
||||
return {
|
||||
id,
|
||||
timestamp: message.timestamp,
|
||||
title: buildVisibleActivityTitle(message),
|
||||
text,
|
||||
sourceLabel: 'OpenCode visible activity',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisibleActivityId(message: InboxMessage): string {
|
||||
const messageKey =
|
||||
message.messageId ??
|
||||
[message.timestamp, message.from, message.to ?? '', message.summary ?? '', message.text].join(
|
||||
'\u0000'
|
||||
);
|
||||
return `opencode-visible:${shortHash(messageKey)}`;
|
||||
}
|
||||
|
||||
function buildVisibleActivityTitle(message: InboxMessage): string {
|
||||
if (message.messageKind === 'task_comment_notification' || isCommentSummary(message.summary)) {
|
||||
return 'Comment added';
|
||||
}
|
||||
if (message.messageKind === 'agent_error') {
|
||||
return 'Agent error';
|
||||
}
|
||||
const text = `${message.summary ?? ''} ${message.text ?? ''}`.toLowerCase();
|
||||
if (/\b(done|completed|approved|fixed|verified)\b/i.test(text) || /заверш|готов/i.test(text)) {
|
||||
return 'Task completed';
|
||||
}
|
||||
if (message.to) {
|
||||
return 'Message sent';
|
||||
}
|
||||
return 'Team message';
|
||||
}
|
||||
|
||||
function isCommentSummary(value: string | undefined): boolean {
|
||||
return value?.trim().toLowerCase().startsWith('comment on #') === true;
|
||||
}
|
||||
|
||||
function compareInboxMessagesNewestFirst(left: InboxMessage, right: InboxMessage): number {
|
||||
const byTime = Date.parse(right.timestamp) - Date.parse(left.timestamp);
|
||||
if (byTime !== 0) {
|
||||
return byTime;
|
||||
}
|
||||
return buildVisibleActivityId(right).localeCompare(buildVisibleActivityId(left));
|
||||
}
|
||||
|
||||
function removeHiddenActivityBlocks(value: string): string {
|
||||
let result = value;
|
||||
for (const tag of HIDDEN_ACTIVITY_BLOCK_TAGS) {
|
||||
result = result.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'), ' ');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripAngleTags(value: string): string {
|
||||
let result = '';
|
||||
let insideTag = false;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const char = value[index];
|
||||
if (!insideTag && char === '<') {
|
||||
const next = value[index + 1] ?? '';
|
||||
if (/[A-Za-z/!]/.test(next)) {
|
||||
insideTag = true;
|
||||
result += ' ';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (insideTag) {
|
||||
if (char === '>') {
|
||||
insideTag = false;
|
||||
result += ' ';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result += char;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -17,10 +17,12 @@ import { CodexNativeMemberTracePreviewSource } from '../CodexNativeMemberTracePr
|
|||
import { CodexNativeMemberTraceStreamSource } from '../CodexNativeMemberTraceStreamSource';
|
||||
import { OpenCodeMemberRuntimePreviewSource } from '../OpenCodeMemberRuntimePreviewSource';
|
||||
import { OpenCodeMemberRuntimeStreamSource } from '../OpenCodeMemberRuntimeStreamSource';
|
||||
import { OpenCodeMemberVisibleActivityReader } from '../OpenCodeMemberVisibleActivityReader';
|
||||
|
||||
import type { MemberLogPreviewSourceInput } from '../../../../../core/application/ports/MemberLogPreviewSource';
|
||||
import type { MemberLogStreamSourceInput } from '../../../../../core/application/ports/MemberLogStreamSource';
|
||||
import type { EnhancedChunk, ParsedMessage } from '@main/types';
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
function parsedMessage(uuid: string, timestamp: string): ParsedMessage {
|
||||
return {
|
||||
|
|
@ -133,6 +135,41 @@ async function writeOpenCodePromptLedger(input: {
|
|||
return ledgerPath;
|
||||
}
|
||||
|
||||
async function writeTeamLeadInbox(input: {
|
||||
claudeRoot: string;
|
||||
teamName: string;
|
||||
messages: InboxMessage[];
|
||||
}): Promise<string> {
|
||||
const inboxPath = path.join(
|
||||
input.claudeRoot,
|
||||
'teams',
|
||||
input.teamName,
|
||||
'inboxes',
|
||||
'team-lead.json'
|
||||
);
|
||||
await mkdir(path.dirname(inboxPath), { recursive: true });
|
||||
await writeFile(inboxPath, `${JSON.stringify(input.messages, null, 2)}\n`);
|
||||
return inboxPath;
|
||||
}
|
||||
|
||||
function inboxMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
|
||||
return {
|
||||
from: overrides.from ?? 'alice',
|
||||
to: overrides.to ?? 'team-lead',
|
||||
text: overrides.text ?? '#abc12345 done. Implemented visible team activity.',
|
||||
timestamp: overrides.timestamp ?? '2026-04-04T00:00:00.000Z',
|
||||
read: overrides.read ?? false,
|
||||
taskRefs: overrides.taskRefs ?? [
|
||||
{ taskId: 'task-1', displayId: 'abc12345', teamName: 'alpha-team' },
|
||||
],
|
||||
summary: overrides.summary ?? '#abc12345 done - visible activity',
|
||||
messageId: overrides.messageId ?? 'visible-message-1',
|
||||
source: overrides.source ?? 'runtime_delivery',
|
||||
messageKind: overrides.messageKind,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function openCodeLedgerRecord(
|
||||
overrides: Partial<OpenCodePromptDeliveryLedgerRecord> = {}
|
||||
): OpenCodePromptDeliveryLedgerRecord {
|
||||
|
|
@ -431,6 +468,110 @@ describe('OpenCodeMemberRuntimeStreamSource', () => {
|
|||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to visible OpenCode team activity when runtime transcript is empty', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
await writeTeamLeadInbox({
|
||||
claudeRoot,
|
||||
teamName: 'alpha-team',
|
||||
messages: [
|
||||
inboxMessage({
|
||||
from: 'bob',
|
||||
messageId: 'other-member',
|
||||
text: 'Wrong member should not appear.',
|
||||
}),
|
||||
inboxMessage({
|
||||
messageId: 'visible-message-1',
|
||||
text: '#abc12345 done. <info_for_agent>hidden</info_for_agent> Verified docs.',
|
||||
summary: '#abc12345 completed - docs verified',
|
||||
timestamp: '2026-04-04T00:03:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
|
||||
sessionId: 'opencode-session',
|
||||
logProjection: { messages: [] },
|
||||
});
|
||||
const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [
|
||||
fakeChunk(messages[0]?.uuid ?? 'chunk'),
|
||||
]);
|
||||
const source = new OpenCodeMemberRuntimeStreamSource(
|
||||
{ getOpenCodeTranscript } as never,
|
||||
{ buildBundleChunks } as never,
|
||||
{ resolve: vi.fn().mockResolvedValue('/mock/orchestrator') }
|
||||
);
|
||||
|
||||
const result = await source.load(sourceInput({ memberName: 'alice' }));
|
||||
|
||||
expect(result.status).toBe('included');
|
||||
expect(result.segments[0]?.source).toMatchObject({
|
||||
provider: 'opencode_runtime',
|
||||
label: 'OpenCode visible activity',
|
||||
messageCount: 1,
|
||||
});
|
||||
expect(buildBundleChunks).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
uuid: expect.stringMatching(/^opencode-visible:/),
|
||||
content: expect.stringContaining('#abc12345 completed - docs verified'),
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(buildBundleChunks.mock.calls[0]?.[0])).not.toContain('info_for_agent');
|
||||
});
|
||||
|
||||
it('uses visible OpenCode team activity when the runtime bridge is unavailable', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
await writeTeamLeadInbox({
|
||||
claudeRoot,
|
||||
teamName: 'alpha-team',
|
||||
messages: [
|
||||
inboxMessage({
|
||||
from: 'jack',
|
||||
messageId: 'jack-visible-message',
|
||||
summary: '#e54d70b9 completed - release notes page added',
|
||||
text: 'Task #e54d70b9 completed: added release notes page.',
|
||||
timestamp: '2026-04-04T00:04:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const source = new OpenCodeMemberRuntimeStreamSource(
|
||||
{ getOpenCodeTranscript: vi.fn() } as never,
|
||||
{ buildBundleChunks: vi.fn(() => [fakeChunk('visible-activity-chunk')]) } as never,
|
||||
{ resolve: vi.fn().mockResolvedValue(null) }
|
||||
);
|
||||
|
||||
const result = await source.load(sourceInput({ memberName: 'jack' }));
|
||||
|
||||
expect(result.status).toBe('included');
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual([
|
||||
'opencode_runtime_unavailable',
|
||||
]);
|
||||
expect(result.segments[0]?.source.label).toBe('OpenCode visible activity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenCodeMemberVisibleActivityReader', () => {
|
||||
it('reuses one team inbox read across member lookups and respects force refresh', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
const getMessages = vi.fn(async () => [
|
||||
inboxMessage({ from: 'alice', messageId: 'alice-message' }),
|
||||
inboxMessage({ from: 'bob', messageId: 'bob-message' }),
|
||||
]);
|
||||
const reader = new OpenCodeMemberVisibleActivityReader({ getMessages });
|
||||
|
||||
const alice = await reader.list({ teamName: 'alpha-team', memberName: 'alice' });
|
||||
const bob = await reader.list({ teamName: 'alpha-team', memberName: 'bob' });
|
||||
const aliceForced = await reader.list({
|
||||
teamName: 'alpha-team',
|
||||
memberName: 'alice',
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
expect(claudeRoot).toContain('member-log-source-');
|
||||
expect(alice.map((entry) => entry.message.messageId)).toEqual(['alice-message']);
|
||||
expect(bob.map((entry) => entry.message.messageId)).toEqual(['bob-message']);
|
||||
expect(aliceForced.map((entry) => entry.message.messageId)).toEqual(['alice-message']);
|
||||
expect(getMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenCodeMemberRuntimePreviewSource', () => {
|
||||
|
|
@ -524,7 +665,134 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('renders terminal OpenCode delivery errors as error-toned previews', async () => {
|
||||
it('merges visible team activity with ledger previews before using runtime transcript', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
await writeOpenCodePromptLedger({
|
||||
claudeRoot,
|
||||
teamName: 'alpha-team',
|
||||
laneId,
|
||||
records: [
|
||||
openCodeLedgerRecord({
|
||||
laneId,
|
||||
responseState: 'responded_non_visible_tool',
|
||||
observedAssistantPreview: null,
|
||||
observedToolCallNames: ['task_get', 'glob', 'bash'],
|
||||
updatedAt: '2026-04-04T00:02:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
await writeTeamLeadInbox({
|
||||
claudeRoot,
|
||||
teamName: 'alpha-team',
|
||||
messages: [
|
||||
inboxMessage({
|
||||
messageId: 'visible-message-newer',
|
||||
summary: '#abc12345 completed - visible reply',
|
||||
text: '#abc12345 done. <system-reminder>hidden</system-reminder> Full result posted.',
|
||||
timestamp: '2026-04-04T00:03:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const getOpenCodeTranscript = vi.fn();
|
||||
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
|
||||
resolve: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await source.loadPreview(previewInput({ laneId }));
|
||||
|
||||
expect(result.status).toBe('included');
|
||||
expect(result.items.map((item) => item.title)).toEqual(['Task completed', 'Tool activity']);
|
||||
expect(result.items[0]?.preview).toContain('#abc12345 completed - visible reply');
|
||||
expect(result.items[0]?.preview).not.toContain('system-reminder');
|
||||
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses visible team activity instead of delayed empty state for warning-only ledger records', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
await writeOpenCodePromptLedger({
|
||||
claudeRoot,
|
||||
teamName: 'alpha-team',
|
||||
laneId,
|
||||
records: [
|
||||
openCodeLedgerRecord({
|
||||
laneId,
|
||||
status: 'failed_terminal',
|
||||
responseState: 'session_stale',
|
||||
observedAssistantPreview: null,
|
||||
observedToolCallNames: [],
|
||||
failedAt: '2026-04-04T00:03:00.000Z',
|
||||
updatedAt: '2026-04-04T00:03:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
await writeTeamLeadInbox({
|
||||
claudeRoot,
|
||||
teamName: 'alpha-team',
|
||||
messages: [
|
||||
inboxMessage({
|
||||
messageId: 'visible-message-after-stale-session',
|
||||
summary: '#abc12345 done - visible fallback',
|
||||
text: '#abc12345 done. Runtime session was stale but the team message is visible.',
|
||||
timestamp: '2026-04-04T00:04:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const getOpenCodeTranscript = vi.fn();
|
||||
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
|
||||
resolve: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await source.loadPreview(previewInput({ laneId }));
|
||||
|
||||
expect(result.status).toBe('included');
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
title: 'Task completed',
|
||||
preview: expect.stringContaining('#abc12345 done - visible fallback'),
|
||||
});
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual(['opencode_delivery_delayed']);
|
||||
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps assistant evidence from terminal ledger records and reports delayed delivery', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
await writeOpenCodePromptLedger({
|
||||
claudeRoot,
|
||||
teamName: 'alpha-team',
|
||||
laneId,
|
||||
records: [
|
||||
openCodeLedgerRecord({
|
||||
laneId,
|
||||
status: 'failed_terminal',
|
||||
responseState: 'responded_plain_text',
|
||||
observedAssistantPreview: 'I completed the requested update.',
|
||||
failedAt: '2026-04-04T00:01:00.000Z',
|
||||
updatedAt: '2026-04-04T00:01:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const getOpenCodeTranscript = vi.fn();
|
||||
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
|
||||
resolve: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await source.loadPreview(previewInput({ laneId }));
|
||||
|
||||
expect(result.status).toBe('included');
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'text',
|
||||
title: 'OpenCode reply',
|
||||
preview: 'I completed the requested update.',
|
||||
tone: 'neutral',
|
||||
});
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual(['opencode_delivery_delayed']);
|
||||
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns terminal ledger failures without evidence into delayed warnings and falls back', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
await writeOpenCodePromptLedger({
|
||||
|
|
@ -544,20 +812,136 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
|
|||
}),
|
||||
],
|
||||
});
|
||||
const source = new OpenCodeMemberRuntimePreviewSource(
|
||||
{ getOpenCodeTranscript: vi.fn() } as never,
|
||||
{ resolve: vi.fn() }
|
||||
);
|
||||
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
|
||||
sessionId: 'opencode-session',
|
||||
logProjection: {
|
||||
messages: [
|
||||
{
|
||||
uuid: 'opencode-transcript-1',
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-04T00:02:00.000Z',
|
||||
role: 'assistant',
|
||||
content: 'Transcript recovered after delayed delivery.',
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isMeta: false,
|
||||
sessionId: 'opencode-session',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
|
||||
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
|
||||
});
|
||||
|
||||
const result = await source.loadPreview(previewInput({ laneId }));
|
||||
|
||||
expect(result.status).toBe('included');
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Tool error',
|
||||
preview: 'tool failed with stderr output',
|
||||
tone: 'error',
|
||||
kind: 'text',
|
||||
title: 'Assistant',
|
||||
preview: 'Transcript recovered after delayed delivery.',
|
||||
});
|
||||
expect(result.warnings).toEqual([
|
||||
{
|
||||
code: 'opencode_delivery_delayed',
|
||||
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
|
||||
},
|
||||
]);
|
||||
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves delayed ledger warnings when transcript fallback times out', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
await writeOpenCodePromptLedger({
|
||||
claudeRoot,
|
||||
teamName: 'alpha-team',
|
||||
laneId,
|
||||
records: [
|
||||
openCodeLedgerRecord({
|
||||
laneId,
|
||||
status: 'failed_terminal',
|
||||
responseState: 'reconcile_failed',
|
||||
observedAssistantPreview: null,
|
||||
observedToolCallNames: [],
|
||||
lastReason:
|
||||
'opencode_message_delivery_exception: Bridge server runtime manifest high watermark is stale',
|
||||
failedAt: '2026-04-04T00:01:00.000Z',
|
||||
updatedAt: '2026-04-04T00:01:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const getOpenCodeTranscript = vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error(`Command failed: runtime transcript --lane ${laneId}`), {
|
||||
killed: true,
|
||||
signal: 'SIGTERM',
|
||||
})
|
||||
);
|
||||
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
|
||||
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
|
||||
});
|
||||
|
||||
const result = await source.loadPreview(previewInput({ laneId }));
|
||||
|
||||
expect(result.status).toBe('skipped');
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual([
|
||||
'opencode_delivery_delayed',
|
||||
'opencode_runtime_timeout',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps delayed ledger warnings when transcript is empty', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
await writeOpenCodePromptLedger({
|
||||
claudeRoot,
|
||||
teamName: 'alpha-team',
|
||||
laneId,
|
||||
records: [
|
||||
openCodeLedgerRecord({
|
||||
id: 'opencode-prompt:session-error',
|
||||
laneId,
|
||||
status: 'accepted',
|
||||
responseState: 'session_error',
|
||||
observedAssistantPreview: null,
|
||||
observedToolCallNames: [],
|
||||
lastReason: 'Key limit exceeded',
|
||||
}),
|
||||
openCodeLedgerRecord({
|
||||
id: 'opencode-prompt:empty-turn',
|
||||
laneId,
|
||||
status: 'failed_terminal',
|
||||
responseState: 'empty_assistant_turn',
|
||||
observedAssistantPreview: null,
|
||||
observedToolCallNames: [],
|
||||
lastReason: 'empty_assistant_turn',
|
||||
failedAt: '2026-04-04T00:01:00.000Z',
|
||||
updatedAt: '2026-04-04T00:01:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
|
||||
sessionId: 'opencode-session',
|
||||
logProjection: { messages: [] },
|
||||
});
|
||||
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
|
||||
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
|
||||
});
|
||||
|
||||
const result = await source.loadPreview(previewInput({ laneId }));
|
||||
|
||||
expect(result.status).toBe('skipped');
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.warnings).toEqual([
|
||||
{
|
||||
code: 'opencode_delivery_delayed',
|
||||
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
|
||||
},
|
||||
]);
|
||||
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the real relay-works bob ledger shape as tool activity without runtime transcript', async () => {
|
||||
|
|
@ -631,7 +1015,7 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
|
|||
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the real relay-works jack visible-reply failure as a readable error', async () => {
|
||||
it('renders the real relay-works jack visible-reply failure as activity with a warning', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
const teamName = 'relay-works';
|
||||
const memberName = 'jack';
|
||||
|
|
@ -694,13 +1078,19 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
|
|||
|
||||
expect(result.status).toBe('included');
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Visible reply missing',
|
||||
preview: 'visible reply destination not found yet',
|
||||
tone: 'error',
|
||||
kind: 'tool_use',
|
||||
title: 'Tool activity',
|
||||
preview: 'task_get, message_send',
|
||||
tone: 'neutral',
|
||||
sessionId: 'relay-jack-session',
|
||||
laneId,
|
||||
});
|
||||
expect(result.warnings).toEqual([
|
||||
{
|
||||
code: 'opencode_delivery_delayed',
|
||||
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
|
||||
},
|
||||
]);
|
||||
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -737,6 +1127,51 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('does not count warning-only ledger records as preview overflow', async () => {
|
||||
const claudeRoot = await createTempClaudeRoot();
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
await writeOpenCodePromptLedger({
|
||||
claudeRoot,
|
||||
teamName: 'alpha-team',
|
||||
laneId,
|
||||
records: [
|
||||
...Array.from({ length: 5 }, (_, index) =>
|
||||
openCodeLedgerRecord({
|
||||
id: `opencode-prompt:warning-only-${index}`,
|
||||
laneId,
|
||||
status: 'failed_terminal',
|
||||
responseState: 'reconcile_failed',
|
||||
observedAssistantPreview: null,
|
||||
observedToolCallNames: [],
|
||||
lastReason:
|
||||
'opencode_message_delivery_exception: Bridge server runtime manifest high watermark is stale',
|
||||
failedAt: `2026-04-04T00:00:0${index}.000Z`,
|
||||
updatedAt: `2026-04-04T00:00:0${index}.000Z`,
|
||||
})
|
||||
),
|
||||
openCodeLedgerRecord({
|
||||
id: 'opencode-prompt:visible',
|
||||
laneId,
|
||||
observedAssistantPreview: 'Visible event',
|
||||
updatedAt: '2026-04-04T00:01:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const getOpenCodeTranscript = vi.fn();
|
||||
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
|
||||
resolve: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await source.loadPreview(previewInput({ laneId, maxItems: 3 }));
|
||||
|
||||
expect(result.status).toBe('included');
|
||||
expect(result.items.map((item) => item.preview)).toEqual(['Visible event']);
|
||||
expect(result.truncated).toBe(false);
|
||||
expect(result.overflowCount).toBe(0);
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual(['opencode_delivery_delayed']);
|
||||
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to OpenCode transcript when the delivery ledger has no renderable records', async () => {
|
||||
await createTempClaudeRoot();
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
export type ClaudePreflightCommandCapabilities = {
|
||||
export interface ClaudePreflightCommandCapabilities {
|
||||
bare: boolean;
|
||||
strictMcpConfig: boolean;
|
||||
mcpConfig: boolean;
|
||||
settingSources: boolean;
|
||||
inlineSettings: boolean;
|
||||
tools: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ClaudePreflightCommandResult =
|
||||
| { ok: true; args: string[]; omittedFlags: string[] }
|
||||
|
|
@ -29,7 +29,7 @@ export function buildClaudeWorkspaceTrustPreflightArgs(input: {
|
|||
...(input.capabilities ?? {}),
|
||||
};
|
||||
|
||||
const requiredProtectedFlags: Array<keyof ClaudePreflightCommandCapabilities> = [
|
||||
const requiredProtectedFlags: (keyof ClaudePreflightCommandCapabilities)[] = [
|
||||
'strictMcpConfig',
|
||||
'mcpConfig',
|
||||
'settingSources',
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@ import { buildClaudeWorkspaceTrustPreflightArgs } from './ClaudePreflightCommand
|
|||
import { runPtyDialogEngine } from './PtyDialogEngine';
|
||||
import { detectClaudeStartupState, normalizeTerminalText } from './StartupDialogRules';
|
||||
|
||||
import type { WorkspaceTrustDiagnosticStrategyResult, WorkspaceTrustWorkspace } from '../domain';
|
||||
import type {
|
||||
ProviderStateProbe,
|
||||
PtyProcessPort,
|
||||
TerminalSnapshot,
|
||||
TempEmptyMcpConfigStore,
|
||||
TerminalSnapshot,
|
||||
} from './ports';
|
||||
import type { WorkspaceTrustDiagnosticStrategyResult, WorkspaceTrustWorkspace } from '../domain';
|
||||
|
||||
const WORKSPACE_TRUST_RAW_TAIL_LIMIT = 4096;
|
||||
|
||||
export type ClaudePtyWorkspaceTrustStrategyInput = {
|
||||
export interface ClaudePtyWorkspaceTrustStrategyInput {
|
||||
claudePath: string;
|
||||
workspaces: WorkspaceTrustWorkspace[];
|
||||
env: Record<string, string | undefined>;
|
||||
|
|
@ -22,7 +22,7 @@ export type ClaudePtyWorkspaceTrustStrategyInput = {
|
|||
isCancelled(): boolean;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function toPtyEnv(env: Record<string, string | undefined>): Record<string, string> {
|
||||
const output: Record<string, string> = {};
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export type PtyDialogEngineResult =
|
|||
lastSnapshot?: TerminalSnapshot;
|
||||
};
|
||||
|
||||
export type PtyDialogEngineInput = {
|
||||
export interface PtyDialogEngineInput {
|
||||
session: PtySessionPort;
|
||||
detect(snapshotText: string): StartupReadinessState;
|
||||
isCancelled(): boolean;
|
||||
|
|
@ -43,7 +43,7 @@ export type PtyDialogEngineInput = {
|
|||
actions: PtyKeyAction[];
|
||||
snapshot: TerminalSnapshot;
|
||||
}) => Promise<{ action: 'continue' } | { action: 'stop'; reason: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ function hasClaudeWorkspaceTrustPrompt(lower: string, compact: string): boolean
|
|||
/trust this folder/,
|
||||
]) ||
|
||||
(/(quicksafetycheck|projectyoucreated|workspacetrust)/.test(compact) &&
|
||||
/trustthisfolder/.test(compact));
|
||||
compact.includes('trustthisfolder'));
|
||||
if (knownPrompt) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,23 +8,24 @@ import {
|
|||
type WorkspaceTrustWorkspace,
|
||||
} from '../domain';
|
||||
|
||||
import type { ClaudePtyWorkspaceTrustStrategy } from './ClaudePtyWorkspaceTrustStrategy';
|
||||
import {
|
||||
WorkspaceTrustLockCancelledError,
|
||||
WorkspaceTrustLockRegistry,
|
||||
WorkspaceTrustLockTimeoutError,
|
||||
} from './WorkspaceTrustLocks';
|
||||
|
||||
export type WorkspaceTrustArgsOnlyPlanRequest = {
|
||||
import type { ClaudePtyWorkspaceTrustStrategy } from './ClaudePtyWorkspaceTrustStrategy';
|
||||
|
||||
export interface WorkspaceTrustArgsOnlyPlanRequest {
|
||||
providers: WorkspaceTrustProvider[];
|
||||
workspaces: WorkspaceTrustWorkspace[];
|
||||
targetSurfaces?: WorkspaceTrustLaunchArgTargetSurface[];
|
||||
featureFlags: WorkspaceTrustFeatureFlags;
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceTrustArgsOnlyPlanResult = {
|
||||
export interface WorkspaceTrustArgsOnlyPlanResult {
|
||||
launchArgPatches: WorkspaceTrustLaunchArgPatch[];
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceTrustFullPlanRequest = WorkspaceTrustArgsOnlyPlanRequest;
|
||||
|
||||
|
|
@ -32,13 +33,13 @@ export type WorkspaceTrustFullPlanResult = WorkspaceTrustArgsOnlyPlanResult & {
|
|||
workspaces: WorkspaceTrustWorkspace[];
|
||||
};
|
||||
|
||||
export type WorkspaceTrustExecutionPlan = {
|
||||
export interface WorkspaceTrustExecutionPlan {
|
||||
claudePath: string;
|
||||
workspaces: WorkspaceTrustWorkspace[];
|
||||
env: Record<string, string | undefined>;
|
||||
featureFlags: WorkspaceTrustFeatureFlags;
|
||||
isCancelled(): boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceTrustExecutionResult = Awaited<
|
||||
ReturnType<ClaudePtyWorkspaceTrustStrategy['execute']>
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ export class WorkspaceTrustLockCancelledError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export type WorkspaceTrustLockOptions = {
|
||||
export interface WorkspaceTrustLockOptions {
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
isCancelled(): boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * from './ClaudePreflightCommand';
|
||||
export * from './ClaudePtyWorkspaceTrustStrategy';
|
||||
export type * from './ports';
|
||||
export * from './PtyDialogEngine';
|
||||
export * from './StartupDialogRules';
|
||||
export * from './WorkspaceTrustCoordinator';
|
||||
export * from './WorkspaceTrustLocks';
|
||||
export type * from './ports';
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import type { WorkspaceTrustWorkspace } from '../domain';
|
||||
|
||||
export type TerminalSnapshot = {
|
||||
export interface TerminalSnapshot {
|
||||
text: string;
|
||||
capturedAtMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type PtyKeyAction = {
|
||||
export interface PtyKeyAction {
|
||||
id: string;
|
||||
label: string;
|
||||
sequence: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PtySpawnInput = {
|
||||
export interface PtySpawnInput {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
|
|
@ -19,7 +19,7 @@ export type PtySpawnInput = {
|
|||
cols?: number;
|
||||
rows?: number;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PtySpawnResult =
|
||||
| { ok: true; session: PtySessionPort }
|
||||
|
|
@ -44,10 +44,10 @@ export interface ProviderStateProbe {
|
|||
readTrustState(workspace: WorkspaceTrustWorkspace): Promise<ProviderTrustState>;
|
||||
}
|
||||
|
||||
export type TempEmptyMcpConfigHandle = {
|
||||
export interface TempEmptyMcpConfigHandle {
|
||||
path: string;
|
||||
cleanup(): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TempEmptyMcpConfigStore {
|
||||
create(): Promise<TempEmptyMcpConfigHandle>;
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ export const CODEX_WORKSPACE_TRUST_CONFIG_OVERRIDES_KEY = 'config_overrides';
|
|||
const CODEX_WORKSPACE_TRUST_OVERRIDE_PATTERN =
|
||||
/^projects\."(?:[^"\\\x00-\x1F]|\\["\\bfnrt]|\\u[0-9a-fA-F]{4}|\\U[0-9a-fA-F]{8})+"\.trust_level="trusted"$/;
|
||||
|
||||
export type CodexWorkspaceTrustSettingsObject = {
|
||||
export interface CodexWorkspaceTrustSettingsObject {
|
||||
codex: {
|
||||
agent_teams_workspace_trust: {
|
||||
config_overrides: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function toHex(value: number, width: number): string {
|
||||
return value.toString(16).padStart(width, '0').toUpperCase();
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@ export type WorkspaceTrustLaunchArgPatchSkipReason =
|
|||
| 'empty_patch'
|
||||
| 'malformed_patch_settings';
|
||||
|
||||
export type WorkspaceTrustLaunchArgPatchApplication = {
|
||||
export interface WorkspaceTrustLaunchArgPatchApplication {
|
||||
args: string[];
|
||||
appliedPatchIds: string[];
|
||||
skippedPatches: Array<{ id: string; reason: WorkspaceTrustLaunchArgPatchSkipReason }>;
|
||||
skippedPatches: { id: string; reason: WorkspaceTrustLaunchArgPatchSkipReason }[];
|
||||
addedWorkspaceTrustOverrideCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string): Record<string, unknown> | null {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import type {
|
||||
WorkspaceTrustDiagnosticStrategyResult,
|
||||
WorkspaceTrustDiagnosticsManifest,
|
||||
WorkspaceTrustDiagnosticStrategyResult,
|
||||
} from './WorkspaceTrustTypes';
|
||||
|
||||
export type WorkspaceTrustDiagnosticsBudgetLimits = {
|
||||
export interface WorkspaceTrustDiagnosticsBudgetLimits {
|
||||
maxStrategyResults: number;
|
||||
maxWorkspaceIdsPerResult: number;
|
||||
maxEvidencePerResult: number;
|
||||
maxEvidenceLength: number;
|
||||
maxRawTailLength: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKSPACE_TRUST_DIAGNOSTICS_BUDGET: WorkspaceTrustDiagnosticsBudgetLimits = {
|
||||
maxStrategyResults: 20,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import type {
|
|||
|
||||
export type WorkspaceTrustPathPlatform = 'posix' | 'win32';
|
||||
|
||||
export type WorkspaceTrustPathOptions = {
|
||||
export interface WorkspaceTrustPathOptions {
|
||||
platform?: WorkspaceTrustPathPlatform;
|
||||
};
|
||||
}
|
||||
|
||||
export type BuildWorkspaceTrustPathCandidatesInput = WorkspaceTrustPathOptions & {
|
||||
cwd: string;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export type WorkspaceTrustNonPersistableReason =
|
|||
| 'filesystem_root'
|
||||
| 'unavailable';
|
||||
|
||||
export type WorkspaceTrustWorkspace = {
|
||||
export interface WorkspaceTrustWorkspace {
|
||||
id: string;
|
||||
displayCwd: string;
|
||||
cwd: string;
|
||||
|
|
@ -23,15 +23,15 @@ export type WorkspaceTrustWorkspace = {
|
|||
memberId?: string;
|
||||
persistable: boolean;
|
||||
nonPersistableReason?: WorkspaceTrustNonPersistableReason;
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceTrustFeatureFlags = {
|
||||
export interface WorkspaceTrustFeatureFlags {
|
||||
enabled: boolean;
|
||||
claudePty: boolean;
|
||||
codexArgs: boolean;
|
||||
retry: boolean;
|
||||
fileLock: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceTrustLaunchArgTargetSurface =
|
||||
| 'primary_provider_args'
|
||||
|
|
@ -44,7 +44,7 @@ export type WorkspaceTrustLaunchArgDialect =
|
|||
| 'claude-codex-runtime-settings'
|
||||
| 'codex-direct-cli-config';
|
||||
|
||||
export type WorkspaceTrustLaunchArgPatch = {
|
||||
export interface WorkspaceTrustLaunchArgPatch {
|
||||
id: string;
|
||||
owner: 'workspace-trust';
|
||||
targetProvider: WorkspaceTrustProvider;
|
||||
|
|
@ -54,11 +54,11 @@ export type WorkspaceTrustLaunchArgPatch = {
|
|||
dedupeKey: string;
|
||||
sourceWorkspaceIds: string[];
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceTrustExecutionStatus = 'ok' | 'soft_failed' | 'blocked' | 'cancelled';
|
||||
|
||||
export type WorkspaceTrustDiagnosticStrategyResult = {
|
||||
export interface WorkspaceTrustDiagnosticStrategyResult {
|
||||
id: string;
|
||||
provider: WorkspaceTrustProvider;
|
||||
status: WorkspaceTrustExecutionStatus | 'skipped';
|
||||
|
|
@ -70,11 +70,11 @@ export type WorkspaceTrustDiagnosticStrategyResult = {
|
|||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
rawTail?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceTrustDiagnosticsManifest = {
|
||||
export interface WorkspaceTrustDiagnosticsManifest {
|
||||
attempt: number;
|
||||
featureFlags: WorkspaceTrustFeatureFlags;
|
||||
strategyResults: WorkspaceTrustDiagnosticStrategyResult[];
|
||||
omittedCounts?: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export * from './contracts';
|
||||
export type * from './contracts';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { IPty } from 'node-pty';
|
||||
import type * as NodePty from 'node-pty';
|
||||
import type {
|
||||
PtyKeyAction,
|
||||
PtyProcessPort,
|
||||
|
|
@ -10,6 +8,8 @@ import type {
|
|||
PtySpawnResult,
|
||||
TerminalSnapshot,
|
||||
} from '../../../core/application';
|
||||
import type { IPty } from 'node-pty';
|
||||
import type * as NodePty from 'node-pty';
|
||||
|
||||
const logger = createLogger('WorkspaceTrustNodePtyProcessAdapter');
|
||||
const MAX_TRANSCRIPT_CHARS = 64 * 1024;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export type * from '../core/application';
|
||||
export {
|
||||
ClaudePtyWorkspaceTrustStrategy,
|
||||
buildClaudeWorkspaceTrustPreflightArgs,
|
||||
ClaudePtyWorkspaceTrustStrategy,
|
||||
runPtyDialogEngine,
|
||||
} from '../core/application';
|
||||
export {
|
||||
|
|
@ -19,11 +20,10 @@ export {
|
|||
normalizeWorkspaceTrustComparisonKey,
|
||||
normalizeWorkspaceTrustConfigKey,
|
||||
} from '../core/domain';
|
||||
export type * from '../core/domain/WorkspaceTrustTypes';
|
||||
export { FileClaudeStateProbe } from './adapters/output/ClaudeStateProbe';
|
||||
export { NodePtyProcessAdapter } from './adapters/output/NodePtyProcessAdapter';
|
||||
export { FileTempEmptyMcpConfigStore } from './adapters/output/TempEmptyMcpConfigStore';
|
||||
export { createWorkspaceTrustCoordinator } from './composition/createWorkspaceTrustCoordinator';
|
||||
export { resolveWorkspaceTrustFeatureFlags } from './infrastructure/WorkspaceTrustFeatureFlags';
|
||||
export { buildWorkspaceTrustPreflightEnv } from './infrastructure/workspaceTrustPreflightEnv';
|
||||
export type * from '../core/application';
|
||||
export type * from '../core/domain/WorkspaceTrustTypes';
|
||||
|
|
|
|||
|
|
@ -2588,10 +2588,13 @@ void app.whenReady().then(async () => {
|
|||
// Sync Sentry telemetry opt-in flag from persisted config
|
||||
syncTelemetryFlag(config.general.telemetryEnabled);
|
||||
|
||||
// Apply launch-at-login setting only in packaged builds.
|
||||
// In dev, macOS may deny this (and Electron logs a noisy error to stderr).
|
||||
// Also guard by platform: Electron only supports this on macOS/Windows.
|
||||
if (app.isPackaged && (process.platform === 'darwin' || process.platform === 'win32')) {
|
||||
// Apply launch-at-login only where Electron can persist it without noisy OS errors.
|
||||
// Local packaged macOS smoke builds run outside /Applications and cannot set login items.
|
||||
const canSyncLaunchAtLogin =
|
||||
app.isPackaged &&
|
||||
(process.platform === 'win32' ||
|
||||
(process.platform === 'darwin' && app.isInApplicationsFolder()));
|
||||
if (canSyncLaunchAtLogin) {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: config.general.launchAtLogin,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@ function resolveRendererPath(): string | null {
|
|||
candidates.unshift(
|
||||
// Standalone: dist-standalone/index.cjs → ../out/renderer
|
||||
join(__dirname, '../out/renderer'),
|
||||
// Electron production (asar fallback): app.asar/out/renderer
|
||||
join(__dirname, '../../out/renderer'),
|
||||
// Electron production (asarUnpack): app.asar.unpacked/out/renderer (real filesystem)
|
||||
join(__dirname, '../../out/renderer').replace('app.asar', 'app.asar.unpacked')
|
||||
join(__dirname, '../../out/renderer').replace('app.asar', 'app.asar.unpacked'),
|
||||
// Electron production fallback: app.asar/out/renderer
|
||||
join(__dirname, '../../out/renderer')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -556,8 +556,8 @@ import type {
|
|||
TeamMember,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
TeamProvisioningPrepareIssue,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
TeamProvisioningPrepareIssue,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamProvisioningState,
|
||||
|
|
@ -1055,7 +1055,7 @@ function getRunRuntimeFailureLabel(run: ProvisioningRun): string {
|
|||
}
|
||||
|
||||
if (providerIds.size === 1) {
|
||||
return getProviderRuntimeFailureLabel([...providerIds][0]!);
|
||||
return getProviderRuntimeFailureLabel([...providerIds][0]);
|
||||
}
|
||||
|
||||
return getCliFlavorUiOptions(getConfiguredCliFlavor()).displayName;
|
||||
|
|
@ -7773,6 +7773,50 @@ export class TeamProvisioningService {
|
|||
return diagnostics.some((diagnostic) => isProbeTimeoutMessage(diagnostic));
|
||||
}
|
||||
|
||||
private isOpenCodeRuntimeManifestWatermarkDeliveryFailure(
|
||||
record: OpenCodePromptDeliveryLedgerRecord
|
||||
): boolean {
|
||||
return [record.lastReason, ...record.diagnostics].some(
|
||||
(reason) =>
|
||||
typeof reason === 'string' &&
|
||||
reason.toLowerCase().includes('runtime manifest high watermark is stale')
|
||||
);
|
||||
}
|
||||
|
||||
private async requeueOpenCodeRuntimeManifestWatermarkDeliveryIfNeeded(input: {
|
||||
ledger: OpenCodePromptDeliveryLedgerStore;
|
||||
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
if (
|
||||
input.ledgerRecord.status !== 'failed_terminal' ||
|
||||
input.ledgerRecord.inboxReadCommittedAt ||
|
||||
!this.isOpenCodeRuntimeManifestWatermarkDeliveryFailure(input.ledgerRecord)
|
||||
) {
|
||||
return input.ledgerRecord;
|
||||
}
|
||||
|
||||
const scheduledAt = nowIso();
|
||||
const requeued = await input.ledger.markNextAttemptScheduled({
|
||||
id: input.ledgerRecord.id,
|
||||
status: 'retry_scheduled',
|
||||
nextAttemptAt: scheduledAt,
|
||||
reason: 'opencode_prompt_delivery_requeued_after_runtime_manifest_high_watermark_fix',
|
||||
scheduledAt,
|
||||
});
|
||||
logger.info(
|
||||
'opencode_prompt_delivery_requeued_after_runtime_manifest_high_watermark_fix',
|
||||
JSON.stringify({
|
||||
teamName: requeued.teamName,
|
||||
memberName: requeued.memberName,
|
||||
laneId: requeued.laneId,
|
||||
runId: requeued.runId,
|
||||
inboxMessageId: requeued.inboxMessageId,
|
||||
attempts: requeued.attempts,
|
||||
})
|
||||
);
|
||||
return requeued;
|
||||
}
|
||||
|
||||
private isOpenCodeDirectUserPromptDelivery(
|
||||
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
|
||||
): boolean {
|
||||
|
|
@ -9606,7 +9650,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
if (recovered) {
|
||||
runtimeRunId = await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId);
|
||||
runtimeActive = true;
|
||||
runtimeActive = await this.isOpenCodeRuntimeLaneIndexActive(teamName, laneIdentity.laneId);
|
||||
}
|
||||
}
|
||||
if (
|
||||
|
|
@ -9886,6 +9930,11 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
ledgerRecord = await this.requeueOpenCodeRuntimeManifestWatermarkDeliveryIfNeeded({
|
||||
ledger,
|
||||
ledgerRecord,
|
||||
});
|
||||
|
||||
if (ledgerRecord.status === 'failed_terminal') {
|
||||
this.logOpenCodePromptDeliveryEvent(
|
||||
'opencode_prompt_delivery_terminal_failure',
|
||||
|
|
@ -10893,6 +10942,21 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
|
||||
private async tryRecoverOpenCodeRuntimeLaneForConfiguredMemberAndVerifyActive(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneId: string;
|
||||
}): Promise<boolean> {
|
||||
const recovered = await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
}).catch(() => false);
|
||||
if (!recovered) {
|
||||
return false;
|
||||
}
|
||||
return this.isOpenCodeRuntimeLaneIndexActive(input.teamName, input.laneId).catch(() => false);
|
||||
}
|
||||
|
||||
private async tryRecoverOpenCodeRuntimeLanesForDeliveryWatchdog(
|
||||
teamName: string,
|
||||
options: { allowCommittedSessionRecoveryWithoutTeamRuntime?: boolean } = {}
|
||||
|
|
@ -13258,9 +13322,10 @@ export class TeamProvisioningService {
|
|||
() => null
|
||||
);
|
||||
if (laneIndex?.lanes[identity.laneId]?.state !== 'active') {
|
||||
const recovered = await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery({
|
||||
const recovered = await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberAndVerifyActive({
|
||||
teamName: input.teamName,
|
||||
memberName: identity.canonicalMemberName,
|
||||
laneId: identity.laneId,
|
||||
});
|
||||
if (!recovered) {
|
||||
return null;
|
||||
|
|
@ -13391,7 +13456,14 @@ export class TeamProvisioningService {
|
|||
if (!laneIndex) {
|
||||
return { busy: true, reason: 'opencode_lane_index_unavailable', retryAfterIso };
|
||||
}
|
||||
if (laneIndex.lanes[identity.laneId]?.state !== 'active') {
|
||||
if (
|
||||
laneIndex.lanes[identity.laneId]?.state !== 'active' &&
|
||||
!(await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberAndVerifyActive({
|
||||
teamName: input.teamName,
|
||||
memberName: identity.canonicalMemberName,
|
||||
laneId: identity.laneId,
|
||||
}).catch(() => false))
|
||||
) {
|
||||
return { busy: true, reason: 'opencode_no_active_lane', retryAfterIso };
|
||||
}
|
||||
|
||||
|
|
@ -22486,7 +22558,7 @@ export class TeamProvisioningService {
|
|||
.slice(0, 10);
|
||||
|
||||
for (const message of unread) {
|
||||
const existingRecord = await promptLedger
|
||||
let existingRecord = await promptLedger
|
||||
.getByInboxMessage({
|
||||
teamName,
|
||||
memberName: memberIdentity.canonicalMemberName,
|
||||
|
|
@ -22494,6 +22566,17 @@ export class TeamProvisioningService {
|
|||
inboxMessageId: message.messageId,
|
||||
})
|
||||
.catch(() => null);
|
||||
if (existingRecord?.status === 'failed_terminal') {
|
||||
const requeuedRecord = await this.requeueOpenCodeRuntimeManifestWatermarkDeliveryIfNeeded(
|
||||
{
|
||||
ledger: promptLedger,
|
||||
ledgerRecord: existingRecord,
|
||||
}
|
||||
);
|
||||
if (requeuedRecord.status !== 'failed_terminal') {
|
||||
existingRecord = requeuedRecord;
|
||||
}
|
||||
}
|
||||
if (existingRecord?.status === 'failed_terminal') {
|
||||
let recoveredRecord: OpenCodePromptDeliveryLedgerRecord | null = null;
|
||||
let recoveredVisibleReply: OpenCodeVisibleReplyProof | null = null;
|
||||
|
|
@ -22852,7 +22935,17 @@ export class TeamProvisioningService {
|
|||
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), input.teamName).catch(
|
||||
() => null
|
||||
);
|
||||
if (laneIndex?.lanes[identity.laneId]?.state !== 'active') {
|
||||
if (!laneIndex) {
|
||||
return bypassMessageIds;
|
||||
}
|
||||
const laneActive =
|
||||
laneIndex.lanes[identity.laneId]?.state === 'active' ||
|
||||
(await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberAndVerifyActive({
|
||||
teamName: input.teamName,
|
||||
memberName: identity.canonicalMemberName,
|
||||
laneId: identity.laneId,
|
||||
}).catch(() => false));
|
||||
if (!laneActive) {
|
||||
return bypassMessageIds;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -742,6 +742,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
|
|||
capabilitySnapshotId: string | null;
|
||||
manifest: RuntimeStoreManifestEvidence;
|
||||
idempotencyKey: string;
|
||||
enforceManifestHighWatermark?: boolean;
|
||||
}): asserts input is {
|
||||
result: OpenCodeBridgeSuccess<unknown>;
|
||||
requestId: string;
|
||||
|
|
@ -760,6 +761,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
|
|||
|
||||
const resultManifestHighWatermark = extractManifestHighWatermark(input.result.data);
|
||||
if (
|
||||
input.enforceManifestHighWatermark !== false &&
|
||||
typeof resultManifestHighWatermark === 'number' &&
|
||||
resultManifestHighWatermark < input.manifest.highWatermark
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ import {
|
|||
stableHash,
|
||||
validateOpenCodeBridgeHandshake,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
import { OpenCodeBridgeCommandLeaseError } from './OpenCodeBridgeCommandLedgerStore';
|
||||
|
||||
import type {
|
||||
OpenCodeBridgeCommandLeaseStore,
|
||||
OpenCodeBridgeCommandLease,
|
||||
OpenCodeBridgeCommandLeaseStore,
|
||||
OpenCodeBridgeCommandLedger,
|
||||
} from './OpenCodeBridgeCommandLedgerStore';
|
||||
import { OpenCodeBridgeCommandLeaseError } from './OpenCodeBridgeCommandLedgerStore';
|
||||
|
||||
const DEFAULT_COMMAND_LEASE_ACQUIRE_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_COMMAND_LEASE_ACQUIRE_RETRY_DELAY_MS = 100;
|
||||
|
|
@ -117,11 +117,17 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
}): Promise<OpenCodeBridgeResult<TData>> {
|
||||
const normalizedLaneId = input.laneId ?? null;
|
||||
const manifest = await this.manifestReader.read(input.teamName, normalizedLaneId);
|
||||
const enforceManifestHighWatermark = commandRequiresRuntimeStoreManifestPrecondition(
|
||||
input.command
|
||||
);
|
||||
const expectedManifestHighWatermark = enforceManifestHighWatermark
|
||||
? manifest.highWatermark
|
||||
: null;
|
||||
const handshake = await this.handshakePort.handshake({
|
||||
requiredCommand: input.command,
|
||||
expectedRunId: input.runId,
|
||||
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
|
||||
expectedManifestHighWatermark: manifest.highWatermark,
|
||||
expectedManifestHighWatermark,
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const handshakeValidation = validateOpenCodeBridgeHandshake({
|
||||
|
|
@ -129,7 +135,7 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
expectedClient: this.expectedClientIdentity,
|
||||
requiredCommand: input.command,
|
||||
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
|
||||
expectedManifestHighWatermark: manifest.highWatermark,
|
||||
expectedManifestHighWatermark,
|
||||
expectedRunId: input.runId,
|
||||
requiresDeliveryAcceptanceContract: requiresOpenCodeDeliveryAcceptanceContract(
|
||||
input.command,
|
||||
|
|
@ -164,7 +170,7 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
expectedRunId: input.runId,
|
||||
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
|
||||
expectedBehaviorFingerprint: input.behaviorFingerprint,
|
||||
expectedManifestHighWatermark: manifest.highWatermark,
|
||||
expectedManifestHighWatermark,
|
||||
commandLeaseId: lease.leaseId,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
|
@ -183,7 +189,7 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
runId: input.runId,
|
||||
capabilitySnapshotId: input.capabilitySnapshotId,
|
||||
behaviorFingerprint: input.behaviorFingerprint,
|
||||
manifestHighWatermark: manifest.highWatermark,
|
||||
manifestHighWatermark: expectedManifestHighWatermark,
|
||||
body: input.body,
|
||||
}),
|
||||
});
|
||||
|
|
@ -238,6 +244,7 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
capabilitySnapshotId: input.capabilitySnapshotId,
|
||||
manifest,
|
||||
idempotencyKey,
|
||||
enforceManifestHighWatermark,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.ledger.markFailed({
|
||||
|
|
@ -357,6 +364,12 @@ function requiresOpenCodeDeliveryAcceptanceContract(
|
|||
return body.settlementMode === 'acceptance';
|
||||
}
|
||||
|
||||
function commandRequiresRuntimeStoreManifestPrecondition(
|
||||
command: OpenCodeBridgeCommandName
|
||||
): boolean {
|
||||
return command !== 'opencode.sendMessage';
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService';
|
||||
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
|
||||
import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver';
|
||||
import { TeamTaskReader } from '../../TeamTaskReader';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
|
||||
|
||||
import { mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage } from './OpenCodeRuntimeProjectionMapper';
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ function parseTimestampMs(value: string | null | undefined): number {
|
|||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function minTimestampIso(values: Array<string | null | undefined>): string | undefined {
|
||||
function minTimestampIso(values: (string | null | undefined)[]): string | undefined {
|
||||
const times = values.map(parseTimestampMs).filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (times.length === 0) {
|
||||
return undefined;
|
||||
|
|
@ -65,7 +65,7 @@ function minTimestampIso(values: Array<string | null | undefined>): string | und
|
|||
return new Date(Math.min(...times)).toISOString();
|
||||
}
|
||||
|
||||
function maxTimestampIso(values: Array<string | null | undefined>): string | undefined {
|
||||
function maxTimestampIso(values: (string | null | undefined)[]): string | undefined {
|
||||
const times = values.map(parseTimestampMs).filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (times.length === 0) {
|
||||
return undefined;
|
||||
|
|
@ -192,7 +192,7 @@ async function mapWithConcurrency<TInput, TOutput>(
|
|||
while (index < inputs.length) {
|
||||
const currentIndex = index;
|
||||
index += 1;
|
||||
results[currentIndex] = await mapper(inputs[currentIndex] as TInput);
|
||||
results[currentIndex] = await mapper(inputs[currentIndex]);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ type ProvisioningDetailSummary =
|
|||
| 'Selected model unavailable'
|
||||
| 'Selected model verification timed out'
|
||||
| 'Selected model check failed'
|
||||
| 'Selected model ping not confirmed'
|
||||
| 'Ready with notes'
|
||||
| 'Needs attention';
|
||||
|
||||
|
|
@ -161,7 +162,8 @@ function isFormattedModelDetail(lower: string): boolean {
|
|||
lower.includes(' - available for launch') ||
|
||||
lower.includes(' - compatible, deep verification pending') ||
|
||||
lower.includes(' - unavailable') ||
|
||||
lower.includes(' - check failed')
|
||||
lower.includes(' - check failed') ||
|
||||
lower.includes(' - ping not confirmed')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -257,6 +259,9 @@ function summarizeDetail(
|
|||
if (lower.includes(' - check failed -')) {
|
||||
return 'Selected model check failed';
|
||||
}
|
||||
if (lower.includes(' - ping not confirmed')) {
|
||||
return 'Selected model ping not confirmed';
|
||||
}
|
||||
|
||||
if (status === 'notes') {
|
||||
return 'Ready with notes';
|
||||
|
|
@ -274,6 +279,7 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
let unavailableCount = 0;
|
||||
let timedOutCount = 0;
|
||||
let checkFailedCount = 0;
|
||||
let pingNotConfirmedCount = 0;
|
||||
let checkingCount = 0;
|
||||
|
||||
for (const detail of details) {
|
||||
|
|
@ -321,6 +327,10 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
checkFailedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (lower.includes(' - ping not confirmed')) {
|
||||
pingNotConfirmedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (lower.includes(' - checking...')) {
|
||||
checkingCount += 1;
|
||||
}
|
||||
|
|
@ -336,6 +346,9 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
if (timedOutCount > 0) {
|
||||
parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`);
|
||||
}
|
||||
if (pingNotConfirmedCount > 0) {
|
||||
parts.push(`${pingNotConfirmedCount} ping not confirmed`);
|
||||
}
|
||||
if (compatibilityPendingCount > 0) {
|
||||
parts.push(`${compatibilityPendingCount} compatible, deep verification pending`);
|
||||
}
|
||||
|
|
@ -397,6 +410,9 @@ function getDetailTone(
|
|||
if (summary === 'Selected model verification timed out') {
|
||||
return 'neutral';
|
||||
}
|
||||
if (summary === 'Selected model ping not confirmed') {
|
||||
return 'neutral';
|
||||
}
|
||||
if (
|
||||
summary === 'Selected model unavailable' ||
|
||||
summary === 'Selected model check failed' ||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export interface ProviderPrepareDiagnosticsResult {
|
|||
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
}
|
||||
|
||||
type TeamProvisioningPrepareIssue = NonNullable<TeamProvisioningPrepareResult['issues']>[number];
|
||||
|
||||
export function buildReusableProviderPrepareModelResults(
|
||||
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>
|
||||
): Record<string, ProviderPrepareDiagnosticsModelResult> {
|
||||
|
|
@ -235,18 +237,88 @@ function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined
|
|||
);
|
||||
}
|
||||
|
||||
function getBlockingProviderIssue(
|
||||
providerId: TeamProviderId,
|
||||
result: TeamProvisioningPrepareResult
|
||||
): TeamProvisioningPrepareIssue | null {
|
||||
return (
|
||||
result.issues?.find(
|
||||
(entry) =>
|
||||
entry.scope === 'provider' &&
|
||||
entry.severity === 'blocking' &&
|
||||
(!entry.providerId || entry.providerId === providerId) &&
|
||||
entry.message.trim().length > 0
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getBlockingProviderIssueMessage(
|
||||
providerId: TeamProviderId,
|
||||
result: TeamProvisioningPrepareResult
|
||||
): string | null {
|
||||
const issue = result.issues?.find(
|
||||
(entry) =>
|
||||
entry.scope === 'provider' &&
|
||||
entry.severity === 'blocking' &&
|
||||
(!entry.providerId || entry.providerId === providerId) &&
|
||||
entry.message.trim().length > 0
|
||||
return getBlockingProviderIssue(providerId, result)?.message.trim() ?? null;
|
||||
}
|
||||
|
||||
function isAdvisoryOpenCodeDeepVerificationIssue(
|
||||
issue: TeamProvisioningPrepareIssue | null,
|
||||
reason: string | null | undefined
|
||||
): boolean {
|
||||
if (issue?.code?.trim().toLowerCase() !== 'unknown_error') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lower = [issue.message, reason]
|
||||
.map((entry) => entry?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.toLowerCase();
|
||||
|
||||
if (!lower) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasHardRuntimeMarker =
|
||||
lower.includes('mcp_unavailable') ||
|
||||
lower.includes('not_authenticated') ||
|
||||
lower.includes('authentication') ||
|
||||
lower.includes('credential') ||
|
||||
lower.includes('api key') ||
|
||||
lower.includes('/experimental/tool') ||
|
||||
lower.includes('runtime store') ||
|
||||
lower.includes('opencode cli');
|
||||
if (hasHardRuntimeMarker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
lower.includes('unable to connect') ||
|
||||
lower.includes('timed out') ||
|
||||
lower.includes('timeout') ||
|
||||
lower.includes('aborted') ||
|
||||
lower.includes('econnreset') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('fetch failed') ||
|
||||
lower.includes('socket hang up') ||
|
||||
lower.includes('networkerror')
|
||||
);
|
||||
return issue?.message.trim() ?? null;
|
||||
}
|
||||
|
||||
function buildOpenCodeAdvisoryDeepVerificationWarning(reason: string | null | undefined): string {
|
||||
const normalizedReason =
|
||||
normalizeModelReason(reason?.trim() ?? '') || 'Model ping was not confirmed';
|
||||
return `OpenCode model ping was not confirmed. ${normalizedReason}`;
|
||||
}
|
||||
|
||||
function createOpenCodeAdvisoryDeepVerificationModelResult(
|
||||
providerId: TeamProviderId,
|
||||
modelId: string
|
||||
): ProviderPrepareDiagnosticsModelResult {
|
||||
const line = `${getModelLabel(providerId, modelId)} - ping not confirmed`;
|
||||
return {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
};
|
||||
}
|
||||
|
||||
function getResultReason(modelId: string, result: TeamProvisioningPrepareResult): string | null {
|
||||
|
|
@ -1036,32 +1108,54 @@ export async function runProviderPrepareDiagnostics({
|
|||
compatibilityPassedModelIds,
|
||||
batchedModelResult
|
||||
);
|
||||
const structuredProviderScopedFailure = getBlockingProviderIssueMessage(
|
||||
const structuredProviderScopedIssue = getBlockingProviderIssue(
|
||||
providerId,
|
||||
batchedModelResult
|
||||
);
|
||||
const structuredProviderScopedFailure =
|
||||
structuredProviderScopedIssue?.message.trim() ?? null;
|
||||
let handledAdvisoryDeepFailure = false;
|
||||
if (structuredProviderScopedFailure || providerScopedFailure) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [
|
||||
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
|
||||
],
|
||||
warnings: [],
|
||||
modelResultsById: {},
|
||||
};
|
||||
const failureReason =
|
||||
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed';
|
||||
if (
|
||||
isAdvisoryOpenCodeDeepVerificationIssue(structuredProviderScopedIssue, failureReason)
|
||||
) {
|
||||
hasNotes = true;
|
||||
runtimeDetailLines = [];
|
||||
runtimeWarnings = uniquePrepareLines([
|
||||
...runtimeWarnings,
|
||||
buildOpenCodeAdvisoryDeepVerificationWarning(failureReason),
|
||||
]);
|
||||
for (const modelId of compatibilityPassedModelIds) {
|
||||
recordTerminalModelResult(
|
||||
modelId,
|
||||
createOpenCodeAdvisoryDeepVerificationModelResult(providerId, modelId)
|
||||
);
|
||||
}
|
||||
handledAdvisoryDeepFailure = true;
|
||||
} else {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [failureReason],
|
||||
warnings: [],
|
||||
modelResultsById: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
|
||||
!handledAdvisoryDeepFailure &&
|
||||
(shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
|
||||
result: batchedModelResult,
|
||||
modelIds: compatibilityPassedModelIds,
|
||||
modelScopedEntriesPresent: hasModelScopedEntries,
|
||||
runtimeDetailLines,
|
||||
runtimeWarnings,
|
||||
}) ||
|
||||
(!batchedModelResult.ready &&
|
||||
!hasModelScopedEntries &&
|
||||
(compatibilityPassedModelIds.length > 1 ||
|
||||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)))
|
||||
(!batchedModelResult.ready &&
|
||||
!hasModelScopedEntries &&
|
||||
(compatibilityPassedModelIds.length > 1 ||
|
||||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))))
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
|
|
@ -1073,21 +1167,27 @@ export async function runProviderPrepareDiagnostics({
|
|||
modelResultsById: {},
|
||||
};
|
||||
}
|
||||
if (!hasModelScopedEntries && compatibilityPassedModelIds.length === 1) {
|
||||
if (
|
||||
!handledAdvisoryDeepFailure &&
|
||||
!hasModelScopedEntries &&
|
||||
compatibilityPassedModelIds.length === 1
|
||||
) {
|
||||
runtimeDetailLines = [];
|
||||
runtimeWarnings = [];
|
||||
}
|
||||
|
||||
for (const modelId of compatibilityPassedModelIds) {
|
||||
recordTerminalModelResult(
|
||||
modelId,
|
||||
resolveModelResultFromBatch(
|
||||
providerId,
|
||||
if (!handledAdvisoryDeepFailure) {
|
||||
for (const modelId of compatibilityPassedModelIds) {
|
||||
recordTerminalModelResult(
|
||||
modelId,
|
||||
batchedModelResult,
|
||||
compatibilityPassedModelIds.length === 1
|
||||
)
|
||||
);
|
||||
resolveModelResultFromBatch(
|
||||
providerId,
|
||||
modelId,
|
||||
batchedModelResult,
|
||||
compatibilityPassedModelIds.length === 1
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
hasNotes = true;
|
||||
|
|
|
|||
|
|
@ -1438,7 +1438,7 @@ a[href],
|
|||
.team-row-zebra-card,
|
||||
.project-row-zebra-card {
|
||||
--row-card-bg: color-mix(in srgb, var(--color-surface) 50%, var(--color-surface-raised) 50%);
|
||||
--row-zebra-bg: color-mix(in srgb, var(--color-surface-raised) 96%, var(--color-text) 4%);
|
||||
--row-zebra-bg: color-mix(in srgb, var(--color-surface-raised) 99%, var(--color-text) 1%);
|
||||
--row-zebra-hover-bg: color-mix(in srgb, var(--row-zebra-bg) 97%, var(--color-text) 3%);
|
||||
background-color: var(--row-card-bg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,49 @@ describe('OpenCodeStateChangingBridgeCommandService', () => {
|
|||
expect(bridge.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not apply runtime-store high watermark preconditions to sendMessage delivery', async () => {
|
||||
clientIdentity.bridgeProtocol.supportedCommands.push('opencode.sendMessage');
|
||||
const server = peerIdentity('agent_teams_orchestrator', {
|
||||
runtimeStoreManifestHighWatermark: 0,
|
||||
});
|
||||
server.bridgeProtocol.supportedCommands.push('opencode.sendMessage');
|
||||
server.bridgeProtocol.opencodeDeliveryAcceptanceContractVersion =
|
||||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION;
|
||||
handshakePort.nextHandshake = buildHandshakeWithAcceptedCommands(
|
||||
{ client: clientIdentity, server },
|
||||
['opencode.launchTeam', 'opencode.stopTeam', 'opencode.sendMessage']
|
||||
);
|
||||
bridge.resultFactory = ({ body, command, options }) =>
|
||||
bridgeSuccess({
|
||||
requestId: options.requestId,
|
||||
command,
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: body.preconditions.idempotencyKey,
|
||||
runtimeStoreManifestHighWatermark: 0,
|
||||
},
|
||||
});
|
||||
const service = createService();
|
||||
|
||||
await expect(service.execute(buildSendInput('acceptance'))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(bridge.calls).toHaveLength(1);
|
||||
expect(bridge.calls[0].body.preconditions).toMatchObject({
|
||||
expectedManifestHighWatermark: null,
|
||||
idempotencyKey: expect.stringMatching(
|
||||
/^opencode:opencode\.sendMessage:team-a:secondary_opencode_bob:run-1:/
|
||||
),
|
||||
});
|
||||
await expect(ledger.getByIdempotencyKey(bridge.calls[0].body.preconditions.idempotencyKey))
|
||||
.resolves.toMatchObject({
|
||||
requestId: 'cmd-1',
|
||||
status: 'completed',
|
||||
retryable: false,
|
||||
});
|
||||
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('adds preconditions, commits ledger, and releases lease on success', async () => {
|
||||
bridge.resultFactory = ({ body, options }) =>
|
||||
bridgeSuccess({
|
||||
|
|
|
|||
|
|
@ -5501,6 +5501,39 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not deliver direct OpenCode messages when recovery leaves the lane inactive', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_direct_lane',
|
||||
runtimePid: 456,
|
||||
diagnostics: [],
|
||||
}));
|
||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||
(svc as any).deleteSecondaryRuntimeRun('team-a', 'secondary:opencode:bob');
|
||||
vi.spyOn(svc as any, 'tryRecoverOpenCodeRuntimeLaneBeforeDelivery').mockResolvedValue(false);
|
||||
const committedRecoverySpy = vi
|
||||
.spyOn(svc as any, 'tryRecoverOpenCodeRuntimeLaneFromCommittedSessionBeforeDelivery')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello bob',
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: false,
|
||||
reason: 'opencode_runtime_not_active',
|
||||
});
|
||||
|
||||
expect(committedRecoverySpy).toHaveBeenCalled();
|
||||
expect(sendMessageToMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists verified OpenCode bridge runtime pids so member cards can show memory', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
|
|
|
|||
|
|
@ -2833,6 +2833,96 @@ Messages:
|
|||
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('retries failed-terminal OpenCode rows caused by stale runtime manifest watermark', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack');
|
||||
expect(identity.ok).toBe(true);
|
||||
const staleRecord = {
|
||||
id: 'ledger-terminal-stale-manifest',
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
runId: 'run-1',
|
||||
status: 'failed_terminal',
|
||||
responseState: 'reconcile_failed',
|
||||
attempts: 3,
|
||||
maxAttempts: 3,
|
||||
inboxMessageId: 'opencode-terminal-stale-manifest',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: null,
|
||||
taskRefs: [],
|
||||
source: 'watcher',
|
||||
lastReason:
|
||||
'opencode_message_delivery_exception: Bridge server runtime manifest high watermark is stale',
|
||||
diagnostics: [
|
||||
'opencode_message_delivery_exception: Bridge server runtime manifest high watermark is stale',
|
||||
],
|
||||
};
|
||||
const markNextAttemptScheduled = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
...staleRecord,
|
||||
status: input.status,
|
||||
nextAttemptAt: input.nextAttemptAt,
|
||||
lastReason: input.reason,
|
||||
}));
|
||||
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||
getByInboxMessage: vi.fn(async (input: { inboxMessageId: string }) =>
|
||||
input.inboxMessageId === 'opencode-terminal-stale-manifest' ? staleRecord : null
|
||||
),
|
||||
markNextAttemptScheduled,
|
||||
});
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Old stale manifest row.',
|
||||
timestamp: '2026-02-23T17:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-terminal-stale-manifest',
|
||||
},
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'New row should wait behind the retried old row.',
|
||||
timestamp: '2026-02-23T17:00:02.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-terminal-new',
|
||||
},
|
||||
]);
|
||||
const deliverSpy = vi
|
||||
.spyOn(service, 'deliverOpenCodeMemberMessage')
|
||||
.mockResolvedValue({ delivered: true, diagnostics: [] });
|
||||
|
||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||
|
||||
expect(markNextAttemptScheduled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'ledger-terminal-stale-manifest',
|
||||
status: 'retry_scheduled',
|
||||
reason: 'opencode_prompt_delivery_requeued_after_runtime_manifest_high_watermark_fix',
|
||||
})
|
||||
);
|
||||
expect(relay).toMatchObject({ relayed: 1, attempted: 1, delivered: 1, failed: 0 });
|
||||
expect(deliverSpy).toHaveBeenCalledTimes(1);
|
||||
expect(deliverSpy).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
expect.objectContaining({ messageId: 'opencode-terminal-stale-manifest' })
|
||||
);
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('fails OpenCode secondary rows with missing attachment payloads terminally without text-only delivery', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
@ -3653,11 +3743,23 @@ Messages:
|
|||
canonicalMemberName: memberName,
|
||||
laneId,
|
||||
}));
|
||||
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({
|
||||
version: 1,
|
||||
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||
lanes: {},
|
||||
});
|
||||
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex')
|
||||
.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||
lanes: {},
|
||||
})
|
||||
.mockResolvedValue({
|
||||
version: 1,
|
||||
updatedAt: '2026-02-23T17:30:01.000Z',
|
||||
lanes: {
|
||||
[laneId]: {
|
||||
laneId,
|
||||
state: 'active',
|
||||
updatedAt: '2026-02-23T17:30:01.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
const recoverySpy = vi
|
||||
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
|
||||
.mockResolvedValue(true);
|
||||
|
|
@ -3696,6 +3798,192 @@ Messages:
|
|||
);
|
||||
});
|
||||
|
||||
it('does not use an active OpenCode prompt ledger when recovery leaves the lane inactive', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
const memberName = 'jack';
|
||||
const laneId = 'secondary:opencode:jack';
|
||||
const teamsBasePath = getTeamsBasePath();
|
||||
hoisted.files.set(
|
||||
`${teamsBasePath}/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: memberName, role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
hoisted.files.set(
|
||||
`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`,
|
||||
JSON.stringify([
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: memberName,
|
||||
text: 'New task assigned to you.',
|
||||
timestamp: '2026-02-23T17:31:00.000Z',
|
||||
read: false,
|
||||
messageId: 'foreground-message-1',
|
||||
messageKind: 'default',
|
||||
},
|
||||
])
|
||||
);
|
||||
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
|
||||
ok: true,
|
||||
canonicalMemberName: memberName,
|
||||
laneId,
|
||||
}));
|
||||
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({
|
||||
version: 1,
|
||||
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||
lanes: {},
|
||||
});
|
||||
const recoverySpy = vi
|
||||
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
|
||||
.mockResolvedValue(true);
|
||||
const wakeSpy = vi
|
||||
.spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake')
|
||||
.mockImplementation(() => undefined);
|
||||
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||
getActiveForMember: vi.fn(async () => ({
|
||||
id: 'ledger-foreground-message-1',
|
||||
teamName,
|
||||
memberName,
|
||||
laneId,
|
||||
inboxMessageId: 'foreground-message-1',
|
||||
messageKind: 'default',
|
||||
status: 'pending',
|
||||
responseState: 'not_observed',
|
||||
nextAttemptAt: '2026-02-23T17:33:00.000Z',
|
||||
})),
|
||||
});
|
||||
|
||||
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||
teamName,
|
||||
memberName,
|
||||
nowIso: '2026-02-23T17:31:10.000Z',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
});
|
||||
|
||||
expect(recoverySpy).toHaveBeenCalledWith({ teamName, memberName });
|
||||
expect(busy).toMatchObject({
|
||||
busy: true,
|
||||
reason: 'opencode_foreground_inbox_unread',
|
||||
activeMessageId: 'foreground-message-1',
|
||||
});
|
||||
expect(wakeSpy).toHaveBeenCalledWith({
|
||||
teamName,
|
||||
memberName,
|
||||
messageId: 'foreground-message-1',
|
||||
delayMs: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers a missing OpenCode lane before treating work-sync delivery as unavailable', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
const memberName = 'jack';
|
||||
const laneId = 'secondary:opencode:jack';
|
||||
const teamsBasePath = getTeamsBasePath();
|
||||
hoisted.files.set(
|
||||
`${teamsBasePath}/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: memberName, role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
hoisted.files.set(`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`, JSON.stringify([]));
|
||||
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
|
||||
ok: true,
|
||||
canonicalMemberName: memberName,
|
||||
laneId,
|
||||
}));
|
||||
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex')
|
||||
.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||
lanes: {},
|
||||
})
|
||||
.mockResolvedValue({
|
||||
version: 1,
|
||||
updatedAt: '2026-02-23T17:30:01.000Z',
|
||||
lanes: {
|
||||
[laneId]: {
|
||||
laneId,
|
||||
state: 'active',
|
||||
updatedAt: '2026-02-23T17:30:01.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
const recoverySpy = vi
|
||||
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
|
||||
.mockResolvedValue(true);
|
||||
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||
getActiveForMember: vi.fn(async () => null),
|
||||
});
|
||||
|
||||
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||
teamName,
|
||||
memberName,
|
||||
nowIso: '2026-02-23T17:31:10.000Z',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
});
|
||||
|
||||
expect(recoverySpy).toHaveBeenCalledWith({ teamName, memberName });
|
||||
expect(busy).toEqual({ busy: false });
|
||||
});
|
||||
|
||||
it('keeps OpenCode work-sync busy when recovery reports success but lane index is still inactive', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
const memberName = 'jack';
|
||||
const laneId = 'secondary:opencode:jack';
|
||||
const teamsBasePath = getTeamsBasePath();
|
||||
hoisted.files.set(
|
||||
`${teamsBasePath}/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: memberName, role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
hoisted.files.set(`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`, JSON.stringify([]));
|
||||
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
|
||||
ok: true,
|
||||
canonicalMemberName: memberName,
|
||||
laneId,
|
||||
}));
|
||||
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({
|
||||
version: 1,
|
||||
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||
lanes: {},
|
||||
});
|
||||
const recoverySpy = vi
|
||||
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||
teamName,
|
||||
memberName,
|
||||
nowIso: '2026-02-23T17:31:10.000Z',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
});
|
||||
|
||||
expect(recoverySpy).toHaveBeenCalledWith({ teamName, memberName });
|
||||
expect(busy).toMatchObject({
|
||||
busy: true,
|
||||
reason: 'opencode_no_active_lane',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let proof-missing recovery get blocked by its original unread message', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
@ -3843,6 +4131,128 @@ Messages:
|
|||
expect(busy).toEqual({ busy: false });
|
||||
});
|
||||
|
||||
it('allows OpenCode agenda-sync proof-missing bypass after recovering a missing lane index', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
const laneId = 'secondary:opencode:jack';
|
||||
const teamsBasePath = getTeamsBasePath();
|
||||
const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' };
|
||||
hoisted.files.set(
|
||||
`${teamsBasePath}/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
hoisted.files.set(
|
||||
`${teamsBasePath}/${teamName}/inboxes/jack.json`,
|
||||
JSON.stringify([
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'jack',
|
||||
text: 'Please continue task #task1234.',
|
||||
timestamp: '2026-02-23T17:31:00.000Z',
|
||||
read: false,
|
||||
messageId: 'proof-missing-message-1',
|
||||
messageKind: 'default',
|
||||
taskRefs: [taskRef],
|
||||
},
|
||||
])
|
||||
);
|
||||
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
|
||||
ok: true,
|
||||
canonicalMemberName: 'jack',
|
||||
laneId,
|
||||
}));
|
||||
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex')
|
||||
.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||
lanes: {},
|
||||
})
|
||||
.mockResolvedValue({
|
||||
version: 1,
|
||||
updatedAt: '2026-02-23T17:30:01.000Z',
|
||||
lanes: {
|
||||
[laneId]: {
|
||||
laneId,
|
||||
state: 'active',
|
||||
updatedAt: '2026-02-23T17:30:01.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
const recoverySpy = vi
|
||||
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
|
||||
.mockResolvedValue(true);
|
||||
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||
list: vi.fn(async () => [
|
||||
buildOpenCodeProofMissingRecord({
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
laneId,
|
||||
inboxMessageId: 'proof-missing-message-1',
|
||||
taskRefs: [taskRef],
|
||||
}),
|
||||
]),
|
||||
getActiveForMember: vi.fn(async () => null),
|
||||
});
|
||||
|
||||
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
nowIso: '2026-02-23T17:32:00.000Z',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
taskRefs: [taskRef],
|
||||
});
|
||||
|
||||
expect(recoverySpy).toHaveBeenCalledWith({ teamName, memberName: 'jack' });
|
||||
expect(busy).toEqual({ busy: false });
|
||||
});
|
||||
|
||||
it('keeps OpenCode agenda-sync proof-missing bypass disabled when lane index is unreadable', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
const laneId = 'secondary:opencode:jack';
|
||||
const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' };
|
||||
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
|
||||
ok: true,
|
||||
canonicalMemberName: 'jack',
|
||||
laneId,
|
||||
}));
|
||||
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockRejectedValue(
|
||||
new Error('temporary read failure')
|
||||
);
|
||||
const recoverySpy = vi
|
||||
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const bypass = await (service as any).getOpenCodeAgendaSyncRecoveryBypassMessageIds({
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
taskRefs: [taskRef],
|
||||
foregroundMessages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'jack',
|
||||
text: 'Please continue task #task1234.',
|
||||
timestamp: '2026-02-23T17:31:00.000Z',
|
||||
read: false,
|
||||
messageId: 'proof-missing-message-1',
|
||||
messageKind: 'default',
|
||||
taskRefs: [taskRef],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(recoverySpy).not.toHaveBeenCalled();
|
||||
expect(bypass).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('allows OpenCode agenda-sync recovery for legacy proof-missing foreground ids', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
|
|||
|
|
@ -165,6 +165,43 @@ describe('ProvisioningProviderStatusList', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('summarizes OpenCode advisory ping misses without failure wording', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProvisioningProviderStatusList, {
|
||||
checks: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'notes',
|
||||
backendSummary: 'OpenCode CLI',
|
||||
details: ['big-pickle - ping not confirmed'],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain(
|
||||
'OpenCode (OpenCode CLI): Selected model checks - 1 ping not confirmed'
|
||||
);
|
||||
expect(host.textContent).not.toContain('model check failed');
|
||||
expect(host.textContent).not.toContain('Needs attention');
|
||||
|
||||
const detailLines = Array.from(host.querySelectorAll('p'));
|
||||
expect(detailLines[0]?.className).toContain('text-[var(--color-text-muted)]');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not count generic one-shot diagnostic timeouts as model timeouts', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -626,6 +626,121 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps transient OpenCode deep ping failures advisory after compatibility passed', async () => {
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: 'compatibility' | 'deep'
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) => {
|
||||
if (modelVerificationMode === 'compatibility') {
|
||||
expect(selectedModels).toEqual(['opencode/big-pickle']);
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch',
|
||||
details: [
|
||||
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
expect(modelVerificationMode).toBe('deep');
|
||||
expect(selectedModels).toEqual(['opencode/big-pickle']);
|
||||
return Promise.resolve({
|
||||
ready: false,
|
||||
message: 'Unable to connect. Is the computer able to access the url?',
|
||||
details: ['Unable to connect. Is the computer able to access the url?'],
|
||||
issues: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
scope: 'provider',
|
||||
severity: 'blocking',
|
||||
code: 'unknown_error',
|
||||
message: 'Unable to connect. Is the computer able to access the url?',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: ['opencode/big-pickle'],
|
||||
prepareProvisioning,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('notes');
|
||||
expect(result.details).toEqual(['big-pickle - ping not confirmed']);
|
||||
expect(result.warnings).toEqual([
|
||||
'OpenCode model ping was not confirmed. Unable to connect. Is the computer able to access the url?',
|
||||
'big-pickle - ping not confirmed',
|
||||
]);
|
||||
expect(result.modelResultsById).toEqual({
|
||||
'opencode/big-pickle': {
|
||||
status: 'notes',
|
||||
line: 'big-pickle - ping not confirmed',
|
||||
warningLine: 'big-pickle - ping not confirmed',
|
||||
},
|
||||
});
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps hard OpenCode deep provider issues blocking after compatibility passed', async () => {
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: 'compatibility' | 'deep'
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) => {
|
||||
if (modelVerificationMode === 'compatibility') {
|
||||
expect(selectedModels).toEqual(['opencode/big-pickle']);
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch',
|
||||
details: [
|
||||
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
expect(modelVerificationMode).toBe('deep');
|
||||
expect(selectedModels).toEqual(['opencode/big-pickle']);
|
||||
return Promise.resolve({
|
||||
ready: false,
|
||||
message: 'OpenCode: mcp_unavailable',
|
||||
issues: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
scope: 'provider',
|
||||
severity: 'blocking',
|
||||
code: 'mcp_unavailable',
|
||||
message: 'OpenCode: mcp_unavailable',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: ['opencode/big-pickle'],
|
||||
prepareProvisioning,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.details).toEqual(['OpenCode: mcp_unavailable']);
|
||||
expect(result.modelResultsById).toEqual({});
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps OpenCode deep selected-model failures scoped to the selected model', async () => {
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
|
|
|
|||
|
|
@ -873,6 +873,215 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows delayed OpenCode delivery as a distinct empty state', async () => {
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'idle',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
memberName: 'alice',
|
||||
items: [],
|
||||
coverage: [
|
||||
{
|
||||
provider: 'opencode_runtime',
|
||||
status: 'skipped',
|
||||
reason: 'opencode_delivery_delayed',
|
||||
},
|
||||
],
|
||||
warnings: [
|
||||
{
|
||||
code: 'opencode_delivery_delayed',
|
||||
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[node]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode logs delayed');
|
||||
expect(host.textContent).not.toContain('Logs unavailable');
|
||||
expect(host.textContent).not.toContain('No recent logs');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a loader while refreshing a stale empty OpenCode runtime failure', async () => {
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'idle',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
mockedLoading = true;
|
||||
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
memberName: 'alice',
|
||||
items: [],
|
||||
coverage: [
|
||||
{
|
||||
provider: 'opencode_runtime',
|
||||
status: 'skipped',
|
||||
reason: 'opencode_runtime_timeout',
|
||||
},
|
||||
],
|
||||
warnings: [
|
||||
{
|
||||
code: 'opencode_runtime_timeout',
|
||||
message: 'OpenCode runtime preview timed out.',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[node]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Loading logs');
|
||||
expect(host.textContent).not.toContain('Logs unavailable');
|
||||
expect(host.querySelector('button[aria-busy="true"]')).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a loader while refreshing stale delayed OpenCode delivery', async () => {
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'idle',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
mockedLoading = true;
|
||||
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
memberName: 'alice',
|
||||
items: [],
|
||||
coverage: [
|
||||
{
|
||||
provider: 'opencode_runtime',
|
||||
status: 'skipped',
|
||||
reason: 'opencode_delivery_delayed',
|
||||
},
|
||||
],
|
||||
warnings: [
|
||||
{
|
||||
code: 'opencode_delivery_delayed',
|
||||
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[node]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Loading logs');
|
||||
expect(host.textContent).not.toContain('OpenCode logs delayed');
|
||||
expect(host.querySelector('button[aria-busy="true"]')).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading only before a member preview has been loaded', async () => {
|
||||
const quietNode: GraphNode = {
|
||||
id: 'member:alpha-team:quiet-dev',
|
||||
|
|
|
|||
Loading…
Reference in a new issue