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

17 KiB

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:

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

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

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:

const contentBlocks = buildInlineInService(message, attachments);

After:

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:

const payload = JSON.stringify({
  type: 'user',
  message: {
    role: 'user',
    content: contentBlocks,
  },
});

this.attachmentBudgetValidator.assertSerializedPayloadWithinBudget(payload);

If blocked, return actionable error:

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:

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:

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:

private async sendMessageToRun(run, message, attachments) {
  const contentBlocks = [{ type: 'text', text: message }];
  // inline attachment serialization here
  stdin.write(JSON.stringify({ ...contentBlocks }) + '\n');
}

After:

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

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.

expect(toClaudeContentBlock(imageAttachment)).toEqual({
  type: 'image',
  source: {
    type: 'base64',
    media_type: 'image/png',
    data: '...',
  },
});

For text:

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.

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:

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:

prepareClaudeStreamJsonContentBlocks(input): Promise<Record<string, unknown>[]>

or a generic:

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.

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

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

const payload = buildClaudeStreamJsonPayload([{ type: 'text', text }, imageBlock]);
expect(() => validator.assertWithinBudget(payload)).not.toThrow();

Negative payload budget assertion

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:

legacy attachments -> Claude content blocks

Do not touch:

run selection
stdin lifecycle
process close handling
lead activity state
message persistence

Claude adapter detailed API

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

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.

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

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.