From 3f2b807bbc331fc0eaa0e7c11aa9af7afe341a9a Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 12 May 2026 13:26:33 +0300 Subject: [PATCH] feat(opencode): improve runtime delivery diagnostics --- .gitignore | 3 + landing/product-docs/.vitepress/config.ts | 21 +- landing/product-docs/guide/installation.md | 2 +- landing/product-docs/guide/quickstart.md | 4 +- landing/product-docs/guide/runtime-setup.md | 5 - landing/product-docs/guide/troubleshooting.md | 7 +- landing/product-docs/reference/faq.md | 12 +- .../reference/privacy-local-data.md | 10 - .../reference/providers-runtimes.md | 12 +- package.json | 11 +- packages/agent-graph/src/canvas/draw-tasks.ts | 72 +- .../src/constants/canvas-constants.ts | 30 +- .../agent-graph/src/layout/kanbanLayout.ts | 42 +- .../src/layout/stableSlotGeometry.ts | 2 +- pnpm-lock.yaml | 113 ++- runtime.lock.json | 12 +- scripts/team-changes-real-data-smoke.ts | 488 ++++++++++++ .../renderer/ui/GraphActivityHud.tsx | 82 +- .../renderer/ui/GraphMemberLogPreviewHud.tsx | 36 +- src/main/index.ts | 8 + src/main/ipc/handlers.ts | 14 + src/main/ipc/openCodeRuntime.ts | 78 ++ .../OpenCodeRuntimeInstallerService.ts | 578 ++++++++++++++ src/main/services/infrastructure/index.ts | 1 + .../services/runtime/providerAwareCliEnv.ts | 10 + src/main/services/team/TaskBoundaryParser.ts | 21 +- src/main/services/team/TaskChangeComputer.ts | 33 +- .../services/team/TeamMemberLogsFinder.ts | 224 +++--- .../services/team/TeamProvisioningService.ts | 6 + .../bridge/OpenCodeBridgeCommandContract.ts | 41 + .../bridge/OpenCodeReadinessBridge.ts | 105 ++- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 2 + .../runtime/RuntimeDiagnosticClassifier.ts | 12 + src/main/utils/cliPathMerge.ts | 33 +- src/main/utils/shellEnv.ts | 110 ++- src/preload/constants/ipcChannels.ts | 10 + src/preload/index.ts | 32 +- src/renderer/api/httpClient.ts | 19 + .../components/dashboard/CliStatusBanner.tsx | 99 ++- .../runtime/ProviderModelBadges.tsx | 31 +- .../components/team/TeamDetailView.tsx | 342 ++++++++- .../__tests__/teamChangesRequestPlan.test.ts | 64 +- .../useTeamChangesSummaries.test.tsx | 332 +++++++- .../team/dialogs/TeamModelSelector.tsx | 724 ++++++++++++------ .../components/team/teamChangesRequestPlan.ts | 47 +- .../team/useTeamChangesSummaries.ts | 155 +++- src/renderer/hooks/useCliInstaller.ts | 26 +- src/renderer/store/index.ts | 29 + .../store/slices/cliInstallerSlice.ts | 78 +- src/renderer/store/slices/teamSlice.ts | 301 +++++++- src/renderer/utils/memberHelpers.ts | 6 + .../openCodeRuntimeDeliveryDiagnostics.ts | 5 + src/shared/types/api.ts | 7 +- src/shared/types/cliInstaller.ts | 39 + .../OpenCodeRuntimeInstallerService.test.ts | 74 ++ .../team/OpenCodeReadinessBridge.test.ts | 293 +++++++ .../team/RuntimeDiagnosticClassifier.test.ts | 11 + .../services/team/TaskBoundaryParser.test.ts | 59 ++ .../services/team/TaskChangeComputer.test.ts | 93 +++ .../team/TeamMemberLogsFinder.test.ts | 189 +++++ test/main/utils/cliPathMerge.test.ts | 33 + test/main/utils/shellEnv.integration.test.ts | 206 +++++ test/main/utils/shellEnv.test.ts | 549 +++++++++++++ .../cli/CliStatusVisibility.test.ts | 20 +- .../runtime/ProviderModelBadges.test.tsx | 47 ++ .../TeamModelSelectorDisabledState.test.ts | 83 +- .../agent-graph/GraphActivityHud.test.ts | 145 +++- .../GraphMemberLogPreviewHud.test.tsx | 50 +- .../features/agent-graph/kanbanLayout.test.ts | 12 +- test/renderer/store/cliInstallerSlice.test.ts | 35 +- test/renderer/store/teamSlice.test.ts | 546 +++++++++++++ test/renderer/utils/memberHelpers.test.ts | 22 + ...openCodeRuntimeDeliveryDiagnostics.test.ts | 24 + tsconfig.json | 2 +- 74 files changed, 6437 insertions(+), 642 deletions(-) create mode 100644 scripts/team-changes-real-data-smoke.ts create mode 100644 src/main/ipc/openCodeRuntime.ts create mode 100644 src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts create mode 100644 test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts create mode 100644 test/main/services/team/TaskChangeComputer.test.ts create mode 100644 test/main/utils/shellEnv.integration.test.ts create mode 100644 test/main/utils/shellEnv.test.ts diff --git a/.gitignore b/.gitignore index 4f9e30fc..e6288688 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ remotion/* .board-task-log-freshness/ .serena/ + +# Local release operator notes +/ORCHESTRATOR_RELEASE_RUNBOOK.local.md diff --git a/landing/product-docs/.vitepress/config.ts b/landing/product-docs/.vitepress/config.ts index 0e891546..dc246339 100644 --- a/landing/product-docs/.vitepress/config.ts +++ b/landing/product-docs/.vitepress/config.ts @@ -183,13 +183,18 @@ export default defineConfig({ provider: "local", options: { translations: { - button: "Search...", - buttonAriaLabel: "Search documentation", - noResultsText: "No results found", - suggestedQueryText: "Try searching for", - reportMissing: "Found a problem? Create an issue", - reportMissingText: "Report missing result", - reportMissingLink: "https://github.com/777genius/agent-teams-ai/issues/new" + button: { + buttonText: "Search...", + buttonAriaLabel: "Search documentation" + }, + modal: { + noResultsText: "No results found", + footer: { + selectText: "to select", + navigateText: "to navigate", + closeText: "to close" + } + } } } }, @@ -214,7 +219,6 @@ export default defineConfig({ lang: "en-US", themeConfig: { nav: rootNav, - sidebar: rootGuide, docFooter: { prev: "Previous", next: "Next" @@ -228,7 +232,6 @@ export default defineConfig({ description: "Документация Agent Teams, локального desktop-приложения для оркестрации AI-агентов.", themeConfig: { nav: ruNav, - sidebar: ruGuide, outline: { level: [2, 3], label: "На этой странице" diff --git a/landing/product-docs/guide/installation.md b/landing/product-docs/guide/installation.md index 67131b65..720bb1a9 100644 --- a/landing/product-docs/guide/installation.md +++ b/landing/product-docs/guide/installation.md @@ -9,7 +9,7 @@ Agent Teams is distributed as a desktop app for macOS, Windows, and Linux. ## Download builds -Use the download page or the latest [GitHub release](https://github.com/777genius/agent-teams-ai/releases) when you want the packaged app: +Use the download page or the latest [GitHub release](https://github.com/777genius/agent-teams-ai/releases) when you want the packaged app: - macOS Apple Silicon: `.dmg` - macOS Intel: `.dmg` diff --git a/landing/product-docs/guide/quickstart.md b/landing/product-docs/guide/quickstart.md index 5feb7aba..ac4c1216 100644 --- a/landing/product-docs/guide/quickstart.md +++ b/landing/product-docs/guide/quickstart.md @@ -9,7 +9,7 @@ This guide gets you from a fresh install to a running team in a few minutes. ## 1. Install Agent Teams -Download the latest release for your platform from the download page or [GitHub releases](https://github.com/777genius/agent-teams-ai/releases). +Download the latest release for your platform from the download page or [GitHub releases](https://github.com/777genius/agent-teams-ai/releases). ::: tip The app is free and open source. The agent runtime you choose may still require provider access — see [Installation](/guide/installation) for details. @@ -47,7 +47,7 @@ Gemini support is in development and will appear in the runtime list when availa See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider. -To verify the selected runtime outside the app, run the matching version command: +To verify the selected runtime outside the app, run its version command: ```bash claude --version diff --git a/landing/product-docs/guide/runtime-setup.md b/landing/product-docs/guide/runtime-setup.md index 0c83ccaa..0bf4aeb5 100644 --- a/landing/product-docs/guide/runtime-setup.md +++ b/landing/product-docs/guide/runtime-setup.md @@ -1,8 +1,3 @@ ---- -title: Runtime Setup -description: Configure Claude Code, Codex, or OpenCode runtimes and provider authentication for agent teams. ---- - --- title: Runtime Setup – Agent Teams Docs description: Configure Claude Code, Codex, or OpenCode runtimes. Covers auth, provider access, multimodel mode, and prelaunch checks. diff --git a/landing/product-docs/guide/troubleshooting.md b/landing/product-docs/guide/troubleshooting.md index 9bf941f0..d5910fbb 100644 --- a/landing/product-docs/guide/troubleshooting.md +++ b/landing/product-docs/guide/troubleshooting.md @@ -1,8 +1,3 @@ ---- -title: Troubleshooting -description: Fix launch failures, missing agent replies, rate limits, auth issues, and lane bootstrap problems in Agent Teams. ---- - --- title: Troubleshooting – Agent Teams Docs description: Fix team launch issues, missing agent replies, rate limits, CLI auth problems, and lane bootstrap stalls with local diagnostics. @@ -88,7 +83,7 @@ If a provider reports a known reset time, Agent Teams can nudge the lead to cont ## CLI auth issues -### `claude login` not persist +### `claude login` does not persist If the CLI is authenticated in one terminal but the app says it is not, verify the auth is saved to the expected config path and that the app process sees the same `$HOME`. diff --git a/landing/product-docs/reference/faq.md b/landing/product-docs/reference/faq.md index 108b567b..8950695e 100644 --- a/landing/product-docs/reference/faq.md +++ b/landing/product-docs/reference/faq.md @@ -1,18 +1,8 @@ --- -title: FAQ +title: FAQ – Agent Teams Docs description: Frequently asked questions about Agent Teams — pricing, model access, runtimes, privacy, review, and troubleshooting. --- ---- -title: FAQ – Agent Teams Docs -description: Frequently asked questions about pricing, model access, runtime setup, data privacy, worktree isolation, and code review. ---- - ---- -title: FAQ – Agent Teams Docs -description: Frequently asked questions about Agent Teams — pricing, model access, runtimes, privacy, review, and debugging. ---- - # FAQ ## Is Agent Teams free? diff --git a/landing/product-docs/reference/privacy-local-data.md b/landing/product-docs/reference/privacy-local-data.md index 3cb771b0..ce968712 100644 --- a/landing/product-docs/reference/privacy-local-data.md +++ b/landing/product-docs/reference/privacy-local-data.md @@ -1,18 +1,8 @@ ---- -title: Privacy and Local Data -description: What the Agent Teams desktop app stores locally and what data may leave your machine through provider-backed models. ---- - --- title: Privacy and Local Data – Agent Teams Docs description: What Agent Teams stores locally, what may leave your machine through provider-backed model calls, and practical privacy guidance. --- ---- -title: Privacy and Local Data – Agent Teams Docs -description: What the Agent Teams desktop app stores locally and what data may leave your machine through provider-backed model calls. ---- - # Privacy and Local Data Agent Teams is local-first, but the selected runtime/provider path still matters. This page describes what the desktop app stores locally and what may leave your machine when agents call provider-backed models. diff --git a/landing/product-docs/reference/providers-runtimes.md b/landing/product-docs/reference/providers-runtimes.md index 925cdbb1..d112e7f5 100644 --- a/landing/product-docs/reference/providers-runtimes.md +++ b/landing/product-docs/reference/providers-runtimes.md @@ -1,18 +1,8 @@ ---- -title: Providers and Runtimes -description: Supported runtime paths, provider ids, model ids, multi-provider strategy, and capability checks in Agent Teams. ---- - --- title: Providers and Runtimes – Agent Teams Docs description: Supported runtime paths (Claude Code, Codex, OpenCode), provider IDs, model naming, multi-provider strategies, and capability checks. --- ---- -title: Providers and Runtimes – Agent Teams Docs -description: Supported runtime paths, provider ids, model ids, multi-provider strategy, and capability checks in Agent Teams. ---- - # Providers and Runtimes Agent Teams separates orchestration from model access. The app manages teams, tasks, messages, launch state, and review UI; the selected runtime/provider path performs the actual model work. @@ -43,7 +33,7 @@ The runtime provides: ## Supported runtime paths | Runtime path | Provider/model path | Best fit | Notes | -| --- | --- | +| --- | --- | --- | --- | | Claude Code | Anthropic / Claude models | Claude Code users and Anthropic-backed workflows | Default local-first path for Claude teams. Requires the runtime and account access to be available locally. | | Codex | Codex / OpenAI-backed models | Codex-native workflows | Uses Codex runtime integration and Codex auth/account state where available. Some diagnostics are different from Claude transcripts. | | OpenCode | OpenCode-managed model routing | Multi-provider teams and broad model coverage | OpenCode can route through many model providers. Agent Teams treats OpenCode lanes as runtime-specific evidence and avoids guessing when lane identity is ambiguous. | diff --git a/package.json b/package.json index 213bc3dc..1a31909b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs", "team:prove-provider-launch-stress": "node ./scripts/prove-provider-launch-stress.mjs", "team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts", + "team:smoke-changes-real-data": "tsx scripts/team-changes-real-data-smoke.ts", "prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", "build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build", "dist": "electron-builder --mac --win --linux", @@ -110,7 +111,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@fastify/cors": "^11.2.0", - "@fastify/static": "^9.0.0", + "@fastify/static": "^9.1.3", "@floating-ui/dom": "^1.7.6", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -126,7 +127,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@sentry/electron": "^7.10.0", "@sentry/react": "^10.45.0", - "@tanstack/react-virtual": "^3.10.8", + "@tanstack/react-virtual": "^3.13.24", "@tiptap/extension-placeholder": "^3.20.4", "@tiptap/markdown": "^3.20.4", "@tiptap/pm": "^3.20.4", @@ -146,7 +147,7 @@ "diff": "^8.0.3", "dompurify": "^3.3.1", "electron-updater": "^6.7.3", - "fastify": "^5.7.4", + "fastify": "^5.8.5", "highlight.js": "^11.11.1", "idb-keyval": "^6.2.2", "isbinaryfile": "^6.0.0", @@ -169,7 +170,7 @@ "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", - "simple-git": "^3.32.3", + "simple-git": "^3.36.0", "ssh-config": "^5.0.4", "ssh2": "^1.17.0", "strip-markdown": "^6.0.0", @@ -196,7 +197,7 @@ "@vitejs/plugin-react": "^4.3.1", "@vitest/coverage-v8": "^3.1.4", "autoprefixer": "^10.4.17", - "electron": "^40.3.0", + "electron": "^40.10.0", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", "eslint": "^9.39.2", diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index fda78d88..ef370902 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -11,6 +11,10 @@ import { drawPillShell, drawPillStackLayer } from './draw-pill-shell'; import { hexWithAlpha } from './render-cache'; import type { KanbanZoneInfo } from '../layout/kanbanLayout'; +const KANBAN_HEADER_FONT = '600 10px monospace'; +const KANBAN_HEADER_ALPHA = 0.92; +const KANBAN_HEADER_LETTER_SPACING = 2; + /** * Draw all task nodes as pill-shaped cards. */ @@ -159,9 +163,9 @@ function drawTaskPill( const hasReviewChip = node.reviewState !== 'approved' && (node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && !!node.reviewerName)); - const maxW = hasReviewChip ? w - 64 : w - 18; + const maxW = hasReviewChip ? w - 88 : w - 24; const subject = truncateText(ctx, node.sublabel, maxW, ctx.font); - ctx.fillText(subject, textX, -4); + ctx.fillText(subject, textX, -12); } // Display ID (secondary — small) @@ -170,11 +174,11 @@ function drawTaskPill( ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = COLORS.textDim; - ctx.fillText(displayId, -halfW + 10, 8); + ctx.fillText(displayId, -halfW + 10, 12); // Approved badge: checkmark at right side if (node.reviewState === 'approved') { - ctx.font = 'bold 11px sans-serif'; + ctx.font = 'bold 13px sans-serif'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = COLORS.reviewApproved; @@ -367,11 +371,11 @@ function drawOverflowStack( ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = COLORS.textPrimary; - ctx.fillText(node.label, -halfW + 12, -2); + ctx.fillText(node.label, -halfW + 14, -8); - ctx.font = '7px monospace'; + ctx.font = '10px monospace'; ctx.fillStyle = COLORS.textDim; - ctx.fillText('more tasks', -halfW + 12, 10); + ctx.fillText('more tasks', -halfW + 14, 12); } function drawReviewChip( @@ -413,6 +417,34 @@ function drawReviewChip( } } +function measureSpacedText( + ctx: CanvasRenderingContext2D, + text: string, + letterSpacing: number +): number { + const chars = Array.from(text); + const glyphWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0); + return glyphWidth + Math.max(0, chars.length - 1) * letterSpacing; +} + +function drawCenteredSpacedText( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + letterSpacing: number +): void { + const chars = Array.from(text); + const previousAlign = ctx.textAlign; + ctx.textAlign = 'left'; + let cursorX = x - measureSpacedText(ctx, text, letterSpacing) / 2; + for (const char of chars) { + ctx.fillText(char, cursorX, y); + cursorX += ctx.measureText(char).width + letterSpacing; + } + ctx.textAlign = previousAlign; +} + /** * Draw kanban column headers above task columns. */ @@ -425,12 +457,12 @@ export function drawColumnHeaders( for (const zone of zones) { // Section header for unassigned tasks — larger, centered above all columns if (zone.ownerId === '__unassigned__') { - ctx.font = 'bold 10px monospace'; + ctx.font = KANBAN_HEADER_FONT; ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillStyle = hexWithAlpha(COLORS.taskPending, 0.5); - const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) - 16; - ctx.fillText('Unassigned', zone.ownerX, labelY); + ctx.textBaseline = 'middle'; + ctx.fillStyle = hexWithAlpha(COLORS.taskPending, KANBAN_HEADER_ALPHA); + const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) + 10; + drawCenteredSpacedText(ctx, 'Unassigned', zone.ownerX, labelY, KANBAN_HEADER_LETTER_SPACING); // Overflow badge for (const header of zone.headers) { @@ -446,16 +478,22 @@ export function drawColumnHeaders( } for (const header of zone.headers) { - ctx.font = 'bold 8px monospace'; + ctx.font = KANBAN_HEADER_FONT; ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillStyle = hexWithAlpha(header.color, 0.6); - ctx.fillText(header.label, header.x, header.y - 2); + ctx.textBaseline = 'middle'; + ctx.fillStyle = hexWithAlpha(header.color, KANBAN_HEADER_ALPHA); + drawCenteredSpacedText( + ctx, + header.label, + header.x, + header.y + 10, + KANBAN_HEADER_LETTER_SPACING + ); // Overflow badge: "+N more" if (header.overflowCount > 0) { const badgeText = `+${header.overflowCount} more`; - ctx.font = '7px monospace'; + ctx.font = '10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = hexWithAlpha(header.color, 0.45); diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 90c576a7..23c027eb 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -70,19 +70,19 @@ export const NODE = { // ─── Task pill dimensions ─────────────────────────────────────────────────── export const TASK_PILL = { - width: 160, - height: 36, - borderRadius: 6, + width: 260, + height: 72, + borderRadius: 8, statusDotRadius: 4, statusDotX: 12, - /** Font size for display ID */ - idFontSize: 9, - /** Font size for subject text */ - subjectFontSize: 7, + /** Font size for the task title */ + idFontSize: 16.5, + /** Font size for the display ID */ + subjectFontSize: 10, /** Max chars for subject before truncation */ - subjectMaxChars: 18, + subjectMaxChars: 32, /** X offset for text content */ - textOffsetX: 20, + textOffsetX: 18, } as const; // ─── Agent drawing constants ──────────────────────────────────────────────── @@ -259,12 +259,12 @@ export const BACKGROUND = { // ─── Kanban zone layout ───────────────────────────────────────────────────── export const KANBAN_ZONE = { - /** Column width: pill (160) + gap (20) */ - columnWidth: 180, - /** Row height: pill (36) + gap (10) */ - rowHeight: 46, - /** Space reserved for column header label */ - headerHeight: 20, + /** Column width: task card (260) + gap (20) */ + columnWidth: 280, + /** Row height: task card (72) + gap (8) */ + rowHeight: 80, + /** Task center offset from band top: header (20) + gap (4) + half card */ + headerHeight: 60, /** Zone starts this far below member node center */ offsetY: 70, /** Column sequence: pending → wip → done → review → approved */ diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 4c1475ad..fe4b7911 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -217,8 +217,16 @@ export class KanbanLayoutEngine { for (const [rowIdx, task] of col.tasks.entries()) { const targetX = colX; const targetY = baseY + headerHeight + rowIdx * rowHeight; - task.x = slotFrame ? targetX : task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; - task.y = slotFrame ? targetY : task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; + task.x = slotFrame + ? targetX + : task.x != null + ? task.x + (targetX - task.x) * 0.15 + : targetX; + task.y = slotFrame + ? targetY + : task.y != null + ? task.y + (targetY - task.y) * 0.15 + : targetY; task.fx = task.x; task.fy = task.y; task.vx = 0; @@ -254,18 +262,19 @@ export class KanbanLayoutEngine { if (unassignedTaskRect) { const cols = Math.min(Math.max(tasks.length, 1), 5); const baseX = unassignedTaskRect.left + TASK_PILL.width / 2; - const baseY = unassignedTaskRect.top; + const headerY = unassignedTaskRect.top; + const baseY = headerY + KANBAN_ZONE.headerHeight; const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0); this.zones.push({ ownerId: '__unassigned__', ownerX: 0, - ownerY: baseY - 48, + ownerY: headerY - 48, headers: [ { label: 'Unassigned', x: 0, - y: baseY, + y: headerY, color: COLORS.taskPending, overflowCount, overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, @@ -305,7 +314,8 @@ export class KanbanLayoutEngine { const centerX = memberCount > 0 ? sumX / memberCount : 0; // Place unassigned tasks well below the lowest element - const baseY = (maxY > -Infinity ? maxY : 0) + 150; + const headerY = (maxY > -Infinity ? maxY : 0) + 150; + const baseY = headerY + KANBAN_ZONE.headerHeight; const cols = Math.min(tasks.length, 4); const totalWidth = cols * columnWidth; const baseX = centerX - totalWidth / 2; @@ -316,15 +326,17 @@ export class KanbanLayoutEngine { this.zones.push({ ownerId: '__unassigned__', ownerX: centerX, - ownerY: baseY - 70, - headers: [{ - label: 'Unassigned', - x: centerX, - y: baseY - 10, - color: COLORS.taskPending, - overflowCount, - overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, - }], + ownerY: headerY - 70, + headers: [ + { + label: 'Unassigned', + x: centerX, + y: headerY, + color: COLORS.taskPending, + overflowCount, + overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, + }, + ], }); } diff --git a/packages/agent-graph/src/layout/stableSlotGeometry.ts b/packages/agent-graph/src/layout/stableSlotGeometry.ts index c49016c5..f11c39d5 100644 --- a/packages/agent-graph/src/layout/stableSlotGeometry.ts +++ b/packages/agent-graph/src/layout/stableSlotGeometry.ts @@ -14,7 +14,7 @@ export const STABLE_SLOT_GEOMETRY = { ownerMinWidth: 200, processBandHeight: 32, processRailWidth: 220, - taskMaxVisibleRows: 5, + taskMaxVisibleRows: 3, } as const; export const STABLE_SLOT_SECTOR_VECTORS = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 709fcc7c..5cc86e3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,8 +102,8 @@ importers: specifier: ^11.2.0 version: 11.2.0 '@fastify/static': - specifier: ^9.0.0 - version: 9.0.0 + specifier: ^9.1.3 + version: 9.1.3 '@floating-ui/dom': specifier: ^1.7.6 version: 1.7.6 @@ -150,8 +150,8 @@ importers: specifier: ^10.45.0 version: 10.45.0(react@19.2.4) '@tanstack/react-virtual': - specifier: ^3.10.8 - version: 3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^3.13.24 + version: 3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tiptap/extension-placeholder': specifier: ^3.20.4 version: 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) @@ -210,8 +210,8 @@ importers: specifier: ^6.7.3 version: 6.7.3 fastify: - specifier: ^5.7.4 - version: 5.7.4 + specifier: ^5.8.5 + version: 5.8.5 highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -279,8 +279,8 @@ importers: specifier: ^11.0.0 version: 11.0.0 simple-git: - specifier: ^3.32.3 - version: 3.32.3 + specifier: ^3.36.0 + version: 3.36.0 ssh-config: specifier: ^5.0.4 version: 5.0.4 @@ -355,8 +355,8 @@ importers: specifier: ^10.4.17 version: 10.4.23(postcss@8.5.6) electron: - specifier: ^40.3.0 - version: 40.3.0 + specifier: ^40.10.0 + version: 40.10.0 electron-builder: specifier: ^26.8.1 version: 26.8.1(electron-builder-squirrel-windows@26.8.1) @@ -1714,8 +1714,8 @@ packages: '@fastify/send@4.1.0': resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} - '@fastify/static@9.0.0': - resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==} + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -4077,6 +4077,12 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@simple-git/args-pathspec@1.0.3': + resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==} + + '@simple-git/argv-parser@1.1.1': + resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} + '@sindresorhus/base62@1.0.0': resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} engines: {node: '>=18'} @@ -4114,14 +4120,14 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tanstack/react-virtual@3.13.18': - resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/virtual-core@3.13.18': - resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} '@tiptap/core@3.20.4': resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==} @@ -6300,8 +6306,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@40.3.0: - resolution: {integrity: sha512-ZaDkTZpNHr863tyZHieoqbaiLI0e3RVCXoEC5y1Ld70/Q5H1mPV9d5TK0h1dWtaSFVOW0w8iDvtdLwAXtasXpg==} + electron@40.10.0: + resolution: {integrity: sha512-e7XVcAfyWoFQGS7ZhgxeNn0AijHaqgRCa6uA6TYOrvBWv8smI6JILvMR/8DYBIn07oqvxDLRC90tu/xa2cJCow==} engines: {node: '>= 12.20.55'} hasBin: true @@ -6815,8 +6821,8 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - fastify@5.7.4: - resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} fastmcp@3.34.0: resolution: {integrity: sha512-xKOXjU+MK7OZy91BY3FS5aenSiclJBCRMaZtXb3HYaKZVFbq4qYvAlFu6xYI3UU1NGLtv+h8izoStnOQ1By0BA==} @@ -7092,10 +7098,6 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@13.0.2: - resolution: {integrity: sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==} - engines: {node: 20 || >=22} - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -8319,10 +8321,6 @@ packages: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -8743,10 +8741,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.1: - resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} - engines: {node: 20 || >=22} - path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -9820,12 +9814,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-git@3.32.3: - resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==} - simple-git@3.33.0: resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==} + simple-git@3.36.0: + resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} + simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -12415,14 +12409,14 @@ snapshots: http-errors: 2.0.1 mime: 3.0.0 - '@fastify/static@9.0.0': + '@fastify/static@9.1.3': dependencies: '@fastify/accept-negotiator': 2.0.1 '@fastify/send': 4.1.0 content-disposition: 1.0.1 fastify-plugin: 5.1.0 fastq: 1.20.1 - glob: 13.0.2 + glob: 13.0.6 '@floating-ui/core@1.7.5': dependencies: @@ -14849,6 +14843,12 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@simple-git/args-pathspec@1.0.3': {} + + '@simple-git/argv-parser@1.1.1': + dependencies: + '@simple-git/args-pathspec': 1.0.3 + '@sindresorhus/base62@1.0.0': {} '@sindresorhus/is@4.6.0': {} @@ -14880,13 +14880,13 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) - '@tanstack/react-virtual@3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-virtual@3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/virtual-core': 3.13.18 + '@tanstack/virtual-core': 3.14.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@tanstack/virtual-core@3.13.18': {} + '@tanstack/virtual-core@3.14.0': {} '@tiptap/core@3.20.4(@tiptap/pm@3.20.4)': dependencies: @@ -15519,7 +15519,7 @@ snapshots: '@typescript-eslint/types': 8.57.1 '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 - minimatch: 10.2.2 + minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17415,7 +17415,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@40.3.0: + electron@40.10.0: dependencies: '@electron/get': 2.0.3 '@types/node': 24.10.12 @@ -17787,7 +17787,7 @@ snapshots: eslint: 9.39.2(jiti@1.21.7) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 - minimatch: 10.2.2 + minimatch: 10.2.5 semver: 7.7.4 stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 @@ -17807,7 +17807,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 - minimatch: 10.2.2 + minimatch: 10.2.5 semver: 7.7.4 stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 @@ -18309,7 +18309,7 @@ snapshots: fastify-plugin@5.1.0: {} - fastify@5.7.4: + fastify@5.8.5: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -18324,7 +18324,7 @@ snapshots: process-warning: 5.0.0 rfdc: 1.4.1 secure-json-parse: 4.1.0 - semver: 7.7.3 + semver: 7.7.4 toad-cache: 3.7.0 fastmcp@3.34.0: @@ -18630,15 +18630,9 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@13.0.2: - dependencies: - minimatch: 10.2.2 - minipass: 7.1.2 - path-scurry: 2.0.1 - glob@13.0.6: dependencies: - minimatch: 10.2.2 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -20222,8 +20216,6 @@ snapshots: dependencies: yallist: 4.0.0 - minipass@7.1.2: {} - minipass@7.1.3: {} minisearch@7.2.0: {} @@ -20941,11 +20933,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 - path-scurry@2.0.1: - dependencies: - lru-cache: 11.2.6 - minipass: 7.1.2 - path-scurry@2.0.2: dependencies: lru-cache: 11.2.6 @@ -22138,7 +22125,7 @@ snapshots: signal-exit@4.1.0: {} - simple-git@3.32.3: + simple-git@3.33.0: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 @@ -22146,10 +22133,12 @@ snapshots: transitivePeerDependencies: - supports-color - simple-git@3.33.0: + simple-git@3.36.0: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 + '@simple-git/args-pathspec': 1.0.3 + '@simple-git/argv-parser': 1.1.1 debug: 4.4.3 transitivePeerDependencies: - supports-color diff --git a/runtime.lock.json b/runtime.lock.json index 639b8e59..7d9e8d92 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.29", - "sourceRef": "v0.0.29", + "version": "0.0.30", + "sourceRef": "v0.0.30", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/agent-teams-ai", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.29.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.30.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.29.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.30.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.29.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.30.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.29.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.30.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/scripts/team-changes-real-data-smoke.ts b/scripts/team-changes-real-data-smoke.ts new file mode 100644 index 00000000..3f481901 --- /dev/null +++ b/scripts/team-changes-real-data-smoke.ts @@ -0,0 +1,488 @@ +/* eslint-disable security/detect-non-literal-fs-filename, security/detect-object-injection */ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import type { TeamTask, TeamTaskChangeSummaryItem, TeamTaskWithKanban } from '../src/shared/types'; + +process.env.CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE = '0'; + +const FIRST_STAGE_REQUESTS = 3; +const SECOND_STAGE_REQUESTS = 9; +const FIRST_STAGE_UNKNOWN_SCAN_LIMIT = 3; +const SECOND_STAGE_UNKNOWN_SCAN_LIMIT = 6; +const DEFAULT_TEAM_LIMIT = 3; + +interface Args { + teams: string[]; + limit: number; +} + +interface PresenceEntry { + presence?: string; +} + +interface CandidateSuccess { + teamName: string; + tasks: TeamTaskWithKanban[]; + taskCount: number; + changedPresenceCount: number; + eligibleCount: number; + presenceCounts: Record; +} + +interface CandidateFailure { + teamName: string; + error: string; +} + +type Candidate = CandidateSuccess | CandidateFailure; + +interface RuntimeModules { + TeamTaskReader: typeof import('../src/main/services/team/TeamTaskReader')['TeamTaskReader']; + ChangeExtractorService: typeof import('../src/main/services/team/ChangeExtractorService')['ChangeExtractorService']; + TeamMemberLogsFinder: typeof import('../src/main/services/team/TeamMemberLogsFinder')['TeamMemberLogsFinder']; + TaskBoundaryParser: typeof import('../src/main/services/team/TaskBoundaryParser')['TaskBoundaryParser']; + TaskChangeWorkerClient: typeof import('../src/main/services/team/TaskChangeWorkerClient')['TaskChangeWorkerClient']; + buildTeamChangeRequestPlan: typeof import('../src/renderer/components/team/teamChangesRequestPlan')['buildTeamChangeRequestPlan']; + TEAM_CHANGES_MAX_REQUESTS: typeof import('../src/renderer/components/team/teamChangesRequestPlan')['TEAM_CHANGES_MAX_REQUESTS']; +} + +interface StageReport { + label: string; + requested: number; + duplicateRequests: string[]; + responseItems: number; + truncated: boolean; + ms: number; + deferredBeforeResponse: number; + satisfiedAfterStage: number; + itemErrors: number; + nullItems: number; + countableItems: number; + fileRows: number; + confidenceCounts: Record; + sourceKindCounts: Record; + firstTaskIds: string[]; +} + +interface TeamSmokeReport { + kind: 'team-smoke'; + teamName: string; + taskCount: number; + changedPresenceCount: number; + eligibleCount: number; + stages: StageReport[]; +} + +interface ForceRefreshSmokeReport { + kind: 'force-refresh-smoke'; + teamName: string; + requested: number; + allForceFresh: boolean; + responseItems: number; + ms: number; + taskIds: string[]; +} + +function parseArgs(argv: string[]): Args { + const teams: string[] = []; + let limit = DEFAULT_TEAM_LIMIT; + let index = 0; + + while (index < argv.length) { + const arg = argv[index]; + const next = argv[index + 1] ?? ''; + if (arg === '--team' || arg === '--teams') { + teams.push(...next.split(',').map((teamName) => teamName.trim()).filter(Boolean)); + index += 2; + continue; + } + if (arg === '--limit') { + const parsedLimit = Number.parseInt(next, 10); + if (Number.isFinite(parsedLimit) && parsedLimit > 0) { + limit = parsedLimit; + } + index += 2; + continue; + } + index += 1; + } + + return { teams: [...new Set(teams)], limit }; +} + +async function loadRuntimeModules(): Promise { + const { TeamTaskReader } = await import('../src/main/services/team/TeamTaskReader'); + const { ChangeExtractorService } = await import( + '../src/main/services/team/ChangeExtractorService' + ); + const { TeamMemberLogsFinder } = await import('../src/main/services/team/TeamMemberLogsFinder'); + const { TaskBoundaryParser } = await import('../src/main/services/team/TaskBoundaryParser'); + const { TaskChangeWorkerClient } = await import( + '../src/main/services/team/TaskChangeWorkerClient' + ); + const { buildTeamChangeRequestPlan, TEAM_CHANGES_MAX_REQUESTS } = await import( + '../src/renderer/components/team/teamChangesRequestPlan' + ); + + return { + TeamTaskReader, + ChangeExtractorService, + TeamMemberLogsFinder, + TaskBoundaryParser, + TaskChangeWorkerClient, + buildTeamChangeRequestPlan, + TEAM_CHANGES_MAX_REQUESTS, + }; +} + +async function readTeamNames(): Promise { + const teamsDir = path.join(os.homedir(), '.claude', 'teams'); + const entries = await fs.readdir(teamsDir, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)); +} + +async function readPresence(teamName: string): Promise> { + const filePath = path.join( + os.homedir(), + '.claude', + 'task-change-presence', + `${encodeURIComponent(teamName)}.json` + ); + try { + const parsed: unknown = JSON.parse(await fs.readFile(filePath, 'utf8')); + return parsed && typeof parsed === 'object' + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function overlayPresence( + tasks: TeamTask[], + presenceByTaskId: Record +): TeamTaskWithKanban[] { + return tasks.map((task) => { + const presence = presenceByTaskId[task.id]?.presence; + if ( + presence === 'has_changes' || + presence === 'needs_attention' || + presence === 'no_changes' || + presence === 'unknown' + ) { + return { ...task, changePresence: presence }; + } + return task; + }); +} + +function increment(counts: Record, rawKey: string | undefined): void { + const key = rawKey && rawKey.trim().length > 0 ? rawKey : 'unknown'; + counts[key] = (counts[key] ?? 0) + 1; +} + +function isCandidateSuccess(candidate: Candidate): candidate is CandidateSuccess { + return !('error' in candidate); +} + +function isCountableSummary(item: TeamTaskChangeSummaryItem): boolean { + if (item.error) return true; + const changeSet = item.changeSet; + if (!changeSet) return false; + const fileCount = Array.isArray(changeSet.files) ? changeSet.files.length : 0; + const diagnosticCount = Array.isArray(changeSet.reviewDiagnostics) + ? changeSet.reviewDiagnostics.length + : 0; + const warningCount = Array.isArray(changeSet.warnings) ? changeSet.warnings.length : 0; + return ( + fileCount > 0 || + diagnosticCount > 0 || + warningCount > 0 + ); +} + +function isSatisfiedSummary(item: TeamTaskChangeSummaryItem): boolean { + return !item.error && item.changeSet !== null; +} + +function createChangeExtractorService(modules: RuntimeModules): InstanceType< + RuntimeModules['ChangeExtractorService'] +> { + return new modules.ChangeExtractorService( + new modules.TeamMemberLogsFinder(), + new modules.TaskBoundaryParser(), + undefined, + undefined, + new modules.TaskChangeWorkerClient({ enabled: false }), + null + ); +} + +async function loadCandidate( + modules: RuntimeModules, + taskReader: InstanceType, + teamName: string +): Promise { + try { + const rawTasks = await taskReader.getTasks(teamName); + const presence = await readPresence(teamName); + const tasks = overlayPresence(rawTasks, presence); + const eligiblePlan = modules.buildTeamChangeRequestPlan(tasks, 0, false, { + maxRequests: modules.TEAM_CHANGES_MAX_REQUESTS, + }); + const presenceCounts: Record = {}; + for (const entry of Object.values(presence)) { + increment(presenceCounts, entry.presence); + } + + return { + teamName, + tasks, + taskCount: rawTasks.length, + changedPresenceCount: + (presenceCounts.has_changes ?? 0) + (presenceCounts.needs_attention ?? 0), + eligibleCount: eligiblePlan.eligibleCount, + presenceCounts, + }; + } catch (error) { + return { + teamName, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function selectCandidates(candidates: Candidate[], limit: number): CandidateSuccess[] { + return candidates + .filter(isCandidateSuccess) + .sort((left, right) => { + const leftScore = left.taskCount + left.changedPresenceCount; + const rightScore = right.taskCount + right.changedPresenceCount; + return ( + rightScore - leftScore || + right.changedPresenceCount - left.changedPresenceCount || + right.taskCount - left.taskCount || + left.teamName.localeCompare(right.teamName) + ); + }) + .slice(0, limit); +} + +function summarizeStageItems( + items: TeamTaskChangeSummaryItem[], + satisfiedTaskIds: Set +): Omit< + StageReport, + | 'label' + | 'requested' + | 'duplicateRequests' + | 'responseItems' + | 'truncated' + | 'ms' + | 'deferredBeforeResponse' + | 'satisfiedAfterStage' + | 'firstTaskIds' +> { + const confidenceCounts: Record = {}; + const sourceKindCounts: Record = {}; + let itemErrors = 0; + let nullItems = 0; + let countableItems = 0; + let fileRows = 0; + + for (const item of items) { + if (item.error) itemErrors += 1; + if (!item.changeSet) nullItems += 1; + if (isCountableSummary(item)) countableItems += 1; + if (isSatisfiedSummary(item)) satisfiedTaskIds.add(item.taskId); + fileRows += Array.isArray(item.changeSet?.files) ? item.changeSet.files.length : 0; + increment(confidenceCounts, item.changeSet?.confidence); + increment(sourceKindCounts, item.changeSet?.provenance?.sourceKind); + } + + return { itemErrors, nullItems, countableItems, fileRows, confidenceCounts, sourceKindCounts }; +} + +async function runTeamSmoke( + modules: RuntimeModules, + team: CandidateSuccess +): Promise { + const service = createChangeExtractorService(modules); + const satisfiedTaskIds = new Set(); + const requestedTaskIds = new Set(); + let cursor = 0; + const stages: StageReport[] = []; + const stageInputs = [ + { + label: 'stage1-first-paint', + maxRequests: FIRST_STAGE_REQUESTS, + unknownScanLimit: FIRST_STAGE_UNKNOWN_SCAN_LIMIT, + }, + { + label: 'stage2-expand', + maxRequests: SECOND_STAGE_REQUESTS, + unknownScanLimit: SECOND_STAGE_UNKNOWN_SCAN_LIMIT, + }, + { + label: 'stage3-full', + maxRequests: modules.TEAM_CHANGES_MAX_REQUESTS, + unknownScanLimit: undefined, + }, + ]; + + for (const stage of stageInputs) { + const plan = modules.buildTeamChangeRequestPlan(team.tasks, cursor, false, { + maxRequests: stage.maxRequests, + unknownScanLimit: stage.unknownScanLimit, + satisfiedTaskIds, + }); + cursor = plan.nextUnknownScanCursor; + if (plan.requests.length === 0) break; + + const duplicateRequests = plan.requests + .map((request) => request.taskId) + .filter((taskId) => requestedTaskIds.has(taskId)); + for (const request of plan.requests) { + requestedTaskIds.add(request.taskId); + } + + const startedAt = Date.now(); + const response = await service.getTeamTaskChangeSummaries(team.teamName, plan.requests); + const summary = summarizeStageItems(response.items, satisfiedTaskIds); + stages.push({ + label: stage.label, + requested: plan.requests.length, + duplicateRequests, + responseItems: response.items.length, + truncated: response.truncated === true, + ms: Date.now() - startedAt, + deferredBeforeResponse: plan.deferredCount, + satisfiedAfterStage: satisfiedTaskIds.size, + firstTaskIds: plan.requests.slice(0, 5).map((request) => request.taskId.slice(0, 8)), + ...summary, + }); + + if (plan.deferredCount === 0) break; + } + + return { + kind: 'team-smoke', + teamName: team.teamName, + taskCount: team.taskCount, + changedPresenceCount: team.changedPresenceCount, + eligibleCount: team.eligibleCount, + stages, + }; +} + +function assertTeamSmoke(report: TeamSmokeReport): void { + const problems: string[] = []; + if (report.eligibleCount > 0 && report.stages.length === 0) { + problems.push('eligible tasks produced no staged requests'); + } + for (const stage of report.stages) { + if (stage.duplicateRequests.length > 0) { + problems.push(`${stage.label} duplicated ${stage.duplicateRequests.join(', ')}`); + } + if (stage.responseItems > stage.requested) { + problems.push(`${stage.label} returned more items than requested`); + } + if (stage.requested === 0) { + problems.push(`${stage.label} was recorded with zero requests`); + } + } + const lastStage = report.stages.at(-1); + if (lastStage && lastStage.deferredBeforeResponse > 0 && lastStage.label !== 'stage3-full') { + problems.push(`${lastStage.label} left deferred work without reaching the full stage`); + } + if (problems.length > 0) { + throw new Error(`Team Changes real-data smoke failed for ${report.teamName}: ${problems.join('; ')}`); + } +} + +async function runForceRefreshSmoke( + modules: RuntimeModules, + team: CandidateSuccess +): Promise { + const service = createChangeExtractorService(modules); + const plan = modules.buildTeamChangeRequestPlan(team.tasks, 0, true, { + maxRequests: FIRST_STAGE_REQUESTS, + unknownScanLimit: FIRST_STAGE_UNKNOWN_SCAN_LIMIT, + }); + const startedAt = Date.now(); + const response = await service.getTeamTaskChangeSummaries(team.teamName, plan.requests); + return { + kind: 'force-refresh-smoke', + teamName: team.teamName, + requested: plan.requests.length, + allForceFresh: plan.requests.every((request) => request.options?.forceFresh === true), + responseItems: response.items.length, + ms: Date.now() - startedAt, + taskIds: plan.requests.map((request) => request.taskId.slice(0, 8)), + }; +} + +function assertForceRefreshSmoke(report: ForceRefreshSmokeReport): void { + const problems: string[] = []; + if (report.requested > 0 && !report.allForceFresh) { + problems.push('not every force refresh request carried forceFresh=true'); + } + if (report.responseItems > report.requested) { + problems.push('force refresh returned more items than requested'); + } + if (problems.length > 0) { + throw new Error(`Team Changes force-refresh smoke failed for ${report.teamName}: ${problems.join('; ')}`); + } +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const modules = await loadRuntimeModules(); + const taskReader = new modules.TeamTaskReader(); + const teamNames = args.teams.length > 0 ? args.teams : await readTeamNames(); + const candidates = await Promise.all( + teamNames.map((teamName) => loadCandidate(modules, taskReader, teamName)) + ); + const selected = selectCandidates(candidates, args.limit); + const report: unknown[] = [ + { + kind: 'selection', + selected: selected.map( + ({ teamName, taskCount, changedPresenceCount, eligibleCount, presenceCounts }) => ({ + teamName, + taskCount, + changedPresenceCount, + eligibleCount, + presenceCounts, + }) + ), + skipped: candidates.filter((candidate) => !isCandidateSuccess(candidate)), + }, + ]; + + for (const team of selected) { + const teamReport = await runTeamSmoke(modules, team); + assertTeamSmoke(teamReport); + report.push(teamReport); + } + if (selected[0]) { + const forceRefreshReport = await runForceRefreshSmoke(modules, selected[0]); + assertForceRefreshSmoke(forceRefreshReport); + report.push(forceRefreshReport); + } + + console.log(JSON.stringify(report, null, 2)); +} + +void main().then( + () => process.exit(0), + (error) => { + console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); + process.exit(1); + } +); diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 0f7fc916..21863e37 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -28,6 +28,7 @@ const ACTIVITY_SHELL_HEIGHT = ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + ACTIVITY_LANE.overflowHeight; +const NEW_ACTIVITY_HIGHLIGHT_MS = 1_000; interface GraphActivityHudProps { teamName: string; @@ -56,6 +57,10 @@ interface GraphActivityHudProps { ) => void; } +function buildRenderedActivityItemKey(ownerNodeId: string, itemId: string): string { + return JSON.stringify([ownerNodeId, itemId]); +} + export const GraphActivityHud = ({ teamName, nodes, @@ -73,7 +78,12 @@ export const GraphActivityHud = ({ const shellRefs = useRef(new Map()); const connectorRefs = useRef(new Map()); const connectorPathRefs = useRef(new Map()); + const knownActivityItemIdsByOwnerRef = useRef(new Map>()); + const highlightTimersRef = useRef(new Map>()); const [expandedItem, setExpandedItem] = useState(null); + const [highlightedActivityItemIds, setHighlightedActivityItemIds] = useState>( + () => new Set() + ); const { teamData, teams } = useGraphActivityContext(teamName); const teamSnapshot = teamData; const members = teamData?.members ?? []; @@ -114,8 +124,23 @@ export const GraphActivityHud = ({ useEffect(() => { setExpandedItem(null); + knownActivityItemIdsByOwnerRef.current.clear(); + setHighlightedActivityItemIds(new Set()); + for (const timer of highlightTimersRef.current.values()) { + clearTimeout(timer); + } + highlightTimersRef.current.clear(); }, [teamName]); + useEffect(() => { + return () => { + for (const timer of highlightTimersRef.current.values()) { + clearTimeout(timer); + } + highlightTimersRef.current.clear(); + }; + }, []); + const visibleLanes = useMemo(() => { return ownerNodes .map((node) => { @@ -143,6 +168,51 @@ export const GraphActivityHud = ({ ); }, [entryMapByOwnerNodeId, ownerNodes]); + useEffect(() => { + if (!enabled) return; + + const newItemKeys: string[] = []; + for (const lane of visibleLanes) { + const currentIds = new Set(lane.entries.map((entry) => entry.graphItem.id)); + const knownIds = knownActivityItemIdsByOwnerRef.current.get(lane.node.id); + if (knownIds) { + for (const itemId of currentIds) { + if (!knownIds.has(itemId)) { + newItemKeys.push(buildRenderedActivityItemKey(lane.node.id, itemId)); + } + } + } + knownActivityItemIdsByOwnerRef.current.set(lane.node.id, currentIds); + } + + if (newItemKeys.length === 0) return; + + setHighlightedActivityItemIds((current) => { + const next = new Set(current); + for (const itemKey of newItemKeys) { + next.add(itemKey); + } + return next; + }); + + for (const itemKey of newItemKeys) { + const existingTimer = highlightTimersRef.current.get(itemKey); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + highlightTimersRef.current.delete(itemKey); + setHighlightedActivityItemIds((current) => { + if (!current.has(itemKey)) return current; + const next = new Set(current); + next.delete(itemKey); + return next; + }); + }, NEW_ACTIVITY_HIGHLIGHT_MS); + highlightTimersRef.current.set(itemKey, timer); + } + }, [enabled, visibleLanes]); + useLayoutEffect(() => { if (!enabled || visibleLanes.length === 0) { for (const shell of shellRefs.current.values()) { @@ -377,11 +447,20 @@ export const GraphActivityHud = ({ message: entry.message, }; const isUnread = !entry.message.read && !readSet.has(messageKey); + const isHighlighted = highlightedActivityItemIds.has( + buildRenderedActivityItemKey(entry.ownerNodeId, entry.graphItem.id) + ); return (
handleMessageClick(timelineItem)} @@ -405,6 +484,7 @@ export const GraphActivityHud = ({ [ handleMessageClick, handleMessageKeyDown, + highlightedActivityItemIds, messageContext, onOpenMemberProfile, onOpenTaskDetail, diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index 5af8b1b8..ef2d209d 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -116,8 +116,8 @@ function resolveEmptyText( if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) { return 'Unsupported provider'; } - if (loading && (!preview || preview.items.length === 0)) return 'Loading logs'; - if (error && (!preview || preview.items.length === 0)) return 'Logs unavailable'; + if (loading && !preview) return 'Loading logs'; + if (error && !preview) return 'Logs unavailable'; return 'No recent logs'; } @@ -215,6 +215,26 @@ function setShellHidden(shell: HTMLDivElement): void { shell.style.pointerEvents = 'none'; } +function renderLoadingSkeleton(): React.JSX.Element { + return ( + + ); +} + export const GraphMemberLogPreviewHud = ({ teamName, nodes, @@ -532,6 +552,7 @@ export const GraphMemberLogPreviewHud = ({ : node.label; const preview = previewsByMember.get(normalizeMemberName(memberName)); const items = preview?.items ?? []; + const isInitialLoading = loading && !preview; return (
{items.length > 0 ? ( items.slice(0, 3).map((item) => renderItem(memberName, item)) + ) : isInitialLoading ? ( + ) : (
- {shouldShowOpenCodeDownloadAction(provider, showSkeleton) ? ( + {shouldShowOpenCodeInstallAction( + provider, + showSkeleton, + openCodeRuntimeStatus + ) ? ( ) : null} + ); + }; return (
@@ -483,229 +816,158 @@ export const TeamModelSelector: React.FC = ({ />
) : null} - {hasRecommendedOpenCodeModels ? ( -
- setRecommendedOnly(checked === true)} - className="size-3.5" - /> - + {(effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) || + hasRecommendedOpenCodeModels ? ( +
+ {effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? ( + + + + + + +
+ +
+ + + No providers found. + + {selectedOpenCodeSourceIds.size > 0 && !openCodeSourceQuery.trim() ? ( + setSelectedOpenCodeSourceIds(new Set())} + className="flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-[var(--color-text-muted)] outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]" + > + + All OpenCode providers + + ) : null} + {filteredOpenCodeSourceOptions.map((source) => { + const selected = selectedOpenCodeSourceIds.has(source.id); + return ( + toggleOpenCodeSourceFilter(source.id)} + className="flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]" + > + toggleOpenCodeSourceFilter(source.id)} + onClick={(event) => event.stopPropagation()} + className="size-3.5" + aria-label={`Filter ${source.label}`} + /> + + {source.label} + + + {source.count} + + + ); + })} + +
+
+
+ ) : null} + {hasRecommendedOpenCodeModels ? ( +
+ setRecommendedOnly(checked === true)} + className="size-3.5" + /> + +
+ ) : null}
) : null} -
- {visibleModelOptions.map((opt) => - (() => { - const modelDisabledReason = getTeamModelUiDisabledReason( - effectiveProviderId, - opt.value, - runtimeProviderStatus - ); - const availabilityStatus = - opt.value === '' ? 'available' : (opt.availabilityStatus ?? 'available'); - const availabilityReason = - opt.value === '' ? null : (opt.availabilityReason ?? null); - const runtimeUnavailableReason = - opt.value !== '' && availabilityStatus === 'unavailable' - ? (availabilityReason ?? 'Unavailable in current runtime') - : null; - const modelIssueReason = - opt.value === '' ? null : (modelIssueReasonByValue?.[opt.value] ?? null); - const modelUnavailableReason = - opt.value === '' - ? null - : (modelUnavailableReasonByValue?.[opt.value] ?? - getOpenCodeOpenAiRouteAuthUnavailableReason( - effectiveProviderId, - opt.value, - runtimeProviderStatus - ) ?? - runtimeUnavailableReason); - const hasModelIssue = Boolean(modelIssueReason || modelUnavailableReason); - const modelSelectable = - activeProviderSelectable && - !modelUnavailableReason && - !modelDisabledReason && - (opt.value === '' || - availabilityStatus == null || - availabilityStatus === 'available'); - const modelStatusMessage = - modelUnavailableReason ?? - modelIssueReason ?? - modelDisabledReason ?? - availabilityReason ?? - null; - const sourceBadgeLabel = - effectiveProviderId === 'opencode' && opt.value !== '' - ? opt.badgeLabel?.trim() || null - : null; - const modelRecommendation = getTeamModelRecommendation( - effectiveProviderId, - opt.value - ); - - return ( - - ); - })() - )} -
+
+
+ {group.options.map(renderModelOption)} +
+ + ))} +
+ ) : ( +
+ {visibleModelOptions.map(renderModelOption)} +
+ )} {visibleModelOptions.length === 0 ? (
{trimmedModelQuery diff --git a/src/renderer/components/team/teamChangesRequestPlan.ts b/src/renderer/components/team/teamChangesRequestPlan.ts index f057d3d4..f7f7c190 100644 --- a/src/renderer/components/team/teamChangesRequestPlan.ts +++ b/src/renderer/components/team/teamChangesRequestPlan.ts @@ -13,6 +13,12 @@ export const TEAM_CHANGES_MAX_REQUESTS = 120; export const TEAM_CHANGES_UNKNOWN_SCAN_LIMIT = 32; export const TEAM_CHANGES_MAX_RENDERED_FILE_ROWS = 300; +interface TeamChangeRequestPlanOptions { + maxRequests?: number; + unknownScanLimit?: number; + satisfiedTaskIds?: ReadonlySet; +} + interface TeamChangeCandidate { task: TeamTaskWithKanban; options: TaskChangeRequestOptions; @@ -49,6 +55,13 @@ function rotateCandidates(items: T[], cursor: number): T[] { return [...items.slice(start), ...items.slice(0, start)]; } +function normalizePositiveLimit(value: number | undefined, fallback: number): number { + if (!Number.isFinite(value) || !value || value <= 0) { + return fallback; + } + return Math.max(1, Math.floor(value)); +} + function hasTaskChangeScanEvidence(task: TeamTaskWithKanban): boolean { if ((task.workIntervals?.length ?? 0) > 0 || (task.reviewIntervals?.length ?? 0) > 0) { return true; @@ -77,8 +90,15 @@ function getRelevantHistoryEvents(task: TeamTaskWithKanban): { type: string; tim export function buildTeamChangeRequestPlan( tasks: TeamTaskWithKanban[], unknownScanCursor: number, - forceFresh: boolean + forceFresh: boolean, + options: TeamChangeRequestPlanOptions = {} ): TeamChangeRequestPlan { + const maxRequests = normalizePositiveLimit(options.maxRequests, TEAM_CHANGES_MAX_REQUESTS); + const unknownScanLimit = Math.min( + normalizePositiveLimit(options.unknownScanLimit, TEAM_CHANGES_UNKNOWN_SCAN_LIMIT), + maxRequests + ); + const satisfiedTaskIds = options.satisfiedTaskIds; const primary: TeamChangeCandidate[] = []; const active: TeamChangeCandidate[] = []; const unknown: TeamChangeCandidate[] = []; @@ -128,11 +148,22 @@ export function buildTeamChangeRequestPlan( const eligibleTaskIds = new Set( [...primary, ...active, ...unknown].map((candidate) => candidate.task.id) ); - const unknownWindow = rotateCandidates(unknown, unknownScanCursor).slice( + const satisfiedEligibleTaskIds = new Set(); + const filterUnsatisfied = (candidate: TeamChangeCandidate): boolean => { + if (!satisfiedTaskIds?.has(candidate.task.id)) { + return true; + } + satisfiedEligibleTaskIds.add(candidate.task.id); + return false; + }; + const requestPrimary = primary.filter(filterUnsatisfied); + const requestActive = active.filter(filterUnsatisfied); + const requestUnknown = unknown.filter(filterUnsatisfied); + const unknownWindow = rotateCandidates(requestUnknown, unknownScanCursor).slice( 0, - TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + unknownScanLimit ); - const selected = [...primary, ...active, ...unknownWindow].slice(0, TEAM_CHANGES_MAX_REQUESTS); + const selected = [...requestPrimary, ...requestActive, ...unknownWindow].slice(0, maxRequests); const requestOptionsByTaskId = new Map(); const requests = selected.map((candidate) => { const options = { @@ -148,9 +179,9 @@ export function buildTeamChangeRequestPlan( }); const eligibleCount = primary.length + active.length + unknown.length; const nextUnknownScanCursor = - unknown.length > 0 - ? (unknownScanCursor + Math.min(TEAM_CHANGES_UNKNOWN_SCAN_LIMIT, unknown.length)) % - unknown.length + requestUnknown.length > 0 + ? (unknownScanCursor + Math.min(unknownScanLimit, requestUnknown.length)) % + requestUnknown.length : 0; return { @@ -159,7 +190,7 @@ export function buildTeamChangeRequestPlan( eligibleTaskIds, eligibleCount, requestedCount: requests.length, - deferredCount: Math.max(0, eligibleCount - requests.length), + deferredCount: Math.max(0, eligibleCount - satisfiedEligibleTaskIds.size - requests.length), nextUnknownScanCursor, }; } diff --git a/src/renderer/components/team/useTeamChangesSummaries.ts b/src/renderer/components/team/useTeamChangesSummaries.ts index 78c2a37f..9fb79adf 100644 --- a/src/renderer/components/team/useTeamChangesSummaries.ts +++ b/src/renderer/components/team/useTeamChangesSummaries.ts @@ -9,6 +9,7 @@ import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout'; import { buildTeamChangeRequestPlan, buildTeamChangesTasksFingerprint, + TEAM_CHANGES_MAX_REQUESTS, } from './teamChangesRequestPlan'; import type { @@ -20,6 +21,14 @@ import type { const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000; const TEAM_CHANGES_COUNTER_AUTO_REFRESH_MS = 60_000; +const TEAM_CHANGES_FIRST_PAINT_REQUESTS = 3; +const TEAM_CHANGES_SECOND_PAINT_REQUESTS = 9; +const TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT = 3; +const TEAM_CHANGES_SECOND_UNKNOWN_SCAN_LIMIT = 6; +const TEAM_CHANGES_INITIAL_STAGED_REFRESH_PLAN = [ + TEAM_CHANGES_SECOND_PAINT_REQUESTS, + TEAM_CHANGES_MAX_REQUESTS, +] as const; const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000; export interface TeamChangeSummaryState { @@ -41,6 +50,11 @@ interface TeamChangesLoadOptions { storeSummaries?: boolean; reportError?: boolean; blockAutoRetryOnError?: boolean; + maxRequests?: number; + unknownScanLimit?: number; + queueDeferredRefresh?: boolean; + satisfiedTaskIds?: ReadonlySet; + stagedRefreshPlan?: readonly number[]; } interface UseTeamChangesSummariesInput { @@ -180,6 +194,40 @@ function isDocumentHidden(): boolean { return typeof document !== 'undefined' && document.visibilityState === 'hidden'; } +function isSilentCounterLoad(options: TeamChangesLoadOptions | null): boolean { + return Boolean( + options && + options.storeSummaries === false && + options.reportError === false && + options.showSpinner !== true + ); +} + +function getUnknownScanLimitForStage(maxRequests: number | undefined): number | undefined { + if (maxRequests === TEAM_CHANGES_FIRST_PAINT_REQUESTS) { + return TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT; + } + if (maxRequests === TEAM_CHANGES_SECOND_PAINT_REQUESTS) { + return TEAM_CHANGES_SECOND_UNKNOWN_SCAN_LIMIT; + } + return undefined; +} + +function mergeSuccessfulTaskIds( + existingTaskIds: ReadonlySet | undefined, + responseItems: TeamTaskChangeSummaryItem[], + requestOptionsByTaskId: ReadonlyMap +): ReadonlySet | undefined { + const next = new Set(existingTaskIds); + for (const item of responseItems) { + if (item.error || item.changeSet === null || !requestOptionsByTaskId.has(item.taskId)) { + continue; + } + next.add(item.taskId); + } + return next.size > 0 ? next : undefined; +} + export function useTeamChangesSummaries({ teamName, tasks, @@ -205,6 +253,7 @@ export function useTeamChangesSummaries({ const mountedRef = useRef(true); const requestSeqRef = useRef(0); const activeRequestSeqRef = useRef(null); + const activeRequestOptionsRef = useRef(null); const queuedRefreshOptionsRef = useRef(null); const autoRefreshBlockedUntilRef = useRef(0); const unknownScanCursorRef = useRef(0); @@ -218,6 +267,7 @@ export function useTeamChangesSummaries({ mountedRef.current = false; requestSeqRef.current += 1; activeRequestSeqRef.current = null; + activeRequestOptionsRef.current = null; queuedRefreshOptionsRef.current = null; autoRefreshBlockedUntilRef.current = 0; hasLoadedRef.current = false; @@ -235,6 +285,11 @@ export function useTeamChangesSummaries({ storeSummaries = true, reportError = true, blockAutoRetryOnError = true, + maxRequests, + unknownScanLimit, + queueDeferredRefresh = false, + satisfiedTaskIds, + stagedRefreshPlan, }: TeamChangesLoadOptions = {}): Promise => { if (forceFresh) { autoRefreshBlockedUntilRef.current = 0; @@ -242,6 +297,17 @@ export function useTeamChangesSummaries({ return; } + const shouldPreemptSilentCounterLoad = + activeRequestSeqRef.current !== null && + storeSummaries && + isSilentCounterLoad(activeRequestOptionsRef.current); + if (shouldPreemptSilentCounterLoad) { + requestSeqRef.current += 1; + activeRequestSeqRef.current = null; + activeRequestOptionsRef.current = null; + queuedRefreshOptionsRef.current = null; + } + if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) { const previous = queuedRefreshOptionsRef.current; queuedRefreshOptionsRef.current = { @@ -255,6 +321,31 @@ export function useTeamChangesSummaries({ blockAutoRetryOnError: previous ? Boolean(previous.blockAutoRetryOnError || blockAutoRetryOnError) : blockAutoRetryOnError, + maxRequests: + maxRequests === undefined + ? undefined + : previous?.maxRequests === undefined + ? maxRequests + : Math.max(previous.maxRequests, maxRequests), + unknownScanLimit: + unknownScanLimit === undefined + ? undefined + : previous?.unknownScanLimit === undefined + ? unknownScanLimit + : Math.max(previous.unknownScanLimit, unknownScanLimit), + queueDeferredRefresh: Boolean(previous?.queueDeferredRefresh || queueDeferredRefresh), + satisfiedTaskIds: + previous?.satisfiedTaskIds && satisfiedTaskIds + ? new Set( + [...previous.satisfiedTaskIds].filter((taskId) => satisfiedTaskIds.has(taskId)) + ) + : undefined, + stagedRefreshPlan: + stagedRefreshPlan !== undefined + ? stagedRefreshPlan + : maxRequests === undefined && unknownScanLimit === undefined + ? undefined + : previous?.stagedRefreshPlan, }; if (showSpinner) { setLoading(true); @@ -267,7 +358,11 @@ export function useTeamChangesSummaries({ return; } - const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh); + const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh, { + maxRequests, + unknownScanLimit, + satisfiedTaskIds, + }); unknownScanCursorRef.current = plan.nextUnknownScanCursor; const requestSeq = requestSeqRef.current + 1; requestSeqRef.current = requestSeq; @@ -296,6 +391,19 @@ export function useTeamChangesSummaries({ setRefreshing(true); } activeRequestSeqRef.current = requestSeq; + activeRequestOptionsRef.current = { + forceFresh, + showSpinner, + preserveOnError, + storeSummaries, + reportError, + blockAutoRetryOnError, + maxRequests, + unknownScanLimit, + queueDeferredRefresh, + satisfiedTaskIds, + stagedRefreshPlan, + }; try { const response = await withTeamChangesLoadTimeout( @@ -358,11 +466,32 @@ export function useTeamChangesSummaries({ return next; }); } + if (storeSummaries && queueDeferredRefresh && plan.deferredCount > 0) { + const [nextStageMaxRequests, ...remainingStages] = stagedRefreshPlan ?? []; + const successfulTaskIds = mergeSuccessfulTaskIds( + satisfiedTaskIds, + responseItems, + plan.requestOptionsByTaskId + ); + queuedRefreshOptionsRef.current = { + forceFresh, + showSpinner: false, + preserveOnError: true, + storeSummaries: true, + reportError: true, + blockAutoRetryOnError: true, + maxRequests: nextStageMaxRequests, + unknownScanLimit: getUnknownScanLimitForStage(nextStageMaxRequests), + queueDeferredRefresh: remainingStages.length > 0, + stagedRefreshPlan: remainingStages.length > 0 ? remainingStages : undefined, + satisfiedTaskIds: successfulTaskIds, + }; + } } catch (err) { if (!mountedRef.current || requestSeqRef.current !== requestSeq) { return; } - const queuedOptions = queuedRefreshOptionsRef.current as TeamChangesLoadOptions | null; + const queuedOptions = queuedRefreshOptionsRef.current; const shouldRunVisibleQueuedRefreshAfterSilentFailure = !storeSummaries && !reportError && @@ -385,6 +514,7 @@ export function useTeamChangesSummaries({ const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null; if (activeRequestSeqRef.current === requestSeq) { activeRequestSeqRef.current = null; + activeRequestOptionsRef.current = null; } if (hasQueuedRefresh && activeRequestSeqRef.current === null) { setQueuedRefreshTick((value) => value + 1); @@ -406,6 +536,7 @@ export function useTeamChangesSummaries({ hasLoadedRef.current = false; requestSeqRef.current += 1; activeRequestSeqRef.current = null; + activeRequestOptionsRef.current = null; queuedRefreshOptionsRef.current = null; autoRefreshBlockedUntilRef.current = 0; unknownScanCursorRef.current = 0; @@ -422,6 +553,7 @@ export function useTeamChangesSummaries({ if (!sectionOpen) { requestSeqRef.current += 1; activeRequestSeqRef.current = null; + activeRequestOptionsRef.current = null; queuedRefreshOptionsRef.current = null; autoRefreshBlockedUntilRef.current = 0; hasLoadedRef.current = false; @@ -457,7 +589,14 @@ export function useTeamChangesSummaries({ } hasLoadedRef.current = true; lastRequestedTasksFingerprintRef.current = tasksFingerprint; - void loadSummaries({ showSpinner: true, preserveOnError: false }); + void loadSummaries({ + showSpinner: true, + preserveOnError: false, + maxRequests: TEAM_CHANGES_FIRST_PAINT_REQUESTS, + unknownScanLimit: TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT, + queueDeferredRefresh: true, + stagedRefreshPlan: TEAM_CHANGES_INITIAL_STAGED_REFRESH_PLAN, + }); }, [loadSummaries, sectionOpen, tasksFingerprint]); useEffect(() => { @@ -527,7 +666,15 @@ export function useTeamChangesSummaries({ }, [loadSummaries, sectionOpen]); const refresh = useCallback(() => { - void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false }); + void loadSummaries({ + forceFresh: true, + showSpinner: true, + preserveOnError: false, + maxRequests: TEAM_CHANGES_FIRST_PAINT_REQUESTS, + unknownScanLimit: TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT, + queueDeferredRefresh: true, + stagedRefreshPlan: TEAM_CHANGES_INITIAL_STAGED_REFRESH_PLAN, + }); }, [loadSummaries]); return { diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index c62bf068..bc6eac18 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -8,7 +8,7 @@ import { useStore } from '@renderer/store'; import { useShallow } from 'zustand/react/shallow'; -import type { CliInstallationStatus, CliProviderId } from '@shared/types'; +import type { CliInstallationStatus, CliProviderId, OpenCodeRuntimeStatus } from '@shared/types'; export function useCliInstaller(): { cliStatus: CliInstallationStatus | null; @@ -30,6 +30,9 @@ export function useCliInstaller(): { installerDetail: string | null; installerRawChunks: string[]; completedVersion: string | null; + openCodeRuntimeStatus: OpenCodeRuntimeStatus | null; + openCodeRuntimeStatusLoading: boolean; + openCodeRuntimeError: string | null; bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise; fetchCliStatus: () => Promise; fetchCliProviderStatus: ( @@ -38,6 +41,9 @@ export function useCliInstaller(): { ) => Promise; invalidateCliStatus: () => Promise; installCli: () => void; + fetchOpenCodeRuntimeStatus: () => Promise; + installOpenCodeRuntime: () => Promise; + invalidateOpenCodeRuntimeStatus: () => Promise; isBusy: boolean; } { const { @@ -53,11 +59,17 @@ export function useCliInstaller(): { installerDetail, installerRawChunks, completedVersion, + openCodeRuntimeStatus, + openCodeRuntimeStatusLoading, + openCodeRuntimeError, bootstrapCliStatus, fetchCliStatus, fetchCliProviderStatus, invalidateCliStatus, installCli, + fetchOpenCodeRuntimeStatus, + installOpenCodeRuntime, + invalidateOpenCodeRuntimeStatus, } = useStore( useShallow((s) => ({ cliStatus: s.cliStatus, @@ -72,11 +84,17 @@ export function useCliInstaller(): { installerDetail: s.cliInstallerDetail, installerRawChunks: s.cliInstallerRawChunks, completedVersion: s.cliCompletedVersion, + openCodeRuntimeStatus: s.openCodeRuntimeStatus, + openCodeRuntimeStatusLoading: s.openCodeRuntimeStatusLoading, + openCodeRuntimeError: s.openCodeRuntimeError, bootstrapCliStatus: s.bootstrapCliStatus, fetchCliStatus: s.fetchCliStatus, fetchCliProviderStatus: s.fetchCliProviderStatus, invalidateCliStatus: s.invalidateCliStatus, installCli: s.installCli, + fetchOpenCodeRuntimeStatus: s.fetchOpenCodeRuntimeStatus, + installOpenCodeRuntime: s.installOpenCodeRuntime, + invalidateOpenCodeRuntimeStatus: s.invalidateOpenCodeRuntimeStatus, })) ); @@ -96,11 +114,17 @@ export function useCliInstaller(): { installerDetail, installerRawChunks, completedVersion, + openCodeRuntimeStatus, + openCodeRuntimeStatusLoading, + openCodeRuntimeError, bootstrapCliStatus, fetchCliStatus, fetchCliProviderStatus, invalidateCliStatus, installCli, + fetchOpenCodeRuntimeStatus, + installOpenCodeRuntime, + invalidateOpenCodeRuntimeStatus, isBusy, }; } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index c078e3f3..b9130f71 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -70,6 +70,7 @@ import type { CliInstallerProgress, CliProviderId, LeadContextUsage, + OpenCodeRuntimeStatus, ScheduleChangeEvent, TeamChangeEvent, TeamProvisioningProgress, @@ -244,6 +245,9 @@ export function initializeNotificationListeners(): () => void { cliStatusTimer = null; }, delayMs); } + if (api.openCodeRuntime) { + void useStore.getState().fetchOpenCodeRuntimeStatus(); + } // Remaining fetches have no data dependency on each other — run in parallel // to avoid blocking teams/notifications behind a slow repository scan. @@ -2300,6 +2304,31 @@ export function initializeNotificationListeners(): () => void { } } + if (api.openCodeRuntime?.onProgress) { + const cleanup = api.openCodeRuntime.onProgress((_event: unknown, data: unknown) => { + const status = data as OpenCodeRuntimeStatus; + useStore.setState({ + openCodeRuntimeStatus: status, + openCodeRuntimeError: status.error ?? null, + openCodeRuntimeStatusLoading: + status.state === 'checking' || + status.state === 'downloading' || + status.state === 'installing', + }); + if (status.installed && status.state === 'ready') { + void (async () => { + await api.cliInstaller?.invalidateStatus(); + await useStore.getState().fetchCliProviderStatus('opencode', { + silent: false, + }); + })(); + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + // Listen for updater status events from main process if (api.updater?.onStatus) { const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => { diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 0cad9079..054a179e 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -7,7 +7,12 @@ import { createLogger } from '@shared/utils/logger'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import type { AppState } from '../types'; -import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types'; +import type { + CliInstallationStatus, + CliProviderId, + CliProviderStatus, + OpenCodeRuntimeStatus, +} from '@shared/types'; import type { StateCreator } from 'zustand'; const logger = createLogger('Store:cliInstaller'); @@ -283,6 +288,9 @@ export interface CliInstallerSlice { cliInstallerLogs: string[]; cliInstallerRawChunks: string[]; cliCompletedVersion: string | null; + openCodeRuntimeStatus: OpenCodeRuntimeStatus | null; + openCodeRuntimeStatusLoading: boolean; + openCodeRuntimeError: string | null; // Actions bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise; @@ -293,12 +301,16 @@ export interface CliInstallerSlice { ) => Promise; invalidateCliStatus: () => Promise; installCli: () => void; + fetchOpenCodeRuntimeStatus: () => Promise; + installOpenCodeRuntime: () => Promise; + invalidateOpenCodeRuntimeStatus: () => Promise; } let cliStatusInFlight: Promise | null = null; const cliProviderStatusInFlight = new Map>(); let cliStatusEpoch = 0; const cliProviderStatusSeq = new Map(); +let openCodeRuntimeStatusInFlight: Promise | null = null; // ============================================================================= // Slice Creator @@ -322,6 +334,9 @@ export const createCliInstallerSlice: StateCreator { if (!api.cliInstaller) return; @@ -690,4 +705,65 @@ export const createCliInstallerSlice: StateCreator { + if (!api.openCodeRuntime) return; + if (openCodeRuntimeStatusInFlight) return openCodeRuntimeStatusInFlight; + + openCodeRuntimeStatusInFlight = (async () => { + set({ openCodeRuntimeStatusLoading: true, openCodeRuntimeError: null }); + try { + const status = await api.openCodeRuntime.getStatus(); + set({ openCodeRuntimeStatus: status, openCodeRuntimeError: status.error ?? null }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to check OpenCode runtime status'; + logger.error('Failed to fetch OpenCode runtime status:', error); + set({ openCodeRuntimeError: message }); + } finally { + set({ openCodeRuntimeStatusLoading: false }); + openCodeRuntimeStatusInFlight = null; + } + })(); + + return openCodeRuntimeStatusInFlight; + }, + + installOpenCodeRuntime: async () => { + if (!api.openCodeRuntime) return; + set({ + openCodeRuntimeStatusLoading: true, + openCodeRuntimeError: null, + openCodeRuntimeStatus: { + installed: false, + source: 'missing', + state: 'checking', + progress: { + phase: 'checking', + detail: 'Resolving latest OpenCode package...', + }, + }, + }); + try { + const status = await api.openCodeRuntime.install(); + set({ openCodeRuntimeStatus: status, openCodeRuntimeError: status.error ?? null }); + if (status.installed) { + await api.openCodeRuntime.invalidateStatus(); + await api.cliInstaller?.invalidateStatus(); + const epoch = ++cliStatusEpoch; + await get().fetchCliProviderStatus('opencode', { silent: false, epoch }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to install OpenCode runtime'; + logger.error('Failed to install OpenCode runtime:', error); + set({ openCodeRuntimeError: message }); + } finally { + set({ openCodeRuntimeStatusLoading: false }); + } + }, + + invalidateOpenCodeRuntimeStatus: async () => { + await api.openCodeRuntime?.invalidateStatus(); + set({ openCodeRuntimeStatus: null }); + }, }); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 26c0afca..e6d83953 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1870,6 +1870,7 @@ const resolvedMembersSelectorCache = new Map< { snapshotRef: TeamViewSnapshot['members']; configMembersRef: TeamViewSnapshot['config']['members'] | undefined; + summaryRef: TeamSummary | undefined; tasksRef: TeamViewSnapshot['tasks'] | undefined; metaMembersRef: TeamMemberActivityMeta['members'] | undefined; result: ResolvedTeamMember[]; @@ -1982,10 +1983,196 @@ function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMem return fallbackMembers; } -function getResolvableMemberSnapshots(snapshot: TeamViewSnapshot): readonly TeamMemberSnapshot[] { - return snapshot.members.length > 0 - ? snapshot.members - : buildConfigFallbackMemberSnapshots(snapshot); +function buildSummaryFallbackMemberSnapshots( + snapshot: TeamViewSnapshot, + summary: TeamSummary | undefined +): TeamMemberSnapshot[] { + if (!summary) { + return []; + } + const summaryMembers = summary.members ?? []; + if (summaryMembers.length === 0 || summary.memberCount <= 0) { + return []; + } + + const seenNames = new Set(); + const buildSnapshot = ( + name: string, + source?: { agentId?: string; role?: string; color?: string }, + lead = false + ): TeamMemberSnapshot | null => { + const trimmed = name.trim(); + if (!trimmed) return null; + const key = trimmed.toLowerCase(); + if (seenNames.has(key)) return null; + seenNames.add(key); + + const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed); + const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask); + return { + name: trimmed, + agentId: source?.agentId, + currentTaskId: currentTask?.id ?? null, + taskCount: ownedTasks.length, + color: source?.color ?? getMemberColorByName(trimmed), + agentType: lead ? 'team-lead' : undefined, + role: source?.role ?? (lead ? 'Team Lead' : undefined), + }; + }; + + const teammates = summaryMembers.flatMap((member) => { + const name = member.name?.trim(); + if (!name || name === 'user' || isLeadMember(member)) { + return []; + } + const item = buildSnapshot(name, member); + return item ? [item] : []; + }); + if (teammates.length === 0) { + return []; + } + + const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member)); + if (existingLead) { + return [existingLead, ...teammates]; + } + + const configuredLead = snapshot.config.members?.find( + (member) => !member.removedAt && isLeadMember(member) + ); + const leadName = configuredLead?.name?.trim() || summary.leadName?.trim(); + const lead = leadName + ? buildSnapshot( + leadName, + { + agentId: configuredLead?.agentId, + role: configuredLead?.role, + color: configuredLead?.color ?? summary.leadColor, + }, + true + ) + : null; + + return lead ? [lead, ...teammates] : teammates; +} + +function getResolvableMemberSnapshots( + snapshot: TeamViewSnapshot, + summary?: TeamSummary +): readonly TeamMemberSnapshot[] { + if ( + snapshot.members.length > 0 && + (hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot)) + ) { + return snapshot.members; + } + + const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot); + if (configFallbackMembers.length > 0) { + return configFallbackMembers; + } + + const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary); + if (summaryFallbackMembers.length > 0) { + return summaryFallbackMembers; + } + + return snapshot.members; +} + +function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] { + if (!snapshot) { + return []; + } + const names = new Set(); + for (const member of snapshot.members) { + const name = member.name.trim(); + if (!name || name === 'user' || member.removedAt || isLeadMember(member)) { + continue; + } + names.add(name.toLowerCase()); + } + return Array.from(names).sort((left, right) => left.localeCompare(right)); +} + +function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { + return getActiveRawTeammateNameKeys(snapshot).length > 0; +} + +function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { + return Boolean(snapshot?.members.some((member) => member.removedAt)); +} + +function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { + return Boolean( + snapshot?.config.members?.some((member) => { + const name = member.name?.trim(); + return Boolean(name) && !member.removedAt && !isLeadMember(member); + }) + ); +} + +function getSummaryTeammateNameKeys(summary: TeamSummary): string[] { + const names = new Set(); + for (const member of summary.members ?? []) { + const name = member.name?.trim(); + if (!name || name === 'user' || isLeadMember(member)) { + continue; + } + names.add(name.toLowerCase()); + } + return Array.from(names).sort((left, right) => left.localeCompare(right)); +} + +function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((name, index) => name === right[index]); +} + +function summaryConfirmsActiveTeammateRoster( + current: TeamViewSnapshot, + summary: TeamSummary +): boolean { + if (summary.memberCount <= 0) { + return false; + } + + const currentNames = getActiveRawTeammateNameKeys(current); + const summaryNames = getSummaryTeammateNameKeys(summary); + if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) { + return false; + } + + return areNameKeyListsEqual(summaryNames, currentNames); +} + +function shouldPreserveSelectedTeamSnapshot( + current: TeamViewSnapshot | null, + baseline: TeamViewSnapshot | null | undefined, + incoming: TeamViewSnapshot, + summary: TeamSummary | undefined +): boolean { + if (!current || !hasActiveRawTeammateRoster(current)) { + return false; + } + if ( + hasActiveRawTeammateRoster(incoming) || + hasRemovedRawMemberRoster(incoming) || + hasConfigTeammateRoster(incoming) + ) { + return false; + } + const currentNames = getActiveRawTeammateNameKeys(current); + if ( + current !== baseline && + !areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline)) + ) { + return true; + } + if (summary) { + return summaryConfirmsActiveTeammateRoster(current, summary); + } + + return false; } function buildResolvedMember( @@ -2042,8 +2229,13 @@ function structurallyShareMemberActivityFacts( return changed ? shared : previous; } +type TeamDataSelectorState = Pick< + TeamSlice, + 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' +>; + export function selectTeamDataForName( - state: Pick, + state: TeamDataSelectorState, teamName: string | null | undefined ): TeamViewSnapshot | null { if (!teamName) { @@ -2058,6 +2250,12 @@ export function selectTeamDataForName( ); } +type ResolvedMemberSelectorState = Pick< + TeamSlice, + 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' +> & + Partial>; + function migrateStableSlotAssignmentsForMembers( assignments: TeamGraphSlotAssignments | undefined, members: readonly TeamGraphMemberSeedInput[] @@ -2088,10 +2286,7 @@ function migrateStableSlotAssignmentsForMembers( } export function selectResolvedMembersForTeamName( - state: Pick< - TeamSlice, - 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' - >, + state: ResolvedMemberSelectorState, teamName: string | null | undefined ): ResolvedTeamMember[] { const snapshot = selectTeamDataForName(state, teamName); @@ -2101,23 +2296,28 @@ export function selectResolvedMembersForTeamName( const meta = state.memberActivityMetaByTeam[teamName]; const metaMembers = meta?.members; - const shouldUseConfigFallback = snapshot.members.length === 0; - const configMembersRef = shouldUseConfigFallback ? snapshot.config.members : undefined; - const tasksRef = shouldUseConfigFallback ? snapshot.tasks : undefined; + const shouldUseMemberFallback = + snapshot.members.length === 0 || + (!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot)); + const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined; + const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined; + const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined; const cached = resolvedMembersSelectorCache.get(teamName); if ( cached?.snapshotRef === snapshot.members && cached.configMembersRef === configMembersRef && + cached.summaryRef === summaryRef && cached.tasksRef === tasksRef && cached.metaMembersRef === metaMembers ) { return cached.result; } - const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot), meta); + const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta); resolvedMembersSelectorCache.set(teamName, { snapshotRef: snapshot.members, configMembersRef, + summaryRef, tasksRef, metaMembersRef: metaMembers, result, @@ -2126,10 +2326,7 @@ export function selectResolvedMembersForTeamName( } export function selectResolvedMemberForTeamName( - state: Pick< - TeamSlice, - 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' - >, + state: ResolvedMemberSelectorState, teamName: string | null | undefined, memberName: string | null | undefined ): ResolvedTeamMember | null { @@ -2138,7 +2335,7 @@ export function selectResolvedMemberForTeamName( return null; } - const snapshotMember = getResolvableMemberSnapshots(snapshot).find( + const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find( (member) => member.name === memberName ); if (!snapshotMember) { @@ -2162,21 +2359,21 @@ export function selectResolvedMemberForTeamName( } export function selectTeamMemberSnapshotsForName( - state: Pick, + state: TeamDataSelectorState, teamName: string | null | undefined ): TeamViewSnapshot['members'] { return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS; } export function selectTeamTasksForName( - state: Pick, + state: TeamDataSelectorState, teamName: string | null | undefined ): TeamViewSnapshot['tasks'] { return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS; } export function selectTeamIsAliveForName( - state: Pick, + state: TeamDataSelectorState, teamName: string | null | undefined ): boolean | undefined { return selectTeamDataForName(state, teamName)?.isAlive; @@ -3856,14 +4053,52 @@ export const createTeamSlice: StateCreator = (set, set({ teamByName: { ...prevByName, [teamName]: patched } }); } - const projectedTeamData = previousData - ? { - ...data, - tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), - } - : data; - const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData); + let committedTeamData: TeamViewSnapshot = data; set((state) => { + if ( + state.selectedTeamName === teamName && + shouldPreserveSelectedTeamSnapshot( + state.selectedTeamData, + previousData, + data, + state.teamByName[teamName] + ) + ) { + const preservedTeamData = state.selectedTeamData; + committedTeamData = preservedTeamData ?? data; + const nextCache = + preservedTeamData && state.teamDataCacheByName[teamName] !== preservedTeamData + ? { + ...state.teamDataCacheByName, + [teamName]: preservedTeamData, + } + : state.teamDataCacheByName; + + return { + selectedTeamName: teamName, + selectedTeamData: preservedTeamData, + teamDataCacheByName: nextCache, + selectedTeamLoading: false, + selectedTeamError: null, + }; + } + + const previousForProjection = selectTeamDataForName(state, teamName) ?? previousData; + const projectedTeamData = previousForProjection + ? { + ...data, + tasks: preserveKnownTaskChangePresence( + teamName, + previousForProjection.tasks, + data.tasks + ), + } + : data; + const nextTeamData = structurallyShareTeamSnapshot( + previousForProjection, + projectedTeamData + ); + committedTeamData = nextTeamData; const nextCache = state.teamDataCacheByName[teamName] === nextTeamData ? state.teamDataCacheByName @@ -3884,7 +4119,11 @@ export const createTeamSlice: StateCreator = (set, try { const invalidationState = previousData - ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) + ? collectTaskChangeInvalidationState( + teamName, + previousData.tasks, + committedTeamData.tasks + ) : { cacheKeys: [], taskIds: [] }; if (invalidationState.cacheKeys.length > 0) { get().invalidateTaskChangePresence(invalidationState.cacheKeys); @@ -3896,7 +4135,7 @@ export const createTeamSlice: StateCreator = (set, } // Sync tab label with the team's display name from config. - const displayName = data.config.name || teamName; + const displayName = committedTeamData.config.name || teamName; const allTabs = get().getAllPaneTabs(); const relatedTabs = allTabs.filter( (tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName @@ -3911,7 +4150,7 @@ export const createTeamSlice: StateCreator = (set, // Auto-select the project associated with this team's cwd/projectPath. // Must search both flat projects and grouped repositoryGroups/worktrees // because the default viewMode is 'grouped' and flat projects may be empty. - const projectPath = data.config.projectPath; + const projectPath = committedTeamData.config.projectPath; if ( !opts?.skipProjectAutoSelect && projectPath && diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index b8bf6211..6eafdbd8 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -134,6 +134,8 @@ export const SPAWN_PRESENCE_LABELS: Record = { const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000; export const MEMBER_STARTING_STALE_AFTER_MS = 2 * 60 * 1000; +const OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE = + 'OpenCode bridge outcome unknown after timeout, retrying/observing.'; function isLaunchStillStarting( spawnStatus: MemberSpawnStatus | undefined, @@ -332,6 +334,7 @@ function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined): displayMessage.startsWith('OpenCode runtime delivery') || displayMessage.startsWith('OpenCode returned an empty assistant turn') || displayMessage.startsWith('OpenCode accepted the prompt') || + displayMessage.startsWith('OpenCode bridge outcome unknown after timeout') || displayMessage.startsWith('OpenCode responded, but did not create') || displayMessage.startsWith('OpenCode created a reply without') || displayMessage.startsWith('OpenCode used tools, but did not create') @@ -349,6 +352,9 @@ function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): strin if (trimmed === 'prompt_delivered_no_assistant_message') { return 'OpenCode accepted the prompt, but no assistant turn was recorded.'; } + if (trimmed === 'opencode_prompt_acceptance_unknown_after_bridge_timeout') { + return OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE; + } if ( trimmed === 'visible_reply_still_required' || trimmed === 'visible_reply_ack_only_still_requires_answer' || diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index 9925702b..088dc012 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -33,6 +33,8 @@ const FAILED_WARNING = 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.'; const ATTACHMENT_FAILED_WARNING = 'OpenCode attachment was not sent. Message was saved to inbox, but live delivery cannot include this attachment.'; +const OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE = + 'OpenCode bridge outcome unknown after timeout, retrying/observing.'; function isOpenCodeAttachmentDeliveryFailureReason(reason: string | null | undefined): boolean { const normalized = reason?.trim().toLowerCase(); @@ -55,6 +57,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde if (normalizedLower === 'prompt_delivered_no_assistant_message') { return 'OpenCode accepted the prompt, but no assistant turn was recorded.'; } + if (normalizedLower === 'opencode_prompt_acceptance_unknown_after_bridge_timeout') { + return OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE; + } if ( normalizedLower === 'visible_reply_still_required' || normalizedLower === 'visible_reply_ack_only_still_requires_answer' || diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 8b392a46..ed91a48f 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -8,7 +8,7 @@ */ import type { CliArgsValidationResult } from '../utils/cliArgsParser'; -import type { CliInstallerAPI } from './cliInstaller'; +import type { CliInstallerAPI, OpenCodeRuntimeAPI } from './cliInstaller'; import type { EditorAPI, EditorFileChangeEvent, ProjectAPI } from './editor'; import type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI, SkillsCatalogAPI } from './extensions'; import type { @@ -79,9 +79,9 @@ import type { TeamCreateRequest, TeamCreateResponse, TeamGetDataOptions, + TeamLaunchFailureDiagnosticsBundle, TeamLaunchRequest, TeamLaunchResponse, - TeamLaunchFailureDiagnosticsBundle, TeamMemberActivityMeta, TeamMessageNotificationData, TeamProvisioningModelVerificationMode, @@ -927,6 +927,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec // CLI Installer API cliInstaller: CliInstallerAPI; + // OpenCode app-managed runtime installer API + openCodeRuntime: OpenCodeRuntimeAPI; + // Runtime nested provider management API runtimeProviderManagement: RuntimeProviderManagementApi; diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index e824d6ee..96e0f20b 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -327,3 +327,42 @@ export interface CliInstallerAPI { /** Subscribe to progress events. Returns cleanup function. */ onProgress: (cb: (event: unknown, data: CliInstallerProgress) => void) => () => void; } + +// ============================================================================= +// OpenCode Runtime Installer +// ============================================================================= + +export type OpenCodeRuntimeSource = 'app-managed' | 'path' | 'missing'; + +export type OpenCodeRuntimeInstallerState = + | 'idle' + | 'checking' + | 'downloading' + | 'installing' + | 'ready' + | 'failed'; + +export interface OpenCodeRuntimeInstallProgress { + phase: OpenCodeRuntimeInstallerState; + downloadedBytes?: number; + totalBytes?: number; + percent?: number; + detail?: string | null; +} + +export interface OpenCodeRuntimeStatus { + installed: boolean; + binaryPath?: string; + version?: string; + source: OpenCodeRuntimeSource; + state: OpenCodeRuntimeInstallerState; + progress?: OpenCodeRuntimeInstallProgress; + error?: string; +} + +export interface OpenCodeRuntimeAPI { + getStatus: () => Promise; + install: () => Promise; + invalidateStatus: () => Promise; + onProgress: (cb: (event: unknown, data: OpenCodeRuntimeStatus) => void) => () => void; +} diff --git a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts new file mode 100644 index 00000000..394d498e --- /dev/null +++ b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts @@ -0,0 +1,74 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { resolveAppManagedOpenCodeRuntimeBinaryPath } from '@main/services/infrastructure/OpenCodeRuntimeInstallerService'; +import { setAppDataBasePath } from '@main/utils/pathDecoder'; + +let tempRoot: string | null = null; + +describe('OpenCodeRuntimeInstallerService resolver', () => { + beforeEach(async () => { + tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-resolver-')); + setAppDataBasePath(tempRoot); + }); + + afterEach(async () => { + setAppDataBasePath(null); + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + it('returns the current app-managed OpenCode binary path only when manifest and binary exist', async () => { + const binaryPath = path.join( + tempRoot!, + 'data', + 'runtimes', + 'opencode', + 'versions', + '1.0.0', + 'opencode-test', + 'opencode' + ); + const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json'); + await mkdir(path.dirname(binaryPath), { recursive: true }); + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + await writeFile( + manifestPath, + `${JSON.stringify({ + schemaVersion: 1, + version: '1.0.0', + platformPackage: 'opencode-test', + binaryPath, + integrity: 'sha512-test', + installedAt: '2026-05-12T00:00:00.000Z', + })}\n`, + 'utf8' + ); + + expect(resolveAppManagedOpenCodeRuntimeBinaryPath()).toBe(binaryPath); + }); + + it('ignores a manifest whose binary path is missing', async () => { + const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json'); + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile( + manifestPath, + `${JSON.stringify({ + schemaVersion: 1, + version: '1.0.0', + platformPackage: 'opencode-test', + binaryPath: path.join(tempRoot!, 'missing-opencode'), + integrity: 'sha512-test', + installedAt: '2026-05-12T00:00:00.000Z', + })}\n`, + 'utf8' + ); + + expect(resolveAppManagedOpenCodeRuntimeBinaryPath()).toBeNull(); + }); +}); diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index b530e445..85f46b3e 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -262,6 +262,267 @@ describe('OpenCodeReadinessBridge', () => { ); }); + it('does not query commandStatus on successful OpenCode sendMessage', async () => { + const executor = fakeExecutor( + bridgeCommandSuccess({ + command: 'opencode.sendMessage', + requestId: 'send-req-1', + data: { + accepted: true, + memberName: 'bob', + sessionId: 'session-bob', + diagnostics: [], + }, + }) + ); + const bridge = new OpenCodeReadinessBridge(executor); + + await expect( + bridge.sendOpenCodeTeamMessage({ + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + }) + ).resolves.toMatchObject({ + accepted: true, + sessionId: 'session-bob', + }); + + expect(executor.execute).toHaveBeenCalledOnce(); + expect(executor.execute).toHaveBeenCalledWith( + 'opencode.sendMessage', + expect.objectContaining({ + deliveryAttemptId: 'ledger-1:1:payload', + payloadHash: expect.any(String), + }), + expect.objectContaining({ + cwd: '/repo', + timeoutMs: 45_000, + requestId: expect.stringMatching(/^opencode-send-/), + }) + ); + }); + + it('recovers accepted OpenCode sendMessage after bridge timeout through commandStatus when enabled', async () => { + const previous = process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY; + process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = '1'; + const executor = fakeSequenceExecutor([ + bridgeFailure('timeout', 'OpenCode bridge command timed out', []), + bridgeCommandSuccess({ + command: 'opencode.commandStatus', + requestId: 'status-req-1', + data: { + status: 'prompt_accepted', + safeToRetry: false, + accepted: true, + sessionId: 'session-bob', + runtimePromptMessageId: 'msg_prompt_1', + diagnostics: ['OpenCode prompt acceptance recovered from offline_sqlite.'], + }, + }), + ]); + const bridge = new OpenCodeReadinessBridge(executor); + + try { + await expect( + bridge.sendOpenCodeTeamMessage({ + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + }) + ).resolves.toMatchObject({ + accepted: true, + sessionId: 'session-bob', + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + code: 'opencode_send_recovered_after_bridge_timeout', + }), + ]), + }); + } finally { + if (previous === undefined) { + delete process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY; + } else { + process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = previous; + } + } + + expect(executor.execute).toHaveBeenCalledTimes(2); + const sendOptions = executor.execute.mock.calls[0]?.[2] as { requestId?: string } | undefined; + expect(executor.execute.mock.calls[1]).toEqual([ + 'opencode.commandStatus', + expect.objectContaining({ + originalCommand: 'opencode.sendMessage', + originalRequestId: sendOptions?.requestId, + deliveryAttemptId: 'ledger-1:1:payload', + payloadHash: expect.any(String), + }), + { + cwd: '/repo', + timeoutMs: 5_000, + }, + ]); + }); + + it('does not query commandStatus for non-timeout OpenCode sendMessage failures', async () => { + await withCommandStatusRecoveryEnabled(async () => { + const executor = fakeExecutor( + bridgeFailure('provider_error', 'OpenCode send failed', []) + ); + const bridge = new OpenCodeReadinessBridge(executor); + + await expect( + bridge.sendOpenCodeTeamMessage({ + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + }) + ).resolves.toMatchObject({ + accepted: false, + memberName: 'bob', + diagnostics: [ + expect.objectContaining({ + code: 'provider_error', + }), + ], + }); + + expect(executor.execute).toHaveBeenCalledOnce(); + }); + }); + + it('keeps the old send failure path when timeout commandStatus is unknown', async () => { + await withCommandStatusRecoveryEnabled(async () => { + const executor = fakeSequenceExecutor([ + bridgeFailure('timeout', 'OpenCode bridge command timed out', []), + bridgeCommandSuccess({ + command: 'opencode.commandStatus', + requestId: 'status-req-1', + data: { + status: 'unknown', + safeToRetry: false, + accepted: false, + diagnostics: ['No orchestrator-side command outcome record matched the requested OpenCode command.'], + }, + }), + ]); + const bridge = new OpenCodeReadinessBridge(executor); + + await expect( + bridge.sendOpenCodeTeamMessage({ + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + }) + ).resolves.toMatchObject({ + accepted: false, + memberName: 'bob', + diagnostics: [ + expect.objectContaining({ + code: 'timeout', + }), + ], + }); + + expect(executor.execute).toHaveBeenCalledTimes(2); + }); + }); + + it('keeps the old send failure path when timeout commandStatus is unavailable', async () => { + await withCommandStatusRecoveryEnabled(async () => { + const executor = fakeSequenceExecutor([ + bridgeFailure('timeout', 'OpenCode bridge command timed out', []), + bridgeFailure('timeout', 'OpenCode commandStatus timed out', []), + ]); + const bridge = new OpenCodeReadinessBridge(executor); + + await expect( + bridge.sendOpenCodeTeamMessage({ + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + }) + ).resolves.toMatchObject({ + accepted: false, + memberName: 'bob', + diagnostics: [ + expect.objectContaining({ + code: 'timeout', + }), + ], + }); + + expect(executor.execute).toHaveBeenCalledTimes(2); + }); + }); + + it('keeps the old send failure path when timeout commandStatus reports precondition mismatch', async () => { + await withCommandStatusRecoveryEnabled(async () => { + const executor = fakeSequenceExecutor([ + bridgeFailure('timeout', 'OpenCode bridge command timed out', []), + bridgeCommandSuccess({ + command: 'opencode.commandStatus', + requestId: 'status-req-1', + data: { + status: 'precondition_mismatch', + safeToRetry: false, + accepted: false, + diagnostics: ['OpenCode command status payloadHash mismatch.'], + }, + }), + ]); + const bridge = new OpenCodeReadinessBridge(executor); + + await expect( + bridge.sendOpenCodeTeamMessage({ + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + }) + ).resolves.toMatchObject({ + accepted: false, + memberName: 'bob', + diagnostics: [ + expect.objectContaining({ + code: 'timeout', + }), + ], + }); + + expect(executor.execute).toHaveBeenCalledTimes(2); + }); + }); + it('routes state-changing launch commands through the guarded command service when configured', async () => { const executor = fakeExecutor( bridgeFailure('internal_error', 'direct bridge must not run', []) @@ -331,6 +592,38 @@ function fakeExecutor( }; } +function fakeSequenceExecutor( + results: OpenCodeBridgeResult[] +): OpenCodeReadinessBridgeCommandExecutor & { + execute: ReturnType; +} { + const execute = vi.fn(async () => { + const next = results.shift(); + if (!next) { + throw new Error('No fake bridge result queued'); + } + return next; + }); + return { + execute: execute as unknown as OpenCodeReadinessBridgeCommandExecutor['execute'] & + ReturnType, + }; +} + +async function withCommandStatusRecoveryEnabled(callback: () => Promise): Promise { + const previous = process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY; + process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = '1'; + try { + return await callback(); + } finally { + if (previous === undefined) { + delete process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY; + } else { + process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = previous; + } + } +} + function bridgeSuccess( data: OpenCodeTeamLaunchReadiness ): OpenCodeBridgeSuccess { diff --git a/test/main/services/team/RuntimeDiagnosticClassifier.test.ts b/test/main/services/team/RuntimeDiagnosticClassifier.test.ts index c89eca87..d106abb7 100644 --- a/test/main/services/team/RuntimeDiagnosticClassifier.test.ts +++ b/test/main/services/team/RuntimeDiagnosticClassifier.test.ts @@ -48,6 +48,17 @@ describe('RuntimeDiagnosticClassifier', () => { }); }); + it('classifies OpenCode bridge outcome timeouts as backend delivery state', () => { + expect( + classifyRuntimeDiagnostic('opencode_prompt_acceptance_unknown_after_bridge_timeout') + ).toMatchObject({ + reasonCode: 'backend_error', + normalizedMessage: 'OpenCode bridge outcome unknown after timeout, retrying/observing.', + generic: true, + actionRequired: false, + }); + }); + it('keeps pure empty assistant turns as generic backend fallback', () => { expect(classifyRuntimeDiagnostic('empty_assistant_turn')).toMatchObject({ reasonCode: 'backend_error', diff --git a/test/main/services/team/TaskBoundaryParser.test.ts b/test/main/services/team/TaskBoundaryParser.test.ts index e60b7cb1..7d179d0e 100644 --- a/test/main/services/team/TaskBoundaryParser.test.ts +++ b/test/main/services/team/TaskBoundaryParser.test.ts @@ -64,6 +64,65 @@ describe('TaskBoundaryParser', () => { expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true); }); + it('dedupes concurrent boundary parsing and invalidates when the file changes', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); + const jsonlPath = path.join(tmpDir, 'concurrent.jsonl'); + await fs.writeFile( + jsonlPath, + JSON.stringify({ + timestamp: '2026-03-01T10:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-1', + name: 'task_start', + input: { taskId: 'task-123' }, + }, + ], + }, + }) + '\n', + 'utf8' + ); + + const parser = new TaskBoundaryParser(); + const [first, second, third] = await Promise.all([ + parser.parseBoundaries(jsonlPath), + parser.parseBoundaries(jsonlPath), + parser.parseBoundaries(jsonlPath), + ]); + + expect(first.boundaries).toHaveLength(1); + expect(second).toEqual(first); + expect(third).toEqual(first); + + await new Promise((resolve) => setTimeout(resolve, 20)); + await fs.appendFile( + jsonlPath, + JSON.stringify({ + timestamp: '2026-03-01T10:10:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-2', + name: 'task_complete', + input: { taskId: 'task-123' }, + }, + ], + }, + }) + '\n', + 'utf8' + ); + + const afterChange = await parser.parseBoundaries(jsonlPath); + expect(afterChange.boundaries).toHaveLength(2); + }); + it('detects fully-qualified agent-teams MCP task boundaries', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); const jsonlPath = path.join(tmpDir, 'mcp-qualified.jsonl'); diff --git a/test/main/services/team/TaskChangeComputer.test.ts b/test/main/services/team/TaskChangeComputer.test.ts new file mode 100644 index 00000000..c0cd50fe --- /dev/null +++ b/test/main/services/team/TaskChangeComputer.test.ts @@ -0,0 +1,93 @@ +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import * as fs from 'fs/promises'; + +import { TaskChangeComputer } from '../../../../src/main/services/team/TaskChangeComputer'; + +async function writeJsonl(filePath: string, entries: object[]): Promise { + await fs.writeFile( + filePath, + entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', + 'utf8' + ); +} + +function writeToolUse(toolUseId: string, filePath: string, content: string): object { + return { + timestamp: '2026-03-01T10:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: toolUseId, + name: 'Write', + input: { file_path: filePath, content }, + }, + ], + }, + }; +} + +describe('TaskChangeComputer', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + it('shares concurrent JSONL parsing and invalidates when the file changes', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-')); + const logPath = path.join(tmpDir, 'agent.jsonl'); + await writeJsonl(logPath, [writeToolUse('tool-1', '/repo/src/a.ts', 'export const a = 1;\n')]); + + const logsFinder = { + findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'alice' }]), + }; + const boundaryParser = { + parseBoundaries: () => + Promise.resolve({ + boundaries: [], + scopes: [], + isSingleTaskSession: true, + detectedMechanism: 'none' as const, + }), + }; + const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never); + const input = { + teamName: 'team-a', + taskId: 'task-1', + taskMeta: null, + effectiveOptions: {}, + projectPath: '/repo', + includeDetails: false, + }; + + const [first, second] = await Promise.all([ + computer.computeTaskChanges(input), + computer.computeTaskChanges(input), + ]); + + expect(first.files.map((file) => file.relativePath)).toEqual(['src/a.ts']); + expect(second.files).toEqual(first.files); + + await new Promise((resolve) => setTimeout(resolve, 20)); + await writeJsonl(logPath, [ + writeToolUse('tool-1', '/repo/src/a.ts', 'export const a = 1;\n'), + writeToolUse('tool-2', '/repo/src/b.ts', 'export const b = 2;\n'), + ]); + + const afterChange = await computer.computeTaskChanges(input); + expect( + afterChange.files + .map((file) => file.relativePath) + .sort((left, right) => left.localeCompare(right)) + ).toEqual(['src/a.ts', 'src/b.ts']); + }); +}); diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 764ef3a0..199e91d5 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -109,6 +109,104 @@ describe('TeamMemberLogsFinder', () => { ]); }); + it('dedupes concurrent log source discovery for the same team', async () => { + const teamName = 'dedupe-context-team'; + let resolveContext!: (value: unknown) => void; + const contextPromise = new Promise((resolve) => { + resolveContext = resolve; + }); + const projectResolver = { + getContext: vi.fn(() => contextPromise), + getLiveBaseContext: vi.fn(), + }; + const inboxReader = { listInboxNames: vi.fn(async () => []) }; + const membersMetaStore = { getMembers: vi.fn(async () => []) }; + const finder = new TeamMemberLogsFinder( + undefined, + inboxReader as never, + membersMetaStore as never, + projectResolver as never + ); + + const first = finder.getLogSourceWatchContext(teamName); + const second = finder.getLogSourceWatchContext(teamName); + await Promise.resolve(); + + expect(projectResolver.getContext).toHaveBeenCalledTimes(1); + resolveContext({ + projectDir: '/tmp/project', + projectId: 'project', + sessionIds: ['session-1'], + config: { name: teamName, projectPath: '/repo', members: [] }, + }); + + await expect(Promise.all([first, second])).resolves.toEqual([ + { + projectDir: '/tmp/project', + projectPath: '/repo', + leadSessionId: undefined, + sessionIds: ['session-1'], + }, + { + projectDir: '/tmp/project', + projectPath: '/repo', + leadSessionId: undefined, + sessionIds: ['session-1'], + }, + ]); + + await finder.getLogSourceWatchContext(teamName); + expect(projectResolver.getContext).toHaveBeenCalledTimes(1); + }); + + it('honors forceRefresh after cached log source discovery', async () => { + const teamName = 'force-refresh-context-team'; + const contexts = [ + { + projectDir: '/tmp/project-old', + projectId: 'project-old', + sessionIds: ['old-session'], + config: { name: teamName, projectPath: '/repo-old', members: [] }, + }, + { + projectDir: '/tmp/project-new', + projectId: 'project-new', + sessionIds: ['new-session'], + config: { name: teamName, projectPath: '/repo-new', members: [] }, + }, + ]; + const projectResolver = { + getContext: vi.fn(async () => contexts.shift() ?? contexts[0]), + getLiveBaseContext: vi.fn(), + }; + const inboxReader = { listInboxNames: vi.fn(async () => []) }; + const membersMetaStore = { getMembers: vi.fn(async () => []) }; + const finder = new TeamMemberLogsFinder( + undefined, + inboxReader as never, + membersMetaStore as never, + projectResolver as never + ); + + await expect(finder.getLogSourceWatchContext(teamName)).resolves.toMatchObject({ + projectDir: '/tmp/project-old', + sessionIds: ['old-session'], + }); + await finder.getLogSourceWatchContext(teamName); + expect(projectResolver.getContext).toHaveBeenCalledTimes(1); + + await expect( + finder.getLogSourceWatchContext(teamName, { forceRefresh: true }) + ).resolves.toMatchObject({ + projectDir: '/tmp/project-new', + sessionIds: ['new-session'], + }); + expect(projectResolver.getContext).toHaveBeenCalledTimes(2); + + await finder.getLogSourceWatchContext(teamName); + expect(projectResolver.getContext).toHaveBeenCalledTimes(2); + }); + it('returns subagent logs for a member and lead session for team-lead', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); @@ -1535,6 +1633,97 @@ describe('TeamMemberLogsFinder', () => { expect(refs[0].filePath).toContain('agent-ref1.jsonl'); }); + it('indexes task mentions without changing matching semantics', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-index-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'mention-index-team'; + const projectPath = '/Users/test/mention-index-proj'; + const projectId = '-Users-test-mention-index-proj'; + const sessionId = 'six'; + const fullTaskId = 'abcdef12-1111-4222-8333-444444444444'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify({ + name: teamName, + projectPath, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'dev', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); + await fs.writeFile( + path.join(projectRoot, sessionId, 'subagents', 'agent-indexed.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { + role: 'user', + content: `You are dev, a developer on team "${teamName}" (${teamName}).`, + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'TaskGet', input: { taskId: 'ignored-task' } }, + { + type: 'tool_use', + name: 'TaskUpdate', + input: { taskId: fullTaskId.slice(0, 8), status: 'completed' }, + }, + { + type: 'tool_use', + name: 'TaskUpdate', + input: { taskId: 'wrong-team-task', teamName: 'other-team', status: 'completed' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, sessionId, 'subagents', 'agent-no-team.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:03.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'TaskUpdate', + input: { taskId: 'no-team-task', status: 'completed' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + + await expect(finder.findLogFileRefsForTask(teamName, fullTaskId)).resolves.toHaveLength(1); + await expect(finder.findLogFileRefsForTask(teamName, 'ignored-task')).resolves.toHaveLength(0); + await expect(finder.findLogFileRefsForTask(teamName, 'wrong-team-task')).resolves.toHaveLength( + 0 + ); + await expect(finder.findLogFileRefsForTask(teamName, 'no-team-task')).resolves.toHaveLength(0); + }); + it('findLogFileRefsForTask does not mix tasks across teams sharing a projectPath', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-cross-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/main/utils/cliPathMerge.test.ts b/test/main/utils/cliPathMerge.test.ts index a6dd8c8f..56a48ceb 100644 --- a/test/main/utils/cliPathMerge.test.ts +++ b/test/main/utils/cliPathMerge.test.ts @@ -16,6 +16,9 @@ vi.mock('@main/utils/pathDecoder', () => ({ describe('buildMergedCliPath', () => { let buildMergedCliPath: typeof import('@main/utils/cliPathMerge').buildMergedCliPath; const originalPlatform = process.platform; + const originalLocalAppData = process.env.LOCALAPPDATA; + const originalProgramFiles = process.env.ProgramFiles; + const originalPath = process.env.PATH; beforeEach(async () => { vi.resetModules(); @@ -28,6 +31,21 @@ describe('buildMergedCliPath', () => { afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + if (originalProgramFiles === undefined) { + delete process.env.ProgramFiles; + } else { + process.env.ProgramFiles = originalProgramFiles; + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } }); it('on darwin/linux with cold shell cache prepends standard user bin dirs before process PATH', () => { @@ -40,12 +58,24 @@ describe('buildMergedCliPath', () => { '/home/testuser/.local/bin', '/home/testuser/.npm-global/bin', '/home/testuser/.npm/bin', + '/home/testuser/.asdf/shims', + '/home/testuser/.local/share/mise/shims', + '/home/testuser/.volta/bin', + '/home/testuser/Library/pnpm', + '/home/testuser/.local/share/pnpm', + '/home/testuser/.cargo/bin', + '/home/testuser/.nix-profile/bin', '/usr/local/bin', '/opt/homebrew/bin', + '/opt/local/bin', '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', ]) ); expect(p.startsWith('/home/testuser/.claude/local/node_modules/.bin')).toBe(true); + expect(p.split(':').filter((part) => part === '/usr/bin')).toHaveLength(1); }); it('on win32 with cold shell cache uses semicolon and npm-style dirs', () => { @@ -57,6 +87,9 @@ describe('buildMergedCliPath', () => { const parts = p.split(';'); expect(parts.some((x) => /Roaming[/\\]npm/i.test(x))).toBe(true); expect(parts.some((x) => /Programs[/\\]claude/i.test(x))).toBe(true); + expect(parts.some((x) => /AppData[/\\]Local[/\\]pnpm/i.test(x))).toBe(true); + expect(parts.some((x) => /[.]volta[/\\]bin/i.test(x))).toBe(true); + expect(parts.some((x) => /Program Files[/\\]nodejs/i.test(x))).toBe(true); expect(parts[parts.length - 1]).toBe('/usr/bin'); }); diff --git a/test/main/utils/shellEnv.integration.test.ts b/test/main/utils/shellEnv.integration.test.ts new file mode 100644 index 00000000..183b9500 --- /dev/null +++ b/test/main/utils/shellEnv.integration.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment node +import { chmod, mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearShellEnvCache, + getCachedShellEnv, + resolveInteractiveShellEnv, + resolveInteractiveShellEnvBestEffort, +} from '@main/utils/shellEnv'; + +const describePosix = process.platform === 'win32' ? describe.skip : describe; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForCachedEnv(timeoutMs = 2_000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const cached = getCachedShellEnv(); + if (cached) { + return cached; + } + await sleep(25); + } + return getCachedShellEnv(); +} + +async function createFakeShell(tempDir: string, name: string, source: string): Promise { + const shellPath = path.join(tempDir, name); + await writeFile(shellPath, `#!/usr/bin/env node\n${source}\n`, 'utf8'); + await chmod(shellPath, 0o755); + return shellPath; +} + +function envWriterSource(envExpression: string): string { + return ` +function writeEnv(env) { + process.stdout.write(Object.entries(env).map(([key, value]) => key + '=' + value).join('\\0') + '\\0'); +} +${envExpression} +`; +} + +describePosix('shellEnv real child-process integration', () => { + const originalShell = process.env.SHELL; + const originalInvocationFile = process.env.FAKE_SHELL_INVOCATIONS; + let tempDir = ''; + + beforeEach(async () => { + clearShellEnvCache(); + tempDir = await mkdtemp(path.join(tmpdir(), 'agent-teams-shell-env-')); + delete process.env.FAKE_SHELL_INVOCATIONS; + }); + + afterEach(async () => { + clearShellEnvCache(); + if (originalShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = originalShell; + } + if (originalInvocationFile === undefined) { + delete process.env.FAKE_SHELL_INVOCATIONS; + } else { + process.env.FAKE_SHELL_INVOCATIONS = originalInvocationFile; + } + await rm(tempDir, { recursive: true, force: true }); + }); + + it('returns a real shell env from an executable shell path before best-effort timeout', async () => { + const fakeShell = await createFakeShell( + tempDir, + 'fast-shell.js', + envWriterSource(` +writeEnv({ + PATH: '/fake-fast/bin:/usr/bin', + HOME: '/fake-home', + SHELL: process.argv[1], +}); +`) + ); + process.env.SHELL = fakeShell; + + const env = await resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1_000, + fallbackEnv: { PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' }, + }); + + expect(env).toMatchObject({ + PATH: '/fake-fast/bin:/usr/bin', + HOME: '/fake-home', + SHELL: fakeShell, + }); + expect(getCachedShellEnv()).toMatchObject({ + PATH: '/fake-fast/bin:/usr/bin', + HOME: '/fake-home', + }); + }); + + it('returns fallback quickly while a slow shell warms the cache in the background', async () => { + const fakeShell = await createFakeShell( + tempDir, + 'slow-shell.js', + envWriterSource(` +setTimeout(() => { + writeEnv({ + PATH: '/slow-real/bin:/usr/bin', + HOME: '/slow-home', + }); +}, 200); +`) + ); + process.env.SHELL = fakeShell; + + const startedAt = Date.now(); + const env = await resolveInteractiveShellEnvBestEffort({ + timeoutMs: 25, + fallbackEnv: { PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' }, + }); + const elapsedMs = Date.now() - startedAt; + + expect(elapsedMs).toBeLessThan(150); + expect(env).toMatchObject({ PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' }); + expect(getCachedShellEnv()).toBeNull(); + + await expect(waitForCachedEnv()).resolves.toMatchObject({ + PATH: '/slow-real/bin:/usr/bin', + HOME: '/slow-home', + }); + }); + + it('falls back from a failed login shell process to a successful interactive shell process', async () => { + const fakeShell = await createFakeShell( + tempDir, + 'login-fails-shell.js', + envWriterSource(` +if ((process.argv[2] || '').includes('l')) { + process.exit(42); +} +writeEnv({ + PATH: '/interactive-real/bin:/usr/bin', + HOME: '/interactive-home', +}); +`) + ); + process.env.SHELL = fakeShell; + + await expect(resolveInteractiveShellEnv()).resolves.toMatchObject({ + PATH: '/interactive-real/bin:/usr/bin', + HOME: '/interactive-home', + }); + expect(console.warn).toHaveBeenCalledWith( + '[Utils:shellEnv]', + 'Failed to resolve login shell env: shell env command exited with code 42' + ); + vi.mocked(console.warn).mockClear(); + expect(getCachedShellEnv()).toMatchObject({ + PATH: '/interactive-real/bin:/usr/bin', + HOME: '/interactive-home', + }); + }); + + it('coalesces concurrent best-effort calls into one real shell process', async () => { + const invocationFile = path.join(tempDir, 'invocations.log'); + process.env.FAKE_SHELL_INVOCATIONS = invocationFile; + const fakeShell = await createFakeShell( + tempDir, + 'coalesced-shell.js', + envWriterSource(` +const fs = require('fs'); +fs.appendFileSync(process.env.FAKE_SHELL_INVOCATIONS, 'spawned\\n'); +setTimeout(() => { + writeEnv({ + PATH: '/coalesced-real/bin:/usr/bin', + HOME: '/coalesced-home', + }); +}, 200); +`) + ); + process.env.SHELL = fakeShell; + + const results = await Promise.all( + Array.from({ length: 10 }, async (_, index) => + resolveInteractiveShellEnvBestEffort({ + timeoutMs: 25, + fallbackEnv: { PATH: `FALLBACK_${index}`, HOME: `FALLBACK_HOME_${index}` }, + }) + ) + ); + + expect(results).toHaveLength(10); + expect(results.every((env, index) => env.PATH === `FALLBACK_${index}`)).toBe(true); + await expect(waitForCachedEnv()).resolves.toMatchObject({ + PATH: '/coalesced-real/bin:/usr/bin', + HOME: '/coalesced-home', + }); + + const invocations = await readFile(invocationFile, 'utf8'); + expect(invocations.trim().split('\n')).toHaveLength(1); + }); +}); diff --git a/test/main/utils/shellEnv.test.ts b/test/main/utils/shellEnv.test.ts new file mode 100644 index 00000000..35aa053c --- /dev/null +++ b/test/main/utils/shellEnv.test.ts @@ -0,0 +1,549 @@ +// @vitest-environment node +import { EventEmitter } from 'events'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + spawn: vi.fn(), + loggerWarn: vi.fn(), +})); + +vi.mock('child_process', () => ({ + spawn: hoisted.spawn, +})); + +vi.mock('@main/utils/pathDecoder', () => ({ + getHomeDir: () => '/Users/tester', +})); + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + warn: hoisted.loggerWarn, + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }), +})); + +class MockChildProcess extends EventEmitter { + stdout = new EventEmitter(); + kill = vi.fn(); +} + +function createChild(): MockChildProcess { + return new MockChildProcess(); +} + +function emitEnv(child: MockChildProcess, env: Record): void { + const dump = `${Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join('\0')}\0`; + child.stdout.emit('data', Buffer.from(dump)); + child.emit('close', 0); +} + +function emitEnvChunks(child: MockChildProcess, chunks: string[]): void { + for (const chunk of chunks) { + child.stdout.emit('data', Buffer.from(chunk)); + } + child.emit('close', 0); +} + +function emitError(child: MockChildProcess, message: string): void { + child.emit('error', new Error(message)); +} + +function emitClose(child: MockChildProcess, code: number | null, signal: NodeJS.Signals | null): void { + child.emit('close', code, signal); +} + +async function importShellEnv(): Promise { + return import('@main/utils/shellEnv'); +} + +describe('shellEnv', () => { + const originalPlatform = process.platform; + const originalShell = process.env.SHELL; + + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + vi.clearAllMocks(); + process.env.SHELL = '/bin/zsh'; + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + writable: true, + }); + if (originalShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = originalShell; + } + }); + + it('keeps the strict resolver login then interactive fallback order', async () => { + const children: MockChildProcess[] = []; + hoisted.spawn.mockImplementation(() => { + const child = createChild(); + children.push(child); + queueMicrotask(() => { + if (children.length === 1) { + emitError(child, 'login failed'); + } else { + emitEnv(child, { PATH: '/interactive/bin', HOME: '/Users/tester' }); + } + }); + return child; + }); + + const shellEnv = await importShellEnv(); + + await expect(shellEnv.resolveInteractiveShellEnv()).resolves.toMatchObject({ + PATH: '/interactive/bin', + HOME: '/Users/tester', + }); + expect(hoisted.spawn).toHaveBeenCalledTimes(2); + expect(hoisted.spawn).toHaveBeenNthCalledWith( + 1, + '/bin/zsh', + ['-lic', 'env -0'], + expect.any(Object) + ); + expect(hoisted.spawn).toHaveBeenNthCalledWith( + 2, + '/bin/zsh', + ['-ic', 'env -0'], + expect.any(Object) + ); + }); + + it('returns fallback on soft timeout without caching it, then caches background success', async () => { + const children: MockChildProcess[] = []; + hoisted.spawn.mockImplementation(() => { + const child = createChild(); + children.push(child); + return child; + }); + + const shellEnv = await importShellEnv(); + const fallbackEnv = { PATH: '/fallback/bin', HOME: '/fallback' }; + const result = shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 10, + fallbackEnv, + }); + + await vi.advanceTimersByTimeAsync(10); + await expect(result).resolves.toBe(fallbackEnv); + expect(shellEnv.getCachedShellEnv()).toBeNull(); + expect(hoisted.spawn).toHaveBeenCalledTimes(1); + + emitEnv(children[0], { PATH: '/real/bin', HOME: '/Users/tester' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(shellEnv.getCachedShellEnv()).toMatchObject({ + PATH: '/real/bin', + HOME: '/Users/tester', + }); + }); + + it('returns real env when shell resolves before the soft timeout', async () => { + const child = createChild(); + hoisted.spawn.mockReturnValueOnce(child); + + const shellEnv = await importShellEnv(); + const result = shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 100, + fallbackEnv: { PATH: '/fallback/should-not-win' }, + }); + + emitEnv(child, { PATH: '/fast/bin', HOME: '/Users/tester' }); + + await expect(result).resolves.toMatchObject({ + PATH: '/fast/bin', + HOME: '/Users/tester', + }); + expect(shellEnv.getCachedShellEnv()).toMatchObject({ + PATH: '/fast/bin', + HOME: '/Users/tester', + }); + }); + + it('does not let a soft fallback override getShellPreferredHome before cache warms', async () => { + const child = createChild(); + hoisted.spawn.mockReturnValueOnce(child); + + const shellEnv = await importShellEnv(); + const result = shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 5, + fallbackEnv: { PATH: '/fallback/bin', HOME: '/fallback-home' }, + }); + + await vi.advanceTimersByTimeAsync(5); + await expect(result).resolves.toMatchObject({ HOME: '/fallback-home' }); + expect(shellEnv.getCachedShellEnv()).toBeNull(); + expect(shellEnv.getShellPreferredHome()).toBe('/Users/tester'); + + emitEnv(child, { PATH: '/real/bin', HOME: '/real-home' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(shellEnv.getShellPreferredHome()).toBe('/real-home'); + }); + + it('parses chunked env output and ignores malformed records', async () => { + const child = createChild(); + hoisted.spawn.mockReturnValueOnce(child); + + const shellEnv = await importShellEnv(); + const result = shellEnv.resolveInteractiveShellEnv(); + + emitEnvChunks(child, [ + 'PATH=/chunk', + 'ed/bin\0', + 'MALFORMED\0', + '=bad\0', + 'EMPTY=\0', + 'HOME=/Users/tester\0', + ]); + + await expect(result).resolves.toMatchObject({ + PATH: '/chunked/bin', + EMPTY: '', + HOME: '/Users/tester', + }); + expect(shellEnv.getCachedShellEnv()).toMatchObject({ + PATH: '/chunked/bin', + HOME: '/Users/tester', + }); + }); + + it('starts background resolution even with a zero soft timeout', async () => { + const children: MockChildProcess[] = []; + hoisted.spawn.mockImplementation(() => { + const child = createChild(); + children.push(child); + return child; + }); + + const shellEnv = await importShellEnv(); + const fallbackEnv = { PATH: '/fallback/zero' }; + + await expect( + shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 0, + fallbackEnv, + }) + ).resolves.toBe(fallbackEnv); + + expect(hoisted.spawn).toHaveBeenCalledTimes(1); + expect(shellEnv.getCachedShellEnv()).toBeNull(); + + emitEnv(children[0], { PATH: '/real/zero', HOME: '/Users/tester' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(shellEnv.getCachedShellEnv()).toMatchObject({ + PATH: '/real/zero', + HOME: '/Users/tester', + }); + }); + + it('keeps resolving in the background through the strict interactive fallback', async () => { + const children: MockChildProcess[] = []; + hoisted.spawn.mockImplementation(() => { + const child = createChild(); + children.push(child); + return child; + }); + + const shellEnv = await importShellEnv(); + const fallbackEnv = { PATH: '/fallback/login-timeout' }; + const result = shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 10, + fallbackEnv, + }); + + await vi.advanceTimersByTimeAsync(10); + await expect(result).resolves.toBe(fallbackEnv); + expect(children).toHaveLength(1); + + emitError(children[0], 'login failed'); + await Promise.resolve(); + await Promise.resolve(); + expect(children).toHaveLength(2); + + emitEnv(children[1], { PATH: '/interactive/bin', HOME: '/Users/tester' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(shellEnv.getCachedShellEnv()).toMatchObject({ + PATH: '/interactive/bin', + HOME: '/Users/tester', + }); + }); + + it('treats non-zero shell exit with no env output as a failed probe', async () => { + const children: MockChildProcess[] = []; + hoisted.spawn.mockImplementation(() => { + const child = createChild(); + children.push(child); + queueMicrotask(() => { + if (children.length === 1) { + emitClose(child, 42, null); + } else { + emitEnv(child, { PATH: '/interactive-after-exit/bin', HOME: '/Users/tester' }); + } + }); + return child; + }); + + const shellEnv = await importShellEnv(); + + await expect(shellEnv.resolveInteractiveShellEnv()).resolves.toMatchObject({ + PATH: '/interactive-after-exit/bin', + HOME: '/Users/tester', + }); + expect(hoisted.spawn).toHaveBeenCalledTimes(2); + expect(hoisted.loggerWarn).toHaveBeenCalledWith( + 'Failed to resolve login shell env: shell env command exited with code 42' + ); + }); + + it('coalesces concurrent best-effort calls behind one shell process', async () => { + const children: MockChildProcess[] = []; + hoisted.spawn.mockImplementation(() => { + const child = createChild(); + children.push(child); + return child; + }); + + const shellEnv = await importShellEnv(); + const firstFallback = { PATH: '/fallback/one' }; + const secondFallback = { PATH: '/fallback/two' }; + + const first = shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 5, + fallbackEnv: firstFallback, + }); + const second = shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 5, + fallbackEnv: secondFallback, + }); + + await vi.advanceTimersByTimeAsync(5); + + await expect(first).resolves.toBe(firstFallback); + await expect(second).resolves.toBe(secondFallback); + expect(hoisted.spawn).toHaveBeenCalledTimes(1); + + emitEnv(children[0], { PATH: '/real/bin' }); + await Promise.resolve(); + await Promise.resolve(); + }); + + it('uses failure cooldown after a hard shell failure and avoids respawning immediately', async () => { + hoisted.spawn.mockImplementation(() => { + const child = createChild(); + const callNumber = hoisted.spawn.mock.calls.length; + queueMicrotask(() => emitError(child, `failure ${callNumber}`)); + return child; + }); + + const shellEnv = await importShellEnv(); + const firstFallback = { PATH: '/fallback/first' }; + const secondFallback = { PATH: '/fallback/second' }; + + await expect( + shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1_000, + fallbackEnv: firstFallback, + }) + ).resolves.toBe(firstFallback); + expect(hoisted.spawn).toHaveBeenCalledTimes(2); + + await expect( + shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1_000, + fallbackEnv: secondFallback, + }) + ).resolves.toBe(secondFallback); + expect(hoisted.spawn).toHaveBeenCalledTimes(2); + }); + + it('expires failure cooldown so a later best-effort call can retry shell resolution', async () => { + hoisted.spawn.mockImplementation(() => { + const child = createChild(); + queueMicrotask(() => emitError(child, 'blocked')); + return child; + }); + + const shellEnv = await importShellEnv(); + + await expect( + shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1_000, + fallbackEnv: { PATH: '/fallback/first' }, + }) + ).resolves.toMatchObject({ PATH: '/fallback/first' }); + expect(hoisted.spawn).toHaveBeenCalledTimes(2); + + await expect( + shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1_000, + fallbackEnv: { PATH: '/fallback/cooldown' }, + }) + ).resolves.toMatchObject({ PATH: '/fallback/cooldown' }); + expect(hoisted.spawn).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(60_001); + + await expect( + shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1_000, + fallbackEnv: { PATH: '/fallback/retry' }, + }) + ).resolves.toMatchObject({ PATH: '/fallback/retry' }); + expect(hoisted.spawn).toHaveBeenCalledTimes(4); + }); + + it('terminates stuck login and interactive shell probes before returning fallback', async () => { + const children: MockChildProcess[] = []; + hoisted.spawn.mockImplementation(() => { + const child = createChild(); + children.push(child); + return child; + }); + + const shellEnv = await importShellEnv(); + const result = shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 30_000, + fallbackEnv: { PATH: '/fallback/stuck' }, + }); + + await vi.advanceTimersByTimeAsync(12_000); + await Promise.resolve(); + await Promise.resolve(); + + expect(children).toHaveLength(2); + expect(children[0].kill).toHaveBeenCalledWith(); + + await vi.advanceTimersByTimeAsync(12_000); + + await expect(result).resolves.toMatchObject({ PATH: '/fallback/stuck' }); + expect(children[1].kill).toHaveBeenCalledWith(); + expect(shellEnv.getCachedShellEnv()).toBeNull(); + + await vi.advanceTimersByTimeAsync(3_000); + + expect(children[0].kill).toHaveBeenCalledWith('SIGKILL'); + expect(children[1].kill).toHaveBeenCalledWith('SIGKILL'); + }); + + it('clears failure cooldown when the shell env cache is cleared', async () => { + let fail = true; + hoisted.spawn.mockImplementation(() => { + const child = createChild(); + queueMicrotask(() => { + if (fail) { + emitError(child, 'blocked'); + } else { + emitEnv(child, { PATH: '/recovered/bin', HOME: '/Users/tester' }); + } + }); + return child; + }); + + const shellEnv = await importShellEnv(); + + await expect( + shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1_000, + fallbackEnv: { PATH: '/fallback/blocked' }, + }) + ).resolves.toMatchObject({ PATH: '/fallback/blocked' }); + expect(hoisted.spawn).toHaveBeenCalledTimes(2); + + fail = false; + shellEnv.clearShellEnvCache(); + + await expect( + shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1_000, + fallbackEnv: { PATH: '/fallback/recovered' }, + }) + ).resolves.toMatchObject({ + PATH: '/recovered/bin', + HOME: '/Users/tester', + }); + expect(hoisted.spawn).toHaveBeenCalledTimes(3); + }); + + it('uses cached shell env immediately without spawning or returning fallback', async () => { + const firstChild = createChild(); + hoisted.spawn.mockReturnValueOnce(firstChild); + + const shellEnv = await importShellEnv(); + const strictResult = shellEnv.resolveInteractiveShellEnv(); + emitEnv(firstChild, { PATH: '/cached/bin', HOME: '/Users/tester' }); + await expect(strictResult).resolves.toMatchObject({ PATH: '/cached/bin' }); + + hoisted.spawn.mockClear(); + await expect( + shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1, + fallbackEnv: { PATH: '/fallback/should-not-win' }, + }) + ).resolves.toMatchObject({ + PATH: '/cached/bin', + HOME: '/Users/tester', + }); + expect(hoisted.spawn).not.toHaveBeenCalled(); + }); + + it('strict resolver also returns cached shell env without spawning again', async () => { + const firstChild = createChild(); + hoisted.spawn.mockReturnValueOnce(firstChild); + + const shellEnv = await importShellEnv(); + const first = shellEnv.resolveInteractiveShellEnv(); + emitEnv(firstChild, { PATH: '/strict-cached/bin', HOME: '/Users/tester' }); + await expect(first).resolves.toMatchObject({ PATH: '/strict-cached/bin' }); + + hoisted.spawn.mockClear(); + await expect(shellEnv.resolveInteractiveShellEnv()).resolves.toMatchObject({ + PATH: '/strict-cached/bin', + HOME: '/Users/tester', + }); + expect(hoisted.spawn).not.toHaveBeenCalled(); + }); + + it('best-effort on win32 preserves the strict no-spawn behavior', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + writable: true, + }); + + const shellEnv = await importShellEnv(); + + await expect( + shellEnv.resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1, + fallbackEnv: { PATH: '/fallback/win32' }, + }) + ).resolves.toEqual({}); + expect(hoisted.spawn).not.toHaveBeenCalled(); + expect(shellEnv.getCachedShellEnv()).toEqual({}); + }); +}); diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 7b7643ea..661985b9 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -24,11 +24,17 @@ interface StoreState { cliInstallerDetail: string | null; cliInstallerRawChunks: string[]; cliCompletedVersion: string | null; + openCodeRuntimeStatus: Record | null; + openCodeRuntimeStatusLoading: boolean; + openCodeRuntimeError: string | null; bootstrapCliStatus: ReturnType; fetchCliStatus: ReturnType; fetchCliProviderStatus: ReturnType; invalidateCliStatus: ReturnType; installCli: ReturnType; + fetchOpenCodeRuntimeStatus: ReturnType; + installOpenCodeRuntime: ReturnType; + invalidateOpenCodeRuntimeStatus: ReturnType; appConfig: { general: { multimodelEnabled: boolean; @@ -319,11 +325,17 @@ describe('CLI status visibility during completed install state', () => { storeState.cliInstallerDetail = null; storeState.cliInstallerRawChunks = []; storeState.cliCompletedVersion = '2.1.100'; + storeState.openCodeRuntimeStatus = null; + storeState.openCodeRuntimeStatusLoading = false; + storeState.openCodeRuntimeError = null; storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined); storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined); storeState.fetchCliProviderStatus = vi.fn().mockResolvedValue(undefined); storeState.invalidateCliStatus = vi.fn().mockResolvedValue(undefined); storeState.installCli = vi.fn(); + storeState.fetchOpenCodeRuntimeStatus = vi.fn().mockResolvedValue(undefined); + storeState.installOpenCodeRuntime = vi.fn().mockResolvedValue(undefined); + storeState.invalidateOpenCodeRuntimeStatus = vi.fn().mockResolvedValue(undefined); storeState.appConfig = { general: { multimodelEnabled: true, @@ -1011,9 +1023,9 @@ describe('CLI status visibility during completed install state', () => { expect(host.textContent).toContain('Providers: 1/1 connected'); expect(host.textContent).toContain('Anthropic'); - const collapseButton = host.querySelector( + const collapseButton = host.querySelector( 'button[aria-label="Collapse provider details"]' - ) as HTMLButtonElement | null; + ); expect(collapseButton).not.toBeNull(); await act(async () => { @@ -1106,9 +1118,9 @@ describe('CLI status visibility during completed install state', () => { await Promise.resolve(); }); - const collapseButton = firstHost.querySelector( + const collapseButton = firstHost.querySelector( 'button[aria-label="Collapse provider details"]' - ) as HTMLButtonElement | null; + ); expect(collapseButton).not.toBeNull(); await act(async () => { diff --git a/test/renderer/components/runtime/ProviderModelBadges.test.tsx b/test/renderer/components/runtime/ProviderModelBadges.test.tsx index 373ef52e..95960bc0 100644 --- a/test/renderer/components/runtime/ProviderModelBadges.test.tsx +++ b/test/renderer/components/runtime/ProviderModelBadges.test.tsx @@ -62,6 +62,53 @@ describe('ProviderModelBadges', () => { expect(host.textContent).toContain('Check failed'); }); + it('renders catalog badges from verbose provider metadata', () => { + const host = render( + + ); + + expect(host.textContent).toContain('big-pickle'); + expect(host.textContent).toContain('Free'); + }); + it('collapses long model lists and expands them into a bounded scroll area', () => { const models = Array.from( { length: 18 }, diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 3114124d..68b970ee 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -292,6 +292,11 @@ describe('TeamModelSelector disabled Codex models', () => { expect(host.textContent).toContain('Unavailable in OpenCode'); expect(host.textContent).toContain('openai/gpt-oss-20b:free'); expect(host.textContent).toContain('Not recommended'); + const groupLabels = Array.from( + host.querySelectorAll('[data-testid="team-model-selector-opencode-group"] h4') + ).map((heading) => heading.textContent ?? ''); + expect(groupLabels).toContain('OpenCode'); + expect(groupLabels).toContain('OpenRouter'); const buttonTexts = Array.from(host.querySelectorAll('button')).map( (button) => button.textContent ?? '' @@ -579,16 +584,16 @@ describe('TeamModelSelector disabled Codex models', () => { await Promise.resolve(); }); - const modelGrid = host.querySelector( + const modelGrid = host.querySelector( '[data-testid="team-model-selector-model-grid"]' - ) as HTMLElement | null; + ); expect(modelGrid).toBeTruthy(); expect(modelGrid?.style.maxHeight).toBe('400px'); expect(modelGrid?.className).toContain('overflow-y-auto'); - const searchInput = host.querySelector( + const searchInput = host.querySelector( '[data-testid="team-model-selector-model-search"]' - ) as HTMLInputElement | null; + ); expect(searchInput).toBeTruthy(); await act(async () => { @@ -1318,7 +1323,7 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); - it('renders OpenCode source badges and keeps raw model ids on selection', async () => { + it('renders OpenCode source groups and keeps raw model ids on selection', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ @@ -1359,7 +1364,7 @@ describe('TeamModelSelector disabled Codex models', () => { expect(host.textContent).toContain('OpenRouter'); const openRouterButton = Array.from(host.querySelectorAll('button')).find((button) => - button.textContent?.includes('OpenRouter') + button.textContent?.includes('moonshotai/kimi-k2') ); expect(openRouterButton).toBeTruthy(); @@ -1376,4 +1381,70 @@ describe('TeamModelSelector disabled Codex models', () => { await Promise.resolve(); }); }); + + it('filters OpenCode model groups by selected source providers', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: true, + detailMessage: null, + statusMessage: null, + capabilities: { + teamLaunch: true, + }, + models: ['openai/gpt-5.4', 'openrouter/moonshotai/kimi-k2', 'opencode/big-pickle'], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'opencode', + onProviderChange: () => undefined, + value: '', + onValueChange: () => undefined, + }) + ); + await Promise.resolve(); + }); + + const filterButton = host.querySelector( + '[data-testid="team-model-selector-opencode-provider-filter"]' + ); + expect(filterButton).toBeTruthy(); + + await act(async () => { + filterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + const openRouterCheckbox = document.body.querySelector( + '[aria-label="Filter OpenRouter"]' + ); + expect(openRouterCheckbox).toBeTruthy(); + + await act(async () => { + openRouterCheckbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('moonshotai/kimi-k2'); + expect(host.textContent).toContain('OpenRouter'); + expect(host.textContent).not.toContain('GPT-5.4'); + expect(host.textContent).not.toContain('OpenAI'); + expect(host.textContent).not.toContain('big-pickle'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/features/agent-graph/GraphActivityHud.test.ts b/test/renderer/features/agent-graph/GraphActivityHud.test.ts index dd04d705..2e0e8767 100644 --- a/test/renderer/features/agent-graph/GraphActivityHud.test.ts +++ b/test/renderer/features/agent-graph/GraphActivityHud.test.ts @@ -16,10 +16,7 @@ const teamState = { ], tasks: [], }, - teamDataCacheByName: new Map< - string, - { members: Record[]; tasks: unknown[] } - >([ + teamDataCacheByName: new Map[]; tasks: unknown[] }>([ [ 'demo-team', { @@ -106,7 +103,10 @@ describe('GraphActivityHud', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); buildInlineActivityEntries.mockReset(); - vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1) + ); vi.stubGlobal('cancelAnimationFrame', vi.fn()); Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, @@ -124,6 +124,7 @@ describe('GraphActivityHud', () => { afterEach(() => { document.body.innerHTML = ''; + vi.useRealTimers(); vi.unstubAllGlobals(); if (originalOffsetWidthDescriptor) { Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidthDescriptor); @@ -356,4 +357,138 @@ describe('GraphActivityHud', () => { await Promise.resolve(); }); }); + + it('briefly highlights newly appeared activity cards', async () => { + vi.useFakeTimers(); + + const firstMessage: InboxMessage = { + from: 'team-lead', + to: 'jack', + text: 'Initial activity', + summary: 'Initial activity', + timestamp: '2026-04-13T13:36:00.000Z', + read: false, + messageId: 'msg-initial', + }; + const newMessage: InboxMessage = { + from: 'team-lead', + to: 'jack', + text: 'New activity', + summary: 'New activity', + timestamp: '2026-04-13T13:37:00.000Z', + read: false, + messageId: 'msg-new', + }; + const buildEntries = (items: { id: string; message: InboxMessage }[]): Map => + new Map([ + [ + 'member:demo-team:jack', + items.map(({ id, message }) => ({ + ownerNodeId: 'member:demo-team:jack', + graphItem: { + id, + kind: 'inbox_message', + timestamp: message.timestamp, + title: message.summary ?? '', + }, + message, + })), + ], + ]); + + buildInlineActivityEntries.mockReturnValue( + buildEntries([{ id: 'item-initial', message: firstMessage }]) + ); + + const baseNode: GraphNode = { + id: 'member:demo-team:jack', + kind: 'member', + label: 'jack', + state: 'active', + domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'jack' }, + activityItems: [ + { + id: 'item-initial', + kind: 'inbox_message', + timestamp: firstMessage.timestamp, + title: 'Initial activity', + }, + ], + activityOverflowCount: 0, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const renderHud = (node: GraphNode): void => { + root.render( + React.createElement(GraphActivityHud, { + teamName: 'demo-team', + nodes: [node], + getActivityWorldRect: () => ({ + left: 40, + top: 80, + right: 336, + bottom: 372, + width: 296, + height: 292, + }), + getCameraZoom: () => 1, + worldToScreen: (x: number, y: number) => ({ x, y }), + getNodeWorldPosition: () => ({ x: 120, y: 40 }), + getViewportSize: () => ({ width: 1200, height: 800 }), + focusNodeIds: null, + }) + ); + }; + + await act(async () => { + renderHud(baseNode); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-activity-entry-id="item-initial"]')?.className).not.toContain( + 'border-sky-300/70' + ); + + buildInlineActivityEntries.mockReturnValue( + buildEntries([ + { id: 'item-new', message: newMessage }, + { id: 'item-initial', message: firstMessage }, + ]) + ); + const updatedNode: GraphNode = { + ...baseNode, + activityItems: [ + { + id: 'item-new', + kind: 'inbox_message', + timestamp: newMessage.timestamp, + title: 'New activity', + }, + ...baseNode.activityItems!, + ], + }; + + await act(async () => { + renderHud(updatedNode); + await Promise.resolve(); + }); + + const newRow = host.querySelector('[data-activity-entry-id="item-new"]'); + expect(newRow?.className).toContain('border-sky-300/70'); + + await act(async () => { + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + }); + + expect(newRow?.className).not.toContain('border-sky-300/70'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + vi.useRealTimers(); + }); }); diff --git a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx index 302891cf..c197ef87 100644 --- a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx +++ b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx @@ -649,7 +649,7 @@ describe('GraphMemberLogPreviewHud', () => { }); }); - it('shows loading for empty previews while preserving unsupported provider text', async () => { + it('keeps loaded empty previews honest during background loading', async () => { const codexNode: GraphNode = { id: 'member:alpha-team:codex-dev', kind: 'member', @@ -724,8 +724,56 @@ describe('GraphMemberLogPreviewHud', () => { }); expect(host.textContent).toContain('Unsupported provider'); + expect(host.textContent).toContain('No recent logs'); + expect(host.textContent).not.toContain('Loading logs'); + + act(() => { + root.unmount(); + }); + }); + + it('shows loading only before a member preview has been loaded', async () => { + const quietNode: GraphNode = { + id: 'member:alpha-team:quiet-dev', + kind: 'member', + label: 'quiet-dev', + state: 'idle', + domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'quiet-dev' }, + }; + mockedLoading = true; + mockedPreviewsByMember = new Map(); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + ({ + left: 40, + top: 80, + right: 300, + bottom: 372, + width: 260, + height: 292, + })} + getCameraZoom={() => 1} + worldToScreen={(x, y) => ({ x, y })} + getViewportSize={() => ({ width: 1200, height: 800 })} + focusNodeIds={null} + /> + ); + await Promise.resolve(); + }); + expect(host.textContent).toContain('Loading logs'); expect(host.textContent).not.toContain('No recent logs'); + const loadingButton = host.querySelector('button[aria-busy="true"]'); + expect(loadingButton?.className).toContain('flex-1'); + expect(loadingButton?.querySelectorAll('.animate-pulse')).toHaveLength(3); act(() => { root.unmount(); diff --git a/test/renderer/features/agent-graph/kanbanLayout.test.ts b/test/renderer/features/agent-graph/kanbanLayout.test.ts index df4cfc4e..b721b06d 100644 --- a/test/renderer/features/agent-graph/kanbanLayout.test.ts +++ b/test/renderer/features/agent-graph/kanbanLayout.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; +import { + KANBAN_ZONE, + TASK_PILL, +} from '../../../../packages/agent-graph/src/constants/canvas-constants'; import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; import type { GraphNode } from '@claude-teams/agent-graph'; @@ -37,17 +41,17 @@ describe('KanbanLayoutEngine', () => { KanbanLayoutEngine.layout([lead, orphanTask], { unassignedTaskRect: { - left: -80, + left: -TASK_PILL.width / 2, top: 120, - right: 80, + right: TASK_PILL.width / 2, bottom: 540, - width: 160, + width: TASK_PILL.width, height: 420, }, }); expect(orphanTask.x).toBe(0); - expect(orphanTask.y).toBe(120); + expect(orphanTask.y).toBe(120 + KANBAN_ZONE.headerHeight); expect(KanbanLayoutEngine.zones.some((zone) => zone.ownerId === '__unassigned__')).toBe(true); }); }); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 407fea56..1cf1cd3a 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -11,6 +11,12 @@ vi.mock('@renderer/api', () => ({ install: vi.fn(), onProgress: vi.fn(() => vi.fn()), }, + openCodeRuntime: { + getStatus: vi.fn(), + install: vi.fn(), + invalidateStatus: vi.fn(), + onProgress: vi.fn(() => vi.fn()), + }, // Minimal stubs for other api methods referenced by store slices getProjects: vi.fn(() => Promise.resolve([])), getSessions: vi.fn(() => Promise.resolve([])), @@ -137,6 +143,9 @@ describe('cliInstallerSlice', () => { cliDownloadTotal: 0, cliInstallerError: null, cliCompletedVersion: null, + openCodeRuntimeStatus: null, + openCodeRuntimeStatusLoading: false, + openCodeRuntimeError: null, }); }); @@ -688,20 +697,22 @@ describe('cliInstallerSlice', () => { ], }; vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); - vi.mocked(api.cliInstaller.getProviderStatus).mockImplementation(async (providerId) => { + vi.mocked(api.cliInstaller.getProviderStatus).mockImplementation((providerId) => { if (providerId === 'opencode') { - return createMultimodelProvider({ - providerId: 'opencode', - displayName: 'OpenCode', - authenticated: true, - authMethod: 'opencode_managed', - statusMessage: null, - models: ['opencode/minimax-m2.5-free'], - canLoginFromUi: false, - backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, - }); + return Promise.resolve( + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + statusMessage: null, + models: ['opencode/minimax-m2.5-free'], + canLoginFromUi: false, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }) + ); } - throw new Error(`Unexpected provider status request for ${providerId}`); + return Promise.reject(new Error(`Unexpected provider status request for ${providerId}`)); }); await useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 2fb2bb1e..e1e543b9 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -1491,6 +1491,446 @@ describe('teamSlice actions', () => { expect(store.getState().selectedTeamData).toEqual(fullSnapshot); }); + it('does not let a late thin selectTeam snapshot clear members loaded by an earlier full refresh', async () => { + const store = createSliceStore(); + const thinRequest = createDeferredPromise>(); + const fullRequest = createDeferredPromise>(); + const thinSnapshot = createTeamSnapshot({ + config: { name: 'Thin Team' }, + members: [], + }); + const fullSnapshot = createTeamSnapshot({ + config: { name: 'Full Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }], + }); + + store.setState({ + teamByName: { + 'my-team': { + teamName: 'my-team', + displayName: 'My Team', + description: '', + memberCount: 1, + members: [{ name: 'alice', role: 'developer' }], + taskCount: 0, + lastActivity: null, + }, + }, + }); + hoisted.getData + .mockImplementationOnce(() => thinRequest.promise) + .mockImplementationOnce(() => fullRequest.promise); + + const selectPromise = store.getState().selectTeam('my-team'); + await flushMicrotasks(); + const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); + + fullRequest.resolve(fullSnapshot); + await fullPromise; + expect(store.getState().selectedTeamData).toEqual(fullSnapshot); + + thinRequest.resolve(thinSnapshot); + await selectPromise; + + expect(store.getState().selectedTeamData).toEqual(fullSnapshot); + expect(store.getState().teamDataCacheByName['my-team']).toEqual(fullSnapshot); + expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1); + }); + + it('preserves an earlier full refresh even when the cached baseline had the same member names', async () => { + const store = createSliceStore(); + const thinRequest = createDeferredPromise>(); + const fullRequest = createDeferredPromise>(); + const cachedSnapshot = createTeamSnapshot({ + config: { name: 'Cached Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }); + const thinSnapshot = createTeamSnapshot({ + config: { name: 'Thin Team' }, + members: [], + }); + const fullSnapshot = createTeamSnapshot({ + config: { name: 'Full Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }], + }); + + store.setState({ + teamByName: { + 'my-team': { + teamName: 'my-team', + displayName: 'My Team', + description: '', + memberCount: 1, + members: [{ name: 'alice', role: 'developer' }], + taskCount: 0, + lastActivity: null, + }, + }, + teamDataCacheByName: { + 'my-team': cachedSnapshot, + }, + }); + hoisted.getData + .mockImplementationOnce(() => thinRequest.promise) + .mockImplementationOnce(() => fullRequest.promise); + + const selectPromise = store.getState().selectTeam('my-team'); + await flushMicrotasks(); + const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); + + fullRequest.resolve(fullSnapshot); + await fullPromise; + expect(store.getState().selectedTeamData).toEqual(fullSnapshot); + + thinRequest.resolve(thinSnapshot); + await selectPromise; + + expect(store.getState().selectedTeamData).toEqual(fullSnapshot); + expect(store.getState().teamDataCacheByName['my-team']).toEqual(fullSnapshot); + }); + + it('does not let an empty selectTeam snapshot clear an already cached member roster', async () => { + const store = createSliceStore(); + const cachedSnapshot = createTeamSnapshot({ + config: { name: 'Cached Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }], + }); + const thinSnapshot = createTeamSnapshot({ + config: { name: 'Thin Team' }, + members: [], + }); + + store.setState({ + teamByName: { + 'my-team': { + teamName: 'my-team', + displayName: 'My Team', + description: '', + memberCount: 1, + members: [{ name: 'alice', role: 'developer' }], + taskCount: 0, + lastActivity: null, + }, + }, + teamDataCacheByName: { + 'my-team': cachedSnapshot, + }, + }); + hoisted.getData.mockResolvedValueOnce(thinSnapshot); + + await store.getState().selectTeam('my-team'); + + expect(store.getState().selectedTeamData).toEqual(cachedSnapshot); + expect(store.getState().teamDataCacheByName['my-team']).toEqual(cachedSnapshot); + expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1); + }); + + it('does not treat a lead-only selectTeam snapshot as a confirmed teammate roster', async () => { + const store = createSliceStore(); + const cachedSnapshot = createTeamSnapshot({ + config: { name: 'Cached Team' }, + members: [ + { name: 'team-lead', agentType: 'team-lead', currentTaskId: null }, + { name: 'alice', role: 'developer', currentTaskId: null }, + ], + }); + const leadOnlySnapshot = createTeamSnapshot({ + config: { name: 'Lead Only Thin Team' }, + members: [{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null }], + }); + + store.setState({ + teamByName: { + 'my-team': { + teamName: 'my-team', + displayName: 'My Team', + description: '', + memberCount: 1, + members: [{ name: 'alice', role: 'developer' }], + taskCount: 0, + lastActivity: null, + }, + }, + teamDataCacheByName: { + 'my-team': cachedSnapshot, + }, + }); + hoisted.getData.mockResolvedValueOnce(leadOnlySnapshot); + + await store.getState().selectTeam('my-team'); + + expect(store.getState().selectedTeamData).toEqual(cachedSnapshot); + expect( + selectResolvedMembersForTeamName(store.getState(), 'my-team').map((m) => m.name) + ).toEqual(['team-lead', 'alice']); + }); + + it('uses summary fallback instead of a stale cached roster when names no longer match', async () => { + const store = createSliceStore(); + const cachedSnapshot = createTeamSnapshot({ + config: { name: 'Cached Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }); + const emptySnapshot = createTeamSnapshot({ + config: { name: 'Thin Team' }, + members: [], + }); + + store.setState({ + teamByName: { + 'my-team': { + teamName: 'my-team', + displayName: 'My Team', + description: '', + memberCount: 1, + members: [{ name: 'bob', role: 'reviewer' }], + taskCount: 0, + lastActivity: null, + }, + }, + teamDataCacheByName: { + 'my-team': cachedSnapshot, + }, + }); + hoisted.getData.mockResolvedValueOnce(emptySnapshot); + + await store.getState().selectTeam('my-team'); + + expect(store.getState().selectedTeamData).toEqual(emptySnapshot); + expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toMatchObject([ + { name: 'bob', role: 'reviewer' }, + ]); + }); + + it('commits an empty selectTeam snapshot when the team summary is already solo', async () => { + const store = createSliceStore(); + const cachedSnapshot = createTeamSnapshot({ + config: { name: 'Cached Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }); + const soloSnapshot = createTeamSnapshot({ + config: { name: 'Solo Team' }, + members: [], + }); + + store.setState({ + teamByName: { + 'my-team': { + teamName: 'my-team', + displayName: 'Solo Team', + description: '', + memberCount: 0, + taskCount: 0, + lastActivity: null, + }, + }, + teamDataCacheByName: { + 'my-team': cachedSnapshot, + }, + }); + hoisted.getData.mockResolvedValueOnce(soloSnapshot); + + await store.getState().selectTeam('my-team'); + + expect(store.getState().selectedTeamData).toEqual(soloSnapshot); + expect(store.getState().teamDataCacheByName['my-team']).toEqual(soloSnapshot); + expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(0); + }); + + it('commits an empty cached-team selectTeam snapshot when no summary confirms teammates', async () => { + const store = createSliceStore(); + const cachedSnapshot = createTeamSnapshot({ + config: { name: 'Cached Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }); + const emptySnapshot = createTeamSnapshot({ + config: { name: 'Empty Team' }, + members: [], + }); + + store.setState({ + teamDataCacheByName: { + 'my-team': cachedSnapshot, + }, + teamByName: {}, + }); + hoisted.getData.mockResolvedValueOnce(emptySnapshot); + + await store.getState().selectTeam('my-team'); + + expect(store.getState().selectedTeamData).toEqual(emptySnapshot); + expect(store.getState().teamDataCacheByName['my-team']).toEqual(emptySnapshot); + }); + + it('does not preserve a cached roster from a summary count without member names', async () => { + const store = createSliceStore(); + const cachedSnapshot = createTeamSnapshot({ + config: { name: 'Cached Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }); + const emptySnapshot = createTeamSnapshot({ + config: { name: 'Empty Team' }, + members: [], + }); + + store.setState({ + teamDataCacheByName: { + 'my-team': cachedSnapshot, + }, + teamByName: { + 'my-team': { + teamName: 'my-team', + displayName: 'My Team', + description: '', + memberCount: 1, + taskCount: 0, + lastActivity: null, + }, + }, + }); + hoisted.getData.mockResolvedValueOnce(emptySnapshot); + + await store.getState().selectTeam('my-team'); + + expect(store.getState().selectedTeamData).toEqual(emptySnapshot); + expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(0); + }); + + it('commits a late selectTeam snapshot that explicitly marks members as removed', async () => { + const store = createSliceStore(); + const selectRequest = createDeferredPromise>(); + const activeSnapshot = createTeamSnapshot({ + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }); + const removedSnapshot = createTeamSnapshot({ + members: [ + { name: 'alice', role: 'developer', currentTaskId: null, removedAt: 1710000000000 }, + ], + }); + + hoisted.getData.mockImplementationOnce(() => selectRequest.promise); + + const selectPromise = store.getState().selectTeam('my-team'); + await flushMicrotasks(); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: activeSnapshot, + teamDataCacheByName: { + 'my-team': activeSnapshot, + }, + }); + + selectRequest.resolve(removedSnapshot); + await selectPromise; + + expect(store.getState().selectedTeamData).toEqual(removedSnapshot); + expect(store.getState().teamDataCacheByName['my-team']).toEqual(removedSnapshot); + }); + + it('still commits a late selectTeam snapshot when concurrent local state only changed tasks', async () => { + const store = createSliceStore(); + const selectRequest = createDeferredPromise>(); + const previousSnapshot = createTeamSnapshot({ + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + tasks: [{ id: 'task-1', subject: 'Old task', status: 'pending', owner: 'alice' }], + }); + const locallyPatchedSnapshot = createTeamSnapshot({ + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + tasks: [{ id: 'task-1', subject: 'Old task', status: 'pending', owner: 'alice' }], + }); + const incomingSnapshot = createTeamSnapshot({ + config: { name: 'Server Team' }, + members: [ + { name: 'alice', role: 'developer', currentTaskId: null }, + { name: 'bob', role: 'reviewer', currentTaskId: null }, + ], + tasks: [{ id: 'task-2', subject: 'Server task', status: 'pending', owner: 'bob' }], + }); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: previousSnapshot, + teamDataCacheByName: { + 'my-team': previousSnapshot, + }, + }); + hoisted.getData.mockImplementationOnce(() => selectRequest.promise); + + const selectPromise = store.getState().selectTeam('my-team'); + await flushMicrotasks(); + + store.setState({ + selectedTeamData: locallyPatchedSnapshot, + teamDataCacheByName: { + 'my-team': locallyPatchedSnapshot, + }, + }); + + selectRequest.resolve(incomingSnapshot); + await selectPromise; + + expect(store.getState().selectedTeamData).toMatchObject({ + config: { name: 'Server Team' }, + members: [{ name: 'alice' }, { name: 'bob' }], + }); + }); + + it('does not preserve a stale roster when concurrent local state only changed tasks', async () => { + const store = createSliceStore(); + const selectRequest = createDeferredPromise>(); + const previousSnapshot = createTeamSnapshot({ + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + tasks: [{ id: 'task-1', subject: 'Old task', status: 'pending', owner: 'alice' }], + }); + const locallyPatchedSnapshot = createTeamSnapshot({ + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + tasks: [{ id: 'task-1', subject: 'Locally changed task', status: 'pending', owner: 'alice' }], + }); + const leadOnlySnapshot = createTeamSnapshot({ + config: { name: 'Solo Team' }, + members: [{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null }], + tasks: [], + }); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: previousSnapshot, + teamDataCacheByName: { + 'my-team': previousSnapshot, + }, + teamByName: { + 'my-team': { + teamName: 'my-team', + displayName: 'Solo Team', + description: '', + memberCount: 0, + taskCount: 0, + lastActivity: null, + }, + }, + }); + hoisted.getData.mockImplementationOnce(() => selectRequest.promise); + + const selectPromise = store.getState().selectTeam('my-team'); + await flushMicrotasks(); + + store.setState({ + selectedTeamData: locallyPatchedSnapshot, + teamDataCacheByName: { + 'my-team': locallyPatchedSnapshot, + }, + }); + + selectRequest.resolve(leadOnlySnapshot); + await selectPromise; + + expect(store.getState().selectedTeamData).toEqual(leadOnlySnapshot); + expect( + selectResolvedMembersForTeamName(store.getState(), 'my-team').map((m) => m.name) + ).toEqual(['team-lead']); + }); + it('keeps one queued full refresh for repeated fanout while thin selectTeam is pending', async () => { vi.useFakeTimers(); stubAnimationFrameWithTimer(); @@ -2531,6 +2971,112 @@ describe('teamSlice actions', () => { }); }); + it('falls back to team summary roster when detail snapshot temporarily has no members', () => { + const store = createSliceStore(); + const partialSnapshot = createTeamSnapshot({ + config: { + name: 'My Team', + projectPath: '/repo', + }, + members: [], + tasks: [ + { + id: 'task-1', + subject: 'Build', + status: 'in_progress', + owner: 'alice', + }, + ], + }); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: partialSnapshot, + teamDataCacheByName: { + 'my-team': partialSnapshot, + }, + teamByName: { + 'my-team': { + teamName: 'my-team', + displayName: 'My Team', + description: '', + memberCount: 2, + taskCount: 1, + lastActivity: null, + leadName: 'team-lead', + leadColor: 'purple', + members: [ + { name: 'alice', role: 'developer', color: 'blue' }, + { name: 'bob', role: 'reviewer', color: 'green' }, + ], + }, + }, + memberActivityMetaByTeam: {}, + }); + + const members = selectResolvedMembersForTeamName(store.getState(), 'my-team'); + + expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice', 'bob']); + expect(members.find((member) => member.name === 'alice')).toMatchObject({ + role: 'developer', + currentTaskId: 'task-1', + taskCount: 1, + }); + expect(selectResolvedMemberForTeamName(store.getState(), 'my-team', 'bob')).toMatchObject({ + name: 'bob', + role: 'reviewer', + }); + }); + + it('falls back to team summary roster when detail snapshot only has the synthetic lead', () => { + const store = createSliceStore(); + const leadOnlySnapshot = createTeamSnapshot({ + config: { + name: 'My Team', + projectPath: '/repo', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + currentTaskId: null, + role: 'Lead from detail', + color: 'purple', + }, + ], + tasks: [], + }); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: leadOnlySnapshot, + teamDataCacheByName: { + 'my-team': leadOnlySnapshot, + }, + teamByName: { + 'my-team': { + teamName: 'my-team', + displayName: 'My Team', + description: '', + memberCount: 1, + taskCount: 0, + lastActivity: null, + members: [{ name: 'alice', role: 'developer', color: 'blue' }], + }, + }, + memberActivityMetaByTeam: {}, + }); + + const members = selectResolvedMembersForTeamName(store.getState(), 'my-team'); + + expect(members.map((m) => m.name)).toEqual(['team-lead', 'alice']); + expect(members[0]).toMatchObject({ + name: 'team-lead', + role: 'Lead from detail', + color: 'purple', + }); + }); + it('memoizes team-scoped member messages selectors over the merged message feed', () => { const store = createSliceStore(); store.setState({ diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index ac247eab..a80a4248 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -842,6 +842,28 @@ describe('memberHelpers spawn-aware presence', () => { expect(title).not.toContain('runtime_bootstrap_checkin'); }); + it('formats unknown OpenCode bridge outcome timeouts as delivery advisory text', () => { + const advisory = { + kind: 'api_error' as const, + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'backend_error' as const, + message: 'opencode_prompt_acceptance_unknown_after_bridge_timeout', + }; + + expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe( + 'OpenCode delivery error' + ); + + const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); + + expect(title).toContain('OpenCode runtime delivery error.'); + expect(title).toContain( + 'OpenCode bridge outcome unknown after timeout, retrying/observing.' + ); + expect(title).not.toContain('Network or connectivity error'); + expect(title).not.toContain('opencode_prompt_acceptance_unknown_after_bridge_timeout'); + }); + it('formats non-visible tool progress advisory reasons before showing them in titles', () => { const title = getMemberRuntimeAdvisoryTitle( { diff --git a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts index 33f5adef..4a834c8a 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -127,6 +127,30 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => { }); }); + it('surfaces unknown OpenCode bridge outcome as observe/retry state', () => { + const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ + deliveredToInbox: true, + messageId: 'msg-bridge-unknown', + runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: false, + responsePending: false, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'failed_terminal', + reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout', + diagnostics: ['opencode_prompt_acceptance_unknown_after_bridge_timeout'], + }, + }); + + expect(diagnostics.warning).toBe( + 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode bridge outcome unknown after timeout, retrying/observing.' + ); + expect(diagnostics.debugDetails).toMatchObject({ + reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout', + }); + }); + it('surfaces missing visible reply proof as a readable failure', () => { const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ deliveredToInbox: true, diff --git a/tsconfig.json b/tsconfig.json index d299e692..23aeefaa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,6 @@ }, "types": ["node", "vitest/globals"] }, - "include": ["src/**/*", "test/**/*"], + "include": ["src/**/*", "test/**/*", "scripts/team-changes-real-data-smoke.ts"], "exclude": ["node_modules", "dist", "dist-electron"] }