chore(team): checkpoint launch stability work
This commit is contained in:
parent
869a443255
commit
fc3bd61f93
13 changed files with 753 additions and 44 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue