chore: save remaining workspace updates

This commit is contained in:
777genius 2026-05-18 15:58:05 +03:00
parent 7c5832bd7e
commit 20a8e69c4c
27 changed files with 3233 additions and 187 deletions

View file

@ -172,29 +172,34 @@ For feature architecture and implementation guidance:
| Feature | Agent Teams | Gastown | Paperclip | Cursor | Claude Code CLI |
|---|---|---|---|---|---|
| **Cross-team communication** | ✅ Native cross-team messages | ⚠️ Cross-rig coordination | ⚠️ Company-scoped org work | N/A | ❌ |
| **Cross-team communication** | ✅ Messages between separate teams | ⚠️ Coordination across groups | ⚠️ Company-scoped org work | N/A | ❌ |
| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ✅ Mailboxes + handoffs | ⚠️ Comments + @mentions | ❌ | ✅ Team mailbox, no UI |
| **Linked tasks** | ✅ Cross-refs + dependencies | ⚠️ Beads deps + convoys | ✅ Goals, parents, blockers | ❌ | ✅ Shared task list |
| **Session analysis** | ✅ Task logs + token tracking | ⚠️ Session recall, feed, OTEL | ⚠️ Run transcripts + cost audit | ❌ | ⚠️ Usage command, no UI |
| **Linked tasks** | ✅ Tasks can link to and block each other | ⚠️ Task deps + grouped work | ✅ Goals, parent tasks, blockers | ❌ | ✅ Shared task list |
| **Session analysis** | ✅ Task logs + token usage | ⚠️ Session recall, feed, metrics | ⚠️ Run transcripts + cost audit | ❌ | ⚠️ Usage command, no UI |
| **Task attachments** | ✅ Auto-attach, agents read & attach files | ❌ Not task-level | ✅ Docs, attachments, work products | ⚠️ Chat session only | ⚠️ Chat images only |
| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ Bring your own review | ✅ | ❌ |
| **Built-in code editor** | ✅ With Git support | ❌ | ❌ Control plane, not editor | ✅ Full IDE | ❌ |
| **Full autonomy** | ✅ Agents create, assign, review tasks end-to-end | ✅ Mayor, convoys, recovery | ✅ Heartbeats + governance | ⚠️ Background agents, not teams | ✅ Experimental CLI teams |
| **Task dependencies (blocked by)** | ✅ Guaranteed ordering | ✅ DAG waves via Beads | ✅ Blockers + execution locks | ❌ | ✅ Team task deps, no UI |
| **Review workflow** | ✅ Agents review each other + human review UI | ⚠️ Refinery merge queue | ✅ Approvals + governance | ⚠️ PR/BugBot only | ✅ Team review, no UI |
| **Zero setup** | ✅ Guided runtime setup | ❌ Go/Git/Dolt/Beads/tmux | ⚠️ `npx` + embedded Postgres | ✅ | ⚠️ CLI + env flag |
| **Full autonomy** | ✅ Agents plan, assign, work, and review | ✅ Coordinator, grouped work, recovery | ✅ Wake-up runs + governance | ⚠️ Background agents, not teams | ✅ Experimental CLI teams |
| **Task dependencies** | ✅ Tasks wait for blockers automatically | ✅ Dependency waves | ✅ Blockers + execution locks | ❌ | ✅ Team task deps, no UI |
| **Review workflow** | ✅ Agents review each other + human review UI | ⚠️ Merge queue | ✅ Approvals + governance | ⚠️ PR/BugBot only | ✅ Team review, no UI |
| **Zero setup** | ✅ Guided runtime setup | ❌ Manual CLI stack | ⚠️ `npx` + local database | ✅ | ⚠️ CLI + env flag |
| **Kanban board** | ✅ 5 columns, real-time | ❌ Dashboard, not Kanban | ✅ 7 columns, drag-and-drop | ❌ | ❌ |
| **Execution log viewer** | ✅ Tool calls, reasoning, timeline | ⚠️ Feed, OTEL, dashboard | ✅ Run transcripts + ledger | ⚠️ Agent chat + terminal | ❌ |
| **Execution logs** | ✅ Tool calls, reasoning, timeline | ⚠️ Feed, metrics, dashboard | ✅ Run transcripts + audit log | ⚠️ Agent chat + terminal | ❌ |
| **Live processes** | ✅ View, stop, open URLs in browser | ⚠️ Agent health dashboard | ⚠️ Manual services + previews | ⚠️ Native terminal only | ❌ |
| **CPU/RAM per teammate** | ✅ See CPU/RAM history for each live teammate | ⚠️ Shows activity/health, not CPU/RAM | ⚠️ Shows run status/cost, not CPU/RAM | ❌ Remote agent/terminal only | ❌ |
| **Per-task code review** | ✅ Accept / reject / comment | ⚠️ Merge queue, no diff UI | ⚠️ PR/work products, no inline diff | ✅ BugBot on PRs | ❌ |
| **Flexible autonomy** | ✅ Per-action approvals + notifications | ✅ Gates, escalation, recovery | ✅ Board approvals, pause, terminate | ⚠️ BG agents auto-run commands | ✅ Permissions + hooks |
| **Flexible autonomy** | ✅ Per-action approvals + notifications | ✅ Gates, escalation, recovery | ✅ Board approvals, pause, terminate | ⚠️ Background agents auto-run commands | ✅ Permissions + hooks |
| **Git worktree isolation** | ✅ Optional | ✅ Core primitive | ✅ Worktrees / branches | ⚠️ Background branches/VMs | ⚠️ Manual worktrees |
| **Multi-agent backend** | ✅ Claude, Codex + OpenCode teammates | ✅ Claude, Codex, Gemini, Copilot + more | ✅ BYO agents: Claude, Codex, Cursor/OpenCode, HTTP | ⚠️ Multi-model agents, no team backend | ⚠️ Claude-only experimental teams |
| **Mixed AI teammates** | ✅ Claude, Codex, and OpenCode in one team | ✅ Many providers, terminal-first | ✅ Bring your own agents/runtimes | ⚠️ Multi-model agents, no shared team | ⚠️ Claude-only experimental teams |
| **Live team map** | ✅ Map of teammates, tasks, blockers, handoffs, activity, logs | ⚠️ Agent tree + feed panels | ⚠️ Org chart/status, not a task/log map | ❌ | ❌ |
| **Live teammates** | ✅ Watch teammates work and message them directly | ⚠️ Terminal-based agent sessions | ⚠️ Agents wake up for runs, then sleep | ⚠️ Background agents per task | ⚠️ CLI teams, no desktop view |
| **Team workspace** | ✅ Tasks, logs, Kanban, review, and teammates in one app | ⚠️ Mail/feed/dashboard across tools | ⚠️ Board + transcripts, less live teammate view | ⚠️ IDE chats/tasks, not team view | ❌ No desktop UI |
| **Teammate launch status** | ✅ Know who started, who is stuck, and who replied | ⚠️ Session health, less clear message status | ⚠️ Run status, not live teammate status | ❌ | ⚠️ CLI mailbox, no visual status |
| **Org chart / governance** | ⚠️ Roles + approvals, no org chart | ⚠️ Roles + escalation | ✅ Org chart + board governance | ⚠️ Team admin only | ❌ |
| **Budget controls** | ⚠️ Cost/token visibility, no hard caps | ⚠️ Cost tiers + digest, no hard caps | ✅ Per-agent budgets + hard stops | ⚠️ Usage + BG spend limits | ⚠️ `/usage` + workspace limits |
| **Price** | **Free OSS UI**, provider access needed | Free OSS, runtime plans needed | Free OSS, self-hosted + infra | Free + paid usage | Claude plan or API usage |
Fact sources checked on May 16, 2026: [detailed research notes](docs/research/gastown-paperclip-comparison-2026-05-16.md), [Gastown README](https://github.com/gastownhall/gastown), [Gastown provider guide](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md), [Gastown scheduler](https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md), [Gastown release](https://github.com/gastownhall/gastown/releases/tag/v1.1.0), [Paperclip README](https://github.com/paperclipai/paperclip), [Paperclip adapters](https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md), [Paperclip budgets](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md), [Paperclip runtime services](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md), [Paperclip Kanban source](https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx), [Paperclip work products](https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts), [Paperclip release](https://github.com/paperclipai/paperclip/releases/tag/v2026.513.0), [Cursor Background Agents](https://docs.cursor.com/en/background-agents), [Cursor Diffs & Review](https://docs.cursor.com/en/agent/review), [Cursor Bugbot](https://docs.cursor.com/en/bugbot), [Cursor pricing](https://docs.cursor.com/en/account/usage), [Claude Code agent teams](https://code.claude.com/docs/en/agent-teams), [Claude Code subagents](https://code.claude.com/docs/en/sub-agents), [Claude Code workflows](https://code.claude.com/docs/en/common-workflows), [Claude Code costs](https://code.claude.com/docs/en/costs), [Claude pricing](https://claude.com/pricing).
Fact sources checked on May 18, 2026: [detailed research notes](docs/research/gastown-paperclip-comparison-2026-05-16.md), [Gastown README](https://github.com/gastownhall/gastown), [Gastown provider guide](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md), [Gastown scheduler](https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md), [Gastown dashboard source](https://github.com/gastownhall/gastown/blob/main/internal/web/templates/convoy.html), [Gastown release](https://github.com/gastownhall/gastown/releases/tag/v1.1.0), [Paperclip README](https://github.com/paperclipai/paperclip), [Paperclip adapters](https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md), [Paperclip heartbeat protocol](https://github.com/paperclipai/paperclip/blob/master/docs/guides/agent-developer/heartbeat-protocol.md), [Paperclip org chart](https://paperclip.inc/docs/guides/board-operator/org-structure/), [Paperclip OrgChart source](https://github.com/paperclipai/paperclip/blob/master/ui/src/pages/OrgChart.tsx), [Paperclip budgets](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md), [Paperclip runtime services](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md), [Paperclip Kanban source](https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx), [Paperclip work products](https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts), [Paperclip release](https://github.com/paperclipai/paperclip/releases/tag/v2026.517.0), [Cursor Background Agents](https://docs.cursor.com/en/background-agents), [Cursor Diffs & Review](https://docs.cursor.com/en/agent/review), [Cursor Bugbot](https://docs.cursor.com/en/bugbot), [Cursor pricing](https://docs.cursor.com/en/account/usage), [Claude Code agent teams](https://code.claude.com/docs/en/agent-teams), [Claude Code subagents](https://code.claude.com/docs/en/sub-agents), [Claude Code workflows](https://code.claude.com/docs/en/common-workflows), [Claude Code costs](https://code.claude.com/docs/en/costs), [Claude pricing](https://claude.com/pricing).
---

View file

@ -24,8 +24,8 @@ interface ComparisonRow {
const rows = computed<ComparisonRow[]>(() => [
{
feature: t('comparison.features.crossTeam'),
us: { status: 'yes' },
gastown: { status: 'partial', note: 'Cross-rig coordination' },
us: { status: 'yes', note: 'Messages between separate teams' },
gastown: { status: 'partial', note: 'Coordination across groups' },
paperclip: { status: 'partial', note: 'Company-scoped org work' },
cursor: { status: 'na' },
claudeCli: { status: 'no' },
@ -40,16 +40,16 @@ const rows = computed<ComparisonRow[]>(() => [
},
{
feature: t('comparison.features.linkedTasks'),
us: { status: 'yes', note: 'Cross-refs + dependencies' },
gastown: { status: 'partial', note: 'Beads deps + convoys' },
paperclip: { status: 'yes', note: 'Goals, parents, blockers' },
us: { status: 'yes', note: 'Tasks can link to and block each other' },
gastown: { status: 'partial', note: 'Task deps + grouped work' },
paperclip: { status: 'yes', note: 'Goals, parent tasks, blockers' },
cursor: { status: 'no' },
claudeCli: { status: 'yes', note: 'Shared task list' },
},
{
feature: t('comparison.features.sessionAnalysis'),
us: { status: 'yes', note: 'Task logs + token tracking' },
gastown: { status: 'partial', note: 'Session recall, feed, OTEL' },
us: { status: 'yes', note: 'Task logs + token usage' },
gastown: { status: 'partial', note: 'Session recall, feed, metrics' },
paperclip: { status: 'partial', note: 'Run transcripts + cost audit' },
cursor: { status: 'no' },
claudeCli: { status: 'partial', note: 'Usage command, no UI' },
@ -80,16 +80,16 @@ const rows = computed<ComparisonRow[]>(() => [
},
{
feature: t('comparison.features.fullAutonomy'),
us: { status: 'yes', note: 'Create, assign, review end-to-end' },
gastown: { status: 'yes', note: 'Mayor, convoys, recovery' },
paperclip: { status: 'yes', note: 'Heartbeats + governance' },
us: { status: 'yes', note: 'Plan, assign, work, and review' },
gastown: { status: 'yes', note: 'Coordinator, grouped work, recovery' },
paperclip: { status: 'yes', note: 'Wake-up runs + governance' },
cursor: { status: 'partial', note: 'Background agents, not teams' },
claudeCli: { status: 'yes', note: 'Experimental CLI teams' },
},
{
feature: t('comparison.features.taskDeps'),
us: { status: 'yes', note: 'Guaranteed ordering' },
gastown: { status: 'yes', note: 'DAG waves via Beads' },
us: { status: 'yes', note: 'Tasks wait for blockers automatically' },
gastown: { status: 'yes', note: 'Dependency waves' },
paperclip: { status: 'yes', note: 'Blockers + execution locks' },
cursor: { status: 'no' },
claudeCli: { status: 'yes', note: 'Team task deps, no UI' },
@ -97,7 +97,7 @@ const rows = computed<ComparisonRow[]>(() => [
{
feature: t('comparison.features.reviewWorkflow'),
us: { status: 'yes', note: 'Agents review each other' },
gastown: { status: 'partial', note: 'Refinery merge queue' },
gastown: { status: 'partial', note: 'Merge queue' },
paperclip: { status: 'yes', note: 'Approvals + governance' },
cursor: { status: 'partial', note: 'PR/BugBot only' },
claudeCli: { status: 'yes', note: 'Team review, no UI' },
@ -105,8 +105,8 @@ const rows = computed<ComparisonRow[]>(() => [
{
feature: t('comparison.features.zeroSetup'),
us: { status: 'yes', note: 'Guided runtime setup' },
gastown: { status: 'no', note: 'Go/Git/Dolt/Beads/tmux' },
paperclip: { status: 'partial', note: 'npx + embedded Postgres' },
gastown: { status: 'no', note: 'Manual CLI stack' },
paperclip: { status: 'partial', note: 'npx + local database' },
cursor: { status: 'yes' },
claudeCli: { status: 'partial', note: 'CLI + env flag' },
},
@ -121,8 +121,8 @@ const rows = computed<ComparisonRow[]>(() => [
{
feature: t('comparison.features.execLog'),
us: { status: 'yes', note: 'Tool calls, reasoning, timeline' },
gastown: { status: 'partial', note: 'Feed, OTEL, dashboard' },
paperclip: { status: 'yes', note: 'Run transcripts + ledger' },
gastown: { status: 'partial', note: 'Feed, metrics, dashboard' },
paperclip: { status: 'yes', note: 'Run transcripts + audit log' },
cursor: { status: 'partial', note: 'Agent chat + terminal' },
claudeCli: { status: 'no' },
},
@ -134,6 +134,14 @@ const rows = computed<ComparisonRow[]>(() => [
cursor: { status: 'partial', note: 'Native terminal only' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.runtimeLoad'),
us: { status: 'yes', note: 'CPU/RAM history for each live teammate' },
gastown: { status: 'partial', note: 'Activity/health, not CPU/RAM' },
paperclip: { status: 'partial', note: 'Run status/cost, not CPU/RAM' },
cursor: { status: 'no', note: 'Remote agent/terminal only' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.perTaskReview'),
us: { status: 'yes', note: 'Accept / reject / comment' },
@ -147,7 +155,7 @@ const rows = computed<ComparisonRow[]>(() => [
us: { status: 'yes', note: 'Per-action approvals + notifications' },
gastown: { status: 'yes', note: 'Gates, escalation, recovery' },
paperclip: { status: 'yes', note: 'Board approvals, pause, terminate' },
cursor: { status: 'partial', note: 'BG agents auto-run commands' },
cursor: { status: 'partial', note: 'Background agents auto-run commands' },
claudeCli: { status: 'yes', note: 'Permissions + hooks' },
},
{
@ -160,12 +168,44 @@ const rows = computed<ComparisonRow[]>(() => [
},
{
feature: t('comparison.features.multiAgent'),
us: { status: 'yes', note: 'Claude, Codex + OpenCode teammates' },
gastown: { status: 'yes', note: 'Claude, Codex, Gemini, Copilot + more' },
paperclip: { status: 'yes', note: 'BYO agents: Claude, Codex, Cursor/OpenCode, HTTP' },
cursor: { status: 'partial', note: 'Multi-model agents, no team backend' },
us: { status: 'yes', note: 'Claude, Codex, and OpenCode in one team' },
gastown: { status: 'yes', note: 'Many providers, terminal-first' },
paperclip: { status: 'yes', note: 'Bring your own agents/runtimes' },
cursor: { status: 'partial', note: 'Multi-model agents, no shared team' },
claudeCli: { status: 'partial', note: 'Claude-only experimental teams' },
},
{
feature: t('comparison.features.liveWorkGraph'),
us: { status: 'yes', note: 'Teammates, tasks, blockers, handoffs, activity, logs' },
gastown: { status: 'partial', note: 'Agent tree + feed panels' },
paperclip: { status: 'partial', note: 'Org chart/status, not a task/log map' },
cursor: { status: 'no' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.liveTeam'),
us: { status: 'yes', note: 'Watch teammates work and message them directly' },
gastown: { status: 'partial', note: 'Terminal-based agent sessions' },
paperclip: { status: 'partial', note: 'Agents wake up for runs, then sleep' },
cursor: { status: 'partial', note: 'Background agents per task' },
claudeCli: { status: 'partial', note: 'CLI teams, no desktop view' },
},
{
feature: t('comparison.features.teamWorkspace'),
us: { status: 'yes', note: 'Tasks, logs, Kanban, review, and teammates in one app' },
gastown: { status: 'partial', note: 'Mail/feed/dashboard across tools' },
paperclip: { status: 'partial', note: 'Board + transcripts, less live teammate view' },
cursor: { status: 'partial', note: 'IDE chats/tasks, not team view' },
claudeCli: { status: 'no', note: 'No desktop UI' },
},
{
feature: t('comparison.features.launchProof'),
us: { status: 'yes', note: 'Know who started, who is stuck, and who replied' },
gastown: { status: 'partial', note: 'Session health, less clear message status' },
paperclip: { status: 'partial', note: 'Run status, not live teammate status' },
cursor: { status: 'no' },
claudeCli: { status: 'partial', note: 'CLI mailbox, no visual status' },
},
{
feature: t('comparison.features.orgGovernance'),
us: { status: 'partial', note: 'Roles + approvals, no org chart' },
@ -214,12 +254,25 @@ const sourceLinks = [
label: 'Gastown scheduler',
href: 'https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md',
},
{
label: 'Gastown dashboard source',
href: 'https://github.com/gastownhall/gastown/blob/main/internal/web/templates/convoy.html',
},
{ label: 'Gastown release', href: 'https://github.com/gastownhall/gastown/releases/tag/v1.1.0' },
{ label: 'Paperclip README', href: 'https://github.com/paperclipai/paperclip' },
{
label: 'Paperclip adapters',
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md',
},
{
label: 'Paperclip heartbeat protocol',
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/guides/agent-developer/heartbeat-protocol.md',
},
{ label: 'Paperclip org chart', href: 'https://paperclip.inc/docs/guides/board-operator/org-structure/' },
{
label: 'Paperclip OrgChart source',
href: 'https://github.com/paperclipai/paperclip/blob/master/ui/src/pages/OrgChart.tsx',
},
{
label: 'Paperclip budgets',
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md',
@ -236,7 +289,7 @@ const sourceLinks = [
label: 'Paperclip work products',
href: 'https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts',
},
{ label: 'Paperclip release', href: 'https://github.com/paperclipai/paperclip/releases/tag/v2026.513.0' },
{ label: 'Paperclip release', href: 'https://github.com/paperclipai/paperclip/releases/tag/v2026.517.0' },
{ label: 'Cursor Background Agents', href: 'https://docs.cursor.com/en/background-agents' },
{ label: 'Cursor Diffs & Review', href: 'https://docs.cursor.com/en/agent/review' },
{ label: 'Cursor Bugbot', href: 'https://docs.cursor.com/en/bugbot' },
@ -400,7 +453,7 @@ function getStatusIcon(status: string): string {
</div>
<p class="comparison-section__sources">
Fact sources checked on May 16, 2026:
Fact sources checked on May 18, 2026:
<template v-for="(source, index) in sourceLinks" :key="source.href">
<a :href="source.href" target="_blank" rel="noopener noreferrer">
{{ source.label }}

View file

@ -78,12 +78,17 @@
"reviewWorkflow": "سير عمل المراجعة",
"zeroSetup": "بدون إعداد",
"kanban": "لوحة كانبان",
"execLog": "عارض سجلات التنفيذ",
"execLog": "سجلات التنفيذ",
"liveProcesses": "عمليات مباشرة",
"runtimeLoad": "CPU/RAM لكل زميل",
"perTaskReview": "مراجعة كود لكل مهمة",
"flexAutonomy": "استقلالية مرنة",
"worktree": "عزل Git worktree",
"multiAgent": "خلفية متعددة الوكلاء",
"multiAgent": "زملاء AI مختلطون",
"liveWorkGraph": "خريطة فريق حية",
"liveTeam": "زملاء مباشرين",
"teamWorkspace": "مساحة عمل الفريق",
"launchProof": "حالة تشغيل الزملاء",
"orgGovernance": "الهيكل التنظيمي / الحوكمة",
"budgetControls": "ضوابط الميزانية",
"price": "السعر"

View file

@ -78,12 +78,17 @@
"reviewWorkflow": "Review-Workflow",
"zeroSetup": "Keine Einrichtung",
"kanban": "Kanban-Board",
"execLog": "Ausführungsprotokoll",
"execLog": "Ausführungslogs",
"liveProcesses": "Live-Prozesse",
"runtimeLoad": "CPU/RAM pro Teammitglied",
"perTaskReview": "Code-Review pro Aufgabe",
"flexAutonomy": "Flexible Autonomie",
"worktree": "Git-Worktree-Isolation",
"multiAgent": "Multi-Agenten-Backend",
"multiAgent": "Gemischte KI-Teammitglieder",
"liveWorkGraph": "Live-Teamkarte",
"liveTeam": "Live-Teammitglieder",
"teamWorkspace": "Team-Workspace",
"launchProof": "Startstatus der Teammitglieder",
"orgGovernance": "Organigramm / Governance",
"budgetControls": "Budgetkontrollen",
"price": "Preis"

View file

@ -78,12 +78,17 @@
"reviewWorkflow": "Review workflow",
"zeroSetup": "Zero setup",
"kanban": "Kanban board",
"execLog": "Execution log viewer",
"execLog": "Execution logs",
"liveProcesses": "Live processes",
"runtimeLoad": "CPU/RAM per teammate",
"perTaskReview": "Per-task code review",
"flexAutonomy": "Flexible autonomy",
"worktree": "Git worktree isolation",
"multiAgent": "Multi-agent backend",
"multiAgent": "Mixed AI teammates",
"liveWorkGraph": "Live team map",
"liveTeam": "Live teammates",
"teamWorkspace": "Team workspace",
"launchProof": "Teammate launch status",
"orgGovernance": "Org chart / governance",
"budgetControls": "Budget controls",
"price": "Price"

View file

@ -78,12 +78,17 @@
"reviewWorkflow": "Flujo de revisión",
"zeroSetup": "Sin configuración",
"kanban": "Tablero Kanban",
"execLog": "Visor de logs de ejecución",
"execLog": "Logs de ejecución",
"liveProcesses": "Procesos en vivo",
"runtimeLoad": "CPU/RAM por compañero",
"perTaskReview": "Revisión de código por tarea",
"flexAutonomy": "Autonomía flexible",
"worktree": "Aislamiento Git worktree",
"multiAgent": "Backend multi-agente",
"multiAgent": "Compañeros de IA mixtos",
"liveWorkGraph": "Mapa del equipo en vivo",
"liveTeam": "Compañeros en vivo",
"teamWorkspace": "Espacio de trabajo del equipo",
"launchProof": "Estado de inicio de compañeros",
"orgGovernance": "Organigrama / gobernanza",
"budgetControls": "Controles de presupuesto",
"price": "Precio"

View file

@ -78,12 +78,17 @@
"reviewWorkflow": "Flux de revue",
"zeroSetup": "Zéro configuration",
"kanban": "Tableau Kanban",
"execLog": "Journal d'exécution",
"execLog": "Logs d'exécution",
"liveProcesses": "Processus en direct",
"runtimeLoad": "CPU/RAM par coéquipier",
"perTaskReview": "Revue de code par tâche",
"flexAutonomy": "Autonomie flexible",
"worktree": "Isolation Git worktree",
"multiAgent": "Backend multi-agents",
"multiAgent": "Coéquipiers IA mixtes",
"liveWorkGraph": "Carte d'équipe en direct",
"liveTeam": "Coéquipiers en direct",
"teamWorkspace": "Espace de travail d'équipe",
"launchProof": "Statut de lancement des coéquipiers",
"orgGovernance": "Organigramme / gouvernance",
"budgetControls": "Contrôle budgétaire",
"price": "Prix"

View file

@ -78,12 +78,17 @@
"reviewWorkflow": "रिव्यू वर्कफ़्लो",
"zeroSetup": "शून्य सेटअप",
"kanban": "कानबन बोर्ड",
"execLog": "एक्ज़ीक्यूशन लॉग व्यूअर",
"execLog": "एक्ज़ीक्यूशन लॉग",
"liveProcesses": "लाइव प्रोसेस",
"runtimeLoad": "हर टीममेट का CPU/RAM",
"perTaskReview": "प्रति-कार्य कोड रिव्यू",
"flexAutonomy": "लचीली स्वायत्तता",
"worktree": "Git worktree आइसोलेशन",
"multiAgent": "मल्टी-एजेंट बैकएंड",
"multiAgent": "मिक्स्ड AI टीममेट्स",
"liveWorkGraph": "लाइव टीम मैप",
"liveTeam": "लाइव टीममेट्स",
"teamWorkspace": "टीम वर्कस्पेस",
"launchProof": "टीममेट लॉन्च स्टेटस",
"orgGovernance": "ऑर्ग चार्ट / गवर्नेंस",
"budgetControls": "बजट नियंत्रण",
"price": "कीमत"

View file

@ -78,12 +78,17 @@
"reviewWorkflow": "レビューワークフロー",
"zeroSetup": "ゼロ設定",
"kanban": "カンバンボード",
"execLog": "実行ログビューア",
"execLog": "実行ログ",
"liveProcesses": "ライブプロセス",
"runtimeLoad": "チームメイトごとのCPU/RAM",
"perTaskReview": "タスク別コードレビュー",
"flexAutonomy": "柔軟な自律性",
"worktree": "Git worktree分離",
"multiAgent": "マルチエージェントバックエンド",
"multiAgent": "混在AIチームメイト",
"liveWorkGraph": "ライブチームマップ",
"liveTeam": "ライブチームメイト",
"teamWorkspace": "チームワークスペース",
"launchProof": "チームメイトの起動状態",
"orgGovernance": "組織図 / ガバナンス",
"budgetControls": "予算管理",
"price": "価格"

View file

@ -78,12 +78,17 @@
"reviewWorkflow": "Fluxo de revisão",
"zeroSetup": "Sem configuração",
"kanban": "Quadro Kanban",
"execLog": "Visualizador de logs",
"execLog": "Logs de execução",
"liveProcesses": "Processos ao vivo",
"runtimeLoad": "CPU/RAM por colega",
"perTaskReview": "Revisão de código por tarefa",
"flexAutonomy": "Autonomia flexível",
"worktree": "Isolamento Git worktree",
"multiAgent": "Backend multi-agente",
"multiAgent": "Colegas de IA mistos",
"liveWorkGraph": "Mapa da equipe ao vivo",
"liveTeam": "Colegas ao vivo",
"teamWorkspace": "Espaço de trabalho da equipe",
"launchProof": "Status de início dos colegas",
"orgGovernance": "Organograma / governança",
"budgetControls": "Controles de orçamento",
"price": "Preço"

View file

@ -78,12 +78,17 @@
"reviewWorkflow": "Процесс ревью",
"zeroSetup": "Без настройки",
"kanban": "Канбан-доска",
"execLog": "Просмотр логов выполнения",
"execLog": "Логи выполнения",
"liveProcesses": "Живые процессы",
"runtimeLoad": "CPU/RAM каждого участника",
"perTaskReview": "Код-ревью по задачам",
"flexAutonomy": "Гибкая автономность",
"worktree": "Изоляция Git worktree",
"multiAgent": "Мультиагентный бэкенд",
"multiAgent": "Смешанные AI-участники",
"liveWorkGraph": "Живая карта команды",
"liveTeam": "Живые участники",
"teamWorkspace": "Рабочее место команды",
"launchProof": "Статус запуска участников",
"orgGovernance": "Оргструктура / управление",
"budgetControls": "Бюджетные лимиты",
"price": "Цена"

View file

@ -78,12 +78,17 @@
"reviewWorkflow": "审查流程",
"zeroSetup": "零配置",
"kanban": "看板",
"execLog": "执行日志查看器",
"execLog": "执行日志",
"liveProcesses": "实时进程",
"runtimeLoad": "每个队友的 CPU/RAM",
"perTaskReview": "按任务代码审查",
"flexAutonomy": "灵活自主",
"worktree": "Git worktree 隔离",
"multiAgent": "多智能体后端",
"multiAgent": "混合 AI 队友",
"liveWorkGraph": "实时团队地图",
"liveTeam": "实时队友",
"teamWorkspace": "团队工作区",
"launchProof": "队友启动状态",
"orgGovernance": "组织架构 / 治理",
"budgetControls": "预算控制",
"price": "价格"

View file

@ -202,7 +202,9 @@ import {
hashOpenCodePromptDeliveryPayload,
isOpenCodePromptDeliveryAttemptDue,
isOpenCodePromptResponseStateResponded,
isOpenCodeResolvedBehaviorChangedReason,
isOpenCodeSessionRefreshResponseState,
isOpenCodeSessionTransportChangedReason,
OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS,
type OpenCodePromptDeliveryLedgerRecord,
type OpenCodePromptDeliveryLedgerStore,
@ -9348,6 +9350,50 @@ export class TeamProvisioningService {
);
}
private isOpenCodeSessionRefreshRetryRecord(
record: OpenCodePromptDeliveryLedgerRecord,
reason: string | null | undefined
): boolean {
const stampedSessionRefreshReason = record.lastSessionRefreshReason?.trim();
const stampedSessionRefreshReasonIsExplicit = this.isExplicitOpenCodeSessionRefreshStamp(
stampedSessionRefreshReason
);
const currentReason = reason?.trim();
const lastReason = record.lastReason?.trim();
const currentReasonConfirmsStamp = currentReason
? currentReason === stampedSessionRefreshReason
: lastReason === stampedSessionRefreshReason;
if (
record.responseState === 'session_stale' &&
stampedSessionRefreshReason &&
stampedSessionRefreshReasonIsExplicit &&
currentReasonConfirmsStamp
) {
return isOpenCodeSessionRefreshResponseState({
responseState: record.responseState,
reason: currentReason ?? stampedSessionRefreshReason,
});
}
if (record.responseState !== 'session_stale') {
return isOpenCodeSessionRefreshResponseState({
responseState: record.responseState,
reason,
});
}
return isOpenCodeSessionRefreshResponseState({
responseState: record.responseState,
reason,
diagnostics: record.diagnostics,
});
}
private isExplicitOpenCodeSessionRefreshStamp(reason: string | null | undefined): boolean {
return (
isOpenCodeResolvedBehaviorChangedReason(reason) ||
isOpenCodeSessionTransportChangedReason(reason)
);
}
private async scheduleOpenCodePromptLedgerFollowUp(input: {
ledger: OpenCodePromptDeliveryLedgerStore;
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
@ -9358,12 +9404,7 @@ export class TeamProvisioningService {
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
const now = nowIso();
const sessionRefreshRetry =
input.retry &&
isOpenCodeSessionRefreshResponseState({
responseState: input.ledgerRecord.responseState,
reason: input.reason,
diagnostics: input.ledgerRecord.diagnostics,
});
input.retry && this.isOpenCodeSessionRefreshRetryRecord(input.ledgerRecord, input.reason);
if (sessionRefreshRetry) {
const maxSessionRefreshAttempts =
input.ledgerRecord.maxSessionRefreshAttempts ??
@ -10618,11 +10659,7 @@ export class TeamProvisioningService {
const retryShouldRefreshSessionBeforeObserve =
retryDueBeforeObserve &&
ledgerRecord.status === 'retry_scheduled' &&
isOpenCodeSessionRefreshResponseState({
responseState: ledgerRecord.responseState,
reason: ledgerRecord.lastReason ?? undefined,
diagnostics: ledgerRecord.diagnostics,
});
this.isOpenCodeSessionRefreshRetryRecord(ledgerRecord, ledgerRecord.lastReason);
if (
ledgerRecord.status !== 'pending' &&
adapter.observeMessageDelivery &&
@ -10738,11 +10775,7 @@ export class TeamProvisioningService {
if (
retryDue &&
retryable &&
isOpenCodeSessionRefreshResponseState({
responseState: ledgerRecord.responseState,
reason: pendingReason,
diagnostics: ledgerRecord.diagnostics,
})
this.isOpenCodeSessionRefreshRetryRecord(ledgerRecord, pendingReason)
) {
ledgerRecord = await this.scheduleOpenCodePromptLedgerFollowUp({
ledger,
@ -10829,6 +10862,18 @@ export class TeamProvisioningService {
: 'opencode_delivery_response_pending';
const controlUrl =
input.messageKind === 'member_work_sync_nudge' ? await this.resolveControlApiBaseUrl() : null;
if (
!forceOpenCodeSessionRefreshReason &&
ledgerRecord?.status === 'retry_scheduled' &&
isOpenCodePromptDeliveryAttemptDue(ledgerRecord) &&
this.isOpenCodeSessionRefreshRetryRecord(ledgerRecord, ledgerRecord.lastReason)
) {
forceOpenCodeSessionRefreshReason =
ledgerRecord.lastSessionRefreshReason ??
ledgerRecord.lastReason ??
ledgerRecord.responseState ??
'session_stale';
}
const deliveryText = this.buildOpenCodePromptDeliveryAttemptText({
text: input.text,
controlText: this.buildOpenCodePromptDeliveryRepairControlText({

View file

@ -860,13 +860,88 @@ function isOpenCodePromptDeliveryUnansweredResponseState(
export function isOpenCodeResolvedBehaviorChangedReason(
reason: string | null | undefined
): boolean {
return /\bresolved_behavior_changed:[^\s]+->[^\s]+/i.test(reason?.trim() ?? '');
return isCleanOpenCodeSessionRefreshReason(
reason,
/\bresolved_behavior_changed:[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i
);
}
export function isOpenCodeSessionTransportChangedReason(
reason: string | null | undefined
): boolean {
return /\bopencode_app_mcp_transport_changed:[^\s]+->[^\s]+/i.test(reason?.trim() ?? '');
return isCleanOpenCodeSessionRefreshReason(
reason,
/\bopencode_app_mcp_transport_changed:[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i
);
}
const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN =
/(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i;
const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi;
const OPENCODE_SESSION_REFRESH_SAFE_MARKER_STATE_PATTERN =
/\b(?:not_observed|pending|prompt_not_indexed|responded_tool_call|responded_visible_message|responded_non_visible_tool|responded_plain_text|permission_blocked|tool_error|empty_assistant_turn|prompt_delivered_no_assistant_message|session_stale|session_error|reconcile_failed)\b/g;
function isCleanOpenCodeSessionRefreshReason(
reason: string | null | undefined,
pattern: RegExp
): boolean {
const normalized = reason?.trim().toLowerCase() ?? '';
if (!pattern.test(normalized)) {
return false;
}
const markerText = normalized.replace(/^opencode api error(?:[.:\s-]+|$)/i, '');
if (hasOpenCodeSessionRefreshFailureConflict(markerText)) {
return false;
}
const rawRemainder = markerText.replace(OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN, '');
const remainder = rawRemainder.replace(/[().,;:\s-]+/g, '');
if (remainder.length === 0) {
return true;
}
const staleLogProjectionContext =
normalized.includes('session is stale') ||
normalized.includes('stored session is stale') ||
normalized.includes('session reconcile skipped');
if (!staleLogProjectionContext) {
return false;
}
return isBenignOpenCodeSessionRefreshRemainder(rawRemainder);
}
function isBenignOpenCodeSessionRefreshRemainder(rawRemainder: string): boolean {
if (OPENCODE_SESSION_REFRESH_FAILURE_PATTERN.test(rawRemainder)) {
return false;
}
const normalized = rawRemainder.replace(/[().,;:\s-]+/g, ' ').trim();
return (
normalized === 'opencode session is stale' ||
normalized ===
'opencode session is stale reading historical messages for log projection only' ||
normalized === 'opencode session reconcile skipped because the stored session is stale' ||
normalized === 'stored session is stale'
);
}
function isOpenCodeSessionRefreshScheduledReason(message: string | null | undefined): boolean {
const normalized =
message
?.trim()
.toLowerCase()
.replace(/[.:\s-]+$/, '') ?? '';
return (
normalized === 'opencode prompt delivery session refresh scheduled' ||
normalized === 'opencode_prompt_delivery_session_refresh_scheduled' ||
normalized === 'opencode session refresh scheduled after resolved behavior changed' ||
normalized === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed' ||
normalized === 'opencode session changed; refreshing the session before retry'
);
}
function hasOpenCodeSessionRefreshFailureConflict(value: string): boolean {
return OPENCODE_SESSION_REFRESH_FAILURE_PATTERN.test(
value.replace(OPENCODE_SESSION_REFRESH_SAFE_MARKER_STATE_PATTERN, 'state')
);
}
export function isOpenCodeSessionRefreshResponseState(input: {
@ -874,20 +949,40 @@ export function isOpenCodeSessionRefreshResponseState(input: {
reason?: string | null;
diagnostics?: readonly string[];
}): boolean {
const candidates = [input.reason, ...(input.diagnostics ?? [])];
const hasActionRequiredConflict = candidates.some(isOpenCodeSessionRefreshActionRequiredConflict);
if (input.responseState === 'session_stale') {
return true;
return !hasActionRequiredConflict;
}
return (
!hasActionRequiredConflict &&
candidates.some(
(candidate) =>
isOpenCodeResolvedBehaviorChangedReason(candidate) ||
isOpenCodeSessionTransportChangedReason(candidate) ||
isOpenCodeSessionRefreshScheduledReason(candidate)
)
);
}
function isOpenCodeSessionRefreshActionRequiredConflict(
message: string | null | undefined
): boolean {
const normalized = message?.trim().toLowerCase() ?? '';
if (!normalized) {
return false;
}
if (normalized.replace(/[.:\s-]+$/, '') === 'opencode api error') {
return false;
}
if (
isOpenCodeResolvedBehaviorChangedReason(input.reason) ||
isOpenCodeSessionTransportChangedReason(input.reason)
isOpenCodeResolvedBehaviorChangedReason(normalized) ||
isOpenCodeSessionTransportChangedReason(normalized) ||
isOpenCodeSessionRefreshScheduledReason(normalized)
) {
return true;
return false;
}
return (input.diagnostics ?? []).some(
(diagnostic) =>
isOpenCodeResolvedBehaviorChangedReason(diagnostic) ||
isOpenCodeSessionTransportChangedReason(diagnostic)
);
return OPENCODE_SESSION_REFRESH_FAILURE_PATTERN.test(normalized);
}
export function isOpenCodePromptDeliveryAttemptDue(

View file

@ -3,7 +3,11 @@ import {
selectRuntimeDiagnosticClassification,
} from '../../runtime/RuntimeDiagnosticClassifier';
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
import {
isOpenCodeResolvedBehaviorChangedReason,
isOpenCodeSessionTransportChangedReason,
type OpenCodePromptDeliveryLedgerRecord,
} from './OpenCodePromptDeliveryLedger';
export function normalizeOpenCodeRuntimeDeliveryDiagnostic(
message: string | null | undefined
@ -22,12 +26,15 @@ export function selectOpenCodeRuntimeDeliveryReason(
(diagnostic) => !isInformationalOpenCodeRuntimeDeliveryDiagnostic(diagnostic)
);
const selected = selectRuntimeDiagnosticClassification(candidates);
const fallback = getOpenCodeRuntimeDeliveryStateFallback(record);
if (selected && !selected.generic && selected.normalizedMessage) {
if (fallback && isPlainGenericOpenCodeApiError(selected.normalizedMessage)) {
return fallback;
}
return boundOpenCodeRuntimeDeliveryReason(selected.normalizedMessage);
}
const fallback = getOpenCodeRuntimeDeliveryStateFallback(record);
if (fallback) {
return fallback;
}
@ -35,6 +42,15 @@ export function selectOpenCodeRuntimeDeliveryReason(
return selected ? 'OpenCode runtime delivery did not complete.' : null;
}
function isPlainGenericOpenCodeApiError(message: string): boolean {
return (
message
.trim()
.toLowerCase()
.replace(/[.:\s-]+$/, '') === 'opencode api error'
);
}
function isInformationalOpenCodeRuntimeDeliveryDiagnostic(
message: string | null | undefined
): boolean {
@ -45,10 +61,11 @@ function isInformationalOpenCodeRuntimeDeliveryDiagnostic(
'opencode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.' ||
normalized === 'opencode session status busy' ||
normalized === 'opencode_delivery_response_pending' ||
normalized === 'opencode_prompt_delivery_session_refresh_scheduled' ||
normalized === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed' ||
Boolean(
normalized?.startsWith('resolved_behavior_changed:') ||
normalized?.includes('opencode_app_mcp_transport_changed:')
isOpenCodeResolvedBehaviorChangedReason(normalized) ||
isOpenCodeSessionTransportChangedReason(normalized)
)
);
}
@ -66,23 +83,36 @@ function getOpenCodeRuntimeDeliveryStateFallback(
const reason = record.lastReason?.trim();
const normalizedReason = reason?.toLowerCase();
const diagnostics = record.diagnostics.map((diagnostic) => diagnostic.trim().toLowerCase());
const diagnosticText = diagnostics.join('\n');
const hasCleanSessionRefreshDiagnostic = diagnostics.some(
(diagnostic) =>
diagnostic === 'opencode_prompt_delivery_session_refresh_scheduled' ||
diagnostic === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed' ||
isOpenCodeResolvedBehaviorChangedReason(diagnostic) ||
isOpenCodeSessionTransportChangedReason(diagnostic)
);
if (state === 'empty_assistant_turn' || normalizedReason === 'empty_assistant_turn') {
return 'OpenCode returned an empty assistant turn.';
}
if (
normalizedReason === 'visible_reply_missing_task_refs' ||
diagnostics.includes('visible_reply_missing_task_refs') ||
diagnostics.includes('visible_reply_missing_task_refs_after_merge')
normalizedReason?.includes('visible_reply_missing_task_refs') ||
diagnosticText.includes('visible_reply_missing_task_refs')
) {
return 'OpenCode created a reply without the required taskRefs metadata.';
}
if (diagnostics.includes('visible_reply_task_refs_merge_failed')) {
if (
normalizedReason?.includes('visible_reply_task_refs_merge_failed') ||
diagnosticText.includes('visible_reply_task_refs_merge_failed')
) {
return 'OpenCode created a reply without the required taskRefs metadata, and the app could not attach it automatically.';
}
if (
normalizedReason === 'visible_reply_still_required' ||
normalizedReason === 'visible_reply_ack_only_still_requires_answer' ||
normalizedReason === 'plain_text_ack_only_still_requires_answer'
normalizedReason?.includes('visible_reply_still_required') ||
normalizedReason?.includes('visible_reply_ack_only_still_requires_answer') ||
normalizedReason?.includes('plain_text_ack_only_still_requires_answer') ||
diagnosticText.includes('visible_reply_still_required') ||
diagnosticText.includes('visible_reply_ack_only_still_requires_answer') ||
diagnosticText.includes('plain_text_ack_only_still_requires_answer')
) {
return 'OpenCode responded, but did not create a visible message_send reply.';
}
@ -93,21 +123,27 @@ function getOpenCodeRuntimeDeliveryStateFallback(
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
}
if (
state === 'session_stale' ||
normalizedReason?.startsWith('resolved_behavior_changed:') ||
normalizedReason?.startsWith('opencode_app_mcp_transport_changed:')
) {
return 'OpenCode session changed; refreshing the session before retry.';
}
if (
normalizedReason === 'visible_reply_destination_not_found_yet' ||
normalizedReason === 'visible_reply_missing_relayofmessageid'
normalizedReason?.includes('visible_reply_destination_not_found_yet') ||
normalizedReason?.includes('visible_reply_missing_relayofmessageid') ||
diagnosticText.includes('visible_reply_destination_not_found_yet') ||
diagnosticText.includes('visible_reply_missing_relayofmessageid')
) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
}
if (normalizedReason === 'non_visible_tool_without_task_progress') {
if (
normalizedReason?.includes('non_visible_tool_without_task_progress') ||
diagnosticText.includes('non_visible_tool_without_task_progress')
) {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}
if (
state === 'session_stale' ||
isOpenCodeResolvedBehaviorChangedReason(normalizedReason) ||
isOpenCodeSessionTransportChangedReason(normalizedReason) ||
(record.status === 'retry_scheduled' && hasCleanSessionRefreshDiagnostic)
) {
return 'OpenCode session changed; refreshing the session before retry.';
}
return null;
}

View file

@ -10,10 +10,11 @@ export interface RuntimeDiagnosticClassification {
interface RuntimeDiagnosticRule {
reasonCode: RuntimeDiagnosticClassification['reasonCode'];
tokens: readonly string[];
tokens?: readonly string[];
priority: number;
actionRequired?: boolean;
generic?: boolean;
match?: (message: string) => boolean;
normalizeMessage?: (message: string) => string;
}
@ -29,8 +30,73 @@ const OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE =
'OpenCode bridge outcome unknown after timeout, retrying/observing.';
const OPENCODE_SESSION_REFRESH_MESSAGE =
'OpenCode session changed; refreshing the session before retry.';
const OPENCODE_SESSION_REFRESH_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i;
const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN =
/(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i;
const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi;
const OPENCODE_SESSION_REFRESH_SAFE_MARKER_STATE_PATTERN =
/\b(?:not_observed|pending|prompt_not_indexed|responded_tool_call|responded_visible_message|responded_non_visible_tool|responded_plain_text|permission_blocked|tool_error|empty_assistant_turn|prompt_delivered_no_assistant_message|session_stale|session_error|reconcile_failed)\b/g;
function isCleanOpenCodeSessionRefreshDiagnostic(message: string): boolean {
const normalized = message.trim().toLowerCase();
if (
normalized === 'opencode session changed; refreshing the session before retry' ||
normalized === 'opencode session changed; refreshing the session before retry.' ||
normalized === 'opencode session refresh scheduled after resolved behavior changed' ||
normalized === 'opencode_prompt_delivery_session_refresh_scheduled' ||
normalized === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed'
) {
return true;
}
if (!OPENCODE_SESSION_REFRESH_REASON_PATTERN.test(normalized)) {
return false;
}
const markerText = normalized.replace(/^opencode api error(?:[.:\s-]+|$)/i, '');
if (hasOpenCodeSessionRefreshFailureConflict(markerText)) {
return false;
}
const rawRemainder = markerText.replace(OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN, '');
const remainder = rawRemainder.replace(/[().,;:\s-]+/g, '');
if (remainder.length === 0) {
return true;
}
return isBenignOpenCodeSessionRefreshRemainder(rawRemainder);
}
function isBenignOpenCodeSessionRefreshRemainder(rawRemainder: string): boolean {
if (OPENCODE_SESSION_REFRESH_FAILURE_PATTERN.test(rawRemainder)) {
return false;
}
const normalized = rawRemainder.replace(/[().,;:\s-]+/g, ' ').trim();
return (
normalized === 'opencode session is stale' ||
normalized ===
'opencode session is stale reading historical messages for log projection only' ||
normalized === 'opencode session reconcile skipped because the stored session is stale' ||
normalized === 'stored session is stale'
);
}
function hasOpenCodeSessionRefreshFailureConflict(value: string): boolean {
return OPENCODE_SESSION_REFRESH_FAILURE_PATTERN.test(
value.replace(OPENCODE_SESSION_REFRESH_SAFE_MARKER_STATE_PATTERN, 'state')
);
}
function hasDelimitedHttpAuthStatusCode(message: string): boolean {
return /(?:^|[_\s:;.\/()-])(?:401|403)(?=$|[_\s:;.\/(),-])/i.test(message);
}
const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
{
reasonCode: 'backend_error',
match: isCleanOpenCodeSessionRefreshDiagnostic,
priority: 20,
generic: true,
normalizeMessage: () => OPENCODE_SESSION_REFRESH_MESSAGE,
},
{
reasonCode: 'filesystem_error',
tokens: ['enospc', 'no space left on device', 'disk is full', 'local disk is full'],
@ -67,8 +133,20 @@ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
'authentication',
'api key',
'does not have access',
'permission denied',
'permission_denied',
'permission blocked',
'permission_blocked',
'access denied',
'login required',
'not logged in',
'missing credential',
'invalid credential',
'credentials required',
'credentials unavailable',
'please run /login',
],
match: hasDelimitedHttpAuthStatusCode,
priority: 94,
actionRequired: true,
},
@ -82,18 +160,6 @@ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
tokens: ['codex native exec timed out'],
priority: 80,
},
{
reasonCode: 'backend_error',
tokens: [
'resolved_behavior_changed',
'opencode_app_mcp_transport_changed',
'opencode session refresh scheduled after resolved behavior changed',
'opencode_prompt_delivery_session_refresh_scheduled',
],
priority: 20,
generic: true,
normalizeMessage: () => OPENCODE_SESSION_REFRESH_MESSAGE,
},
{
reasonCode: 'backend_error',
tokens: [
@ -112,7 +178,19 @@ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
},
{
reasonCode: 'network_error',
tokens: ['timeout', 'timed out', 'network', 'connection', 'econn', 'enotfound', 'fetch failed'],
tokens: [
'timeout',
'timed out',
'network',
'connection',
'unable to connect',
'connect failed',
'connection refused',
'connection reset',
'econn',
'enotfound',
'fetch failed',
],
priority: 70,
},
{
@ -225,8 +303,10 @@ export function classifyRuntimeDiagnostic(
}
const normalized = normalizedMessage.toLowerCase();
const rule = RUNTIME_DIAGNOSTIC_RULES.find((candidate) =>
candidate.tokens.some((token) => normalized.includes(token))
const rule = RUNTIME_DIAGNOSTIC_RULES.find(
(candidate) =>
Boolean(candidate.match?.(normalizedMessage)) ||
Boolean(candidate.tokens?.some((token) => normalized.includes(token)))
);
if (!rule) {
return {

View file

@ -143,6 +143,7 @@ type ProvisioningDetailSummary =
| 'Authentication required'
| 'Runtime provider is not configured'
| 'CLI preflight failed'
| 'Selected model compatible'
| 'Selected model compatibility pending'
| 'Selected model available'
| 'Selected model verified'
@ -163,6 +164,7 @@ function isFormattedModelDetail(lower: string): boolean {
lower.includes(' - checking...') ||
lower.includes(' - verified') ||
lower.includes(' - available for launch') ||
lower.includes(' - compatible for launch') ||
lower.includes(' - compatible, deep verification pending') ||
lower.includes(' - unavailable') ||
lower.includes(' - check failed') ||
@ -239,6 +241,9 @@ function summarizeDetail(
if (isModelDetail(lower) && lower.includes('compatible, deep verification pending')) {
return 'Selected model compatibility pending';
}
if (isModelDetail(lower) && lower.includes('compatible for launch')) {
return 'Selected model compatible';
}
if (isSelectedModelDetail(lower) && lower.includes('verified for launch')) {
return 'Selected model verified';
}
@ -294,6 +299,7 @@ function summarizeDetail(
function getModelDetailSummary(details: string[]): string | null {
let compatibilityPendingCount = 0;
let compatibleCount = 0;
let availableCount = 0;
let verifiedCount = 0;
let unavailableCount = 0;
@ -312,6 +318,10 @@ function getModelDetailSummary(details: string[]): string | null {
compatibilityPendingCount += 1;
continue;
}
if (lower.includes('compatible for launch')) {
compatibleCount += 1;
continue;
}
if (
lower.includes(' - available for launch') ||
(isSelectedModelDetail(lower) && lower.includes('is available for launch'))
@ -383,6 +393,9 @@ function getModelDetailSummary(details: string[]): string | null {
if (compatibilityPendingCount > 0) {
parts.push(`${compatibilityPendingCount} compatible, deep verification pending`);
}
if (compatibleCount > 0) {
parts.push(`${compatibleCount} compatible`);
}
if (checkingCount > 0) {
parts.push(`${checkingCount} checking`);
}
@ -437,7 +450,11 @@ function getDetailTone(
status: ProvisioningProviderCheckStatus
): 'success' | 'failure' | 'checking' | 'neutral' {
const summary = summarizeDetail(detail, status);
if (summary === 'Selected model verified' || summary === 'Selected model available') {
if (
summary === 'Selected model verified' ||
summary === 'Selected model available' ||
summary === 'Selected model compatible'
) {
return 'success';
}
if (summary === 'Selected model verification timed out') {

View file

@ -208,6 +208,7 @@ export const MemberDetailDialog = ({
teamName,
runId: runtimeRunId,
memberName: member.name,
member,
spawnEntry,
runtimeEntry,
})

View file

@ -192,6 +192,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
teamName: effectiveTeamName,
runId: runtimeRunId ?? memberSpawnSnapshot?.runId ?? progress?.runId,
memberName: member.name,
member,
spawnEntry,
runtimeEntry,
});

View file

@ -377,13 +377,92 @@ function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined)
}
}
function appendRuntimeAdvisoryRawMessage(base: string, message: string | undefined): string {
const trimmed = formatRuntimeAdvisoryDisplayMessage(message);
function appendRuntimeAdvisoryRawMessage(
base: string,
message: string | undefined,
providerId?: TeamProviderId
): string {
const trimmed = formatRuntimeAdvisoryDisplayMessage(message, providerId);
if (trimmed === base) {
return base;
}
return trimmed ? `${base}\n\n${trimmed}` : base;
}
const OPENCODE_SESSION_REFRESH_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i;
const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN =
/(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i;
const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi;
const OPENCODE_SESSION_REFRESH_SAFE_MARKER_STATE_PATTERN =
/\b(?:not_observed|pending|prompt_not_indexed|responded_tool_call|responded_visible_message|responded_non_visible_tool|responded_plain_text|permission_blocked|tool_error|empty_assistant_turn|prompt_delivered_no_assistant_message|session_stale|session_error|reconcile_failed)\b/g;
function isRecoverableOpenCodeSessionRefreshMessage(message: string | undefined): boolean {
const normalized = message?.trim().toLowerCase() ?? '';
if (
normalized === 'session_stale' ||
normalized === 'opencode session changed; refreshing the session before retry' ||
normalized === 'opencode session changed; refreshing the session before retry.' ||
normalized === 'opencode session refresh scheduled after resolved behavior changed' ||
normalized === 'opencode_prompt_delivery_session_refresh_scheduled' ||
normalized === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed'
) {
return true;
}
if (!OPENCODE_SESSION_REFRESH_REASON_PATTERN.test(normalized)) {
return false;
}
const markerText = normalized.replace(/^opencode api error(?:[.:\s-]+|$)/i, '');
if (hasOpenCodeSessionRefreshFailureConflict(markerText)) {
return false;
}
const rawRemainder = markerText.replace(OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN, '');
const remainder = rawRemainder.replace(/[().,;:\s-]+/g, '');
if (remainder.length === 0) {
return true;
}
const staleLogProjectionContext =
normalized.includes('session is stale') ||
normalized.includes('stored session is stale') ||
normalized.includes('session reconcile skipped');
return staleLogProjectionContext && isBenignOpenCodeSessionRefreshRemainder(rawRemainder);
}
function isBenignOpenCodeSessionRefreshRemainder(rawRemainder: string): boolean {
if (OPENCODE_SESSION_REFRESH_FAILURE_PATTERN.test(rawRemainder)) {
return false;
}
const normalized = rawRemainder.replace(/[().,;:\s-]+/g, ' ').trim();
return (
normalized === 'opencode session is stale' ||
normalized ===
'opencode session is stale reading historical messages for log projection only' ||
normalized === 'opencode session reconcile skipped because the stored session is stale' ||
normalized === 'stored session is stale'
);
}
function hasOpenCodeSessionRefreshFailureConflict(value: string): boolean {
return OPENCODE_SESSION_REFRESH_FAILURE_PATTERN.test(
value.replace(OPENCODE_SESSION_REFRESH_SAFE_MARKER_STATE_PATTERN, 'state')
);
}
function canTreatAdvisoryAsOpenCodeSessionRefresh(
advisory: MemberRuntimeAdvisory | undefined
): boolean {
return (
Boolean(advisory) &&
(advisory?.reasonCode == null ||
advisory.reasonCode === 'backend_error' ||
advisory.reasonCode === 'unknown') &&
isRecoverableOpenCodeSessionRefreshMessage(advisory?.message)
);
}
function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined): boolean {
const displayMessage = formatRuntimeAdvisoryDisplayMessage(message);
const displayMessage = formatRuntimeAdvisoryDisplayMessage(message, 'opencode');
return (
displayMessage.startsWith('OpenCode runtime delivery') ||
displayMessage.startsWith('OpenCode returned an empty assistant turn') ||
@ -395,7 +474,10 @@ function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined):
);
}
function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): string {
function formatRuntimeAdvisoryDisplayMessage(
message: string | undefined,
providerId?: TeamProviderId
): string {
const trimmed = message?.trim();
if (!trimmed) {
return '';
@ -409,6 +491,9 @@ function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): strin
if (trimmed === 'opencode_prompt_acceptance_unknown_after_bridge_timeout') {
return OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE;
}
if (providerId === 'opencode' && isRecoverableOpenCodeSessionRefreshMessage(trimmed)) {
return 'OpenCode session changed; refreshing the session before retry.';
}
if (
trimmed === 'visible_reply_still_required' ||
trimmed === 'visible_reply_ack_only_still_requires_answer' ||
@ -450,6 +535,9 @@ function formatRuntimeAdvisoryBaseLabel(
): string {
const providerLabel = getRuntimeAdvisoryProviderLabel(providerId);
if (advisory.kind === 'api_error') {
if (providerId === 'opencode' && canTreatAdvisoryAsOpenCodeSessionRefresh(advisory)) {
return 'OpenCode session refresh';
}
switch (advisory.reasonCode) {
case 'quota_exhausted':
return providerLabel ? `${providerLabel} quota error` : 'Quota error';
@ -512,6 +600,13 @@ function formatRuntimeAdvisoryTitle(
): string {
const providerLabel = getRuntimeAdvisoryProviderLabel(providerId);
if (advisory.kind === 'api_error') {
if (providerId === 'opencode' && canTreatAdvisoryAsOpenCodeSessionRefresh(advisory)) {
return appendRuntimeAdvisoryRawMessage(
'OpenCode session changed; refreshing the session before retry.',
advisory.message,
providerId
);
}
switch (advisory.reasonCode) {
case 'quota_exhausted':
return appendRuntimeAdvisoryRawMessage(
@ -520,7 +615,8 @@ function formatRuntimeAdvisoryTitle(
advisory,
providerId
),
advisory.message
advisory.message,
providerId
);
case 'rate_limited':
return appendRuntimeAdvisoryRawMessage(
@ -529,36 +625,46 @@ function formatRuntimeAdvisoryTitle(
advisory,
providerId
),
advisory.message
advisory.message,
providerId
);
case 'auth_error':
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} authentication error.`,
advisory.message
advisory.message,
providerId
);
case 'codex_native_timeout':
return appendRuntimeAdvisoryRawMessage(
'Codex native mailbox turn timed out. The runtime stopped this turn after its watchdog limit; it was not an automatic SDK retry.',
advisory.message
advisory.message,
providerId
);
case 'network_error':
return appendRuntimeAdvisoryRawMessage('Network or connectivity error.', advisory.message);
return appendRuntimeAdvisoryRawMessage(
'Network or connectivity error.',
advisory.message,
providerId
);
case 'filesystem_error':
return appendRuntimeAdvisoryRawMessage(
'Local disk is full or unavailable.',
advisory.message
advisory.message,
providerId
);
case 'provider_overloaded':
return appendRuntimeAdvisoryRawMessage(
'Provider is temporarily overloaded.',
advisory.message
advisory.message,
providerId
);
case 'protocol_proof_missing':
return appendRuntimeAdvisoryRawMessage(
providerId === 'opencode'
? 'OpenCode delivery completed without required visible/progress proof.'
: 'Runtime delivery completed without required protocol proof.',
advisory.message
advisory.message,
providerId
);
case 'backend_error':
case 'unknown':
@ -568,12 +674,14 @@ function formatRuntimeAdvisoryTitle(
) {
return appendRuntimeAdvisoryRawMessage(
'OpenCode runtime delivery error.',
advisory.message
advisory.message,
providerId
);
}
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} API error.`,
advisory.message
advisory.message,
providerId
);
default:
return advisory.message?.trim() || 'Provider API error.';
@ -584,50 +692,59 @@ function formatRuntimeAdvisoryTitle(
case 'quota_exhausted':
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} quota exhausted. SDK is retrying automatically.`,
advisory.message
advisory.message,
providerId
);
case 'rate_limited':
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} rate limited the request. SDK is retrying automatically.`,
advisory.message
advisory.message,
providerId
);
case 'auth_error':
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} authentication issue. SDK is retrying automatically.`,
advisory.message
advisory.message,
providerId
);
case 'codex_native_timeout':
return appendRuntimeAdvisoryRawMessage(
'Codex native mailbox turn timed out. A retry window was reported by the runtime.',
advisory.message
advisory.message,
providerId
);
case 'network_error':
return appendRuntimeAdvisoryRawMessage(
'Network or connectivity issue. SDK is retrying automatically.',
advisory.message
advisory.message,
providerId
);
case 'filesystem_error':
return appendRuntimeAdvisoryRawMessage(
'Local disk is full or unavailable. SDK is retrying automatically.',
advisory.message
advisory.message,
providerId
);
case 'provider_overloaded':
return appendRuntimeAdvisoryRawMessage(
'Provider is temporarily overloaded. SDK is retrying automatically.',
advisory.message
advisory.message,
providerId
);
case 'protocol_proof_missing':
return appendRuntimeAdvisoryRawMessage(
providerId === 'opencode'
? 'OpenCode delivery is waiting for required visible/progress proof.'
: 'Runtime delivery is waiting for required protocol proof.',
advisory.message
advisory.message,
providerId
);
case 'backend_error':
case 'unknown':
return appendRuntimeAdvisoryRawMessage(
'The SDK is retrying this request after a provider or backend error.',
advisory.message
advisory.message,
providerId
);
default:
return (
@ -673,11 +790,15 @@ export function getMemberRuntimeAdvisoryTitle(
}
export function getMemberRuntimeAdvisoryTone(
advisory: MemberRuntimeAdvisory | undefined
advisory: MemberRuntimeAdvisory | undefined,
providerId?: TeamProviderId
): 'error' | 'warning' | null {
if (!advisory) {
return null;
}
if (providerId === 'opencode' && canTreatAdvisoryAsOpenCodeSessionRefresh(advisory)) {
return 'warning';
}
if (advisory.reasonCode === 'protocol_proof_missing') {
return 'warning';
}
@ -1123,7 +1244,7 @@ export function buildMemberLaunchPresentation({
);
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, member.providerId);
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId);
const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory);
const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory, member.providerId);
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
const startingIsStale =
!hasConfirmedSpawnLaunch &&

View file

@ -75,6 +75,14 @@ const SECRET_FLAG_PATTERN =
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
const SECRET_VALUE_PATTERN =
/\b(sk-[A-Za-z0-9._~+/=-]{12,}|[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,})\b/g;
const OPENCODE_SESSION_REFRESH_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i;
const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN =
/(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i;
const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi;
const OPENCODE_SESSION_REFRESH_SAFE_MARKER_STATE_PATTERN =
/\b(?:not_observed|pending|prompt_not_indexed|responded_tool_call|responded_visible_message|responded_non_visible_tool|responded_plain_text|permission_blocked|tool_error|empty_assistant_turn|prompt_delivered_no_assistant_message|session_stale|session_error|reconcile_failed)\b/g;
type MemberSpawnStatusCollection =
| Record<string, MemberSpawnStatusEntry>
@ -134,10 +142,20 @@ function isRuntimeDiagnosticCardError(params: {
launchState?: MemberLaunchState;
spawnStatus?: MemberSpawnStatus;
hardFailure?: boolean;
providerId?: string;
}): boolean {
if (!params.runtimeDiagnostic) {
return false;
}
if (params.runtimeDiagnosticSeverity === 'info') {
return false;
}
if (
params.providerId === 'opencode' &&
isRecoverableOpenCodeSessionRefreshText(params.runtimeDiagnostic)
) {
return false;
}
return (
params.runtimeDiagnosticSeverity === 'error' ||
params.launchState === 'failed_to_start' ||
@ -148,16 +166,98 @@ function isRuntimeDiagnosticCardError(params: {
function isRecoverableOpenCodeSessionRefreshText(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase() ?? '';
if (
normalized === 'session_stale' ||
normalized === 'opencode session refresh' ||
normalized === 'opencode session changed; refreshing the session before retry' ||
normalized === 'opencode session changed; refreshing the session before retry.' ||
normalized === 'opencode session refresh scheduled after resolved behavior changed' ||
normalized === 'opencode_prompt_delivery_session_refresh_scheduled' ||
normalized === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed'
) {
return true;
}
if (!OPENCODE_SESSION_REFRESH_REASON_PATTERN.test(normalized)) {
return false;
}
const markerText = normalized.replace(/^opencode api error(?:[.:\s-]+|$)/i, '');
if (hasOpenCodeSessionRefreshFailureConflict(markerText)) {
return false;
}
const rawRemainder = markerText.replace(OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN, '');
const remainder = rawRemainder.replace(/[().,;:\s-]+/g, '');
if (remainder.length === 0) {
return true;
}
const staleLogProjectionContext =
normalized.includes('session is stale') ||
normalized.includes('stored session is stale') ||
normalized.includes('session reconcile skipped');
return staleLogProjectionContext && isBenignOpenCodeSessionRefreshRemainder(rawRemainder);
}
function isBenignOpenCodeSessionRefreshRemainder(rawRemainder: string): boolean {
if (OPENCODE_SESSION_REFRESH_FAILURE_PATTERN.test(rawRemainder)) {
return false;
}
const normalized = rawRemainder.replace(/[().,;:\s-]+/g, ' ').trim();
return (
normalized.includes('resolved_behavior_changed:') ||
normalized.includes('opencode_app_mcp_transport_changed:') ||
normalized.includes('opencode session changed; refreshing the session before retry') ||
normalized.includes('opencode_session_refresh_scheduled_after_resolved_behavior_changed')
normalized === 'opencode session is stale' ||
normalized ===
'opencode session is stale reading historical messages for log projection only' ||
normalized === 'opencode session reconcile skipped because the stored session is stale' ||
normalized === 'stored session is stale'
);
}
function isRuntimeAdvisoryCardError(runtimeAdvisory: MemberRuntimeAdvisory | undefined): boolean {
if (isRecoverableOpenCodeSessionRefreshText(runtimeAdvisory?.message)) {
function hasOpenCodeSessionRefreshFailureConflict(value: string): boolean {
return OPENCODE_SESSION_REFRESH_FAILURE_PATTERN.test(
value.replace(OPENCODE_SESSION_REFRESH_SAFE_MARKER_STATE_PATTERN, 'state')
);
}
function isGenericOpenCodeApiErrorText(value: string | undefined): boolean {
const normalized =
value
?.trim()
.toLowerCase()
.replace(/[.:\s-]+$/, '') ?? '';
return normalized === 'opencode api error';
}
function isBenignOpenCodeRefreshContextText(value: string | undefined): boolean {
const normalized =
value
?.trim()
.toLowerCase()
.replace(/[.:\s-]+$/, '') ?? '';
return (
!normalized ||
isRecoverableOpenCodeSessionRefreshText(normalized) ||
isGenericOpenCodeApiErrorText(normalized) ||
normalized === 'matched opencode runtime pid and process identity' ||
normalized === 'bootstrap confirmed' ||
normalized === 'opencode runtime process detected after bootstrap confirmation'
);
}
function hasCleanRecoverableOpenCodeRefreshContext(
values: readonly (string | undefined)[]
): boolean {
const normalizedValues = values
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
return (
normalizedValues.some(isRecoverableOpenCodeSessionRefreshText) &&
normalizedValues.every(isBenignOpenCodeRefreshContextText)
);
}
function isRuntimeAdvisoryCardError(
runtimeAdvisory: MemberRuntimeAdvisory | undefined,
providerId: string | undefined
): boolean {
if (providerId === 'opencode' && isRecoverableOpenCodeSessionRefreshAdvisory(runtimeAdvisory)) {
return false;
}
return (
@ -165,6 +265,18 @@ function isRuntimeAdvisoryCardError(runtimeAdvisory: MemberRuntimeAdvisory | und
);
}
function isRecoverableOpenCodeSessionRefreshAdvisory(
runtimeAdvisory: MemberRuntimeAdvisory | undefined
): boolean {
return (
Boolean(runtimeAdvisory) &&
(runtimeAdvisory?.reasonCode == null ||
runtimeAdvisory.reasonCode === 'backend_error' ||
runtimeAdvisory.reasonCode === 'unknown') &&
isRecoverableOpenCodeSessionRefreshText(runtimeAdvisory?.message)
);
}
export function normalizeMemberLaunchFailureReason(value: string | undefined): string | null {
const normalized = value
?.replace(/\s+/g, ' ')
@ -174,6 +286,30 @@ export function normalizeMemberLaunchFailureReason(value: string | undefined): s
return normalized && normalized.length > 0 ? normalized : null;
}
function firstMemberCardFailureReason(input: {
candidates: (string | undefined)[];
evidence?: readonly (string | undefined)[];
providerId?: string;
}): string | undefined {
const hasCleanRecoverableOpenCodeRefresh =
input.providerId === 'opencode' &&
hasCleanRecoverableOpenCodeRefreshContext([...input.candidates, ...(input.evidence ?? [])]);
for (const value of input.candidates) {
const normalized = normalizeMemberLaunchFailureReason(value);
if (
!normalized ||
(hasCleanRecoverableOpenCodeRefresh &&
input.providerId === 'opencode' &&
isRecoverableOpenCodeSessionRefreshText(normalized)) ||
(hasCleanRecoverableOpenCodeRefresh && isGenericOpenCodeApiErrorText(normalized))
) {
continue;
}
return boundedString(normalized);
}
return undefined;
}
function uniqueDiagnostics(
...groups: (readonly (string | undefined)[] | undefined)[]
): string[] | undefined {
@ -204,11 +340,16 @@ function buildDiagnosticHints(input: {
livenessKind?: TeamAgentRuntimeLivenessKind;
launchState?: MemberLaunchState;
spawnStatus?: MemberSpawnStatus;
providerId?: string;
}): string[] | undefined {
const text = [input.memberCardError, input.runtimeDiagnostic, ...(input.diagnostics ?? [])]
.filter((item): item is string => Boolean(item))
.join('\n')
.toLowerCase();
const openCodeRefreshEvidenceContext = [input.runtimeDiagnostic, ...(input.diagnostics ?? [])];
const hasCleanRecoverableOpenCodeRefreshEvidence =
input.providerId === 'opencode' &&
hasCleanRecoverableOpenCodeRefreshContext(openCodeRefreshEvidenceContext);
const hints: string[] = [];
if (textIncludesAny(text, ['reason=query_active', 'queryguardstatus=running'])) {
@ -260,7 +401,10 @@ function buildDiagnosticHints(input: {
'Persisted runtime pid is dead; this is post-failure liveness, not the original root cause.'
);
}
if (input.launchState === 'failed_to_start' || input.spawnStatus === 'error') {
if (
(input.launchState === 'failed_to_start' || input.spawnStatus === 'error') &&
!(hasCleanRecoverableOpenCodeRefreshEvidence && !input.memberCardError)
) {
hints.push(
'Launch state is terminal for this run; restart/relaunch is required after fixing the cause.'
);
@ -290,16 +434,16 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
const spawnEntry = params.spawnEntry;
const runtimeEntry = params.runtimeEntry;
const runtimeAdvisory = params.runtimeAdvisory;
const providerId = runtimeEntry?.providerId ?? params.member?.providerId;
const providerBackendId = runtimeEntry?.providerBackendId ?? params.member?.providerBackendId;
const laneId = runtimeEntry?.laneId ?? params.member?.laneId;
const laneKind = runtimeEntry?.laneKind ?? params.member?.laneKind;
const runtimeAdvisoryTitle = boundedString(params.runtimeAdvisoryTitle);
const runtimeAdvisoryLabel = boundedString(params.runtimeAdvisoryLabel ?? undefined);
const runtimeAdvisoryMessage = boundedString(runtimeAdvisory?.message);
const runtimeAdvisoryCardError =
isRuntimeAdvisoryCardError(runtimeAdvisory) &&
![runtimeAdvisoryTitle, runtimeAdvisoryLabel, runtimeAdvisoryMessage].some(
isRecoverableOpenCodeSessionRefreshText
)
? (runtimeAdvisoryTitle ?? runtimeAdvisoryLabel ?? runtimeAdvisoryMessage)
: undefined;
const runtimeAdvisoryCardError = isRuntimeAdvisoryCardError(runtimeAdvisory, providerId)
? (runtimeAdvisoryTitle ?? runtimeAdvisoryLabel ?? runtimeAdvisoryMessage)
: undefined;
const runtimeDiagnosticSeverity =
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity;
const spawnRuntimeDiagnosticCardError = isRuntimeDiagnosticCardError({
@ -308,12 +452,14 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
launchState: spawnEntry?.launchState,
spawnStatus: spawnEntry?.status,
hardFailure: spawnEntry?.hardFailure,
providerId,
})
? spawnEntry?.runtimeDiagnostic
: undefined;
const runtimeEntryDiagnosticCardError = isRuntimeDiagnosticCardError({
runtimeDiagnostic: runtimeEntry?.runtimeDiagnostic,
runtimeDiagnosticSeverity: runtimeEntry?.runtimeDiagnosticSeverity,
providerId,
})
? runtimeEntry?.runtimeDiagnostic
: undefined;
@ -323,15 +469,24 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
boundedString(spawnEntry?.hardFailureReason) ??
boundedString(spawnEntry?.error) ??
runtimeAdvisoryMessage;
const memberCardError =
boundedString(
normalizeMemberLaunchFailureReason(
spawnEntry?.error ??
spawnEntry?.hardFailureReason ??
spawnRuntimeDiagnosticCardError ??
runtimeEntryDiagnosticCardError
) ?? undefined
) ?? runtimeAdvisoryCardError;
const memberCardError = firstMemberCardFailureReason({
candidates: [
spawnEntry?.error,
spawnEntry?.hardFailureReason,
spawnRuntimeDiagnosticCardError,
runtimeEntryDiagnosticCardError,
runtimeAdvisoryCardError,
],
evidence: [
spawnEntry?.runtimeDiagnostic,
runtimeEntry?.runtimeDiagnostic,
runtimeAdvisoryTitle,
runtimeAdvisoryLabel,
runtimeAdvisoryMessage,
...(runtimeEntry?.diagnostics ?? []),
],
providerId,
});
const diagnostics = uniqueDiagnostics(
memberCardError ? [memberCardError] : undefined,
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
@ -343,10 +498,6 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
runtimeEntry?.diagnostics
);
const runId = boundedString(params.runId ?? undefined);
const providerId = runtimeEntry?.providerId ?? params.member?.providerId;
const providerBackendId = runtimeEntry?.providerBackendId ?? params.member?.providerBackendId;
const laneId = runtimeEntry?.laneId ?? params.member?.laneId;
const laneKind = runtimeEntry?.laneKind ?? params.member?.laneKind;
const runtimeUpdatedAt = maybeString(runtimeEntry?.updatedAt);
const spawnUpdatedAt = maybeString(spawnEntry?.updatedAt);
const livenessKind = spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind;
@ -359,6 +510,7 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
livenessKind,
launchState,
spawnStatus,
providerId,
});
const probableCause = buildProbableCause(diagnosticHints);
@ -611,6 +763,16 @@ export function hasMemberLaunchDiagnosticsDetails(
}
export function hasMemberLaunchDiagnosticsError(payload: MemberLaunchDiagnosticsPayload): boolean {
if (
payload.providerId === 'opencode' &&
!payload.memberCardError &&
hasCleanRecoverableOpenCodeRefreshContext([
payload.runtimeDiagnostic,
...(payload.diagnostics ?? []),
])
) {
return false;
}
return Boolean(
payload.spawnStatus === 'error' ||
payload.launchState === 'failed_to_start' ||

View file

@ -519,6 +519,288 @@ describe('OpenCodePromptDeliveryLedger', () => {
reason: 'opencode_app_mcp_transport_changed:old->new',
})
).toBe(true);
expect(
isOpenCodeSessionRefreshResponseState({
reason: '(resolved_behavior_changed:old->new)',
})
).toBe(true);
expect(
isOpenCodeSessionRefreshResponseState({
reason: 'resolved_behavior_changed:old.hash/1=abc->new.hash/2=def.',
})
).toBe(true);
expect(
isOpenCodeSessionRefreshResponseState({
reason: 'resolved_behavior_changed:tool_error->session_error',
})
).toBe(true);
expect(
isOpenCodeSessionRefreshResponseState({
reason: 'resolved_behavior_changed:responded_non_visible_tool->pending',
})
).toBe(true);
expect(
isOpenCodeSessionRefreshResponseState({
reason: 'resolved_behavior_changed:permission_blocked->pending',
})
).toBe(true);
expect(
isOpenCodeSessionRefreshResponseState({
reason:
'resolved_behavior_changed:old->new opencode_app_mcp_transport_changed:a->b',
})
).toBe(true);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: [
'OpenCode session is stale (resolved_behavior_changed:old->new); reading historical messages for log projection only',
],
})
).toBe(true);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: [
'OpenCode session is stale (resolved_behavior_changed:old->new); unexpected detail',
],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['resolved_behavior_changed:old->new unexpected detail'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['OpenCode API error', 'resolved_behavior_changed:old->new'],
})
).toBe(true);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['OpenCode API error:', 'resolved_behavior_changed:old->new'],
})
).toBe(true);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['OpenCode API errorresolved_behavior_changed:old->new'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['OpenCode API error.', 'opencode_app_mcp_transport_changed:old->new'],
})
).toBe(true);
for (const reason of [
'opencode_prompt_delivery_session_refresh_scheduled',
'opencode_session_refresh_scheduled_after_resolved_behavior_changed',
'OpenCode session refresh scheduled after resolved behavior changed',
'OpenCode session changed; refreshing the session before retry.',
]) {
expect(
isOpenCodeSessionRefreshResponseState({
responseState: 'pending',
reason,
})
).toBe(true);
}
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: [
'OpenCode session is stale (resolved_behavior_changed:old->new); permission denied',
],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: [
'OpenCode session is stale (resolved_behavior_changed:old->new); network timeout',
],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: [
'OpenCode session is stale (resolved_behavior_changed:old->new); visible_reply_missing_task_refs',
],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['resolved_behavior_changed:old->new', 'unable to connect to provider'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: [
'OpenCode API error',
'resolved_behavior_changed:old->new',
'permission denied',
],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['resolved_behavior_changed:old->new', 'auth_unavailable'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: [
'resolved_behavior_changed:old->new',
'Key limit exceeded (total limit). Manage it using OpenRouter settings.',
],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['resolved_behavior_changed:old->new', '429 too many requests'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
reason: 'resolved_behavior_changed:old->new permission denied',
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
reason: 'resolved_behavior_changed:old->new;permission_denied',
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
reason: 'resolved_behavior_changed:old->new:permission_denied',
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
reason: 'resolved_behavior_changed:old->new_permission_denied',
})
).toBe(false);
for (const suffix of ['error', 'failed', 'failure', 'aborted', 'canceled', 'cancelled', 'interrupted', 'enospc']) {
expect(
isOpenCodeSessionRefreshResponseState({
reason: `resolved_behavior_changed:old->new_${suffix}`,
})
).toBe(false);
}
for (const reason of [
'resolved_behavior_changed:old->new/auth_unavailable',
'resolved_behavior_changed:old->new permission denied',
'resolved_behavior_changed:old->new permission_blocked',
'resolved_behavior_changed:old->new login required',
'resolved_behavior_changed:old->new not logged in',
'resolved_behavior_changed:old->new missing credentials',
'resolved_behavior_changed:old->new access denied',
'resolved_behavior_changed:old->new 401',
'resolved_behavior_changed:old->new;key limit exceeded',
'resolved_behavior_changed:old->new-network_timeout',
'resolved_behavior_changed:old->new(non_visible_tool_without_task_progress)',
'resolved_behavior_changed:old->new interrupted',
'opencode_app_mcp_transport_changed:old->new/permission_denied',
'opencode_app_mcp_transport_changed:old->new;visible_reply_missing_task_refs',
]) {
expect(
isOpenCodeSessionRefreshResponseState({
reason,
})
).toBe(false);
}
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['resolved_behavior_changed:old->new', 'cancelled'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['resolved_behavior_changed:old->new', 'login required'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
reason: 'opencode_app_mcp_transport_changed:old->new/permission_denied',
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
diagnostics: ['opencode_app_mcp_transport_changed:old->new:permission_denied'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
responseState: 'session_stale',
diagnostics: ['permission denied'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
responseState: 'session_stale',
diagnostics: ['permission_blocked'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
responseState: 'session_stale',
diagnostics: ['authentication_failed'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
responseState: 'session_stale',
diagnostics: ['Free usage exceeded, subscribe to Go'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
responseState: 'session_stale',
diagnostics: ['visible_reply_missing_task_refs'],
})
).toBe(false);
expect(
isOpenCodeSessionRefreshResponseState({
responseState: 'session_stale',
diagnostics: ['OpenCode session reconcile skipped because the stored session is stale'],
})
).toBe(true);
});
it('does not treat session_stale with action-required diagnostics as a refresh retry', async () => {
const store = createStore();
const record = await store.ensurePending({
teamName: 'team-a',
memberName: 'jack',
laneId: 'secondary:opencode:jack',
inboxMessageId: 'msg-session-stale-auth-error',
inboxTimestamp: '2026-04-25T09:59:00.000Z',
source: 'watcher',
replyRecipient: 'user',
payloadHash: 'sha256:session-stale-auth-error',
now: '2026-04-25T10:00:00.000Z',
});
const staleWithAuthFailure = await store.applyDeliveryResult({
id: record.id,
accepted: false,
attempted: true,
responseObservation: {
state: 'session_stale',
deliveredUserMessageId: null,
assistantMessageId: null,
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'permission denied',
},
diagnostics: ['permission denied'],
now: '2026-04-25T10:00:05.000Z',
});
expect(staleWithAuthFailure.attempts).toBe(1);
expect(staleWithAuthFailure.responseState).toBe('session_stale');
expect(staleWithAuthFailure.lastSessionRefreshReason).toBeNull();
expect(buildOpenCodePromptDeliveryAttemptId(staleWithAuthFailure)).toBe(
`${record.id}:2:${record.payloadHash.slice(0, 12)}`
);
});
it('keeps schema-1 legacy prompt-id fields compatible and normalizes when touched', async () => {

View file

@ -13,6 +13,10 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
expect(isActionRequiredOpenCodeRuntimeDeliveryReason(reason)).toBe(true);
});
it('treats OpenCode permission-blocked responses as action-required delivery failures', () => {
expect(isActionRequiredOpenCodeRuntimeDeliveryReason('permission_blocked')).toBe(true);
});
it('does not treat protocol proof repair reasons as action-required provider failures', () => {
expect(isActionRequiredOpenCodeRuntimeDeliveryReason('visible_reply_still_required')).toBe(
false
@ -101,6 +105,176 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
).toBe(false);
});
it('treats generic OpenCode API error plus clean refresh evidence as session refresh', () => {
const record = {
diagnostics: ['OpenCode API error', 'resolved_behavior_changed:old->new'],
lastReason: 'OpenCode API error',
responseState: 'not_observed',
status: 'retry_scheduled',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'OpenCode session changed; refreshing the session before retry.'
);
});
it('treats legacy prompt-delivery refresh scheduled diagnostics as session refresh', () => {
const record = {
diagnostics: ['opencode_prompt_delivery_session_refresh_scheduled'],
lastReason: 'OpenCode API error',
responseState: 'not_observed',
status: 'retry_scheduled',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'OpenCode session changed; refreshing the session before retry.'
);
});
it('treats colon-terminated generic OpenCode API errors plus clean refresh evidence as session refresh', () => {
const record = {
diagnostics: ['OpenCode API error:', 'resolved_behavior_changed:old->new'],
lastReason: 'OpenCode API error:',
responseState: 'not_observed',
status: 'retry_scheduled',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'OpenCode session changed; refreshing the session before retry.'
);
});
it('keeps real failure diagnostics above generic OpenCode API error plus refresh evidence', () => {
const record = {
diagnostics: ['OpenCode API error', 'resolved_behavior_changed:old->new', 'permission denied'],
lastReason: 'OpenCode API error',
responseState: 'not_observed',
status: 'retry_scheduled',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe('permission denied');
});
it('does not treat refresh-looking diagnostics with failure details as informational refresh state', () => {
const record = {
diagnostics: ['resolved_behavior_changed:old->new;permission_denied'],
lastReason: 'resolved_behavior_changed:old->new;permission_denied',
responseState: 'reconcile_failed',
status: 'failed_terminal',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).not.toBe(
'OpenCode session changed; refreshing the session before retry.'
);
});
it('does not treat refresh-looking diagnostics with unknown extra text as informational refresh state', () => {
const record = {
diagnostics: ['resolved_behavior_changed:old->new unexpected detail'],
lastReason: 'resolved_behavior_changed:old->new unexpected detail',
responseState: 'reconcile_failed',
status: 'failed_retryable',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'resolved_behavior_changed:old->new unexpected detail'
);
});
it('does not treat stale refresh-looking diagnostics with unknown extra text as informational refresh state', () => {
const reason =
'OpenCode session is stale (resolved_behavior_changed:old->new); unexpected detail';
const record = {
diagnostics: [reason],
lastReason: reason,
responseState: 'reconcile_failed',
status: 'failed_retryable',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(reason);
});
it.each(['permission_denied', 'error', 'failed', 'failure', 'aborted', 'enospc'])(
'does not let refresh pattern consume directly attached failure token _%s',
(suffix) => {
const reason = `resolved_behavior_changed:old->new_${suffix}`;
const record = {
diagnostics: [reason],
lastReason: reason,
responseState: 'reconcile_failed',
status: 'failed_retryable',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
const selected = selectOpenCodeRuntimeDeliveryReason(record);
expect(selected).not.toBe(
'OpenCode session changed; refreshing the session before retry.'
);
expect(selected).toBeTruthy();
}
);
it.each([
'resolved_behavior_changed:old->new/auth_unavailable',
'resolved_behavior_changed:old->new permission denied',
'resolved_behavior_changed:old->new permission_blocked',
'resolved_behavior_changed:old->new;key limit exceeded',
'resolved_behavior_changed:old->new-network_timeout',
'resolved_behavior_changed:old->new(non_visible_tool_without_task_progress)',
'opencode_app_mcp_transport_changed:old->new/permission_denied',
'opencode_app_mcp_transport_changed:old->new;visible_reply_missing_task_refs',
])('keeps separator-attached failure detail visible for %s', (reason) => {
const record = {
diagnostics: [reason],
lastReason: reason,
responseState: 'reconcile_failed',
status: 'failed_retryable',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
const selected = selectOpenCodeRuntimeDeliveryReason(record);
expect(selected).not.toBe('OpenCode session changed; refreshing the session before retry.');
expect(selected).toBeTruthy();
});
it('keeps clean refresh diagnostics recoverable after direct suffix checks', () => {
const record = {
diagnostics: ['resolved_behavior_changed:old->new'],
lastReason: 'resolved_behavior_changed:old->new',
responseState: 'session_stale',
status: 'retry_scheduled',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'OpenCode session changed; refreshing the session before retry.'
);
});
it('surfaces network details when they are mixed with OpenCode refresh markers', () => {
const record = {
diagnostics: ['resolved_behavior_changed:old->new network timeout'],
lastReason: 'resolved_behavior_changed:old->new network timeout',
responseState: 'reconcile_failed',
status: 'failed_retryable',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('network timeout');
expect(selectOpenCodeRuntimeDeliveryReason(record)).not.toBe(
'OpenCode session changed; refreshing the session before retry.'
);
});
it('prioritizes real failure details over session_stale fallback copy', () => {
const record = {
diagnostics: ['permission denied'],
lastReason: 'permission denied',
responseState: 'session_stale',
status: 'failed_retryable',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe('permission denied');
});
it('prioritizes local disk-full diagnostics over secondary aborted assistant errors', () => {
const record = {
diagnostics: [
@ -146,6 +320,47 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
);
});
it('keeps protocol proof failures above session_stale fallback for stale log projections', () => {
const record = {
diagnostics: [
'OpenCode session is stale (resolved_behavior_changed:old->new); visible_reply_missing_task_refs',
],
lastReason: 'resolved_behavior_changed:old->new visible_reply_missing_task_refs',
responseState: 'session_stale',
status: 'failed_retryable',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'OpenCode created a reply without the required taskRefs metadata.'
);
});
it.each([
{
diagnostic:
'OpenCode session is stale (resolved_behavior_changed:old->new); visible_reply_missing_relayofmessageid',
reason: 'resolved_behavior_changed:old->new visible_reply_missing_relayofmessageid',
expected:
'OpenCode created a reply without the required relayOfMessageId correlation.',
},
{
diagnostic:
'OpenCode session is stale (resolved_behavior_changed:old->new); non_visible_tool_without_task_progress',
reason: 'resolved_behavior_changed:old->new non_visible_tool_without_task_progress',
expected:
'OpenCode used tools, but did not create a visible reply or task progress proof.',
},
])('keeps $reason above session_stale fallback', ({ diagnostic, reason, expected }) => {
const record = {
diagnostics: [diagnostic],
lastReason: reason,
responseState: 'session_stale',
status: 'failed_retryable',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(expected);
});
it('formats taskRefs merge verification failures without exposing internal diagnostics', () => {
const record = {
diagnostics: ['visible_reply_missing_task_refs_after_merge'],

View file

@ -160,5 +160,212 @@ describe('RuntimeDiagnosticClassifier', () => {
generic: true,
actionRequired: false,
});
expect(classifyRuntimeDiagnostic('(resolved_behavior_changed:old->new)')).toMatchObject({
reasonCode: 'backend_error',
normalizedMessage: 'OpenCode session changed; refreshing the session before retry.',
generic: true,
actionRequired: false,
});
expect(
classifyRuntimeDiagnostic('resolved_behavior_changed:tool_error->session_error')
).toMatchObject({
reasonCode: 'backend_error',
normalizedMessage: 'OpenCode session changed; refreshing the session before retry.',
generic: true,
actionRequired: false,
});
expect(
classifyRuntimeDiagnostic('resolved_behavior_changed:responded_non_visible_tool->pending')
).toMatchObject({
reasonCode: 'backend_error',
normalizedMessage: 'OpenCode session changed; refreshing the session before retry.',
generic: true,
actionRequired: false,
});
expect(
classifyRuntimeDiagnostic('resolved_behavior_changed:permission_blocked->pending')
).toMatchObject({
reasonCode: 'backend_error',
normalizedMessage: 'OpenCode session changed; refreshing the session before retry.',
generic: true,
actionRequired: false,
});
expect(
classifyRuntimeDiagnostic(
'resolved_behavior_changed:old->new opencode_app_mcp_transport_changed:a->b'
)
).toMatchObject({
reasonCode: 'backend_error',
normalizedMessage: 'OpenCode session changed; refreshing the session before retry.',
generic: true,
actionRequired: false,
});
expect(
classifyRuntimeDiagnostic('OpenCode session changed; refreshing the session before retry.')
).toMatchObject({
reasonCode: 'backend_error',
normalizedMessage: 'OpenCode session changed; refreshing the session before retry.',
generic: true,
actionRequired: false,
});
});
it('does not classify refresh markers with unknown extra text as clean refresh', () => {
const result = classifyRuntimeDiagnostic(
'resolved_behavior_changed:old->new unexpected detail'
);
expect(result.normalizedMessage).toBe(
'resolved_behavior_changed:old->new unexpected detail'
);
expect(result.generic).toBe(false);
});
it('requires a separator after generic OpenCode API error before refresh markers', () => {
const result = classifyRuntimeDiagnostic(
'OpenCode API errorresolved_behavior_changed:old->new'
);
expect(result.normalizedMessage).toBe(
'OpenCode API errorresolved_behavior_changed:old->new'
);
expect(result.generic).toBe(false);
});
it('only allows known stale log-projection text after refresh markers', () => {
expect(
classifyRuntimeDiagnostic(
'OpenCode session is stale (resolved_behavior_changed:old->new); reading historical messages for log projection only'
)
).toMatchObject({
normalizedMessage: 'OpenCode session changed; refreshing the session before retry.',
generic: true,
});
const unknown = classifyRuntimeDiagnostic(
'OpenCode session is stale (resolved_behavior_changed:old->new); unexpected detail'
);
expect(unknown.normalizedMessage).toBe(
'OpenCode session is stale (resolved_behavior_changed:old->new); unexpected detail'
);
expect(unknown.generic).toBe(false);
});
it('does not let OpenCode refresh markers hide network failure details', () => {
expect(
classifyRuntimeDiagnostic('resolved_behavior_changed:old->new network timeout')
).toMatchObject({
reasonCode: 'network_error',
generic: false,
});
expect(
classifyRuntimeDiagnostic('opencode_app_mcp_transport_changed:old->new service unavailable')
).toMatchObject({
reasonCode: 'provider_overloaded',
generic: false,
});
});
it('does not let OpenCode refresh markers hide permission failures', () => {
expect(
classifyRuntimeDiagnostic('resolved_behavior_changed:old->new;permission_denied')
).toMatchObject({
reasonCode: 'auth_error',
actionRequired: true,
});
expect(
classifyRuntimeDiagnostic('opencode_app_mcp_transport_changed:old->new permission denied')
).toMatchObject({
reasonCode: 'auth_error',
actionRequired: true,
});
expect(classifyRuntimeDiagnostic('permission_blocked')).toMatchObject({
reasonCode: 'auth_error',
actionRequired: true,
});
expect(
classifyRuntimeDiagnostic('resolved_behavior_changed:old->new permission_blocked')
).toMatchObject({
reasonCode: 'auth_error',
actionRequired: true,
});
});
it.each(['error', 'failed', 'failure', 'aborted', 'canceled', 'cancelled', 'interrupted', 'enospc'])(
'does not classify directly attached OpenCode refresh suffix _%s as clean refresh',
(suffix) => {
const result = classifyRuntimeDiagnostic(`resolved_behavior_changed:old->new_${suffix}`);
expect(result.normalizedMessage).not.toBe(
'OpenCode session changed; refreshing the session before retry.'
);
}
);
it.each([
['resolved_behavior_changed:old->new/auth_unavailable', 'auth_error'],
['resolved_behavior_changed:old->new permission denied', 'auth_error'],
['resolved_behavior_changed:old->new permission_blocked', 'auth_error'],
['resolved_behavior_changed:old->new login required', 'auth_error'],
['resolved_behavior_changed:old->new not logged in', 'auth_error'],
['resolved_behavior_changed:old->new missing credentials', 'auth_error'],
['resolved_behavior_changed:old->new access denied', 'auth_error'],
['resolved_behavior_changed:old->new 401', 'auth_error'],
['resolved_behavior_changed:old->new 403', 'auth_error'],
['resolved_behavior_changed:old->new;key limit exceeded', 'quota_exhausted'],
['resolved_behavior_changed:old->new-network_timeout', 'network_error'],
['resolved_behavior_changed:old->new interrupted', 'backend_error'],
[
'resolved_behavior_changed:old->new(non_visible_tool_without_task_progress)',
'protocol_proof_missing',
],
['opencode_app_mcp_transport_changed:old->new/permission_denied', 'auth_error'],
[
'opencode_app_mcp_transport_changed:old->new;visible_reply_missing_task_refs',
'protocol_proof_missing',
],
])('classifies separator-attached failure detail %s as %s', (message, reasonCode) => {
expect(classifyRuntimeDiagnostic(message)).toMatchObject({
reasonCode,
});
expect(classifyRuntimeDiagnostic(message).normalizedMessage).not.toBe(
'OpenCode session changed; refreshing the session before retry.'
);
});
it('does not treat embedded HTTP status digits in ids as auth diagnostics', () => {
expect(classifyRuntimeDiagnostic('trace id abc401def')).toMatchObject({
reasonCode: 'backend_error',
actionRequired: false,
});
expect(classifyRuntimeDiagnostic('trace id abc403def')).toMatchObject({
reasonCode: 'backend_error',
actionRequired: false,
});
expect(classifyRuntimeDiagnostic('HTTP 401')).toMatchObject({
reasonCode: 'auth_error',
actionRequired: true,
});
expect(classifyRuntimeDiagnostic('status:403')).toMatchObject({
reasonCode: 'auth_error',
actionRequired: true,
});
expect(classifyRuntimeDiagnostic('status_401')).toMatchObject({
reasonCode: 'auth_error',
actionRequired: true,
});
expect(classifyRuntimeDiagnostic('http_403')).toMatchObject({
reasonCode: 'auth_error',
actionRequired: true,
});
});
it('does not let OpenCode refresh markers hide protocol proof failures', () => {
expect(
classifyRuntimeDiagnostic('resolved_behavior_changed:old->new visible_reply_missing_task_refs')
).toMatchObject({
reasonCode: 'protocol_proof_missing',
generic: true,
});
});
});

View file

@ -851,6 +851,355 @@ describe('TeamProvisioningService', () => {
expect(nextRecord.status).toBe('retry_scheduled');
});
it('uses stamped OpenCode session-refresh evidence instead of stale historical diagnostics', async () => {
const svc = new TeamProvisioningService();
(svc as any).scheduleOpenCodePromptDeliveryWatchdog = vi.fn();
const record = {
id: 'opencode-prompt:session-refresh',
teamName: 'team-a',
memberName: 'atlas',
laneId: 'secondary:opencode:atlas',
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'msg-1',
inboxTimestamp: '2026-05-18T08:31:00.000Z',
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [],
payloadHash: 'sha256:test',
status: 'accepted',
responseState: 'session_stale',
attempts: 1,
maxAttempts: 3,
sessionRefreshAttempts: 0,
maxSessionRefreshAttempts: 5,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: '2026-05-18T08:31:30.000Z',
lastObservedAt: '2026-05-18T08:31:45.000Z',
acceptedAt: '2026-05-18T08:31:30.000Z',
respondedAt: null,
failedAt: null,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'delivered-1',
observedAssistantMessageId: null,
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'resolved_behavior_changed:old->new',
lastSessionRefreshReason: 'resolved_behavior_changed:old->new',
diagnostics: ['network timeout', 'resolved_behavior_changed:old->new'],
createdAt: '2026-05-18T08:31:00.000Z',
updatedAt: '2026-05-18T08:31:45.000Z',
};
const ledger = {
markFailedTerminal: vi.fn(),
markNextAttemptScheduled: vi.fn(),
markSessionRefreshScheduled: vi.fn(async (input: any) => ({
...record,
status: 'retry_scheduled',
responseState: 'session_stale',
nextAttemptAt: input.nextAttemptAt,
sessionRefreshAttempts: 1,
lastSessionRefreshReason: input.reason,
lastReason: input.reason,
updatedAt: input.scheduledAt,
})),
};
const nextRecord = await (svc as any).scheduleOpenCodePromptLedgerFollowUp({
ledger,
ledgerRecord: record,
teamName: 'team-a',
memberName: 'atlas',
retry: true,
reason: 'resolved_behavior_changed:old->new',
});
expect(ledger.markFailedTerminal).not.toHaveBeenCalled();
expect(ledger.markNextAttemptScheduled).not.toHaveBeenCalled();
expect(ledger.markSessionRefreshScheduled).toHaveBeenCalledWith(
expect.objectContaining({
id: record.id,
reason: 'resolved_behavior_changed:old->new',
maxSessionRefreshAttempts: 5,
})
);
expect(nextRecord).toMatchObject({
status: 'retry_scheduled',
sessionRefreshAttempts: 1,
});
});
it('does not reuse stamped OpenCode session-refresh evidence for current action-required stale sessions', async () => {
const svc = new TeamProvisioningService();
(svc as any).scheduleOpenCodePromptDeliveryWatchdog = vi.fn();
const record = {
id: 'opencode-prompt:session-stale-auth',
teamName: 'team-a',
memberName: 'atlas',
laneId: 'secondary:opencode:atlas',
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'msg-1',
inboxTimestamp: '2026-05-18T08:31:00.000Z',
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [],
payloadHash: 'sha256:test',
status: 'accepted',
responseState: 'session_stale',
attempts: 1,
maxAttempts: 3,
sessionRefreshAttempts: 1,
maxSessionRefreshAttempts: 5,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: '2026-05-18T08:31:30.000Z',
lastObservedAt: '2026-05-18T08:31:45.000Z',
acceptedAt: '2026-05-18T08:31:30.000Z',
respondedAt: null,
failedAt: null,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'delivered-1',
observedAssistantMessageId: null,
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'authentication_failed: invalid api key',
lastSessionRefreshReason: 'resolved_behavior_changed:old->new',
diagnostics: [
'resolved_behavior_changed:old->new',
'authentication_failed: invalid api key',
],
createdAt: '2026-05-18T08:31:00.000Z',
updatedAt: '2026-05-18T08:31:45.000Z',
};
const ledger = {
markFailedTerminal: vi.fn(),
markSessionRefreshScheduled: vi.fn(),
markNextAttemptScheduled: vi.fn(async (input: any) => ({
...record,
status: input.status,
nextAttemptAt: input.nextAttemptAt,
lastReason: input.reason,
updatedAt: input.scheduledAt,
})),
};
const nextRecord = await (svc as any).scheduleOpenCodePromptLedgerFollowUp({
ledger,
ledgerRecord: record,
teamName: 'team-a',
memberName: 'atlas',
retry: true,
reason: 'authentication_failed: invalid api key',
});
expect(ledger.markSessionRefreshScheduled).not.toHaveBeenCalled();
expect(ledger.markNextAttemptScheduled).toHaveBeenCalledWith(
expect.objectContaining({
id: record.id,
status: 'retry_scheduled',
reason: 'authentication_failed: invalid api key',
})
);
expect(nextRecord.status).toBe('retry_scheduled');
});
it('does not let generic session-refresh stamps bypass current action-required diagnostics', async () => {
const svc = new TeamProvisioningService();
(svc as any).scheduleOpenCodePromptDeliveryWatchdog = vi.fn();
const record = {
id: 'opencode-prompt:session-stale-generic-auth',
teamName: 'team-a',
memberName: 'atlas',
laneId: 'secondary:opencode:atlas',
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'msg-1',
inboxTimestamp: '2026-05-18T08:31:00.000Z',
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [],
payloadHash: 'sha256:test',
status: 'accepted',
responseState: 'session_stale',
attempts: 1,
maxAttempts: 3,
sessionRefreshAttempts: 1,
maxSessionRefreshAttempts: 5,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: '2026-05-18T08:31:30.000Z',
lastObservedAt: '2026-05-18T08:31:45.000Z',
acceptedAt: '2026-05-18T08:31:30.000Z',
respondedAt: null,
failedAt: null,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'delivered-1',
observedAssistantMessageId: null,
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'OpenCode API error',
lastSessionRefreshReason: 'OpenCode API error',
diagnostics: ['OpenCode API error', 'permission_blocked'],
createdAt: '2026-05-18T08:31:00.000Z',
updatedAt: '2026-05-18T08:31:45.000Z',
};
const ledger = {
markFailedTerminal: vi.fn(),
markSessionRefreshScheduled: vi.fn(),
markNextAttemptScheduled: vi.fn(async (input: any) => ({
...record,
status: input.status,
nextAttemptAt: input.nextAttemptAt,
lastReason: input.reason,
updatedAt: input.scheduledAt,
})),
};
const nextRecord = await (svc as any).scheduleOpenCodePromptLedgerFollowUp({
ledger,
ledgerRecord: record,
teamName: 'team-a',
memberName: 'atlas',
retry: true,
reason: 'OpenCode API error',
});
expect(ledger.markSessionRefreshScheduled).not.toHaveBeenCalled();
expect(ledger.markNextAttemptScheduled).toHaveBeenCalledWith(
expect.objectContaining({
id: record.id,
status: 'retry_scheduled',
reason: 'OpenCode API error',
})
);
expect(nextRecord.status).toBe('retry_scheduled');
expect(
(svc as any).isOpenCodeSessionRefreshRetryRecord(
{
...record,
id: 'opencode-prompt:session-stale-display-auth',
lastReason: 'OpenCode session changed; refreshing the session before retry.',
lastSessionRefreshReason:
'OpenCode session changed; refreshing the session before retry.',
},
'OpenCode session changed; refreshing the session before retry.'
)
).toBe(false);
});
it('does not reuse stale session-refresh stamps for later non-session-stale retries', async () => {
const svc = new TeamProvisioningService();
(svc as any).scheduleOpenCodePromptDeliveryWatchdog = vi.fn();
const record = {
id: 'opencode-prompt:no-assistant-after-refresh',
teamName: 'team-a',
memberName: 'atlas',
laneId: 'secondary:opencode:atlas',
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'msg-1',
inboxTimestamp: '2026-05-18T08:31:00.000Z',
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [],
payloadHash: 'sha256:test',
status: 'accepted',
responseState: 'prompt_delivered_no_assistant_message',
attempts: 3,
maxAttempts: 3,
sessionRefreshAttempts: 1,
maxSessionRefreshAttempts: 5,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: '2026-05-18T08:31:30.000Z',
lastObservedAt: '2026-05-18T08:31:45.000Z',
acceptedAt: '2026-05-18T08:31:30.000Z',
respondedAt: null,
failedAt: null,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'delivered-1',
observedAssistantMessageId: null,
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'prompt_delivered_no_assistant_message',
lastSessionRefreshReason: 'resolved_behavior_changed:old->new',
diagnostics: [
'resolved_behavior_changed:old->new',
'prompt_delivered_no_assistant_message',
],
createdAt: '2026-05-18T08:31:00.000Z',
updatedAt: '2026-05-18T08:31:45.000Z',
};
const ledger = {
markFailedTerminal: vi.fn(),
markSessionRefreshScheduled: vi.fn(),
markNextAttemptScheduled: vi.fn(async (input: any) => ({
...record,
status: input.status,
nextAttemptAt: input.nextAttemptAt,
lastReason: input.reason,
updatedAt: input.scheduledAt,
})),
};
const nextRecord = await (svc as any).scheduleOpenCodePromptLedgerFollowUp({
ledger,
ledgerRecord: record,
teamName: 'team-a',
memberName: 'atlas',
retry: true,
reason: 'prompt_delivered_no_assistant_message',
});
expect(ledger.markSessionRefreshScheduled).not.toHaveBeenCalled();
expect(ledger.markNextAttemptScheduled).toHaveBeenCalledWith(
expect.objectContaining({
id: record.id,
status: 'retry_scheduled',
reason: 'prompt_delivered_no_assistant_message',
})
);
expect(nextRecord.status).toBe('retry_scheduled');
});
it('does not requeue terminal no-assistant delivery after the bounded recovery retry is exhausted', async () => {
const svc = new TeamProvisioningService();
const record = {
@ -7526,6 +7875,9 @@ describe('TeamProvisioningService', () => {
const scheduledEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
data: Array<{
nextAttemptAt: string | null;
diagnostics?: string[];
lastReason?: string | null;
lastSessionRefreshReason?: string | null;
sessionRefreshAttempts?: number;
attempts: number;
maxAttempts: number;
@ -7537,6 +7889,13 @@ describe('TeamProvisioningService', () => {
sessionRefreshAttempts: 1,
});
scheduledEnvelope.data[0].nextAttemptAt = '2000-01-01T00:00:00.000Z';
scheduledEnvelope.data[0].lastReason = 'resolved_behavior_changed:old->new';
scheduledEnvelope.data[0].lastSessionRefreshReason = 'resolved_behavior_changed:old->new';
scheduledEnvelope.data[0].diagnostics = [
'network timeout',
'resolved_behavior_changed:old->new',
'opencode_session_refresh_scheduled_after_resolved_behavior_changed',
];
await fsPromises.writeFile(ledgerPath, JSON.stringify(scheduledEnvelope, null, 2), 'utf8');
await expect(
@ -7562,6 +7921,7 @@ describe('TeamProvisioningService', () => {
cwd: '/repo',
messageId: 'msg-stale-session',
deliveryAttemptId: expect.stringContaining(':refresh1'),
forceSessionRefreshReason: 'resolved_behavior_changed:old->new',
});
});
@ -7921,6 +8281,209 @@ describe('TeamProvisioningService', () => {
});
});
it('stamps legacy OpenCode session transport evidence after a successful send without forcing refresh', 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_legacy_transport_stamp',
prePromptCursor: 'cursor-legacy-transport-stamp',
responseObservation: {
state: 'pending',
deliveredUserMessageId: 'oc-user-legacy-transport-stamp',
assistantMessageId: null,
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'assistant_response_pending',
},
diagnostics: [],
}));
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
await writeCommittedOpenCodeSessionStore({
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
runId: 'opencode-run-bob',
batchKey: 'legacy-transport-stamp',
sessions: [
{
id: 'oc-session-bob',
teamName: 'team-a',
memberName: 'bob',
laneId: 'secondary:opencode:bob',
runId: 'opencode-run-bob',
source: 'app_managed_bootstrap',
},
],
});
const currentTransportEvidence = {
schemaVersion: 1,
transport: 'httpStream',
host: '127.0.0.1',
port: 43129,
endpoint: '/mcp',
url: 'http://127.0.0.1:43129/mcp',
urlHash: 'current-legacy-transport-hash',
generation: 8,
observedAt: '2026-04-25T10:00:00.000Z',
};
const transportSpy = vi.spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle').mockReturnValue({
url: currentTransportEvidence.url,
port: currentTransportEvidence.port,
child: { pid: 43129 },
generation: currentTransportEvidence.generation,
urlHash: currentTransportEvidence.urlHash,
transportEvidence: currentTransportEvidence,
diagnostics: [],
} as any);
try {
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'hello legacy transport stamp bob',
messageId: 'msg-legacy-transport-stamp',
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
responsePending: true,
responseState: 'pending',
});
} finally {
transportSpy.mockRestore();
}
expect(sendMessageToMember).toHaveBeenCalledWith(
expect.not.objectContaining({
forceSessionRefreshReason: expect.any(String),
})
);
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
teamsBasePath: tempTeamsBase,
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
});
expect(evidence.sessions).toHaveLength(1);
expect(evidence.sessions[0]).toMatchObject({
id: 'oc-session-bob',
appMcpTransportHash: 'current-legacy-transport-hash',
});
});
it('dedupes stale and refreshed OpenCode session evidence when stamping a new transport hash', 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-refreshed',
runtimePromptMessageId: 'msg_prompt_deduped_refresh',
prePromptCursor: 'cursor-deduped-refresh',
responseObservation: {
state: 'pending',
deliveredUserMessageId: 'oc-user-deduped-refresh',
assistantMessageId: null,
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'assistant_response_pending',
},
diagnostics: [],
}));
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
await writeCommittedOpenCodeSessionStore({
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
runId: 'opencode-run-bob',
batchKey: 'dedupe-transport-refresh',
sessions: [
{
id: 'oc-session-bob',
teamName: 'team-a',
memberName: 'bob',
laneId: 'secondary:opencode:bob',
runId: 'opencode-run-bob',
source: 'app_managed_bootstrap',
appMcpTransportHash: 'old-deduped-transport-hash',
},
{
id: 'oc-session-bob-refreshed',
teamName: 'team-a',
memberName: 'bob',
laneId: 'secondary:opencode:bob',
runId: 'opencode-run-bob',
source: 'app_managed_bootstrap',
appMcpTransportHash: 'older-duplicate-transport-hash',
},
],
});
const currentTransportEvidence = {
schemaVersion: 1,
transport: 'httpStream',
host: '127.0.0.1',
port: 43130,
endpoint: '/mcp',
url: 'http://127.0.0.1:43130/mcp',
urlHash: 'current-deduped-transport-hash',
generation: 9,
observedAt: '2026-04-25T10:00:00.000Z',
};
const transportSpy = vi.spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle').mockReturnValue({
url: currentTransportEvidence.url,
port: currentTransportEvidence.port,
child: { pid: 43130 },
generation: currentTransportEvidence.generation,
urlHash: currentTransportEvidence.urlHash,
transportEvidence: currentTransportEvidence,
diagnostics: [],
} as any);
try {
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'hello deduped refresh bob',
messageId: 'msg-deduped-refresh-transport',
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
responsePending: true,
responseState: 'pending',
});
} finally {
transportSpy.mockRestore();
}
expect(sendMessageToMember).toHaveBeenCalledWith(
expect.objectContaining({
forceSessionRefreshReason:
'opencode_app_mcp_transport_changed:old-deduped-transport-hash->current-deduped-transport-hash',
})
);
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
teamsBasePath: tempTeamsBase,
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
});
expect(evidence.sessions).toHaveLength(1);
expect(evidence.sessions[0]).toMatchObject({
id: 'oc-session-bob-refreshed',
appMcpTransportHash: 'current-deduped-transport-hash',
});
});
it('fails closed through the delivery ledger when forced refresh reaches an old OpenCode bridge contract', async () => {
const svc = new TeamProvisioningService();
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember: vi.fn() });
@ -7953,17 +8516,15 @@ describe('TeamProvisioningService', () => {
generation: 6,
observedAt: '2026-04-25T10:00:00.000Z',
};
const transportSpy = vi
.spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle')
.mockReturnValue({
url: currentTransportEvidence.url,
port: currentTransportEvidence.port,
child: { pid: 43127 },
generation: currentTransportEvidence.generation,
urlHash: currentTransportEvidence.urlHash,
transportEvidence: currentTransportEvidence,
diagnostics: [],
} as any);
const transportSpy = vi.spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle').mockReturnValue({
url: currentTransportEvidence.url,
port: currentTransportEvidence.port,
child: { pid: 43127 },
generation: currentTransportEvidence.generation,
urlHash: currentTransportEvidence.urlHash,
transportEvidence: currentTransportEvidence,
diagnostics: [],
} as any);
const directBridgeExecute = vi.fn(async () => {
throw new Error('direct OpenCode bridge executor should not be used for acceptance send');
});

View file

@ -919,7 +919,7 @@ describe('memberHelpers spawn-aware presence', () => {
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode proof missing');
expect(getMemberRuntimeAdvisoryTone(advisory)).toBe('warning');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('warning');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
@ -968,6 +968,328 @@ describe('memberHelpers spawn-aware presence', () => {
expect(title).not.toContain('opencode_prompt_acceptance_unknown_after_bridge_timeout');
});
it.each([
'session_stale',
'resolved_behavior_changed:old->new',
'(resolved_behavior_changed:old->new)',
'OpenCode API error: resolved_behavior_changed:old->new',
'resolved_behavior_changed:old.hash/1=abc->new.hash/2=def.',
'resolved_behavior_changed:tool_error->session_error',
'resolved_behavior_changed:responded_non_visible_tool->pending',
'resolved_behavior_changed:permission_blocked->pending',
'resolved_behavior_changed:old->new opencode_app_mcp_transport_changed:a->b',
'OpenCode session is stale (resolved_behavior_changed:old->new); reading historical messages for log projection only',
'opencode_app_mcp_transport_changed:old->new',
'opencode_prompt_delivery_session_refresh_scheduled',
'OpenCode session refresh scheduled after resolved behavior changed',
'opencode_session_refresh_scheduled_after_resolved_behavior_changed',
])('renders recoverable OpenCode session refresh advisory %s as a warning', (message) => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message,
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode session refresh');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('warning');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toBe('OpenCode session changed; refreshing the session before retry.');
expect(title).not.toContain('OpenCode API error');
expect(title).not.toContain(message);
});
it('renders legacy OpenCode session refresh advisories without a reason code as warnings', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
message: 'session_stale',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode session refresh');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('warning');
expect(getMemberRuntimeAdvisoryTitle(advisory, 'opencode')).toBe(
'OpenCode session changed; refreshing the session before retry.'
);
});
it('does not hide real OpenCode API errors that merely mention a refresh marker', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message: 'OpenCode API error. resolved_behavior_changed:old->new permission denied',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain('permission denied');
});
it('does not strip a generic OpenCode API error prefix without a separator', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message: 'OpenCode API errorresolved_behavior_changed:old->new',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
expect(getMemberRuntimeAdvisoryTitle(advisory, 'opencode')).toContain(
'OpenCode API errorresolved_behavior_changed:old->new'
);
});
it('does not format a refresh-prefixed message with extra failure details as a clean session refresh', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message: 'resolved_behavior_changed:old->new permission denied',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain('resolved_behavior_changed:old->new permission denied');
expect(title).not.toBe('OpenCode session changed; refreshing the session before retry.');
});
it('does not format refresh markers with unknown extra text as a clean session refresh', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message: 'resolved_behavior_changed:old->new unexpected detail',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain('unexpected detail');
});
it('does not format colon-suffixed refresh failure details as a clean session refresh', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message: 'resolved_behavior_changed:old->new:permission_denied',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain('resolved_behavior_changed:old->new:permission_denied');
expect(title).not.toBe('OpenCode session changed; refreshing the session before retry.');
});
it('does not format semicolon-attached failure details as a clean session refresh', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message: 'resolved_behavior_changed:old->new;permission_denied',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain('permission_denied');
});
it.each(['permission_denied', 'error', 'failed', 'failure', 'aborted', 'canceled', 'cancelled', 'interrupted', 'enospc'])(
'does not let refresh pattern consume directly attached failure token _%s',
(suffix) => {
const message = `resolved_behavior_changed:old->new_${suffix}`;
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message,
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain(message);
}
);
it.each([
'resolved_behavior_changed:old->new/auth_unavailable',
'resolved_behavior_changed:old->new permission denied',
'resolved_behavior_changed:old->new permission_blocked',
'resolved_behavior_changed:old->new login required',
'resolved_behavior_changed:old->new not logged in',
'resolved_behavior_changed:old->new missing credentials',
'resolved_behavior_changed:old->new access denied',
'resolved_behavior_changed:old->new 401',
'resolved_behavior_changed:old->new;key limit exceeded',
'resolved_behavior_changed:old->new-network_timeout',
'resolved_behavior_changed:old->new interrupted',
'resolved_behavior_changed:old->new(non_visible_tool_without_task_progress)',
'opencode_app_mcp_transport_changed:old->new/permission_denied',
'opencode_app_mcp_transport_changed:old->new;visible_reply_missing_task_refs',
])('keeps separator-attached failure detail as an OpenCode API error for %s', (message) => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message,
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain(message);
});
it('still formats clean refresh markers after direct suffix checks', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message: 'resolved_behavior_changed:old->new',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode session refresh');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('warning');
expect(getMemberRuntimeAdvisoryTitle(advisory, 'opencode')).toBe(
'OpenCode session changed; refreshing the session before retry.'
);
});
it('does not format refresh markers with network failures as a clean session refresh', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message: 'resolved_behavior_changed:old->new network timeout',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain('network timeout');
});
it('does not format refresh markers with auth failures as a clean session refresh', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message: 'resolved_behavior_changed:old->new auth_unavailable',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain('auth_unavailable');
});
it.each([
'OpenCode session is stale (resolved_behavior_changed:old->new); Key limit exceeded (total limit)',
'OpenCode session is stale (resolved_behavior_changed:old->new); 429 too many requests',
'OpenCode session is stale (resolved_behavior_changed:old->new); Free usage exceeded, subscribe to Go',
])('does not format stale refresh text with quota/rate failures as clean refresh: %s', (message) => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message,
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain(message);
});
it('does not format stale refresh text with unknown extra text as clean refresh', () => {
const message =
'OpenCode session is stale (resolved_behavior_changed:old->new); unexpected detail';
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message,
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
expect(getMemberRuntimeAdvisoryTitle(advisory, 'opencode')).toContain(message);
});
it('does not format stale log-projection text with protocol failures as clean session refresh', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message:
'OpenCode session is stale (resolved_behavior_changed:old->new); visible_reply_missing_task_refs',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode API error.');
expect(title).toContain('visible_reply_missing_task_refs');
});
it('does not downgrade action-required OpenCode errors with refresh-looking messages', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'quota_exhausted' as const,
message: 'resolved_behavior_changed:old->new',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode quota error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode quota exhausted.');
});
it('does not downgrade non-OpenCode backend errors that reuse OpenCode refresh-looking text', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error' as const,
message: 'resolved_behavior_changed:old->new',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'anthropic')).toBe('Anthropic API error');
expect(getMemberRuntimeAdvisoryTone(advisory, 'anthropic')).toBe('error');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'anthropic');
expect(title).toContain('Anthropic API error.');
expect(title).toContain('resolved_behavior_changed:old->new');
expect(title).not.toContain('OpenCode session changed');
});
it('formats non-visible tool progress advisory reasons before showing them in titles', () => {
const title = getMemberRuntimeAdvisoryTitle(
{
@ -1062,6 +1384,30 @@ describe('memberHelpers spawn-aware presence', () => {
expect(presentation.dotClass).toContain('bg-red-400');
});
it('keeps recoverable OpenCode session refresh presentation out of the terminal error state', () => {
const presentation = buildMemberLaunchPresentation({
member: { ...member, providerId: 'opencode' },
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnLivenessSource: 'process',
spawnRuntimeAlive: true,
runtimeAdvisory: {
kind: 'api_error',
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error',
message: 'opencode_app_mcp_transport_changed:old->new',
},
isLaunchSettling: false,
isTeamAlive: true,
isTeamProvisioning: false,
});
expect(presentation.presenceLabel).toBe('OpenCode session refresh');
expect(presentation.runtimeAdvisoryLabel).toBe('OpenCode session refresh');
expect(presentation.runtimeAdvisoryTone).toBe('warning');
expect(presentation.dotClass).not.toContain('bg-red-400');
});
it('falls back to the existing generic retry wording when no structured reason is present', () => {
expect(
getMemberRuntimeAdvisoryLabel(

View file

@ -4,6 +4,7 @@ import {
buildMemberLaunchDiagnosticsPayload,
formatMemberLaunchDiagnosticsPayload,
hasMemberLaunchDiagnosticsDetails,
hasMemberLaunchDiagnosticsError,
getMemberLaunchDiagnosticsErrorMessage,
} from '@renderer/utils/memberLaunchDiagnostics';
@ -160,6 +161,32 @@ describe('member launch diagnostics', () => {
);
});
it('does not turn info runtime diagnostics into member card errors even on terminal launch state', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'atlas',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation',
runtimeDiagnosticSeverity: 'info',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'atlas',
providerId: 'opencode',
alive: true,
restartable: false,
runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation',
runtimeDiagnosticSeverity: 'info',
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(payload.runtimeDiagnosticSeverity).toBe('info');
});
it('prefers advisory errors over healthy info liveness diagnostics', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'atlas',
@ -196,8 +223,10 @@ describe('member launch diagnostics', () => {
it('does not surface recoverable OpenCode session refresh advisory as card error', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
runtimeAdvisoryLabel: 'OpenCode session refresh',
runtimeAdvisoryTitle: 'OpenCode session changed; refreshing the session before retry.',
runtimeAdvisoryTitle:
'OpenCode session changed; refreshing the session before retry.',
spawnEntry: {
status: 'online',
launchState: 'confirmed_alive',
@ -225,9 +254,9 @@ describe('member launch diagnostics', () => {
it('does not surface recoverable OpenCode transport refresh advisory as card error', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
runtimeAdvisoryLabel: 'OpenCode session refresh',
runtimeAdvisoryTitle:
'OpenCode session changed; refreshing the session before retry.',
runtimeAdvisoryTitle: 'OpenCode session changed; refreshing the session before retry.',
runtimeAdvisory: {
kind: 'api_error',
observedAt: '2026-05-18T08:31:46.075Z',
@ -238,4 +267,744 @@ describe('member launch diagnostics', () => {
expect(payload.memberCardError).toBeUndefined();
});
it('does not surface legacy OpenCode refresh scheduled advisory as card error', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
runtimeAdvisoryLabel: 'OpenCode session refresh',
runtimeAdvisoryTitle: 'OpenCode session changed; refreshing the session before retry.',
runtimeAdvisory: {
kind: 'api_error',
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error',
message: 'opencode_prompt_delivery_session_refresh_scheduled',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
});
it('suppresses generic OpenCode advisory card errors when clean refresh evidence is present', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
runtimeAdvisoryLabel: 'OpenCode API error',
runtimeAdvisoryTitle: 'OpenCode API error',
runtimeAdvisory: {
kind: 'api_error',
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error',
message: 'OpenCode API error',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: ['resolved_behavior_changed:old->new'],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
});
it('does not treat OpenCode response-state names inside refresh markers as card errors', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error. resolved_behavior_changed:permission_blocked->pending',
hardFailureReason: 'OpenCode API error',
runtimeDiagnostic:
'resolved_behavior_changed:responded_non_visible_tool->pending',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
runtimeDiagnostic:
'resolved_behavior_changed:responded_non_visible_tool->pending',
runtimeDiagnosticSeverity: 'error',
diagnostics: ['resolved_behavior_changed:tool_error->session_error'],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
});
it('does not treat multiple clean OpenCode refresh markers in one diagnostic as card errors', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error:
'OpenCode API error. resolved_behavior_changed:old->new opencode_app_mcp_transport_changed:a->b',
runtimeDiagnostic:
'resolved_behavior_changed:old->new opencode_app_mcp_transport_changed:a->b',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
});
it('does not surface recoverable OpenCode refresh text from stale spawn errors as card error', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error. resolved_behavior_changed:old->new',
hardFailureReason: 'OpenCode API error',
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
runtimeDiagnostic:
'opencode_session_refresh_scheduled_after_resolved_behavior_changed',
runtimeDiagnosticSeverity: 'error',
diagnostics: ['resolved_behavior_changed:old->new'],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(payload.diagnostics).toContain('resolved_behavior_changed:old->new');
expect(payload.diagnosticHints).toBeUndefined();
expect(payload.probableCause).toBeUndefined();
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
expect(getMemberLaunchDiagnosticsErrorMessage(payload)).toBeUndefined();
});
it('treats parenthesized clean OpenCode refresh markers as recoverable UI diagnostics', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error. (resolved_behavior_changed:old->new)',
hardFailureReason: 'OpenCode API error:',
runtimeDiagnostic: '(opencode_app_mcp_transport_changed:old->new)',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
expect(getMemberLaunchDiagnosticsErrorMessage(payload)).toBeUndefined();
});
it('keeps malformed generic OpenCode API error prefixes as card errors', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API errorresolved_behavior_changed:old->new',
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBe(
'OpenCode API errorresolved_behavior_changed:old->new'
);
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it('suppresses card error when all stale spawn failure fields are recoverable refresh diagnostics', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error. resolved_behavior_changed:old->new',
hardFailureReason:
'opencode_session_refresh_scheduled_after_resolved_behavior_changed',
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(payload.diagnostics).toContain(
'OpenCode API error. resolved_behavior_changed:old->new'
);
expect(payload.diagnosticHints).toBeUndefined();
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
});
it('uses runtime diagnostics as refresh evidence without turning them into card errors', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: [
'resolved_behavior_changed:old->new',
'matched OpenCode runtime pid and process identity',
],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(payload.diagnostics).toContain('resolved_behavior_changed:old->new');
expect(payload.memberCardError).not.toBe(
'matched OpenCode runtime pid and process identity'
);
});
it('uses suppressed spawn runtime diagnostics as refresh evidence for generic OpenCode API errors', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
expect(getMemberLaunchDiagnosticsErrorMessage(payload)).toBeUndefined();
});
it('does not suppress stale markers when separate evidence contains real failure details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'session_stale',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: ['permission denied'],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBe('session_stale');
expect(payload.diagnostics).toContain('permission denied');
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it('uses stale OpenCode log-projection diagnostics as refresh evidence without card errors', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: [
'OpenCode session is stale (resolved_behavior_changed:old->new); reading historical messages for log projection only',
],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
expect(getMemberLaunchDiagnosticsErrorMessage(payload)).toBeUndefined();
});
it('keeps card error when stale refresh diagnostics include unknown extra text', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: [
'OpenCode session is stale (resolved_behavior_changed:old->new); unexpected detail',
],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBe('OpenCode API error');
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it('keeps card error when OpenCode API error includes non-refresh failure details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error. resolved_behavior_changed:old->new permission denied',
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBe(
'OpenCode API error. resolved_behavior_changed:old->new permission denied'
);
expect(payload.diagnosticHints).toContain(
'Launch state is terminal for this run; restart/relaunch is required after fixing the cause.'
);
});
it('keeps card error when OpenCode API error includes unknown refresh details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error. resolved_behavior_changed:old->new unexpected detail',
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBe(
'OpenCode API error. resolved_behavior_changed:old->new unexpected detail'
);
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it('keeps card error when refresh marker has colon-suffixed failure details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error. resolved_behavior_changed:old->new:permission_denied',
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBe(
'OpenCode API error. resolved_behavior_changed:old->new:permission_denied'
);
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it.each(['permission_denied', 'error', 'failed', 'failure', 'aborted', 'canceled', 'cancelled', 'interrupted', 'enospc'])(
'keeps card error when refresh marker directly consumes failure-looking suffix _%s',
(suffix) => {
const error = `OpenCode API error. resolved_behavior_changed:old->new_${suffix}`;
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error,
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBe(error);
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
}
);
it.each([
'resolved_behavior_changed:old->new/auth_unavailable',
'resolved_behavior_changed:old->new permission denied',
'resolved_behavior_changed:old->new permission_blocked',
'resolved_behavior_changed:old->new login required',
'resolved_behavior_changed:old->new not logged in',
'resolved_behavior_changed:old->new missing credentials',
'resolved_behavior_changed:old->new access denied',
'resolved_behavior_changed:old->new 401',
'resolved_behavior_changed:old->new;key limit exceeded',
'resolved_behavior_changed:old->new-network_timeout',
'resolved_behavior_changed:old->new interrupted',
'resolved_behavior_changed:old->new(non_visible_tool_without_task_progress)',
'opencode_app_mcp_transport_changed:old->new/permission_denied',
'opencode_app_mcp_transport_changed:old->new;visible_reply_missing_task_refs',
])('keeps card error for separator-attached failure detail %s', (detail) => {
const error = `OpenCode API error. ${detail}`;
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error,
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBe(error);
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it('suppresses card error when refresh marker suffix is clean', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error. resolved_behavior_changed:old->new',
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBeUndefined();
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
});
it('keeps card error when failure details are attached to a refresh marker with punctuation', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error. resolved_behavior_changed:old->new;permission_denied',
runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBe(
'OpenCode API error. resolved_behavior_changed:old->new;permission_denied'
);
expect(payload.diagnosticHints).toContain(
'Launch state is terminal for this run; restart/relaunch is required after fixing the cause.'
);
});
it('keeps generic card error when diagnostics mention refresh plus real failure details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: ['resolved_behavior_changed:old->new permission denied'],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBe('OpenCode API error');
expect(payload.diagnosticHints).toContain(
'Launch state is terminal for this run; restart/relaunch is required after fixing the cause.'
);
});
it('keeps generic card error when clean refresh diagnostics are mixed with separate failure details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: ['resolved_behavior_changed:old->new', 'permission denied'],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBe('OpenCode API error');
expect(payload.diagnostics).toContain('permission denied');
expect(payload.diagnosticHints).toContain(
'Launch state is terminal for this run; restart/relaunch is required after fixing the cause.'
);
});
it('keeps generic card error when clean refresh diagnostics are mixed with network failure details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: ['resolved_behavior_changed:old->new', 'network timeout'],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBe('OpenCode API error');
expect(payload.diagnostics).toContain('network timeout');
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it('keeps generic card error when clean refresh diagnostics are mixed with auth failure details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: ['resolved_behavior_changed:old->new', 'auth_unavailable'],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBe('OpenCode API error');
expect(payload.diagnostics).toContain('auth_unavailable');
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it('keeps generic card error when clean refresh diagnostics are mixed with permission-blocked details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: ['resolved_behavior_changed:old->new', 'permission_blocked'],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBe('OpenCode API error');
expect(payload.diagnostics).toContain('permission_blocked');
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it('keeps generic card error when clean refresh diagnostics are mixed with quota failure details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: [
'resolved_behavior_changed:old->new',
'Key limit exceeded (total limit). Manage it using OpenRouter settings.',
],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBe('OpenCode API error');
expect(payload.diagnostics).toContain(
'Key limit exceeded (total limit). Manage it using OpenRouter settings.'
);
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it('keeps generic card error when stale log-projection diagnostics include protocol failure details', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'OpenCode API error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
runtimeEntry: {
memberName: 'tom',
providerId: 'opencode',
alive: true,
restartable: false,
diagnostics: [
'OpenCode session is stale (resolved_behavior_changed:old->new); visible_reply_missing_task_refs',
],
updatedAt: '2026-05-18T08:34:47.845Z',
},
});
expect(payload.memberCardError).toBe('OpenCode API error');
expect(payload.diagnostics).toContain(
'OpenCode session is stale (resolved_behavior_changed:old->new); visible_reply_missing_task_refs'
);
expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true);
});
it('keeps action-required runtime advisory errors even when the message looks like refresh evidence', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'tom',
member: { name: 'tom', providerId: 'opencode' },
runtimeAdvisoryLabel: 'OpenCode quota error',
runtimeAdvisoryTitle: 'OpenCode quota exhausted.',
runtimeAdvisory: {
kind: 'api_error',
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'quota_exhausted',
message: 'resolved_behavior_changed:old->new',
},
});
expect(payload.memberCardError).toBe('OpenCode quota exhausted.');
expect(payload.runtimeAdvisoryReasonCode).toBe('quota_exhausted');
expect(payload.diagnostics).toContain('OpenCode quota exhausted.');
});
it('does not suppress non-OpenCode runtime diagnostics that look like refresh markers', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'claude',
member: { name: 'claude', providerId: 'anthropic' },
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
error: 'session_stale',
runtimeDiagnostic: 'resolved_behavior_changed:old->new',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-18T08:13:23.902Z',
},
});
expect(payload.memberCardError).toBe('session_stale');
expect(payload.diagnostics).toContain('resolved_behavior_changed:old->new');
expect(payload.diagnosticHints).toContain(
'Launch state is terminal for this run; restart/relaunch is required after fixing the cause.'
);
});
it('does not suppress non-OpenCode advisory errors that look like session refresh', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'claude',
member: { name: 'claude', providerId: 'anthropic' },
runtimeAdvisoryLabel: 'Anthropic API error',
runtimeAdvisoryTitle: 'Anthropic API error.\n\nresolved_behavior_changed:old->new',
runtimeAdvisory: {
kind: 'api_error',
observedAt: '2026-05-18T08:31:46.075Z',
reasonCode: 'backend_error',
message: 'resolved_behavior_changed:old->new',
},
});
expect(payload.memberCardError).toBe('Anthropic API error. resolved_behavior_changed:old->new');
expect(payload.diagnostics).toContain(
'Anthropic API error. resolved_behavior_changed:old->new'
);
});
});