agent-ecosystem/docs/team-management/agent-attachments-phase-2-claude-stream-json-plan.md

615 lines
17 KiB
Markdown

# Phase 2 - Claude stream-json attachment delivery adapter
## Summary
Goal: route existing Claude lead attachment delivery through the new attachment planner, preserving current stream-json content block behavior while adding deterministic budgets and diagnostics.
Chosen approach: **extract current Claude serialization into `ClaudeStreamJsonAttachmentAdapter` and call it from `TeamProvisioningService.sendMessageToRun()`**.
🎯 9.0 🛡️ 8.8 🧠 5.8
Estimated change size: `180-320` LOC.
This phase should not change launch, bootstrap, provider auth, or teammate liveness. It only replaces ad-hoc attachment block assembly with a tested adapter.
## Current behavior to preserve
Current path in `TeamProvisioningService.sendMessageToRun()` builds content blocks:
```ts
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
if (att.mimeType === 'application/pdf') {
contentBlocks.push({
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: att.data,
},
title: att.filename,
});
} else if (att.mimeType === 'text/plain') {
// text or base64 document
} else {
contentBlocks.push({
type: 'image',
source: {
type: 'base64',
media_type: att.mimeType,
data: att.data,
},
});
}
```
Keep the same Claude content block shape.
## Why use adapter
`TeamProvisioningService` should not know image optimization or provider-specific attachment serialization details. Its responsibility is team lifecycle and message routing.
The adapter gives:
- unit-testable serialization;
- budget diagnostics before stdin write;
- future support for variant selection;
- less risk when adding Codex/OpenCode adapters.
## New adapter sketch
```ts
export class ClaudeStreamJsonAttachmentAdapter implements AttachmentDeliveryAdapter {
readonly runtimeKind = 'claude-stream-json' as const;
canDeliver(
ctx: AttachmentRuntimeContext,
attachment: NormalizedAgentAttachment,
): AttachmentCapabilityDecision {
if (attachment.kind === 'image') {
return allowIfMime(attachment, ['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
}
if (attachment.kind === 'document' || attachment.kind === 'text') {
return allow();
}
return block('This attachment type is not supported by Claude.');
}
async prepare(
ctx: AttachmentRuntimeContext,
attachment: NormalizedAgentAttachment,
): Promise<PreparedAttachmentPart> {
const variant = selectClaudeVariant(attachment);
return {
runtimeKind: this.runtimeKind,
attachmentId: attachment.id,
part: {
kind: 'claude-content-block',
value: toClaudeContentBlock(attachment, variant),
},
diagnostics: [`prepared ${attachment.kind} for Claude stream-json`],
};
}
}
```
## Serialization helpers
```ts
function toClaudeContentBlock(
attachment: NormalizedAgentAttachment,
variant: AgentAttachmentVariant,
): Record<string, unknown> {
if (attachment.kind === 'image') {
return {
type: 'image',
source: {
type: 'base64',
media_type: variant.mimeType,
data: readBase64Variant(variant),
},
};
}
if (attachment.kind === 'text') {
return {
type: 'document',
source: {
type: 'text',
media_type: 'text/plain',
data: readTextVariant(variant),
},
title: attachment.originalName,
};
}
return {
type: 'document',
source: {
type: 'base64',
media_type: attachment.mimeType,
data: readBase64Variant(variant),
},
title: attachment.originalName,
};
}
```
## `sendMessageToRun` target shape
Before:
```ts
const contentBlocks = buildInlineInService(message, attachments);
```
After:
```ts
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
if (attachments?.length) {
const prepared = await this.attachmentDeliveryPlanner.prepareAll(
{
teamName: run.teamName,
providerId: run.providerId,
modelId: run.model,
runtimeKind: 'claude-stream-json',
deliveryTarget: 'lead',
},
await this.attachmentNormalizer.normalizeLegacyPayloads(attachments),
);
for (const part of prepared) {
if (part.part.kind !== 'claude-content-block') {
throw new Error('Internal attachment planner returned non-Claude part for Claude runtime');
}
contentBlocks.push(part.part.value);
}
}
```
## Payload write safety
Before writing stdin:
```ts
const payload = JSON.stringify({
type: 'user',
message: {
role: 'user',
content: contentBlocks,
},
});
this.attachmentBudgetValidator.assertSerializedPayloadWithinBudget(payload);
```
If blocked, return actionable error:
```text
Attachments are too large for Claude stream-json input after optimization. Remove one image or send a smaller screenshot.
```
## Edge cases
### Existing text-only sends
No change. If `attachments` is empty, the planner is not called.
### Existing PDF support
Keep current content block shape. Do not optimize PDFs in this phase.
### Non-UTF text files
Keep current behavior: try UTF-8, fallback to base64 document if replacement characters appear.
### Runtime process exits after send
Do not attribute exit to attachment unless the error path can prove stdin write/payload size failure. This phase should only make pre-send failures visible.
### Claude image support in wrong mode
Team lead is long-lived stream-json, so supported. Do not use `claude -p` as e2e validation for this path.
### Multiple images
Send all if under budget. If over budget, send none.
## Diagnostics
Add bounded diagnostics only:
```text
Prepared 2 attachments for Claude stream-json: image/jpeg 612KB, image/png 124KB.
```
Never log:
- base64 content;
- full file paths unless already user-selected and safe;
- API keys;
- raw JSON payload.
## Test plan
### Unit
- image attachment serializes to Claude `image` block;
- PDF serializes to Claude `document` block;
- UTF-8 text serializes to `document` text source;
- non-UTF text falls back to base64 document;
- planner rejects unsupported mime;
- serialized payload over budget rejects before stdin write.
### Service tests
- text-only `sendMessageToRun` does not call planner;
- safe image calls planner and writes stream-json with image block;
- over-budget image throws user-visible error and does not write stdin;
- failure does not mark team offline by itself.
Suggested focused checks:
```bash
pnpm vitest run src/features/agent-attachments/**/*.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/ipc/teams.test.ts
pnpm typecheck --pretty false
```
## Safety checklist
- Current Claude content block schema preserved.
- No Codex/OpenCode paths touched.
- No launch/provisioning path touched.
- No live provider calls in unit tests.
- Existing UI attachment workflow remains compatible.
## Deep implementation details
### Refactor target
The desired refactor is small and reversible.
Before:
```ts
private async sendMessageToRun(run, message, attachments) {
const contentBlocks = [{ type: 'text', text: message }];
// inline attachment serialization here
stdin.write(JSON.stringify({ ...contentBlocks }) + '\n');
}
```
After:
```ts
private async sendMessageToRun(run, message, attachments) {
const contentBlocks = await this.buildClaudeLeadContentBlocks(run, message, attachments);
const payload = this.buildClaudeStreamJsonUserPayload(contentBlocks);
this.agentAttachments.assertPayloadBudget(payload, { runtime: 'claude-stream-json' });
await this.writeToLeadStdin(run, payload);
}
```
This keeps `sendMessageToRun()` readable and moves serialization into testable helpers.
### Helper extraction plan
```ts
private async buildClaudeLeadContentBlocks(
run: ProvisioningRun,
message: string,
attachments?: LegacyAttachmentPayload[],
): Promise<Record<string, unknown>[]> {
const blocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
if (!attachments?.length) return blocks;
const prepared = await this.agentAttachments.prepareForRuntime({
teamName: run.teamName,
providerId: run.providerId,
modelId: run.model,
runtimeKind: 'claude-stream-json',
deliveryTarget: 'lead',
}, attachments);
for (const item of prepared) {
assertPreparedPartKind(item, 'claude-content-block');
blocks.push(item.part.value);
}
return blocks;
}
```
### Content block compatibility tests
Snapshot the exact old shape.
```ts
expect(toClaudeContentBlock(imageAttachment)).toEqual({
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: '...',
},
});
```
For text:
```ts
expect(toClaudeContentBlock(textAttachment)).toEqual({
type: 'document',
source: {
type: 'text',
media_type: 'text/plain',
data: 'hello',
},
title: 'notes.txt',
});
```
### Error handling
Use typed attachment errors and convert at IPC boundary.
```ts
try {
await service.sendMessageToTeam(teamName, message, attachments);
} catch (error) {
if (isAttachmentValidationError(error)) {
throw new Error(error.userMessage);
}
throw error;
}
```
Do not catch and convert provider/runtime errors here.
### More edge cases
| Edge case | Expected behavior |
|---|---|
| Claude lead is alive but stdin not writable | existing `process stdin is not writable` error wins |
| Payload over budget | no stdin write, no message marked delivered |
| Attachment adapter throws unsupported mime | user-visible attachment error, team remains alive |
| Claude process exits after successful stdin write | existing runtime process close handling owns it |
| PDF title contains slash/newline | sanitized title in content block |
| Text file is empty | send empty text document or block? Prefer send with warning `empty text file` |
| Message text empty but image present | allow if composer supports image-only send; text block can be empty or omitted consistently |
| Multiple attachments include one invalid | block all, do not partial-send |
| Optimized variant missing | rebuild from legacy base64 or block with retryable local error |
### Why not change delivery proof
Claude lead message delivery currently depends on process stdin write and subsequent assistant stream/result. This phase does not add proof. It only makes payload construction safe.
Do not add new notifications like “image delivered” because it would imply semantic understanding.
### Regression traps
- Accidentally using optimized JPEG for transparent PNG without user-visible warning.
- Forgetting to include `title` for documents.
- Throwing generic `Internal attachment planner returned...` to user instead of diagnostics.
- Double-validating text-only messages and blocking them due missing attachment metadata.
- Logging full stream-json payload in debug output.
## File-by-file implementation plan
### 1. Add adapter
Create:
```text
src/features/agent-attachments/main/adapters/output/ClaudeStreamJsonAttachmentAdapter.ts
```
This file should depend only on feature contracts/core and small shared helpers.
### 2. Add facade method
In feature composition, expose:
```ts
prepareClaudeStreamJsonContentBlocks(input): Promise<Record<string, unknown>[]>
```
or a generic:
```ts
prepareForRuntime(ctx, attachments): Promise<PreparedAttachmentPart[]>
```
Prefer generic if Phase 3/4 will reuse it soon. Prefer Claude-specific if generic abstraction becomes too abstract too early. The plan's recommendation remains generic, but keep the public facade small.
### 3. Update TeamProvisioningService
Change only the attachment serialization part of `sendMessageToRun()`.
Do not change:
- run tracking;
- process liveness checks;
- stdin writable checks;
- lead activity updates;
- close/error handling.
### 4. Add focused tests
Update existing `TeamProvisioningService.test.ts` only around send message attachment cases. Add adapter unit tests under feature tests.
## Compatibility shim
Because Phase 1 may still use legacy payloads, adapter should accept normalized attachments from a shim.
```ts
async function normalizeForClaudeAdapter(
legacy: LegacyTeamMessageAttachment[],
): Promise<NormalizedAgentAttachment[]> {
return this.normalizer.normalizeLegacyPayloads(legacy, {
preferredRuntime: 'claude-stream-json',
});
}
```
## Detailed failure cases and expected messages
| Failure | User message | Internal diagnostic |
|---|---|---|
| payload over serialized budget | `Attachments are too large for Claude input after optimization.` | include estimated bytes and limit |
| unsupported MIME | `This attachment type is not supported by Claude.` | include MIME and filename sanitized |
| corrupt image missed by renderer | `Cannot send image because it could not be decoded.` | include attachment id only |
| stdin not writable | existing `Team process stdin is not writable` | not attachment diagnostic |
| Claude API says image invalid | preserve provider error | not rewritten as optimizer error |
## Review checklist
- Adapter output equals previous content block shape for same input.
- Payload budget check happens before `stdin.write`.
- Error handling does not mark team offline.
- No base64 in thrown error message.
- No tests require Claude live auth.
- Text-only send test still passes without creating feature attachments.
## More examples
### Image block
```ts
const block = adapter.toClaudeContentBlock(imageAttachment);
expect(block).toMatchObject({
type: 'image',
source: {
type: 'base64',
media_type: 'image/jpeg',
},
});
expect(String((block.source as any).data)).toHaveLength(imageBase64.length);
```
### Full payload budget assertion
```ts
const payload = buildClaudeStreamJsonPayload([{ type: 'text', text }, imageBlock]);
expect(() => validator.assertWithinBudget(payload)).not.toThrow();
```
### Negative payload budget assertion
```ts
const huge = makeFakeBase64(8_000_000);
expect(() => buildAndValidatePayload(huge)).toThrowAgentAttachmentError(
'attachment_serialized_payload_too_large',
);
```
## Phase 2 exit criteria
Phase 2 is complete only when:
- old Claude image/PDF/text content block shapes are preserved;
- text-only sends bypass attachment adapter;
- oversized attachment blocks before stdin write;
- adapter errors do not mark team offline;
- copied diagnostics include attachment summary but no base64;
- no Codex/OpenCode path changes are included.
## Migration seam
Replace only this concern in `TeamProvisioningService`:
```text
legacy attachments -> Claude content blocks
```
Do not touch:
```text
run selection
stdin lifecycle
process close handling
lead activity state
message persistence
```
## Claude adapter detailed API
```ts
export interface ClaudeContentBlockBuildInput {
messageText: string;
attachments: NormalizedAgentAttachment[];
budget: AgentAttachmentBudget;
}
export interface ClaudeContentBlockBuildOutput {
contentBlocks: Record<string, unknown>[];
estimatedSerializedBytes: number;
diagnostics: string[];
}
```
This allows tests to assert payload size without writing to stdin.
## Safe payload builder
```ts
export function buildClaudeStreamJsonUserPayload(
contentBlocks: Record<string, unknown>[],
): string {
return JSON.stringify({
type: 'user',
message: {
role: 'user',
content: contentBlocks,
},
});
}
```
Keep this helper tiny and deterministic.
## Stdin write failure handling
Attachment errors happen before write. Stdin write errors are runtime errors.
```ts
try {
const payload = buildClaudeStreamJsonUserPayload(blocks);
this.agentAttachments.assertPayloadBudget(payload);
await writeLine(stdin, payload);
} catch (error) {
if (isAgentAttachmentError(error)) throw error;
throw new Error(`Team "${run.teamName}" process stdin is not writable`);
}
```
Do not wrap provider/runtime errors as attachment errors.
## More Claude-specific edge cases
| Edge case | Expected behavior |
|---|---|
| `image/webp` sent to Claude | allow only if current existing path allowed it; otherwise block consistently |
| `image/gif` animated | preserve existing behavior if under budget, but warn in Phase 1 |
| empty message with image | allow only if current composer allows it; otherwise composer-level validation |
| PDF over budget | block with attachment size message |
| text file with invalid UTF-8 | fallback base64 document as current code did |
| Claude returns `Could not process image` | show provider error, do not blame optimizer unless image validation failed locally |
| CLI output includes image processing error | include bounded stderr tail in diagnostics through existing runtime mechanisms |
## Test skeleton for no stdin write on budget failure
```ts
it('does not write to stdin when attachment payload exceeds Claude budget', async () => {
const stdin = fakeWritable();
await expect(service.sendMessageToRun(runWithStdin(stdin), 'x', [hugeImage]))
.rejects.toThrow(/too large/i);
expect(stdin.write).not.toHaveBeenCalled();
});
```
## Code review notes
If the diff shows a new `if (mimeType)` ladder inside `TeamProvisioningService`, the refactor failed. That logic belongs in adapter/helper tests.