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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue