chore(team): checkpoint launch stability work

This commit is contained in:
777genius 2026-05-09 05:04:46 +03:00
parent 869a443255
commit fc3bd61f93
13 changed files with 753 additions and 44 deletions

View file

@ -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.

View file

@ -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;

View file

@ -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',
};
}

View file

@ -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 =

View file

@ -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');
}
});
});

View file

@ -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 };
}

View file

@ -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', () => {

View file

@ -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 }
);
}

View file

@ -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 =

View file

@ -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<string | null>(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={

View file

@ -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<RetryFailedOpenCodeSecondaryLanesResult>)
| 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,
};
}

View file

@ -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<string, MemberSpawnStatusEntry>
| Map<string, MemberSpawnStatusEntry>
| 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<string, MemberSpawnStatusEntry>;
updatedAt?: string;
};
runtimeEntries?: Record<string, TeamAgentRuntimeEntry> | 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<string>(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 {

View file

@ -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(