Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
TerminalReplayEvent,
ToolCall,
} from '@linkcode/schema';
import { AGENT_INPUT_CAPABILITIES } from '@linkcode/schema';
import { nullthrow } from 'foxts/guard';
import { wait } from 'foxts/wait';
import { describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -81,7 +82,7 @@ describe('dev mock transport', () => {
expect(seededEvents[0]).toEqual({ type: 'status', status: 'idle' });
expect(seededEvents[3]).toEqual({
type: 'capabilities-update',
capabilities: { slashCommands: true, shellCommand: false },
capabilities: AGENT_INPUT_CAPABILITIES['claude-code'],
});
expect(seededEvents[4]).toMatchObject({
type: 'available-commands-update',
Expand Down Expand Up @@ -212,7 +213,7 @@ describe('dev mock transport', () => {

expect(events).toContainEqual({
type: 'capabilities-update',
capabilities: { slashCommands: true, shellCommand: true },
capabilities: AGENT_INPUT_CAPABILITIES.codex,
});
expect(events.find((event) => event.type === 'available-commands-update')).toEqual({
type: 'available-commands-update',
Expand Down Expand Up @@ -316,7 +317,7 @@ describe('dev mock transport', () => {
{ type: 'effort-update', effort: 'medium' },
{
type: 'capabilities-update',
capabilities: { slashCommands: true, shellCommand: true },
capabilities: AGENT_INPUT_CAPABILITIES.codex,
},
expect.objectContaining({ type: 'available-commands-update' }),
]);
Expand Down Expand Up @@ -516,7 +517,7 @@ describe('dev mock transport', () => {
await eventually(() => events.some((event) => event.type === 'capabilities-update'));
expect(events).toContainEqual({
type: 'capabilities-update',
capabilities: { slashCommands: true, shellCommand: true },
capabilities: AGENT_INPUT_CAPABILITIES.codex,
});
expect(events.some((event) => event.type === 'available-commands-update')).toBe(true);
const terminalId = await eventually(() => {
Expand Down Expand Up @@ -797,7 +798,7 @@ describe('dev mock transport', () => {
{ type: 'effort-update', effort: 'xhigh' },
{
type: 'capabilities-update',
capabilities: { slashCommands: true, shellCommand: true },
capabilities: AGENT_INPUT_CAPABILITIES.codex,
},
expect.objectContaining({ type: 'available-commands-update' }),
]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import type { AgentCapabilities } from '../agent/input';
import {
AGENT_INPUT_CAPABILITIES,
AgentCapabilitiesSchema,
effectiveAttachmentCapability,
} from '../agent/input';
import {
AttachmentCapabilitySchema,
HOST_ATTACHMENT_LIMITS,
intersectAttachmentCapability,
} from '../attachment';
import { MAX_ATTACHMENT_BYTES, SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES } from '../content';

const imageCapability = {
kinds: {
image: {
mimeTypes: [...SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES],
maxBytes: MAX_ATTACHMENT_BYTES,
maxCount: 16,
},
},
representations: ['inline_image'] as const,
};

describe('AttachmentCapability', () => {
it('parses a declared image capability and rejects an empty representation list', () => {
expect(AttachmentCapabilitySchema.safeParse(imageCapability).success).toBe(true);
expect(
AttachmentCapabilitySchema.safeParse({
kinds: imageCapability.kinds,
representations: [],
}).success,
).toBe(false);
});
});

describe('AgentCapabilities.attachments', () => {
it('is optional so mixed-version peers still parse', () => {
expect(
AgentCapabilitiesSchema.safeParse({ slashCommands: true, shellCommand: false }).success,
).toBe(true);
expect(
AgentCapabilitiesSchema.safeParse({
slashCommands: true,
shellCommand: false,
attachments: imageCapability,
}).success,
).toBe(true);
});
});

describe('AttachmentCapability forward compatibility', () => {
it('parses a representation this build does not know so the frame still validates', () => {
const parsed = AttachmentCapabilitySchema.safeParse({
kinds: imageCapability.kinds,
representations: ['inline_image', 'extracted_text'],
});
expect(parsed.success).toBe(true);
});

it('drops the unknown representation at the host intersection', () => {
const effective = intersectAttachmentCapability({
kinds: imageCapability.kinds,
representations: ['inline_image', 'extracted_text'],
});
expect(effective?.representations).toEqual(['inline_image']);
});

it('treats a capability of only unknown representations as no support', () => {
expect(
intersectAttachmentCapability({
kinds: imageCapability.kinds,
representations: ['extracted_text'],
}),
).toBeUndefined();
});
});

describe('intersectAttachmentCapability', () => {
it('returns undefined when the adapter declared nothing', () => {
expect(intersectAttachmentCapability(undefined)).toBeUndefined();
});

it('intersects mime types, byte caps, counts, and representations with the host', () => {
const effective = intersectAttachmentCapability({
kinds: {
image: {
mimeTypes: ['image/png', 'image/svg+xml'],
maxBytes: MAX_ATTACHMENT_BYTES * 2,
maxCount: 4,
},
},
representations: ['inline_image', 'readonly_file'],
});
expect(effective).toEqual({
kinds: {
image: {
mimeTypes: ['image/png'],
maxBytes: MAX_ATTACHMENT_BYTES,
maxCount: 4,
},
},
representations: ['inline_image', 'readonly_file'],
});
});

it('drops a kind whose mime types miss the host allowlist', () => {
expect(
intersectAttachmentCapability({
kinds: {
image: { mimeTypes: ['image/svg+xml'], maxBytes: 1024, maxCount: 1 },
},
representations: ['inline_image'],
}),
).toBeUndefined();
});

it('keeps grok-build dark and the other harnesses on inline images', () => {
expect(effectiveAttachmentCapability('grok-build')).toBeUndefined();
const grok: AgentCapabilities = AGENT_INPUT_CAPABILITIES['grok-build'];
expect(grok.attachments).toBeUndefined();
const imageKinds = ['claude-code', 'codex', 'opencode', 'pi'] as const;
for (let i = 0, len = imageKinds.length; i < len; i++) {
const effective = effectiveAttachmentCapability(imageKinds[i]);
expect(effective?.representations).toEqual(['inline_image']);
expect(effective?.kinds.image?.mimeTypes).toEqual([...SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES]);
expect(effective?.kinds.file).toBeUndefined();
}
});

it('does not advertise file when the host has no file kind', () => {
expect(HOST_ATTACHMENT_LIMITS.kinds.file).toBeUndefined();
const effective = intersectAttachmentCapability({
kinds: {
image: {
mimeTypes: ['image/png'],
maxBytes: MAX_ATTACHMENT_BYTES,
maxCount: 1,
},
file: { mimeTypes: ['application/pdf'], maxBytes: MAX_ATTACHMENT_BYTES, maxCount: 1 },
},
representations: ['inline_image', 'readonly_file'],
});
expect(effective?.kinds.file).toBeUndefined();
expect(effective?.kinds.image).toEqual({
mimeTypes: ['image/png'],
maxBytes: MAX_ATTACHMENT_BYTES,
maxCount: 1,
});
});
});
57 changes: 52 additions & 5 deletions packages/foundation/schema/src/model/agent/input.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { z } from 'zod';
import { ContentBlockSchema } from '../content';
import type { AttachmentCapability } from '../attachment';
import {
AttachmentCapabilitySchema,
DEFAULT_ATTACHMENT_IMAGE_MAX_COUNT,
intersectAttachmentCapability,
} from '../attachment';
import {
ContentBlockSchema,
MAX_ATTACHMENT_BYTES,
SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES,
} from '../content';
import { ImPlatformSchema } from '../im';
import { PermissionOutcomeSchema } from '../permission';
import type { AgentKind } from '../primitives';
Expand Down Expand Up @@ -149,20 +159,57 @@ export type AgentStartCatalog = z.infer<typeof AgentStartCatalogSchema>;
export const AgentCapabilitiesSchema = z.object({
slashCommands: z.boolean(),
shellCommand: z.boolean(),
/** Absent means the harness accepts no attachments. Required would break mixed-version peers. */
attachments: AttachmentCapabilitySchema.optional(),
});
export type AgentCapabilities = z.infer<typeof AgentCapabilitiesSchema>;

/** What `onPrompt` consumes today: image ContentBlocks inlined to the SDK. grok-build declares
* nothing — its prompt is a CLI argument. */
const INLINE_IMAGE_ATTACHMENT_CAPABILITY: AttachmentCapability = {
kinds: {
image: {
mimeTypes: [...SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES],
maxBytes: MAX_ATTACHMENT_BYTES,
maxCount: DEFAULT_ATTACHMENT_IMAGE_MAX_COUNT,
},
},
representations: ['inline_image'],
};

/** Stable pre-session input capabilities. Live clients still trust each session's
* `capabilities-update`; this complete matrix lets drafts and adapters share one source of truth
* before that event stream exists. */
export const AGENT_INPUT_CAPABILITIES = {
'claude-code': { slashCommands: true, shellCommand: false },
codex: { slashCommands: true, shellCommand: true },
opencode: { slashCommands: true, shellCommand: true },
pi: { slashCommands: true, shellCommand: false },
'claude-code': {
slashCommands: true,
shellCommand: false,
attachments: INLINE_IMAGE_ATTACHMENT_CAPABILITY,
},
codex: {
slashCommands: true,
shellCommand: true,
attachments: INLINE_IMAGE_ATTACHMENT_CAPABILITY,
},
opencode: {
slashCommands: true,
shellCommand: true,
attachments: INLINE_IMAGE_ATTACHMENT_CAPABILITY,
},
pi: {
slashCommands: true,
shellCommand: false,
attachments: INLINE_IMAGE_ATTACHMENT_CAPABILITY,
},
'grok-build': { slashCommands: false, shellCommand: false },
} as const satisfies Readonly<Record<AgentKind, AgentCapabilities>>;

/** Host limits ∩ the harness declaration. Undefined means the harness accepts no attachments. */
export function effectiveAttachmentCapability(kind: AgentKind): AttachmentCapability | undefined {
const declared: AgentCapabilities = AGENT_INPUT_CAPABILITIES[kind];
return intersectAttachmentCapability(declared.attachments);
}

/** Input sent up to the agent, normalized into discrete actions. */
export const AgentInputSchema = z.discriminatedUnion('type', [
/** A user prompt as one or more content blocks (text / image / resource …). */
Expand Down
97 changes: 97 additions & 0 deletions packages/foundation/schema/src/model/attachment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { MAX_ATTACHMENT_BYTES, SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES } from './content';
import { AttachmentIdSchema, TimestampSchema } from './primitives';

/**
Expand Down Expand Up @@ -84,3 +85,99 @@ export const UploadLeaseSchema = z.object({
createdAt: TimestampSchema,
});
export type UploadLease = z.infer<typeof UploadLeaseSchema>;

/** Adapter-declared per-kind limits. Host ∩ adapter (∩ model, when known) is the effective cap. */
export const AttachmentKindLimitsSchema = z.object({
mimeTypes: z.array(z.string().min(1)).min(1),
maxBytes: z.number().int().positive(),
maxCount: z.number().int().positive(),
});
export type AttachmentKindLimits = z.infer<typeof AttachmentKindLimitsSchema>;

/** How the engine hands bytes to a harness. `extracted_text` is later. */
export const KNOWN_ATTACHMENT_REPRESENTATIONS = ['inline_image', 'readonly_file'] as const;
export type AttachmentRepresentation = (typeof KNOWN_ATTACHMENT_REPRESENTATIONS)[number];

/** Open on the wire like {@link AttachmentKindSchema}: this rides `capabilities-update`, so a newer
* peer's representation must not fail the whole frame. The host intersection drops what it cannot
* materialize. */
export const AttachmentRepresentationSchema = z.string().min(1).max(32);

export const AttachmentCapabilitySchema = z.object({
kinds: z.object({
image: AttachmentKindLimitsSchema.optional(),
file: AttachmentKindLimitsSchema.optional(),
}),
representations: z.array(AttachmentRepresentationSchema).min(1),
});
export type AttachmentCapability = z.infer<typeof AttachmentCapabilitySchema>;

/** Adapter-declared image count; the 12 MiB prompt aggregate is the tighter bound for large files. */
export const DEFAULT_ATTACHMENT_IMAGE_MAX_COUNT = 16;

/** What the host can materialize. Effective capability is this ∩ the adapter declaration. */
export const HOST_ATTACHMENT_LIMITS: AttachmentCapability = {
kinds: {
image: {
mimeTypes: [...SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES],
maxBytes: MAX_ATTACHMENT_BYTES,
maxCount: DEFAULT_ATTACHMENT_IMAGE_MAX_COUNT,
},
},
representations: [...KNOWN_ATTACHMENT_REPRESENTATIONS],
};

function intersectKindLimits(
declared: AttachmentKindLimits | undefined,
host: AttachmentKindLimits | undefined,
): AttachmentKindLimits | undefined {
if (declared === undefined || host === undefined) return undefined;
const mimeTypes: string[] = [];
for (let i = 0, len = declared.mimeTypes.length; i < len; i++) {
const mimeType = declared.mimeTypes[i];
if (host.mimeTypes.includes(mimeType)) mimeTypes.push(mimeType);
}
if (mimeTypes.length === 0) return undefined;
return {
mimeTypes,
maxBytes: Math.min(declared.maxBytes, host.maxBytes),
maxCount: Math.min(declared.maxCount, host.maxCount),
};
}

/** Absent declaration or empty intersection means the harness accepts no attachments. */
export function intersectAttachmentCapability(
declared: AttachmentCapability | undefined,
host: AttachmentCapability = HOST_ATTACHMENT_LIMITS,
): AttachmentCapability | undefined {
if (declared === undefined) return undefined;
const representations: string[] = [];
for (let i = 0, len = declared.representations.length; i < len; i++) {
const representation = declared.representations[i];
if (host.representations.includes(representation)) representations.push(representation);
}
if (representations.length === 0) return undefined;
const image = intersectKindLimits(declared.kinds.image, host.kinds.image);
const file = intersectKindLimits(declared.kinds.file, host.kinds.file);
if (image === undefined && file === undefined) return undefined;
return {
kinds: {
...(image !== undefined && { image }),
...(file !== undefined && { file }),
},
representations,
};
}

/** Locator a conversation.read user row uses so clients can `attachment.read` without bytes. */
export const ATTACHMENT_URI_SCHEME = 'attachment:';

export function attachmentUri(attachmentId: string): string {
return `${ATTACHMENT_URI_SCHEME}${attachmentId}`;
}

export function attachmentIdFromUri(uri: string): string | undefined {
if (!uri.startsWith(ATTACHMENT_URI_SCHEME)) return undefined;
const id = uri.slice(ATTACHMENT_URI_SCHEME.length);
return id.length > 0 ? id : undefined;
}
Loading
Loading