From fc3bd61f93bf3f45fff666b7d0f81212c7cfde3c Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 9 May 2026 05:04:46 +0300 Subject: [PATCH] chore(team): checkpoint launch stability work --- docs/team-management/agent-attachments.md | 26 +- scripts/smoke/agent-attachments-smoke.mjs | 183 ++++++++-- .../core/domain/capabilities.ts | 38 +- .../agent-attachments/core/domain/types.ts | 6 + .../core/domain/validation.test.ts | 65 ++++ .../core/domain/validation.ts | 42 +++ .../providers/claudeAttachmentAdapter.test.ts | 30 +- .../main/providers/claudeAttachmentAdapter.ts | 4 +- .../team/ProvisioningProgressBlock.tsx | 23 +- .../components/team/TeamProvisioningPanel.tsx | 10 +- .../team/useTeamProvisioningPresentation.ts | 25 ++ src/renderer/utils/memberLaunchDiagnostics.ts | 343 +++++++++++++++++- .../team/TeamProvisioningServiceRelay.test.ts | 2 +- 13 files changed, 753 insertions(+), 44 deletions(-) diff --git a/docs/team-management/agent-attachments.md b/docs/team-management/agent-attachments.md index fe5b702f..9d5cb43d 100644 --- a/docs/team-management/agent-attachments.md +++ b/docs/team-management/agent-attachments.md @@ -10,6 +10,15 @@ This document describes the v1 attachment path for Agent Teams. Do not append base64 to prompt text. Base64 is only valid inside provider-native structured payloads. +## Current non-image file policy + +- Claude: `text/*` files and PDFs are allowed through structured document blocks. +- Codex native: non-image files are blocked before provider delivery. Codex receives images only through the native image channel in this phase. +- OpenCode: non-image files are blocked before provider delivery. OpenCode receives verified image file parts only in this phase. +- Unknown or binary file types are blocked before provider delivery. + +This policy is intentionally conservative. It avoids silent text-only fallbacks, accidental huge stdin payloads, and provider-specific behavior that is not covered by live smokes. + ## Current image model policy - Claude: image attachments are allowed through structured image blocks. @@ -68,12 +77,14 @@ Run Codex native: ```bash node scripts/smoke/agent-attachments-smoke.mjs --case codex-native-gpt-5-4-mini +node scripts/smoke/agent-attachments-smoke.mjs --case codex-native-gpt-5-4-mini-multi-image ``` Run Claude subscription stream-json: ```bash node scripts/smoke/agent-attachments-smoke.mjs --case claude-subscription-streaming +node scripts/smoke/agent-attachments-smoke.mjs --case claude-subscription-streaming-multi-image ``` Run OpenCode OpenAI: @@ -86,11 +97,12 @@ Run OpenRouter cases: ```bash OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-kimi-k2-6 +OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-kimi-k2-6-multi-image OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-glm-4-5v OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-glm-5-1-negative ``` -The script redacts stdout/stderr tails for generated image bytes, data URLs, bearer tokens, API keys, environment-provided secrets, and long provider metadata signatures. +The script extracts assistant/result text from JSONL output before matching expected answers. This prevents false positives from prompts, base64 payloads, or diagnostics. It also redacts stdout/stderr tails for generated image bytes, data URLs, bearer tokens, API keys, environment-provided secrets, and long provider metadata signatures. ## Live verification record @@ -99,23 +111,29 @@ Latest local verification: 2026-05-09. | Scope | Command or case | Result | Notes | | --- | --- | --- | --- | | Claude visual transport | `claude-subscription-streaming` | passed | Real Claude CLI `stream-json` run answered `red` for generated PNG. | +| Claude multi-image transport | `claude-subscription-streaming-multi-image` | passed | Real Claude CLI `stream-json` run received three generated PNGs and answered `red` from extracted assistant text. | | Codex visual transport | `codex-native-gpt-5-4-mini` | passed | Real Codex native `--image` run answered `red` for generated PNG. | -| OpenCode OpenAI visual transport | `opencode-openai-gpt-5-4-mini` | blocked locally | The current local OpenCode OpenAI OAuth token is invalidated. The attachment path reached provider execution, but provider auth returned 401. | +| Codex multi-image transport | `codex-native-gpt-5-4-mini-multi-image` | passed | Real Codex native run received three repeated `--image` args and answered `red` from extracted assistant text. | +| OpenCode OpenAI visual transport | `opencode-openai-gpt-5-4-mini` | passed | Real OpenCode file attachment run answered `red` after local OpenCode OpenAI auth was refreshed. | | OpenRouter Kimi visual transport | `opencode-openrouter-kimi-k2-6` | passed | Real OpenCode file attachment run through OpenRouter answered `red` for generated PNG. | +| OpenRouter Kimi multi-image transport | `opencode-openrouter-kimi-k2-6-multi-image` | passed | Real OpenCode file attachment run through OpenRouter received three generated PNGs and answered `red` from extracted assistant text. | | OpenRouter GLM vision transport | `opencode-openrouter-glm-4-5v` | passed | Real OpenCode file attachment run through OpenRouter answered `red` for generated PNG. | -| OpenRouter GLM non-vision guard | `opencode-openrouter-glm-5-1-negative` | passed as guard | Model responded that it cannot process images. The app policy blocks this model for image attachments. | +| OpenRouter GLM non-vision guard | `opencode-openrouter-glm-5-1-negative` | passed as guard | Model responded that it cannot process images. The app policy blocks this model for image attachments before app delivery. | | CLI process launch | `scripts/prove-agent-cli-launch.mjs` | passed | Real `opencode`, `codex`, and `claude` binaries launched through `execCli` and `spawnCli`. | | OpenCode team provisioning | `scripts/prove-opencode-team-provisioning.mjs` with `OPENCODE_E2E_MODEL=openai/gpt-5.4-mini` | passed | Real pure OpenCode team created through `TeamProvisioningService`, live members verified, then stopped. | | Mixed Anthropic + Codex + OpenCode team launch | `MixedProviderTeamLaunch.live.test.ts` | passed | Real mixed team launch passed with Claude subscription auth, Codex subscription auth, and OpenCode. | -`--all` can return non-zero when it includes a locally invalid provider auth case or an unsupported-model negative case. Treat the per-case rows above as the release signal. +`--all` can return non-zero when local provider auth is invalidated. Treat the per-case rows above as the release signal when debugging local credential issues. ## Release checklist - Text-only messages still work for Claude, Codex, and OpenCode. - Oversized images fail before provider delivery. - Claude image send uses structured image blocks. +- Claude text/PDF file send uses structured document blocks. - Codex image send uses `--image`, not prompt base64. +- Codex non-image files fail before provider delivery. - OpenCode image send is blocked for unknown/non-vision models. +- OpenCode non-image files fail before provider delivery. - Attachment retry reuses the same artifacts or fails loudly. - Copied diagnostics do not include base64 or data URLs. diff --git a/scripts/smoke/agent-attachments-smoke.mjs b/scripts/smoke/agent-attachments-smoke.mjs index 7a17e7d0..2337a32c 100644 --- a/scripts/smoke/agent-attachments-smoke.mjs +++ b/scripts/smoke/agent-attachments-smoke.mjs @@ -6,6 +6,8 @@ import path from 'node:path'; import { deflateSync } from 'node:zlib'; const PROMPT = 'Look at the attached image. Reply with exactly one word: red, green, or blue.'; +const MULTI_IMAGE_PROMPT = + 'Look at every attached image. Each image has a dominant background color. Reply with exactly one word: red, green, or blue. Use red if the dominant background color is red in all images.'; const TIMEOUT_MS = 90_000; const CASES = [ @@ -13,7 +15,7 @@ const CASES = [ id: 'claude-subscription-streaming', runtime: 'claude', model: process.env.CLAUDE_ATTACHMENTS_SMOKE_CLAUDE_MODEL || 'claude-haiku-4-5', - command: async (imagePath, cwd, testCase) => ({ + command: async (imagePath, cwd, testCase, imagePaths) => ({ bin: 'claude', args: [ '-p', @@ -27,7 +29,32 @@ const CASES = [ testCase.model, ], cwd, - stdin: await buildClaudeStreamJsonPrompt(imagePath), + stdin: await buildClaudeStreamJsonPrompt(imagePaths, PROMPT), + }), + expected: /red/i, + }, + { + id: 'claude-subscription-streaming-multi-image', + runtime: 'claude', + model: process.env.CLAUDE_ATTACHMENTS_SMOKE_CLAUDE_MODEL || 'claude-haiku-4-5', + imageCount: 3, + imageWidth: 1600, + imageHeight: 1100, + command: async (imagePath, cwd, testCase, imagePaths) => ({ + bin: 'claude', + args: [ + '-p', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--verbose', + '--no-session-persistence', + '--model', + testCase.model, + ], + cwd, + stdin: await buildClaudeStreamJsonPrompt(imagePaths, MULTI_IMAGE_PROMPT), }), expected: /red/i, }, @@ -53,6 +80,30 @@ const CASES = [ }), expected: /red/i, }, + { + id: 'codex-native-gpt-5-4-mini-multi-image', + runtime: 'codex', + model: 'gpt-5.4-mini', + imageCount: 3, + imageWidth: 1600, + imageHeight: 1100, + command: (imagePath, cwd, testCase, imagePaths) => ({ + bin: 'codex', + args: [ + 'exec', + '--json', + '--skip-git-repo-check', + '-C', + cwd, + '--model', + 'gpt-5.4-mini', + ...imagePaths.flatMap((candidate) => ['--image', candidate]), + '-', + ], + stdin: MULTI_IMAGE_PROMPT, + }), + expected: /red/i, + }, { id: 'opencode-openai-gpt-5-4-mini', runtime: 'opencode', @@ -75,6 +126,31 @@ const CASES = [ }), expected: /red/i, }, + { + id: 'opencode-openrouter-kimi-k2-6-multi-image', + runtime: 'opencode', + model: 'openrouter/moonshotai/kimi-k2.6', + envRequired: ['OPENROUTER_API_KEY'], + imageCount: 3, + imageWidth: 1600, + imageHeight: 1100, + command: (imagePath, cwd, testCase, imagePaths) => ({ + bin: 'opencode', + args: [ + 'run', + '--pure', + '--format', + 'json', + '--dir', + cwd, + '--model', + 'openrouter/moonshotai/kimi-k2.6', + MULTI_IMAGE_PROMPT, + ...imagePaths.flatMap((candidate) => ['-f', candidate]), + ], + }), + expected: /red/i, + }, { id: 'opencode-openrouter-kimi-k2-6', runtime: 'opencode', @@ -196,24 +272,28 @@ function createRedCardPng(width = 320, height = 240) { ]); } -async function buildClaudeStreamJsonPrompt(imagePath) { - const data = await readFile(imagePath, 'base64'); +async function buildClaudeStreamJsonPrompt(imagePaths, prompt) { + const imageBlocks = []; + for (const imagePath of imagePaths) { + const data = await readFile(imagePath, 'base64'); + imageBlocks.push({ + type: 'image', + source: { + // Claude stream-json expects image bytes inside a structured image block. + // Do not replace this with base64-in-text fallback because that tests a different path. + type: 'base64', + media_type: 'image/png', + data, + }, + }); + } return `${JSON.stringify({ type: 'user', message: { role: 'user', content: [ - { type: 'text', text: PROMPT }, - { - type: 'image', - source: { - // Claude stream-json expects image bytes inside a structured image block. - // Do not replace this with base64-in-text fallback because that tests a different path. - type: 'base64', - media_type: 'image/png', - data, - }, - }, + { type: 'text', text: prompt }, + ...imageBlocks, ], }, })}\n`; @@ -286,6 +366,7 @@ function redactSmokeText(value) { .replace(/(data:image\/[a-z0-9.+-]+;base64,)[a-z0-9+/=]+/gi, '$1[redacted]') .replace(/("[Dd]ata"\s*:\s*")[a-z0-9+/=]{80,}(")/g, '$1[redacted]$2') .replace(/("[Ss]ignature"\s*:\s*")[^"]{80,}(")/g, '$1[redacted]$2') + .replace(/\b[A-Za-z0-9+/]{400,}={0,2}\b/g, '[redacted long encoded payload]') .replace(/(Authorization\s*[:=]\s*Bearer\s+)[^\s"']+/gi, '$1[redacted]') .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]{20,}/g, '$1[redacted]') .replace(/\b(sk-(?:ant|or|proj|live|test|codex|openai)[A-Za-z0-9._~+/=-]{12,})\b/g, '[redacted api key]'); @@ -301,6 +382,46 @@ function redactSmokeText(value) { return redacted; } +function extractAssistantTextFromJsonLine(line) { + let parsed; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + + if (typeof parsed.result === 'string') { + return parsed.result; + } + if (parsed.type === 'item.completed' && typeof parsed.item?.text === 'string') { + return parsed.item.text; + } + if (parsed.type === 'text' && typeof parsed.part?.text === 'string') { + return parsed.part.text; + } + const content = parsed.message?.content; + if (Array.isArray(content)) { + return content + .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : '')) + .join('\n') + .trim(); + } + return null; +} + +function extractAssistantText(output) { + const texts = []; + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) continue; + const text = extractAssistantTextFromJsonLine(trimmed); + if (text?.trim()) { + texts.push(text.trim()); + } + } + return texts.join('\n').trim(); +} + async function main() { const args = parseArgs(process.argv.slice(2)); if (args.list) { @@ -319,10 +440,9 @@ async function main() { const cwd = await mkdtemp(path.join(tmpdir(), 'agent-attachments-smoke-')); await mkdir(cwd, { recursive: true }); - const imagePath = path.join(cwd, 'red-card.png'); - await writeFile(imagePath, createRedCardPng()); const results = []; + const imagePathsByCase = {}; for (const testCase of selected) { const missingEnv = (testCase.envRequired ?? []).filter((name) => !process.env[name]); if (missingEnv.length) { @@ -336,12 +456,18 @@ async function main() { continue; } - const command = await testCase.command(imagePath, cwd, testCase); + const imagePaths = await prepareSmokeImages(cwd, testCase); + imagePathsByCase[testCase.id] = imagePaths; + const command = await testCase.command(imagePaths[0], cwd, testCase, imagePaths); const result = await runCommand(command); const output = `${result.stdout}\n${result.stderr}`; - const matched = testCase.expected ? testCase.expected.test(output) : false; + const assistantText = extractAssistantText(output); + const textForMatch = assistantText || output; + const matched = testCase.expected ? testCase.expected.test(textForMatch) : false; const unsupportedMatched = testCase.expectedUnsupported - ? /cannot|unable|unsupported|text-only|vision|image/i.test(output) + ? /cannot|unable|unsupported|text-only|vision|image|не могу|не поддерживает|изображен/i.test( + textForMatch + ) : false; results.push({ id: testCase.id, @@ -353,12 +479,14 @@ async function main() { : 'failed', exitCode: result.exitCode, timedOut: result.timedOut, + assistantText: redactSmokeText(assistantText.slice(-1000)), stdoutTail: redactSmokeText(result.stdout.slice(-4000)), stderrTail: redactSmokeText(result.stderr.slice(-4000)), }); } - const report = { imagePath, results }; + const firstImagePath = Object.values(imagePathsByCase)[0]?.[0] ?? null; + const report = { imagePath: firstImagePath, imagePathsByCase, results }; if (args.jsonPath) { await writeFile(path.resolve(args.jsonPath), `${JSON.stringify(report, null, 2)}\n`); } @@ -368,6 +496,19 @@ async function main() { } } +async function prepareSmokeImages(cwd, testCase) { + const imageCount = testCase.imageCount ?? 1; + const width = testCase.imageWidth ?? 320; + const height = testCase.imageHeight ?? 240; + const paths = []; + for (let index = 0; index < imageCount; index += 1) { + const imagePath = path.join(cwd, `red-card-${testCase.id}-${index + 1}.png`); + await writeFile(imagePath, createRedCardPng(width, height)); + paths.push(imagePath); + } + return paths; +} + main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; diff --git a/src/features/agent-attachments/core/domain/capabilities.ts b/src/features/agent-attachments/core/domain/capabilities.ts index 0e1f5792..3cff77f2 100644 --- a/src/features/agent-attachments/core/domain/capabilities.ts +++ b/src/features/agent-attachments/core/domain/capabilities.ts @@ -2,16 +2,34 @@ import type { AgentAttachmentCapability, AgentAttachmentCapabilityTarget } from const DEFAULT_IMAGE_BYTES_PER_PROVIDER = 4 * 1024 * 1024; const DEFAULT_IMAGE_BYTES_TOTAL = 8 * 1024 * 1024; +const DEFAULT_FILE_BYTES_PER_PROVIDER = 4 * 1024 * 1024; -function supported(displayText: string): AgentAttachmentCapability { +function supportedImagesOnly(displayText: string): AgentAttachmentCapability { return { supportsImages: true, + supportsFiles: false, supportedImageMimeTypes: ['image/png', 'image/jpeg'], + supportedFileMimeTypes: [], maxImages: 5, + maxFiles: 0, maxBytesPerImage: DEFAULT_IMAGE_BYTES_PER_PROVIDER, + maxBytesPerFile: 0, maxBytesTotal: DEFAULT_IMAGE_BYTES_TOTAL, reason: 'known_provider_support', displayText, + filesDisplayText: + 'This provider path currently supports image attachments only. Non-image files are blocked before provider delivery.', + }; +} + +function supportedClaude(displayText: string): AgentAttachmentCapability { + return { + ...supportedImagesOnly(displayText), + supportsFiles: true, + supportedFileMimeTypes: ['application/pdf', 'text/*'], + maxFiles: 5, + maxBytesPerFile: DEFAULT_FILE_BYTES_PER_PROVIDER, + filesDisplayText: 'Claude can receive text files and PDFs through structured document blocks.', }; } @@ -21,12 +39,18 @@ function unsupported( ): AgentAttachmentCapability { return { supportsImages: false, + supportsFiles: false, supportedImageMimeTypes: [], + supportedFileMimeTypes: [], maxImages: 0, + maxFiles: 0, maxBytesPerImage: 0, + maxBytesPerFile: 0, maxBytesTotal: 0, reason, displayText, + filesDisplayText: + 'Selected provider does not support non-image file attachments through this delivery path.', }; } @@ -49,24 +73,28 @@ export function resolveAgentAttachmentCapability( const providerId = target.providerId.trim().toLowerCase(); if (providerId === 'anthropic') { - return supported('Claude can receive image attachments through structured image blocks.'); + return supportedClaude('Claude can receive image attachments through structured image blocks.'); } if (providerId === 'codex') { - return supported('Codex can receive image attachments through the native image channel.'); + return supportedImagesOnly( + 'Codex can receive image attachments through the native image channel.' + ); } if (providerId === 'opencode') { const { model } = canonicalizeOpenCodeModel(target); if (model === 'gpt-5.4-mini') { return { - ...supported('OpenCode model openai/gpt-5.4-mini is verified for image attachments.'), + ...supportedImagesOnly( + 'OpenCode model openai/gpt-5.4-mini is verified for image attachments.' + ), reason: 'known_vision_model', }; } if (model === 'moonshotai/kimi-k2.6' || model === 'z-ai/glm-4.5v') { return { - ...supported(`OpenCode model ${model} is verified for image attachments.`), + ...supportedImagesOnly(`OpenCode model ${model} is verified for image attachments.`), reason: 'known_vision_model', }; } diff --git a/src/features/agent-attachments/core/domain/types.ts b/src/features/agent-attachments/core/domain/types.ts index 190b05f5..dc644327 100644 --- a/src/features/agent-attachments/core/domain/types.ts +++ b/src/features/agent-attachments/core/domain/types.ts @@ -4,6 +4,7 @@ export type AgentAttachmentKind = 'image' | 'file' | 'unsupported'; export type AgentImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp'; export type ProviderImageMimeType = 'image/png' | 'image/jpeg'; +export type ProviderFileMimeType = 'application/pdf' | 'text/*'; export type AttachmentDeliveryFailureCode = | 'attachment_too_large' @@ -96,9 +97,13 @@ export interface AgentAttachmentCapabilityTarget { export interface AgentAttachmentCapability { supportsImages: boolean; + supportsFiles: boolean; supportedImageMimeTypes: ProviderImageMimeType[]; + supportedFileMimeTypes: ProviderFileMimeType[]; maxImages: number; + maxFiles: number; maxBytesPerImage: number; + maxBytesPerFile: number; maxBytesTotal: number; reason: | 'known_provider_support' @@ -107,6 +112,7 @@ export interface AgentAttachmentCapability { | 'unknown_model' | 'unsupported_provider'; displayText: string; + filesDisplayText: string; } export type AttachmentValidationResult = diff --git a/src/features/agent-attachments/core/domain/validation.test.ts b/src/features/agent-attachments/core/domain/validation.test.ts index 933a0f93..4a08834d 100644 --- a/src/features/agent-attachments/core/domain/validation.test.ts +++ b/src/features/agent-attachments/core/domain/validation.test.ts @@ -70,4 +70,69 @@ describe('agent attachment validation', () => { warnings: [], }); }); + + it('allows Claude text file delivery through document blocks', () => { + const capability = resolveAgentAttachmentCapability({ + providerId: 'anthropic', + model: 'claude-haiku-4-5', + }); + const result = validateAttachmentForCapability({ + attachment: fakeImageAttachment({ + id: 'att_text', + originalName: 'notes.md', + mimeType: 'text/markdown', + sizeBytes: 128, + kind: 'file', + }), + capability, + }); + + expect(result).toEqual({ ok: true, warnings: [] }); + }); + + it('blocks non-image files for Codex native delivery', () => { + const capability = resolveAgentAttachmentCapability({ + providerId: 'codex', + model: 'gpt-5.4-mini', + }); + const result = validateAttachmentForCapability({ + attachment: fakeImageAttachment({ + id: 'att_pdf', + originalName: 'spec.pdf', + mimeType: 'application/pdf', + sizeBytes: 1024, + kind: 'file', + }), + capability, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe('attachment_type_unsupported'); + expect(result.message).toContain('image attachments only'); + } + }); + + it('blocks non-image files for OpenCode even when the model supports images', () => { + const capability = resolveAgentAttachmentCapability({ + providerId: 'opencode', + model: 'openrouter/moonshotai/kimi-k2.6', + }); + const result = validateAttachmentForCapability({ + attachment: fakeImageAttachment({ + id: 'att_text', + originalName: 'trace.txt', + mimeType: 'text/plain', + sizeBytes: 1024, + kind: 'file', + }), + capability, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe('attachment_type_unsupported'); + expect(result.message).toContain('image attachments only'); + } + }); }); diff --git a/src/features/agent-attachments/core/domain/validation.ts b/src/features/agent-attachments/core/domain/validation.ts index 3b737446..e30db572 100644 --- a/src/features/agent-attachments/core/domain/validation.ts +++ b/src/features/agent-attachments/core/domain/validation.ts @@ -26,6 +26,12 @@ export function isProviderImageMimeType(mimeType: string): mimeType is ProviderI return PROVIDER_IMAGE_MIME_TYPES.has(mimeType as ProviderImageMimeType); } +function isProviderFileMimeType(mimeType: string, supported: readonly string[]): boolean { + return supported.some((candidate) => + candidate.endsWith('/*') ? mimeType.startsWith(candidate.slice(0, -1)) : candidate === mimeType + ); +} + export function classifyAttachmentMime(mimeType: string): AgentAttachmentKind { if (isAgentImageMimeType(mimeType)) return 'image'; if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) { @@ -85,6 +91,42 @@ export function validateAttachmentForCapability(input: { const warnings = [...attachment.warnings]; if (attachment.kind !== 'image') { + if (attachment.kind !== 'file') { + return { + ok: false, + code: 'attachment_type_unsupported', + message: 'This attachment type is not supported by the selected provider.', + warnings, + }; + } + + if (!capability.supportsFiles) { + return { + ok: false, + code: 'attachment_type_unsupported', + message: capability.filesDisplayText, + warnings, + }; + } + + if (!isProviderFileMimeType(attachment.mimeType, capability.supportedFileMimeTypes)) { + return { + ok: false, + code: 'attachment_type_unsupported', + message: 'This file type is not supported by the selected provider.', + warnings, + }; + } + + if (attachment.sizeBytes > capability.maxBytesPerFile) { + return { + ok: false, + code: 'attachment_too_large', + message: 'File is too large for the selected provider path.', + warnings, + }; + } + return { ok: true, warnings }; } diff --git a/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts index 3fa62034..ad15ea14 100644 --- a/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts +++ b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts @@ -57,13 +57,41 @@ describe('Claude attachment adapter', () => { }); }); + it('serializes text subtypes as text document blocks', () => { + const result = buildClaudeAttachmentDeliveryParts({ + text: 'Read this', + attachments: [ + attachment({ + filename: 'notes.md', + mimeType: 'text/markdown', + data: Buffer.from('# hello', 'utf8').toString('base64'), + }), + ], + }); + + expect(result.blocks[1]).toEqual({ + type: 'document', + source: { type: 'text', media_type: 'text/plain', data: '# hello' }, + title: 'notes.md', + }); + }); + + it('rejects unsupported non-image files before provider send', () => { + expect(() => + buildClaudeAttachmentDeliveryParts({ + text: 'read sheet', + attachments: [attachment({ filename: 'sheet.xlsx', mimeType: 'application/vnd.ms-excel' })], + }) + ).toThrow(/Claude attachment MIME unsupported/); + }); + it('rejects unsupported image mime types before provider send', () => { expect(() => buildClaudeAttachmentDeliveryParts({ text: 'see gif', attachments: [attachment({ mimeType: 'image/gif' })], }) - ).toThrow(/Claude image MIME unsupported/); + ).toThrow(/Claude attachment MIME unsupported/); }); it('redacts image and document bytes in diagnostics', () => { diff --git a/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts index aee60e2d..17b14202 100644 --- a/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts +++ b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts @@ -52,7 +52,7 @@ export function buildClaudeAttachmentDeliveryParts(input: { continue; } - if (attachment.mimeType === 'text/plain') { + if (attachment.mimeType === 'text/plain' || attachment.mimeType.startsWith('text/')) { const decoded = decodeBase64Text(attachment.data); contentBlocks.push( decoded.ok @@ -94,7 +94,7 @@ export function buildClaudeAttachmentDeliveryParts(input: { throw new AgentAttachmentError( 'attachment_type_unsupported', - `Claude image MIME unsupported: ${attachment.mimeType}`, + `Claude attachment MIME unsupported: ${attachment.mimeType}`, { attachmentId: attachment.id, retryable: false } ); } diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index e593cf16..5a79b311 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -22,6 +22,7 @@ import { DISPLAY_STEPS } from './provisioningSteps'; import { StepProgressBar } from './StepProgressBar'; import type { StepProgressBarStep } from './StepProgressBar'; +import type { MemberLaunchDiagnosticsPayload } from '@renderer/utils/memberLaunchDiagnostics'; import type { TeamLaunchDiagnosticItem } from '@shared/types'; /** Pre-built step definitions for the provisioning stepper. */ @@ -36,6 +37,7 @@ const SECRET_FLAG_PATTERN = const SECRET_ENV_ASSIGNMENT_PATTERN = /\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)[A-Z0-9_]*\s*=\s*)("[^"]*"|'[^']*'|\S+)/gi; const AUTH_HEADER_PATTERN = /\b(Authorization\s*:\s*)(Bearer\s+)?("[^"]*"|'[^']*'|\S+)/gi; +const SECRET_VALUE_PATTERN = /\b(sk-[A-Za-z0-9_-]{12,}|[A-Za-z0-9_-]{32,})\b/g; export interface ProvisioningProgressBlockProps { /** Title above the steps, e.g. "Launching team" */ @@ -74,6 +76,8 @@ export interface ProvisioningProgressBlockProps { assistantOutput?: string; /** Bounded structured launch diagnostics */ launchDiagnostics?: TeamLaunchDiagnosticItem[]; + /** Bounded per-member launch/runtime diagnostics for copy payloads. */ + memberDiagnostics?: MemberLaunchDiagnosticsPayload[]; /** Visual surface chrome for the outer block */ surface?: 'raised' | 'flat'; className?: string; @@ -153,7 +157,8 @@ function redactProvisioningDiagnosticsCopy(text: string): string { .replace(PROVIDER_API_KEY_FLAG_PATTERN, '$1[redacted]') .replace(SECRET_FLAG_PATTERN, '$1[redacted]') .replace(SECRET_ENV_ASSIGNMENT_PATTERN, '$1[redacted]') - .replace(AUTH_HEADER_PATTERN, '$1$2[redacted]'); + .replace(AUTH_HEADER_PATTERN, '$1$2[redacted]') + .replace(SECRET_VALUE_PATTERN, '[redacted]'); } function formatOptionalValue(value: string | number | null | undefined): string { @@ -187,6 +192,15 @@ function formatLaunchDiagnosticsCopy( .join('\n'); } +function formatMemberDiagnosticsCopy( + items: readonly MemberLaunchDiagnosticsPayload[] | undefined +): string { + if (!items || items.length === 0) { + return '(none)'; + } + return JSON.stringify(items, null, 2); +} + function buildProvisioningDiagnosticsCopy(input: { title: string; message?: string | null; @@ -200,6 +214,7 @@ function buildProvisioningDiagnosticsCopy(input: { liveOutput?: string | null; cliLogsTail?: string; launchDiagnostics?: TeamLaunchDiagnosticItem[]; + memberDiagnostics?: MemberLaunchDiagnosticsPayload[]; }): string { const payload = [ '# Team provisioning diagnostics', @@ -218,6 +233,9 @@ function buildProvisioningDiagnosticsCopy(input: { '## Launch diagnostics', formatLaunchDiagnosticsCopy(input.launchDiagnostics), '', + '## Member launch snapshots', + formatMemberDiagnosticsCopy(input.memberDiagnostics), + '', '## Live output', input.liveOutput?.trim() || '(empty)', '', @@ -247,6 +265,7 @@ export const ProvisioningProgressBlock = ({ cliLogsTail, assistantOutput, launchDiagnostics, + memberDiagnostics, surface = 'raised', className, }: ProvisioningProgressBlockProps): React.JSX.Element => { @@ -274,6 +293,7 @@ export const ProvisioningProgressBlock = ({ liveOutput: displayAssistantOutput, cliLogsTail, launchDiagnostics, + memberDiagnostics, }), [ title, @@ -288,6 +308,7 @@ export const ProvisioningProgressBlock = ({ displayAssistantOutput, cliLogsTail, launchDiagnostics, + memberDiagnostics, ] ); const visibleLaunchDiagnostics = diff --git a/src/renderer/components/team/TeamProvisioningPanel.tsx b/src/renderer/components/team/TeamProvisioningPanel.tsx index 4a2a24df..9a816137 100644 --- a/src/renderer/components/team/TeamProvisioningPanel.tsx +++ b/src/renderer/components/team/TeamProvisioningPanel.tsx @@ -45,8 +45,13 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({ className, defaultLogsOpen, }: TeamProvisioningPanelProps): React.JSX.Element | null { - const { presentation, cancelProvisioning, retryFailedOpenCodeSecondaryLanes, runInstanceKey } = - useTeamProvisioningPresentation(teamName); + const { + presentation, + cancelProvisioning, + retryFailedOpenCodeSecondaryLanes, + memberDiagnostics, + runInstanceKey, + } = useTeamProvisioningPresentation(teamName); const [dismissed, setDismissed] = useState(false); const [retryingOpenCode, setRetryingOpenCode] = useState(false); const [openCodeRetryMessage, setOpenCodeRetryMessage] = useState(null); @@ -134,6 +139,7 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({ cliLogsTail={presentation.progress.cliLogsTail} assistantOutput={presentation.progress.assistantOutput} launchDiagnostics={presentation.progress.launchDiagnostics} + memberDiagnostics={memberDiagnostics} defaultLiveOutputOpen={presentation.defaultLiveOutputOpen} defaultLogsOpen={defaultLogsOpen} onCancel={ diff --git a/src/renderer/components/team/useTeamProvisioningPresentation.ts b/src/renderer/components/team/useTeamProvisioningPresentation.ts index 970cc9f8..aeb3839c 100644 --- a/src/renderer/components/team/useTeamProvisioningPresentation.ts +++ b/src/renderer/components/team/useTeamProvisioningPresentation.ts @@ -5,9 +5,11 @@ import { getCurrentProvisioningProgressForTeam, selectTeamMemberSnapshotsForName, } from '@renderer/store/slices/teamSlice'; +import { buildTeamMemberLaunchDiagnosticsPayloads } from '@renderer/utils/memberLaunchDiagnostics'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { useShallow } from 'zustand/react/shallow'; +import type { MemberLaunchDiagnosticsPayload } from '@renderer/utils/memberLaunchDiagnostics'; import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import type { RetryFailedOpenCodeSecondaryLanesResult } from '@shared/types'; @@ -17,6 +19,7 @@ export function useTeamProvisioningPresentation(teamName: string): { retryFailedOpenCodeSecondaryLanes: | ((teamName: string) => Promise) | null; + memberDiagnostics: MemberLaunchDiagnosticsPayload[]; runInstanceKey: string | null; } { const { @@ -26,6 +29,7 @@ export function useTeamProvisioningPresentation(teamName: string): { teamMembers, memberSpawnStatuses, memberSpawnSnapshot, + runtimeSnapshot, } = useStore( useShallow((s) => ({ progress: getCurrentProvisioningProgressForTeam(s, teamName), @@ -34,6 +38,7 @@ export function useTeamProvisioningPresentation(teamName: string): { teamMembers: selectTeamMemberSnapshotsForName(s, teamName), memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], + runtimeSnapshot: s.teamAgentRuntimeByTeam?.[teamName], })) ); @@ -47,11 +52,31 @@ export function useTeamProvisioningPresentation(teamName: string): { }), [memberSpawnSnapshot, memberSpawnStatuses, progress, teamMembers] ); + const memberDiagnostics = useMemo( + () => + buildTeamMemberLaunchDiagnosticsPayloads({ + teamName, + runId: runtimeSnapshot?.runId ?? memberSpawnSnapshot?.runId ?? progress?.runId, + members: teamMembers, + memberSpawnStatuses, + memberSpawnSnapshot, + runtimeEntries: runtimeSnapshot?.members, + }), + [ + memberSpawnSnapshot, + memberSpawnStatuses, + progress?.runId, + runtimeSnapshot, + teamMembers, + teamName, + ] + ); return { presentation, cancelProvisioning, retryFailedOpenCodeSecondaryLanes: retryFailedOpenCodeSecondaryLanes ?? null, + memberDiagnostics, runInstanceKey: progress ? `${teamName}:${progress.runId}:${progress.startedAt}` : null, }; } diff --git a/src/renderer/utils/memberLaunchDiagnostics.ts b/src/renderer/utils/memberLaunchDiagnostics.ts index b1aaa960..93fe8201 100644 --- a/src/renderer/utils/memberLaunchDiagnostics.ts +++ b/src/renderer/utils/memberLaunchDiagnostics.ts @@ -13,9 +13,25 @@ export interface MemberLaunchDiagnosticsPayload { teamName?: string; runId?: string; memberName: string; + providerId?: string; + providerBackendId?: string; + model?: string; + runtimeModel?: string; + agentType?: string; + laneId?: string; + laneKind?: 'primary' | 'secondary'; + laneOwnerProviderId?: string; + removedAt?: number; memberCardError?: string; launchState?: MemberLaunchState; spawnStatus?: MemberSpawnStatus; + backendType?: string; + alive?: boolean; + restartable?: boolean; + runtimeAlive?: boolean; + bootstrapConfirmed?: boolean; + agentToolAccepted?: boolean; + hardFailure?: boolean; livenessKind?: TeamAgentRuntimeLivenessKind; livenessSource?: MemberSpawnLivenessSource; pid?: number; @@ -26,17 +42,49 @@ export interface MemberLaunchDiagnosticsPayload { processCommand?: string; runtimePid?: number; runtimeSessionId?: string; + runtimeLeaseExpiresAt?: string; + runtimeLastSeenAt?: string; + historicalBootstrapConfirmed?: boolean; + cwd?: string; + rssBytes?: number; runtimeDiagnostic?: string; runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; bootstrapStalled?: boolean; + pendingPermissionRequestIds?: string[]; + firstSpawnAcceptedAt?: string; + lastHeartbeatAt?: string; + livenessLastCheckedAt?: string; + probableCause?: string; + diagnosticHints?: string[]; diagnostics?: string[]; + spawnUpdatedAt?: string; + runtimeUpdatedAt?: string; updatedAt?: string; } const MAX_DIAGNOSTIC_STRING_LENGTH = 500; const MAX_DIAGNOSTIC_ITEMS = 20; +const MAX_PERMISSION_REQUEST_IDS = 10; const SECRET_FLAG_PATTERN = /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; +const SECRET_VALUE_PATTERN = /\b(sk-[A-Za-z0-9_-]{12,}|[A-Za-z0-9_-]{32,})\b/g; + +type MemberSpawnStatusCollection = + | Record + | Map + | undefined; + +interface MemberDiagnosticsMemberLike { + name: string; + providerId?: string; + providerBackendId?: string; + model?: string; + agentType?: string; + laneId?: string; + laneKind?: 'primary' | 'secondary'; + laneOwnerProviderId?: string; + removedAt?: number; +} function boundedString( value: string | undefined, @@ -44,7 +92,9 @@ function boundedString( ): string | undefined { const trimmed = value?.replace(/\s+/g, ' ').trim(); if (!trimmed) return undefined; - const redacted = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]'); + const redacted = trimmed + .replace(SECRET_FLAG_PATTERN, '$1[redacted]') + .replace(SECRET_VALUE_PATTERN, '[redacted]'); return redacted.length > maxLength ? `${redacted.slice(0, Math.max(0, maxLength - 3))}...` : redacted; @@ -56,6 +106,21 @@ function boundedNumber(value: number | undefined): number | undefined { : undefined; } +function boundedStringArray( + values: readonly string[] | undefined, + limit = MAX_PERMISSION_REQUEST_IDS +): string[] | undefined { + const result = values + ?.map((value) => boundedString(value, 160)) + .filter((value): value is string => Boolean(value)) + .slice(0, limit); + return result && result.length > 0 ? result : undefined; +} + +function maybeString(value: string | undefined): string | undefined { + return boundedString(value, 240); +} + export function normalizeMemberLaunchFailureReason(value: string | undefined): string | null { const normalized = value ?.replace(/\s+/g, ' ') @@ -84,10 +149,91 @@ function uniqueDiagnostics( return diagnostics.length > 0 ? diagnostics : undefined; } +function textIncludesAny(text: string, needles: readonly string[]): boolean { + return needles.some((needle) => text.includes(needle)); +} + +function buildDiagnosticHints(input: { + memberCardError?: string; + runtimeDiagnostic?: string; + diagnostics?: readonly string[]; + livenessKind?: TeamAgentRuntimeLivenessKind; + launchState?: MemberLaunchState; + spawnStatus?: MemberSpawnStatus; +}): string[] | undefined { + const text = [input.memberCardError, input.runtimeDiagnostic, ...(input.diagnostics ?? [])] + .filter((item): item is string => Boolean(item)) + .join('\n') + .toLowerCase(); + const hints: string[] = []; + + if (textIncludesAny(text, ['reason=query_active', 'queryguardstatus=running'])) { + hints.push( + 'Bootstrap submit was rejected because the teammate REPL already had a running query.' + ); + } + if (textIncludesAny(text, ['queryguardstatus=dispatching'])) { + hints.push( + 'Bootstrap submit collided with a queued prompt dispatch before the model turn started.' + ); + } + if ( + textIncludesAny(text, [ + 'reason=command_queue_busy', + 'commandqueuemodes=prompt', + 'commandqueuemodes=bash', + ]) + ) { + hints.push( + 'Bootstrap submit was rejected because local prompt/bash command queue was not empty.' + ); + } + if (textIncludesAny(text, ['no stdin data received in 3s'])) { + hints.push( + 'CLI read empty stdin before bootstrap submit; verify headless teammate runtime flag/env and startup input handling.' + ); + } + if ( + textIncludesAny(text, ['bootstrap_submit_rejected', 'submit rejected by local prompt handler']) + ) { + hints.push( + 'The teammate process observed bootstrap mail, but local prompt submission did not accept the bootstrap turn.' + ); + } + if ( + textIncludesAny(text, [ + 'did not submit bootstrap prompt', + 'timed out waiting for bootstrap_submitted', + ]) + ) { + hints.push('Parent process timed out waiting for durable bootstrap_submitted evidence.'); + } + if ( + input.livenessKind === 'stale_metadata' || + textIncludesAny(text, ['persisted runtime pid is not alive']) + ) { + hints.push( + 'Persisted runtime pid is dead; this is post-failure liveness, not the original root cause.' + ); + } + if (input.launchState === 'failed_to_start' || input.spawnStatus === 'error') { + hints.push( + 'Launch state is terminal for this run; restart/relaunch is required after fixing the cause.' + ); + } + + return hints.length > 0 ? [...new Set(hints)].slice(0, 8) : undefined; +} + +function buildProbableCause(hints: readonly string[] | undefined): string | undefined { + return hints?.[0]; +} + export function buildMemberLaunchDiagnosticsPayload(params: { teamName?: string | null; runId?: string | null; memberName: string; + member?: MemberDiagnosticsMemberLike; spawnStatus?: MemberSpawnStatus; launchState?: MemberLaunchState; livenessSource?: MemberSpawnLivenessSource; @@ -117,21 +263,67 @@ export function buildMemberLaunchDiagnosticsPayload(params: { runtimeEntry?.diagnostics ); const runId = boundedString(params.runId ?? undefined); + const providerId = runtimeEntry?.providerId ?? params.member?.providerId; + const providerBackendId = runtimeEntry?.providerBackendId ?? params.member?.providerBackendId; + const laneId = runtimeEntry?.laneId ?? params.member?.laneId; + const laneKind = runtimeEntry?.laneKind ?? params.member?.laneKind; + const runtimeUpdatedAt = maybeString(runtimeEntry?.updatedAt); + const spawnUpdatedAt = maybeString(spawnEntry?.updatedAt); + const livenessKind = spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind; + const launchState = spawnEntry?.launchState ?? params.launchState; + const spawnStatus = spawnEntry?.status ?? params.spawnStatus; + const diagnosticHints = buildDiagnosticHints({ + memberCardError, + runtimeDiagnostic, + diagnostics, + livenessKind, + launchState, + spawnStatus, + }); + const probableCause = buildProbableCause(diagnosticHints); return { ...(params.teamName ? { teamName: params.teamName } : {}), ...(runId ? { runId } : {}), memberName: params.memberName, + ...(providerId ? { providerId } : {}), + ...(providerBackendId ? { providerBackendId } : {}), + ...(maybeString(params.member?.model) ? { model: maybeString(params.member?.model) } : {}), + ...(maybeString(runtimeEntry?.runtimeModel ?? spawnEntry?.runtimeModel) + ? { runtimeModel: maybeString(runtimeEntry?.runtimeModel ?? spawnEntry?.runtimeModel) } + : {}), + ...(maybeString(params.member?.agentType) + ? { agentType: maybeString(params.member?.agentType) } + : {}), + ...(maybeString(laneId) ? { laneId: maybeString(laneId) } : {}), + ...(laneKind ? { laneKind } : {}), + ...(params.member?.laneOwnerProviderId + ? { laneOwnerProviderId: params.member.laneOwnerProviderId } + : {}), + ...(boundedNumber(params.member?.removedAt) + ? { removedAt: boundedNumber(params.member?.removedAt) } + : {}), ...(memberCardError ? { memberCardError } : {}), - ...((spawnEntry?.launchState ?? params.launchState) - ? { launchState: spawnEntry?.launchState ?? params.launchState } + ...(launchState ? { launchState } : {}), + ...(spawnStatus ? { spawnStatus } : {}), + ...(runtimeEntry?.backendType ? { backendType: runtimeEntry.backendType } : {}), + ...(typeof runtimeEntry?.alive === 'boolean' ? { alive: runtimeEntry.alive } : {}), + ...(typeof runtimeEntry?.restartable === 'boolean' + ? { restartable: runtimeEntry.restartable } : {}), - ...((spawnEntry?.status ?? params.spawnStatus) - ? { spawnStatus: spawnEntry?.status ?? params.spawnStatus } + ...(typeof spawnEntry?.runtimeAlive === 'boolean' + ? { runtimeAlive: spawnEntry.runtimeAlive } : {}), - ...((spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind) - ? { livenessKind: spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind } + ...(typeof spawnEntry?.bootstrapConfirmed === 'boolean' + ? { bootstrapConfirmed: spawnEntry.bootstrapConfirmed } : {}), + ...(typeof spawnEntry?.agentToolAccepted === 'boolean' + ? { agentToolAccepted: spawnEntry.agentToolAccepted } + : {}), + ...(typeof spawnEntry?.hardFailure === 'boolean' + ? { hardFailure: spawnEntry.hardFailure } + : {}), + ...(livenessKind ? { livenessKind } : {}), ...((spawnEntry?.livenessSource ?? params.livenessSource) ? { livenessSource: spawnEntry?.livenessSource ?? params.livenessSource } : {}), @@ -153,6 +345,23 @@ export function buildMemberLaunchDiagnosticsPayload(params: { ...(boundedString(runtimeEntry?.runtimeSessionId) ? { runtimeSessionId: boundedString(runtimeEntry?.runtimeSessionId) } : {}), + ...(maybeString(runtimeEntry?.runtimeLeaseExpiresAt) + ? { runtimeLeaseExpiresAt: maybeString(runtimeEntry?.runtimeLeaseExpiresAt) } + : {}), + ...(maybeString(runtimeEntry?.runtimeLastSeenAt ?? spawnEntry?.lastHeartbeatAt) + ? { + runtimeLastSeenAt: maybeString( + runtimeEntry?.runtimeLastSeenAt ?? spawnEntry?.lastHeartbeatAt + ), + } + : {}), + ...(typeof runtimeEntry?.historicalBootstrapConfirmed === 'boolean' + ? { historicalBootstrapConfirmed: runtimeEntry.historicalBootstrapConfirmed } + : {}), + ...(maybeString(runtimeEntry?.cwd) ? { cwd: maybeString(runtimeEntry?.cwd) } : {}), + ...(boundedNumber(runtimeEntry?.rssBytes) + ? { rssBytes: boundedNumber(runtimeEntry?.rssBytes) } + : {}), ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), ...((spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity) ? { @@ -161,13 +370,133 @@ export function buildMemberLaunchDiagnosticsPayload(params: { } : {}), ...(spawnEntry?.bootstrapStalled === true ? { bootstrapStalled: true } : {}), + ...(boundedStringArray(spawnEntry?.pendingPermissionRequestIds) + ? { pendingPermissionRequestIds: boundedStringArray(spawnEntry?.pendingPermissionRequestIds) } + : {}), + ...(maybeString(spawnEntry?.firstSpawnAcceptedAt) + ? { firstSpawnAcceptedAt: maybeString(spawnEntry?.firstSpawnAcceptedAt) } + : {}), + ...(maybeString(spawnEntry?.lastHeartbeatAt) + ? { lastHeartbeatAt: maybeString(spawnEntry?.lastHeartbeatAt) } + : {}), + ...(maybeString(spawnEntry?.livenessLastCheckedAt) + ? { livenessLastCheckedAt: maybeString(spawnEntry?.livenessLastCheckedAt) } + : {}), + ...(probableCause ? { probableCause } : {}), + ...(diagnosticHints ? { diagnosticHints } : {}), ...(diagnostics ? { diagnostics } : {}), + ...(spawnUpdatedAt ? { spawnUpdatedAt } : {}), + ...(runtimeUpdatedAt ? { runtimeUpdatedAt } : {}), ...(boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt) ? { updatedAt: boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt) } : {}), }; } +function parseStatusUpdatedAtMs(value: string | undefined): number | null { + if (!value) { + return null; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean { + return entry?.launchState === 'failed_to_start' || entry?.status === 'error'; +} + +function shouldPreferSnapshotEntryOverLive(params: { + liveEntry: MemberSpawnStatusEntry | undefined; + snapshotEntry: MemberSpawnStatusEntry | undefined; + snapshotUpdatedAt?: string; +}): boolean { + const { liveEntry, snapshotEntry, snapshotUpdatedAt } = params; + if (!liveEntry || !snapshotEntry) { + return false; + } + if (!isFailedSpawnEntry(liveEntry) || isFailedSpawnEntry(snapshotEntry)) { + return false; + } + + const liveUpdatedAtMs = parseStatusUpdatedAtMs(liveEntry.updatedAt); + const snapshotUpdatedAtMs = + parseStatusUpdatedAtMs(snapshotEntry.updatedAt) ?? parseStatusUpdatedAtMs(snapshotUpdatedAt); + return ( + snapshotUpdatedAtMs != null && + (liveUpdatedAtMs == null || snapshotUpdatedAtMs >= liveUpdatedAtMs) + ); +} + +function getPreferredSpawnEntry(params: { + liveEntry: MemberSpawnStatusEntry | undefined; + snapshotEntry: MemberSpawnStatusEntry | undefined; + snapshotUpdatedAt?: string; +}): MemberSpawnStatusEntry | undefined { + return shouldPreferSnapshotEntryOverLive(params) + ? params.snapshotEntry + : (params.liveEntry ?? params.snapshotEntry); +} + +function getSpawnEntry( + collection: MemberSpawnStatusCollection, + name: string +): MemberSpawnStatusEntry | undefined { + return collection instanceof Map ? collection.get(name) : collection?.[name]; +} + +export function buildTeamMemberLaunchDiagnosticsPayloads(params: { + teamName?: string | null; + runId?: string | null; + members?: readonly MemberDiagnosticsMemberLike[]; + memberSpawnStatuses?: MemberSpawnStatusCollection; + memberSpawnSnapshot?: { + statuses?: Record; + updatedAt?: string; + }; + runtimeEntries?: Record | null; +}): MemberLaunchDiagnosticsPayload[] { + const membersByName = new Map( + (params.members ?? []) + .map((member) => [member.name.trim(), member] as const) + .filter(([name]) => name.length > 0) + ); + const names = new Set(membersByName.keys()); + if (params.memberSpawnStatuses instanceof Map) { + for (const name of params.memberSpawnStatuses.keys()) { + names.add(name); + } + } else { + for (const name of Object.keys(params.memberSpawnStatuses ?? {})) { + names.add(name); + } + } + for (const name of Object.keys(params.memberSpawnSnapshot?.statuses ?? {})) { + names.add(name); + } + for (const name of Object.keys(params.runtimeEntries ?? {})) { + names.add(name); + } + + return [...names] + .sort((left, right) => left.localeCompare(right)) + .map((name) => { + const liveEntry = getSpawnEntry(params.memberSpawnStatuses, name); + const snapshotEntry = params.memberSpawnSnapshot?.statuses?.[name]; + return buildMemberLaunchDiagnosticsPayload({ + teamName: params.teamName, + runId: params.runId, + memberName: name, + member: membersByName.get(name), + spawnEntry: getPreferredSpawnEntry({ + liveEntry, + snapshotEntry, + snapshotUpdatedAt: params.memberSpawnSnapshot?.updatedAt, + }), + runtimeEntry: params.runtimeEntries?.[name], + }); + }); +} + export function hasMemberLaunchDiagnosticsDetails( payload: MemberLaunchDiagnosticsPayload ): boolean { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 0a20d8a3..9ae5647f 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -2249,7 +2249,7 @@ Messages: expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]); }); - it('fails OpenCode secondary rows with missing attachment payloads without text-only delivery', async () => { + it('fails OpenCode secondary rows with missing attachment payloads terminally without text-only delivery', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; hoisted.files.set(