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. 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 ## Current image model policy
- Claude: image attachments are allowed through structured image blocks. - Claude: image attachments are allowed through structured image blocks.
@ -68,12 +77,14 @@ Run Codex native:
```bash ```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
node scripts/smoke/agent-attachments-smoke.mjs --case codex-native-gpt-5-4-mini-multi-image
``` ```
Run Claude subscription stream-json: Run Claude subscription stream-json:
```bash ```bash
node scripts/smoke/agent-attachments-smoke.mjs --case claude-subscription-streaming 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: Run OpenCode OpenAI:
@ -86,11 +97,12 @@ Run OpenRouter cases:
```bash ```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
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-4-5v
OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-glm-5-1-negative 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 ## Live verification record
@ -99,23 +111,29 @@ Latest local verification: 2026-05-09.
| Scope | Command or case | Result | Notes | | 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 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. | | 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 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 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`. | | 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. | | 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. | | 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 ## Release checklist
- Text-only messages still work for Claude, Codex, and OpenCode. - Text-only messages still work for Claude, Codex, and OpenCode.
- Oversized images fail before provider delivery. - Oversized images fail before provider delivery.
- Claude image send uses structured image blocks. - 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 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 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. - Attachment retry reuses the same artifacts or fails loudly.
- Copied diagnostics do not include base64 or data URLs. - 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'; import { deflateSync } from 'node:zlib';
const PROMPT = 'Look at the attached image. Reply with exactly one word: red, green, or blue.'; 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 TIMEOUT_MS = 90_000;
const CASES = [ const CASES = [
@ -13,7 +15,7 @@ const CASES = [
id: 'claude-subscription-streaming', id: 'claude-subscription-streaming',
runtime: 'claude', runtime: 'claude',
model: process.env.CLAUDE_ATTACHMENTS_SMOKE_CLAUDE_MODEL || 'claude-haiku-4-5', 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', bin: 'claude',
args: [ args: [
'-p', '-p',
@ -27,7 +29,32 @@ const CASES = [
testCase.model, testCase.model,
], ],
cwd, 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, expected: /red/i,
}, },
@ -53,6 +80,30 @@ const CASES = [
}), }),
expected: /red/i, 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', id: 'opencode-openai-gpt-5-4-mini',
runtime: 'opencode', runtime: 'opencode',
@ -75,6 +126,31 @@ const CASES = [
}), }),
expected: /red/i, 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', id: 'opencode-openrouter-kimi-k2-6',
runtime: 'opencode', runtime: 'opencode',
@ -196,24 +272,28 @@ function createRedCardPng(width = 320, height = 240) {
]); ]);
} }
async function buildClaudeStreamJsonPrompt(imagePath) { async function buildClaudeStreamJsonPrompt(imagePaths, prompt) {
const data = await readFile(imagePath, 'base64'); 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({ return `${JSON.stringify({
type: 'user', type: 'user',
message: { message: {
role: 'user', role: 'user',
content: [ content: [
{ type: 'text', text: PROMPT }, { type: 'text', text: prompt },
{ ...imageBlocks,
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,
},
},
], ],
}, },
})}\n`; })}\n`;
@ -286,6 +366,7 @@ function redactSmokeText(value) {
.replace(/(data:image\/[a-z0-9.+-]+;base64,)[a-z0-9+/=]+/gi, '$1[redacted]') .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(/("[Dd]ata"\s*:\s*")[a-z0-9+/=]{80,}(")/g, '$1[redacted]$2')
.replace(/("[Ss]ignature"\s*:\s*")[^"]{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(/(Authorization\s*[:=]\s*Bearer\s+)[^\s"']+/gi, '$1[redacted]')
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]{20,}/g, '$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]'); .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; 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() { async function main() {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
if (args.list) { if (args.list) {
@ -319,10 +440,9 @@ async function main() {
const cwd = await mkdtemp(path.join(tmpdir(), 'agent-attachments-smoke-')); const cwd = await mkdtemp(path.join(tmpdir(), 'agent-attachments-smoke-'));
await mkdir(cwd, { recursive: true }); await mkdir(cwd, { recursive: true });
const imagePath = path.join(cwd, 'red-card.png');
await writeFile(imagePath, createRedCardPng());
const results = []; const results = [];
const imagePathsByCase = {};
for (const testCase of selected) { for (const testCase of selected) {
const missingEnv = (testCase.envRequired ?? []).filter((name) => !process.env[name]); const missingEnv = (testCase.envRequired ?? []).filter((name) => !process.env[name]);
if (missingEnv.length) { if (missingEnv.length) {
@ -336,12 +456,18 @@ async function main() {
continue; 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 result = await runCommand(command);
const output = `${result.stdout}\n${result.stderr}`; 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 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; : false;
results.push({ results.push({
id: testCase.id, id: testCase.id,
@ -353,12 +479,14 @@ async function main() {
: 'failed', : 'failed',
exitCode: result.exitCode, exitCode: result.exitCode,
timedOut: result.timedOut, timedOut: result.timedOut,
assistantText: redactSmokeText(assistantText.slice(-1000)),
stdoutTail: redactSmokeText(result.stdout.slice(-4000)), stdoutTail: redactSmokeText(result.stdout.slice(-4000)),
stderrTail: redactSmokeText(result.stderr.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) { if (args.jsonPath) {
await writeFile(path.resolve(args.jsonPath), `${JSON.stringify(report, null, 2)}\n`); 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) => { main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error)); console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1; 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_PER_PROVIDER = 4 * 1024 * 1024;
const DEFAULT_IMAGE_BYTES_TOTAL = 8 * 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 { return {
supportsImages: true, supportsImages: true,
supportsFiles: false,
supportedImageMimeTypes: ['image/png', 'image/jpeg'], supportedImageMimeTypes: ['image/png', 'image/jpeg'],
supportedFileMimeTypes: [],
maxImages: 5, maxImages: 5,
maxFiles: 0,
maxBytesPerImage: DEFAULT_IMAGE_BYTES_PER_PROVIDER, maxBytesPerImage: DEFAULT_IMAGE_BYTES_PER_PROVIDER,
maxBytesPerFile: 0,
maxBytesTotal: DEFAULT_IMAGE_BYTES_TOTAL, maxBytesTotal: DEFAULT_IMAGE_BYTES_TOTAL,
reason: 'known_provider_support', reason: 'known_provider_support',
displayText, 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 { ): AgentAttachmentCapability {
return { return {
supportsImages: false, supportsImages: false,
supportsFiles: false,
supportedImageMimeTypes: [], supportedImageMimeTypes: [],
supportedFileMimeTypes: [],
maxImages: 0, maxImages: 0,
maxFiles: 0,
maxBytesPerImage: 0, maxBytesPerImage: 0,
maxBytesPerFile: 0,
maxBytesTotal: 0, maxBytesTotal: 0,
reason, reason,
displayText, 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(); const providerId = target.providerId.trim().toLowerCase();
if (providerId === 'anthropic') { 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') { 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') { if (providerId === 'opencode') {
const { model } = canonicalizeOpenCodeModel(target); const { model } = canonicalizeOpenCodeModel(target);
if (model === 'gpt-5.4-mini') { if (model === 'gpt-5.4-mini') {
return { 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', reason: 'known_vision_model',
}; };
} }
if (model === 'moonshotai/kimi-k2.6' || model === 'z-ai/glm-4.5v') { if (model === 'moonshotai/kimi-k2.6' || model === 'z-ai/glm-4.5v') {
return { return {
...supported(`OpenCode model ${model} is verified for image attachments.`), ...supportedImagesOnly(`OpenCode model ${model} is verified for image attachments.`),
reason: 'known_vision_model', 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 AgentImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp';
export type ProviderImageMimeType = 'image/png' | 'image/jpeg'; export type ProviderImageMimeType = 'image/png' | 'image/jpeg';
export type ProviderFileMimeType = 'application/pdf' | 'text/*';
export type AttachmentDeliveryFailureCode = export type AttachmentDeliveryFailureCode =
| 'attachment_too_large' | 'attachment_too_large'
@ -96,9 +97,13 @@ export interface AgentAttachmentCapabilityTarget {
export interface AgentAttachmentCapability { export interface AgentAttachmentCapability {
supportsImages: boolean; supportsImages: boolean;
supportsFiles: boolean;
supportedImageMimeTypes: ProviderImageMimeType[]; supportedImageMimeTypes: ProviderImageMimeType[];
supportedFileMimeTypes: ProviderFileMimeType[];
maxImages: number; maxImages: number;
maxFiles: number;
maxBytesPerImage: number; maxBytesPerImage: number;
maxBytesPerFile: number;
maxBytesTotal: number; maxBytesTotal: number;
reason: reason:
| 'known_provider_support' | 'known_provider_support'
@ -107,6 +112,7 @@ export interface AgentAttachmentCapability {
| 'unknown_model' | 'unknown_model'
| 'unsupported_provider'; | 'unsupported_provider';
displayText: string; displayText: string;
filesDisplayText: string;
} }
export type AttachmentValidationResult = export type AttachmentValidationResult =

View file

@ -70,4 +70,69 @@ describe('agent attachment validation', () => {
warnings: [], 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); 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 { export function classifyAttachmentMime(mimeType: string): AgentAttachmentKind {
if (isAgentImageMimeType(mimeType)) return 'image'; if (isAgentImageMimeType(mimeType)) return 'image';
if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) { if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) {
@ -85,6 +91,42 @@ export function validateAttachmentForCapability(input: {
const warnings = [...attachment.warnings]; const warnings = [...attachment.warnings];
if (attachment.kind !== 'image') { 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 }; 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', () => { it('rejects unsupported image mime types before provider send', () => {
expect(() => expect(() =>
buildClaudeAttachmentDeliveryParts({ buildClaudeAttachmentDeliveryParts({
text: 'see gif', text: 'see gif',
attachments: [attachment({ mimeType: 'image/gif' })], attachments: [attachment({ mimeType: 'image/gif' })],
}) })
).toThrow(/Claude image MIME unsupported/); ).toThrow(/Claude attachment MIME unsupported/);
}); });
it('redacts image and document bytes in diagnostics', () => { it('redacts image and document bytes in diagnostics', () => {

View file

@ -52,7 +52,7 @@ export function buildClaudeAttachmentDeliveryParts(input: {
continue; continue;
} }
if (attachment.mimeType === 'text/plain') { if (attachment.mimeType === 'text/plain' || attachment.mimeType.startsWith('text/')) {
const decoded = decodeBase64Text(attachment.data); const decoded = decodeBase64Text(attachment.data);
contentBlocks.push( contentBlocks.push(
decoded.ok decoded.ok
@ -94,7 +94,7 @@ export function buildClaudeAttachmentDeliveryParts(input: {
throw new AgentAttachmentError( throw new AgentAttachmentError(
'attachment_type_unsupported', 'attachment_type_unsupported',
`Claude image MIME unsupported: ${attachment.mimeType}`, `Claude attachment MIME unsupported: ${attachment.mimeType}`,
{ attachmentId: attachment.id, retryable: false } { attachmentId: attachment.id, retryable: false }
); );
} }

View file

@ -22,6 +22,7 @@ import { DISPLAY_STEPS } from './provisioningSteps';
import { StepProgressBar } from './StepProgressBar'; import { StepProgressBar } from './StepProgressBar';
import type { StepProgressBarStep } from './StepProgressBar'; import type { StepProgressBarStep } from './StepProgressBar';
import type { MemberLaunchDiagnosticsPayload } from '@renderer/utils/memberLaunchDiagnostics';
import type { TeamLaunchDiagnosticItem } from '@shared/types'; import type { TeamLaunchDiagnosticItem } from '@shared/types';
/** Pre-built step definitions for the provisioning stepper. */ /** Pre-built step definitions for the provisioning stepper. */
@ -36,6 +37,7 @@ const SECRET_FLAG_PATTERN =
const SECRET_ENV_ASSIGNMENT_PATTERN = const SECRET_ENV_ASSIGNMENT_PATTERN =
/\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)[A-Z0-9_]*\s*=\s*)("[^"]*"|'[^']*'|\S+)/gi; /\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 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 { export interface ProvisioningProgressBlockProps {
/** Title above the steps, e.g. "Launching team" */ /** Title above the steps, e.g. "Launching team" */
@ -74,6 +76,8 @@ export interface ProvisioningProgressBlockProps {
assistantOutput?: string; assistantOutput?: string;
/** Bounded structured launch diagnostics */ /** Bounded structured launch diagnostics */
launchDiagnostics?: TeamLaunchDiagnosticItem[]; launchDiagnostics?: TeamLaunchDiagnosticItem[];
/** Bounded per-member launch/runtime diagnostics for copy payloads. */
memberDiagnostics?: MemberLaunchDiagnosticsPayload[];
/** Visual surface chrome for the outer block */ /** Visual surface chrome for the outer block */
surface?: 'raised' | 'flat'; surface?: 'raised' | 'flat';
className?: string; className?: string;
@ -153,7 +157,8 @@ function redactProvisioningDiagnosticsCopy(text: string): string {
.replace(PROVIDER_API_KEY_FLAG_PATTERN, '$1[redacted]') .replace(PROVIDER_API_KEY_FLAG_PATTERN, '$1[redacted]')
.replace(SECRET_FLAG_PATTERN, '$1[redacted]') .replace(SECRET_FLAG_PATTERN, '$1[redacted]')
.replace(SECRET_ENV_ASSIGNMENT_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 { function formatOptionalValue(value: string | number | null | undefined): string {
@ -187,6 +192,15 @@ function formatLaunchDiagnosticsCopy(
.join('\n'); .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: { function buildProvisioningDiagnosticsCopy(input: {
title: string; title: string;
message?: string | null; message?: string | null;
@ -200,6 +214,7 @@ function buildProvisioningDiagnosticsCopy(input: {
liveOutput?: string | null; liveOutput?: string | null;
cliLogsTail?: string; cliLogsTail?: string;
launchDiagnostics?: TeamLaunchDiagnosticItem[]; launchDiagnostics?: TeamLaunchDiagnosticItem[];
memberDiagnostics?: MemberLaunchDiagnosticsPayload[];
}): string { }): string {
const payload = [ const payload = [
'# Team provisioning diagnostics', '# Team provisioning diagnostics',
@ -218,6 +233,9 @@ function buildProvisioningDiagnosticsCopy(input: {
'## Launch diagnostics', '## Launch diagnostics',
formatLaunchDiagnosticsCopy(input.launchDiagnostics), formatLaunchDiagnosticsCopy(input.launchDiagnostics),
'', '',
'## Member launch snapshots',
formatMemberDiagnosticsCopy(input.memberDiagnostics),
'',
'## Live output', '## Live output',
input.liveOutput?.trim() || '(empty)', input.liveOutput?.trim() || '(empty)',
'', '',
@ -247,6 +265,7 @@ export const ProvisioningProgressBlock = ({
cliLogsTail, cliLogsTail,
assistantOutput, assistantOutput,
launchDiagnostics, launchDiagnostics,
memberDiagnostics,
surface = 'raised', surface = 'raised',
className, className,
}: ProvisioningProgressBlockProps): React.JSX.Element => { }: ProvisioningProgressBlockProps): React.JSX.Element => {
@ -274,6 +293,7 @@ export const ProvisioningProgressBlock = ({
liveOutput: displayAssistantOutput, liveOutput: displayAssistantOutput,
cliLogsTail, cliLogsTail,
launchDiagnostics, launchDiagnostics,
memberDiagnostics,
}), }),
[ [
title, title,
@ -288,6 +308,7 @@ export const ProvisioningProgressBlock = ({
displayAssistantOutput, displayAssistantOutput,
cliLogsTail, cliLogsTail,
launchDiagnostics, launchDiagnostics,
memberDiagnostics,
] ]
); );
const visibleLaunchDiagnostics = const visibleLaunchDiagnostics =

View file

@ -45,8 +45,13 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({
className, className,
defaultLogsOpen, defaultLogsOpen,
}: TeamProvisioningPanelProps): React.JSX.Element | null { }: TeamProvisioningPanelProps): React.JSX.Element | null {
const { presentation, cancelProvisioning, retryFailedOpenCodeSecondaryLanes, runInstanceKey } = const {
useTeamProvisioningPresentation(teamName); presentation,
cancelProvisioning,
retryFailedOpenCodeSecondaryLanes,
memberDiagnostics,
runInstanceKey,
} = useTeamProvisioningPresentation(teamName);
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
const [retryingOpenCode, setRetryingOpenCode] = useState(false); const [retryingOpenCode, setRetryingOpenCode] = useState(false);
const [openCodeRetryMessage, setOpenCodeRetryMessage] = useState<string | null>(null); const [openCodeRetryMessage, setOpenCodeRetryMessage] = useState<string | null>(null);
@ -134,6 +139,7 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({
cliLogsTail={presentation.progress.cliLogsTail} cliLogsTail={presentation.progress.cliLogsTail}
assistantOutput={presentation.progress.assistantOutput} assistantOutput={presentation.progress.assistantOutput}
launchDiagnostics={presentation.progress.launchDiagnostics} launchDiagnostics={presentation.progress.launchDiagnostics}
memberDiagnostics={memberDiagnostics}
defaultLiveOutputOpen={presentation.defaultLiveOutputOpen} defaultLiveOutputOpen={presentation.defaultLiveOutputOpen}
defaultLogsOpen={defaultLogsOpen} defaultLogsOpen={defaultLogsOpen}
onCancel={ onCancel={

View file

@ -5,9 +5,11 @@ import {
getCurrentProvisioningProgressForTeam, getCurrentProvisioningProgressForTeam,
selectTeamMemberSnapshotsForName, selectTeamMemberSnapshotsForName,
} from '@renderer/store/slices/teamSlice'; } from '@renderer/store/slices/teamSlice';
import { buildTeamMemberLaunchDiagnosticsPayloads } from '@renderer/utils/memberLaunchDiagnostics';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import type { MemberLaunchDiagnosticsPayload } from '@renderer/utils/memberLaunchDiagnostics';
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import type { RetryFailedOpenCodeSecondaryLanesResult } from '@shared/types'; import type { RetryFailedOpenCodeSecondaryLanesResult } from '@shared/types';
@ -17,6 +19,7 @@ export function useTeamProvisioningPresentation(teamName: string): {
retryFailedOpenCodeSecondaryLanes: retryFailedOpenCodeSecondaryLanes:
| ((teamName: string) => Promise<RetryFailedOpenCodeSecondaryLanesResult>) | ((teamName: string) => Promise<RetryFailedOpenCodeSecondaryLanesResult>)
| null; | null;
memberDiagnostics: MemberLaunchDiagnosticsPayload[];
runInstanceKey: string | null; runInstanceKey: string | null;
} { } {
const { const {
@ -26,6 +29,7 @@ export function useTeamProvisioningPresentation(teamName: string): {
teamMembers, teamMembers,
memberSpawnStatuses, memberSpawnStatuses,
memberSpawnSnapshot, memberSpawnSnapshot,
runtimeSnapshot,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
progress: getCurrentProvisioningProgressForTeam(s, teamName), progress: getCurrentProvisioningProgressForTeam(s, teamName),
@ -34,6 +38,7 @@ export function useTeamProvisioningPresentation(teamName: string): {
teamMembers: selectTeamMemberSnapshotsForName(s, teamName), teamMembers: selectTeamMemberSnapshotsForName(s, teamName),
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
runtimeSnapshot: s.teamAgentRuntimeByTeam?.[teamName],
})) }))
); );
@ -47,11 +52,31 @@ export function useTeamProvisioningPresentation(teamName: string): {
}), }),
[memberSpawnSnapshot, memberSpawnStatuses, progress, teamMembers] [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 { return {
presentation, presentation,
cancelProvisioning, cancelProvisioning,
retryFailedOpenCodeSecondaryLanes: retryFailedOpenCodeSecondaryLanes ?? null, retryFailedOpenCodeSecondaryLanes: retryFailedOpenCodeSecondaryLanes ?? null,
memberDiagnostics,
runInstanceKey: progress ? `${teamName}:${progress.runId}:${progress.startedAt}` : null, runInstanceKey: progress ? `${teamName}:${progress.runId}:${progress.startedAt}` : null,
}; };
} }

View file

@ -13,9 +13,25 @@ export interface MemberLaunchDiagnosticsPayload {
teamName?: string; teamName?: string;
runId?: string; runId?: string;
memberName: string; memberName: string;
providerId?: string;
providerBackendId?: string;
model?: string;
runtimeModel?: string;
agentType?: string;
laneId?: string;
laneKind?: 'primary' | 'secondary';
laneOwnerProviderId?: string;
removedAt?: number;
memberCardError?: string; memberCardError?: string;
launchState?: MemberLaunchState; launchState?: MemberLaunchState;
spawnStatus?: MemberSpawnStatus; spawnStatus?: MemberSpawnStatus;
backendType?: string;
alive?: boolean;
restartable?: boolean;
runtimeAlive?: boolean;
bootstrapConfirmed?: boolean;
agentToolAccepted?: boolean;
hardFailure?: boolean;
livenessKind?: TeamAgentRuntimeLivenessKind; livenessKind?: TeamAgentRuntimeLivenessKind;
livenessSource?: MemberSpawnLivenessSource; livenessSource?: MemberSpawnLivenessSource;
pid?: number; pid?: number;
@ -26,17 +42,49 @@ export interface MemberLaunchDiagnosticsPayload {
processCommand?: string; processCommand?: string;
runtimePid?: number; runtimePid?: number;
runtimeSessionId?: string; runtimeSessionId?: string;
runtimeLeaseExpiresAt?: string;
runtimeLastSeenAt?: string;
historicalBootstrapConfirmed?: boolean;
cwd?: string;
rssBytes?: number;
runtimeDiagnostic?: string; runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
bootstrapStalled?: boolean; bootstrapStalled?: boolean;
pendingPermissionRequestIds?: string[];
firstSpawnAcceptedAt?: string;
lastHeartbeatAt?: string;
livenessLastCheckedAt?: string;
probableCause?: string;
diagnosticHints?: string[];
diagnostics?: string[]; diagnostics?: string[];
spawnUpdatedAt?: string;
runtimeUpdatedAt?: string;
updatedAt?: string; updatedAt?: string;
} }
const MAX_DIAGNOSTIC_STRING_LENGTH = 500; const MAX_DIAGNOSTIC_STRING_LENGTH = 500;
const MAX_DIAGNOSTIC_ITEMS = 20; const MAX_DIAGNOSTIC_ITEMS = 20;
const MAX_PERMISSION_REQUEST_IDS = 10;
const SECRET_FLAG_PATTERN = const SECRET_FLAG_PATTERN =
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; /(--(?: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( function boundedString(
value: string | undefined, value: string | undefined,
@ -44,7 +92,9 @@ function boundedString(
): string | undefined { ): string | undefined {
const trimmed = value?.replace(/\s+/g, ' ').trim(); const trimmed = value?.replace(/\s+/g, ' ').trim();
if (!trimmed) return undefined; 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 return redacted.length > maxLength
? `${redacted.slice(0, Math.max(0, maxLength - 3))}...` ? `${redacted.slice(0, Math.max(0, maxLength - 3))}...`
: redacted; : redacted;
@ -56,6 +106,21 @@ function boundedNumber(value: number | undefined): number | undefined {
: 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 { export function normalizeMemberLaunchFailureReason(value: string | undefined): string | null {
const normalized = value const normalized = value
?.replace(/\s+/g, ' ') ?.replace(/\s+/g, ' ')
@ -84,10 +149,91 @@ function uniqueDiagnostics(
return diagnostics.length > 0 ? diagnostics : undefined; 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: { export function buildMemberLaunchDiagnosticsPayload(params: {
teamName?: string | null; teamName?: string | null;
runId?: string | null; runId?: string | null;
memberName: string; memberName: string;
member?: MemberDiagnosticsMemberLike;
spawnStatus?: MemberSpawnStatus; spawnStatus?: MemberSpawnStatus;
launchState?: MemberLaunchState; launchState?: MemberLaunchState;
livenessSource?: MemberSpawnLivenessSource; livenessSource?: MemberSpawnLivenessSource;
@ -117,21 +263,67 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
runtimeEntry?.diagnostics runtimeEntry?.diagnostics
); );
const runId = boundedString(params.runId ?? undefined); 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 { return {
...(params.teamName ? { teamName: params.teamName } : {}), ...(params.teamName ? { teamName: params.teamName } : {}),
...(runId ? { runId } : {}), ...(runId ? { runId } : {}),
memberName: params.memberName, 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 } : {}), ...(memberCardError ? { memberCardError } : {}),
...((spawnEntry?.launchState ?? params.launchState) ...(launchState ? { launchState } : {}),
? { launchState: spawnEntry?.launchState ?? params.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) ...(typeof spawnEntry?.runtimeAlive === 'boolean'
? { spawnStatus: spawnEntry?.status ?? params.spawnStatus } ? { runtimeAlive: spawnEntry.runtimeAlive }
: {}), : {}),
...((spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind) ...(typeof spawnEntry?.bootstrapConfirmed === 'boolean'
? { livenessKind: spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind } ? { bootstrapConfirmed: spawnEntry.bootstrapConfirmed }
: {}), : {}),
...(typeof spawnEntry?.agentToolAccepted === 'boolean'
? { agentToolAccepted: spawnEntry.agentToolAccepted }
: {}),
...(typeof spawnEntry?.hardFailure === 'boolean'
? { hardFailure: spawnEntry.hardFailure }
: {}),
...(livenessKind ? { livenessKind } : {}),
...((spawnEntry?.livenessSource ?? params.livenessSource) ...((spawnEntry?.livenessSource ?? params.livenessSource)
? { livenessSource: spawnEntry?.livenessSource ?? params.livenessSource } ? { livenessSource: spawnEntry?.livenessSource ?? params.livenessSource }
: {}), : {}),
@ -153,6 +345,23 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
...(boundedString(runtimeEntry?.runtimeSessionId) ...(boundedString(runtimeEntry?.runtimeSessionId)
? { runtimeSessionId: 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 } : {}), ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
...((spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity) ...((spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity)
? { ? {
@ -161,13 +370,133 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
} }
: {}), : {}),
...(spawnEntry?.bootstrapStalled === true ? { bootstrapStalled: true } : {}), ...(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 } : {}), ...(diagnostics ? { diagnostics } : {}),
...(spawnUpdatedAt ? { spawnUpdatedAt } : {}),
...(runtimeUpdatedAt ? { runtimeUpdatedAt } : {}),
...(boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt) ...(boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt)
? { updatedAt: 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( export function hasMemberLaunchDiagnosticsDetails(
payload: MemberLaunchDiagnosticsPayload payload: MemberLaunchDiagnosticsPayload
): boolean { ): boolean {

View file

@ -2249,7 +2249,7 @@ Messages:
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]); 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 service = new TeamProvisioningService();
const teamName = 'my-team'; const teamName = 'my-team';
hoisted.files.set( hoisted.files.set(