chore: save remaining workspace updates
This commit is contained in:
parent
7c5832bd7e
commit
20a8e69c4c
27 changed files with 3233 additions and 187 deletions
27
README.md
27
README.md
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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": "السعر"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": "कीमत"
|
||||
|
|
|
|||
|
|
@ -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": "価格"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": "Цена"
|
||||
|
|
|
|||
|
|
@ -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": "价格"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ export const MemberDetailDialog = ({
|
|||
teamName,
|
||||
runId: runtimeRunId,
|
||||
memberName: member.name,
|
||||
member,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
|||
teamName: effectiveTeamName,
|
||||
runId: runtimeRunId ?? memberSpawnSnapshot?.runId ?? progress?.runId,
|
||||
memberName: member.name,
|
||||
member,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue