diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index 7d12744ff..7f5f957e8 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -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'; @@ -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', @@ -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', @@ -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' }), ]); @@ -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(() => { @@ -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' }), ]); diff --git a/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts b/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts new file mode 100644 index 000000000..d9df55f27 --- /dev/null +++ b/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts @@ -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, + }); + }); +}); diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 612916b72..da9475aee 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -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'; @@ -149,20 +159,57 @@ export type AgentStartCatalog = z.infer; 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; +/** 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>; +/** 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 …). */ diff --git a/packages/foundation/schema/src/model/attachment.ts b/packages/foundation/schema/src/model/attachment.ts index bc328fe9e..884bf8883 100644 --- a/packages/foundation/schema/src/model/attachment.ts +++ b/packages/foundation/schema/src/model/attachment.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { MAX_ATTACHMENT_BYTES, SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES } from './content'; import { AttachmentIdSchema, TimestampSchema } from './primitives'; /** @@ -84,3 +85,99 @@ export const UploadLeaseSchema = z.object({ createdAt: TimestampSchema, }); export type UploadLease = z.infer; + +/** 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; + +/** 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; + +/** 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; +} diff --git a/packages/host/agent-adapter/src/__tests__/base.test.ts b/packages/host/agent-adapter/src/__tests__/base.test.ts index c121f67e3..f00c967ac 100644 --- a/packages/host/agent-adapter/src/__tests__/base.test.ts +++ b/packages/host/agent-adapter/src/__tests__/base.test.ts @@ -6,8 +6,9 @@ import type { ToolCallContent, ToolCallUpdate, } from '@linkcode/schema'; +import { AGENT_INPUT_CAPABILITIES } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; -import { BaseAgentAdapter } from '../base'; +import { BaseAgentAdapter, UnsupportedAttachmentError } from '../base'; import type { HistoryCheckpoint } from '../history-branch'; import { encodeHistoryBranchCursor } from '../history-branch'; import { asHistoryId } from '../history-util'; @@ -297,10 +298,39 @@ describe('BaseAgentAdapter command/shell defaults', () => { await a.start({ kind: 'pi', cwd: '/repo' }); expect(a.seen).toContainEqual({ type: 'capabilities-update', - capabilities: { slashCommands: true, shellCommand: false }, + capabilities: AGENT_INPUT_CAPABILITIES.pi, }); }); + it('throws when a prompt carries an image the harness did not declare', async () => { + class GrokAdapter extends BaseAgentAdapter { + readonly kind = 'grok-build' as const; + protected onStart(): Promise { + return Promise.resolve(); + } + protected onPrompt(_content: ContentBlock[]): Promise { + return Promise.resolve(); + } + } + const adapter = new GrokAdapter(); + await expect( + adapter.send({ + type: 'prompt', + content: [{ type: 'image', mimeType: 'image/png', data: 'AA==' }], + }), + ).rejects.toBeInstanceOf(UnsupportedAttachmentError); + }); + + it('accepts an image prompt when the harness declared inline_image', async () => { + const adapter = new TestAdapter(); + await expect( + adapter.send({ + type: 'prompt', + content: [{ type: 'image', mimeType: 'image/png', data: 'AA==' }], + }), + ).resolves.toBeUndefined(); + }); + it('rejects a command input unless the adapter overrides onCommand', async () => { const a = new TestAdapter(); await expect(a.send({ type: 'command', name: 'compact' })).rejects.toThrow( diff --git a/packages/host/agent-adapter/src/__tests__/grok-build.test.ts b/packages/host/agent-adapter/src/__tests__/grok-build.test.ts index 21606d6a7..9ba1bb671 100644 --- a/packages/host/agent-adapter/src/__tests__/grok-build.test.ts +++ b/packages/host/agent-adapter/src/__tests__/grok-build.test.ts @@ -141,6 +141,10 @@ describe('GrokBuildAdapter', () => { }); }); + it('declares no attachment support', () => { + expect(new GrokBuildAdapter().capabilities.attachments).toBeUndefined(); + }); + it('fails start when no CLI is resolved', async () => { vi.spyOn(agentRuntimeProber, 'resolveBinary').mockReturnValue(undefined); const adapter = new GrokBuildAdapter(); diff --git a/packages/host/agent-adapter/src/base.ts b/packages/host/agent-adapter/src/base.ts index 57c4d0a89..c5ad604b5 100644 --- a/packages/host/agent-adapter/src/base.ts +++ b/packages/host/agent-adapter/src/base.ts @@ -42,6 +42,14 @@ import { linkCodeGatewayError } from './gateway-error'; import type { HistoryCheckpoint } from './history-branch'; import { encodeHistoryBranchCursor } from './history-branch'; +/** An undeclared representation reached the adapter — silent drops are a contract violation. */ +export class UnsupportedAttachmentError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'UnsupportedAttachmentError'; + } +} + type PermissionResolver = (outcome: PermissionOutcome) => void; type QuestionResolver = (outcome: QuestionOutcome) => void; interface PendingQuestion { @@ -130,6 +138,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { async send(input: AgentInput): Promise { switch (input.type) { case 'prompt': + assertDeclaredAttachmentRepresentations(this.kind, this.capabilities, input.content); await this.onPrompt(input.content); return; case 'command': @@ -521,3 +530,25 @@ export abstract class BaseAgentAdapter implements AgentAdapter { } } } + +function assertDeclaredAttachmentRepresentations( + kind: AgentKind, + capabilities: AgentCapabilities, + content: ContentBlock[], +): void { + const representations = capabilities.attachments?.representations; + const allowsInlineImage = representations?.includes('inline_image') === true; + const allowsReadonlyFile = representations?.includes('readonly_file') === true; + for (let i = 0, len = content.length; i < len; i++) { + const block = content[i]; + if (!allowsInlineImage && block.type === 'image') { + throw new UnsupportedAttachmentError(`${kind}: image attachments are not supported`); + } + if ( + !allowsReadonlyFile && + (block.type === 'audio' || block.type === 'resource' || block.type === 'resource_link') + ) { + throw new UnsupportedAttachmentError(`${kind}: file attachments are not supported`); + } + } +} diff --git a/packages/host/engine/src/__tests__/attachment-admit.test.ts b/packages/host/engine/src/__tests__/attachment-admit.test.ts new file mode 100644 index 000000000..4d452160f --- /dev/null +++ b/packages/host/engine/src/__tests__/attachment-admit.test.ts @@ -0,0 +1,190 @@ +import { + AttachmentIdSchema, + blobIdFromSha256, + effectiveAttachmentCapability, + MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENT_TOTAL_BYTES, +} from '@linkcode/schema'; +import { createFixedArray } from 'foxts/create-fixed-array'; +import { nullthrow } from 'foxts/guard'; +import { describe, expect, it } from 'vitest'; +import { admitPromptAttachments, assertInlineAttachmentsSupported } from '../attachment/admit'; +import type { StoredAttachment } from '../attachment/attachment-store'; +import { RequestError } from '../failure'; + +const ATT_1 = AttachmentIdSchema.parse('att-1'); +const ATT_2 = AttachmentIdSchema.parse('att-2'); + +function stored( + partial: Partial & { attachmentId?: StoredAttachment['attachmentId'] }, +): StoredAttachment { + return { + attachmentId: partial.attachmentId ?? ATT_1, + kind: partial.kind ?? 'image', + name: partial.name ?? 'shot.png', + mimeType: partial.mimeType ?? 'image/png', + sizeBytes: partial.sizeBytes ?? 16, + metadata: {}, + createdAt: 1, + blobId: partial.blobId ?? blobIdFromSha256('a'.repeat(64)), + }; +} + +describe('admitPromptAttachments', () => { + const capability = effectiveAttachmentCapability('claude-code'); + + it('accepts a ready image within the effective capability', () => { + expect(() => + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [stored({})], + capability, + ), + ).not.toThrow(); + }); + + it('refuses refs when the harness declared nothing', () => { + expect(() => + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [stored({})], + undefined, + ), + ).toThrow(RequestError); + try { + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [stored({})], + undefined, + ); + } catch (error) { + expect(error).toMatchObject({ code: 'unsupported_attachment' }); + } + }); + + it('refuses a missing attachment before persist', () => { + try { + admitPromptAttachments([{ type: 'attachment_ref', attachmentId: ATT_1 }], [], capability); + } catch (error) { + expect(error).toMatchObject({ + code: 'unsupported_attachment', + message: 'Unknown attachment', + }); + return; + } + expect.fail('expected a typed refusal'); + }); + + it('refuses an undeclared mime type', () => { + try { + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [stored({ mimeType: 'image/svg+xml' })], + capability, + ); + } catch (error) { + expect(error).toMatchObject({ code: 'unsupported_attachment' }); + return; + } + expect.fail('expected a typed refusal'); + }); + + it('refuses a file that exceeds the per-attachment cap', () => { + try { + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [stored({ sizeBytes: MAX_ATTACHMENT_BYTES + 1 })], + capability, + ); + } catch (error) { + expect(error).toMatchObject({ code: 'limit_exceeded' }); + return; + } + expect.fail('expected a typed refusal'); + }); + + it('refuses a prompt whose unique attachments exceed the aggregate cap', () => { + try { + admitPromptAttachments( + [ + { type: 'attachment_ref', attachmentId: ATT_1 }, + { type: 'attachment_ref', attachmentId: ATT_2 }, + ], + [ + stored({ attachmentId: ATT_1, sizeBytes: MAX_ATTACHMENT_TOTAL_BYTES / 2 }), + stored({ attachmentId: ATT_2, sizeBytes: MAX_ATTACHMENT_TOTAL_BYTES / 2 + 1 }), + ], + capability, + ); + } catch (error) { + expect(error).toMatchObject({ code: 'limit_exceeded' }); + return; + } + expect.fail('expected a typed refusal'); + }); + + it('charges a repeated ref its bytes again so the aggregate cap bounds materialization', () => { + const oversized = Math.ceil(MAX_ATTACHMENT_TOTAL_BYTES / 3); + const blocks = [ + { type: 'attachment_ref' as const, attachmentId: ATT_1 }, + { type: 'attachment_ref' as const, attachmentId: ATT_1 }, + { type: 'attachment_ref' as const, attachmentId: ATT_1 }, + { type: 'attachment_ref' as const, attachmentId: ATT_1 }, + ]; + try { + admitPromptAttachments(blocks, [stored({ sizeBytes: oversized })], capability); + } catch (error) { + expect(error).toBeInstanceOf(RequestError); + expect(error).toMatchObject({ code: 'limit_exceeded' }); + return; + } + expect.fail('expected the repeated ref to exhaust the prompt aggregate cap'); + }); + + it('counts every occurrence against maxCount, not the unique id set', () => { + const capped = nullthrow(capability?.kinds.image?.maxCount, 'image maxCount'); + const blocks = createFixedArray(capped + 1).map(() => ({ + type: 'attachment_ref' as const, + attachmentId: ATT_1, + })); + try { + admitPromptAttachments(blocks, [stored({ sizeBytes: 1 })], capability); + } catch (error) { + expect(error).toMatchObject({ code: 'limit_exceeded', message: 'Too many attachments' }); + return; + } + expect.fail('expected the repeated ref to exhaust maxCount'); + }); +}); + +describe('assertInlineAttachmentsSupported', () => { + it('lets a claude-code image through and refuses grok-build', () => { + const image = { type: 'image' as const, mimeType: 'image/png', data: 'AA==' }; + expect(() => + assertInlineAttachmentsSupported([image], effectiveAttachmentCapability('claude-code')), + ).not.toThrow(); + try { + assertInlineAttachmentsSupported([image], effectiveAttachmentCapability('grok-build')); + } catch (error) { + expect(error).toMatchObject({ code: 'unsupported_attachment' }); + return; + } + expect.fail('expected a typed refusal'); + }); + + it('refuses a resource_link when the harness did not declare readonly_file', () => { + try { + assertInlineAttachmentsSupported( + [{ type: 'resource_link', uri: 'attachment:att-1', name: 'shot.png' }], + effectiveAttachmentCapability('claude-code'), + ); + } catch (error) { + expect(error).toMatchObject({ + code: 'unsupported_attachment', + message: 'Editing a prompt attachment is not supported yet', + }); + return; + } + expect.fail('expected a typed refusal'); + }); +}); diff --git a/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts new file mode 100644 index 000000000..356db7a2d --- /dev/null +++ b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts @@ -0,0 +1,292 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { asHistoryId } from '@linkcode/agent-adapter'; +import type { WirePayload } from '@linkcode/schema'; +import { + AttachmentIdSchema, + attachmentUri, + blobIdFromSha256, + OperationIdSchema, +} from '@linkcode/schema'; +import { nullthrow } from 'foxts/guard'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { InMemoryAttachmentStore } from '../attachment/attachment-store'; +import { FsBlobStore } from '../attachment/blob-store'; +import { InMemoryConversationStore } from '../conversation/conversation-store'; +import { InMemorySessionStore } from '../session/session-store'; +import { + createSessionHarness as harness, + startedSessionId as startedId, +} from './fixtures/session-harness'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function failure(sent: WirePayload[], replyTo: string) { + const reply = sent.find( + (payload) => payload.kind === 'request.failed' && payload.replyTo === replyTo, + ); + if (reply?.kind !== 'request.failed') throw new Error(`no request.failed for ${replyTo}`); + return reply; +} + +async function started(kind: 'claude-code' | 'grok-build' = 'claude-code') { + const stateDir = await mkdtemp(join(tmpdir(), 'linkcode-attach-submit-')); + temporaryDirectories.push(stateDir); + const conversationStore = new InMemoryConversationStore(); + const attachmentStore = new InMemoryAttachmentStore(() => + conversationStore.referencedAttachmentIds(), + ); + const blobStore = new FsBlobStore(join(stateDir, 'blobs')); + const h = harness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + undefined, + undefined, + { + conversationStore, + attachmentStore, + blobStore, + stateDir, + }, + ); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind, cwd: stateDir }, + }); + return { + ...h, + conversationStore, + attachmentStore, + blobStore, + sessionId: startedId(h.sent, 'r1'), + adapter: nullthrow(h.adapters[0]), + }; +} + +async function readyPng(h: Awaited>) { + const digest = sha256(PNG_1X1); + const blobId = blobIdFromSha256(digest); + const stage = await h.blobStore.stage('up-1'); + await stage.write(0, PNG_1X1); + await stage.commit({ sha256: digest, sizeBytes: PNG_1X1.byteLength }); + const attachmentId = AttachmentIdSchema.parse('att-ready'); + await h.attachmentStore.commitAttachment({ + blob: { blobId, sizeBytes: PNG_1X1.byteLength, createdAt: 1 }, + attachment: { + attachmentId, + kind: 'image', + name: 'shot.png', + mimeType: 'image/png', + sizeBytes: PNG_1X1.byteLength, + metadata: {}, + createdAt: 1, + }, + }); + return attachmentId; +} + +describe('turn.submit attachment admit and materialize', () => { + it('refuses a missing ref at admit and persists nothing', async () => { + const h = await started(); + await h.inject({ + kind: 'turn.submit', + clientReqId: 's-missing', + sessionId: h.sessionId, + operationId: OperationIdSchema.parse('op-missing'), + input: { + type: 'prompt', + blocks: [{ type: 'attachment_ref', attachmentId: AttachmentIdSchema.parse('att-missing') }], + }, + }); + expect(failure(h.sent, 's-missing').code).toBe('unsupported_attachment'); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0); + }); + + it('refuses an image on grok-build at admit', async () => { + const h = await started('grok-build'); + const attachmentId = await readyPng(h); + await h.inject({ + kind: 'turn.submit', + clientReqId: 's-grok', + sessionId: h.sessionId, + operationId: OperationIdSchema.parse('op-grok'), + input: { + type: 'prompt', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ], + }, + }); + expect(failure(h.sent, 's-grok')).toMatchObject({ + code: 'unsupported_attachment', + }); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0); + expect(h.adapter.sentInputs).toEqual([]); + }); + + it('materializes a declared image to the adapter without putting bytes on the echo or prompt row', async () => { + const h = await started(); + const attachmentId = await readyPng(h); + await h.inject({ + kind: 'turn.submit', + clientReqId: 's-ok', + sessionId: h.sessionId, + operationId: OperationIdSchema.parse('op-ok'), + input: { + type: 'prompt', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ], + }, + }); + await vi.waitFor(() => { + expect(h.sent).toContainEqual( + expect.objectContaining({ kind: 'turn.submitted', replyTo: 's-ok' }), + ); + }); + expect(h.adapter.sentInputs).toEqual([ + { + type: 'prompt', + content: [ + { type: 'text', text: 'look' }, + { + type: 'image', + data: PNG_1X1.toString('base64'), + mimeType: 'image/png', + name: 'shot.png', + }, + ], + }, + ]); + const echo = h.sent.find( + (payload) => payload.kind === 'agent.event' && payload.event.type === 'user-message', + ); + if (echo?.kind !== 'agent.event' || echo.event.type !== 'user-message') { + throw new Error('no live prompt echo'); + } + expect(echo.event.content).toEqual([{ type: 'text', text: 'look' }]); + expect(JSON.stringify(echo.event.content)).not.toContain(PNG_1X1.toString('base64')); + + const turns = await h.conversationStore.listTurns(h.sessionId); + expect(turns).toHaveLength(1); + const turnInput = nullthrow(turns[0], 'expected a persisted turn').input; + expect(turnInput.type).toBe('prompt'); + const promptId = nullthrow( + turnInput.type === 'prompt' ? turnInput.promptId : null, + 'a prompt turn must persist a promptId', + ); + const prompt = await h.conversationStore.getPrompt(promptId); + expect(prompt?.blocks).toEqual([ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ]); + + await h.inject({ kind: 'conversation.read', clientReqId: 'rr', sessionId: h.sessionId }); + const read = h.sent.find( + (payload) => payload.kind === 'conversation.read.result' && payload.replyTo === 'rr', + ); + if (read?.kind !== 'conversation.read.result') throw new Error('no conversation.read.result'); + const row = read.events.find((item) => 'event' in item && item.event.type === 'user-message'); + if (row === undefined || !('event' in row) || row.event.type !== 'user-message') { + throw new Error('no user row'); + } + expect(row.event.content).toEqual([ + { type: 'text', text: 'look' }, + { + type: 'resource_link', + uri: attachmentUri(attachmentId), + name: 'shot.png', + mimeType: 'image/png', + size: PNG_1X1.byteLength, + description: 'image', + }, + ]); + expect(JSON.stringify(row.event.content)).not.toContain(PNG_1X1.toString('base64')); + }); + + it('refuses a rewrite that round-trips the projected resource_link before persist', async () => { + const h = await started(); + const attachmentId = await readyPng(h); + await h.inject({ + kind: 'turn.submit', + clientReqId: 's-ok', + sessionId: h.sessionId, + operationId: OperationIdSchema.parse('op-ok'), + input: { + type: 'prompt', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ], + }, + }); + await vi.waitFor(() => { + expect(h.sent).toContainEqual( + expect.objectContaining({ kind: 'turn.submitted', replyTo: 's-ok' }), + ); + }); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await h.inject({ kind: 'conversation.read', clientReqId: 'rr', sessionId: h.sessionId }); + const read = h.sent.find( + (payload) => payload.kind === 'conversation.read.result' && payload.replyTo === 'rr', + ); + if (read?.kind !== 'conversation.read.result') throw new Error('no conversation.read.result'); + const row = read.events.find((item) => 'event' in item && item.event.type === 'user-message'); + if ( + row === undefined || + !('event' in row) || + row.event.type !== 'user-message' || + row.event.branchCursor === undefined + ) { + throw new Error('no user row with a branch cursor'); + } + const turnsBefore = await h.conversationStore.listTurns(h.sessionId); + + await h.inject({ + kind: 'history.branch', + clientReqId: 'rewrite', + sourceSessionId: h.sessionId, + sourceMessageId: row.event.messageId, + branchCursor: row.event.branchCursor, + content: [ + { type: 'text', text: 'look again' }, + { + type: 'resource_link', + uri: attachmentUri(attachmentId), + name: 'shot.png', + }, + ], + }); + + expect(failure(h.sent, 'rewrite')).toMatchObject({ + code: 'unsupported_attachment', + message: 'Editing a prompt attachment is not supported yet', + }); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(turnsBefore.length); + expect(h.adapter.sentInputs).toHaveLength(1); + }); +}); diff --git a/packages/host/engine/src/__tests__/engine-session-input.test.ts b/packages/host/engine/src/__tests__/engine-session-input.test.ts index 59925594d..738d1b32b 100644 --- a/packages/host/engine/src/__tests__/engine-session-input.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-input.test.ts @@ -50,13 +50,13 @@ function eventsAfter(sent: WirePayload[], mark: number): AgentEvent[] { return sent.slice(mark).flatMap((p) => (p.kind === 'agent.event' ? [p.event] : [])); } -async function startedHarness() { +async function startedHarness(kind: 'claude-code' | 'grok-build' = 'claude-code') { const h = harness(); await h.engine.start(); await h.inject({ kind: 'session.start', clientReqId: 'r1', - opts: { kind: 'claude-code', cwd: '/repo' }, + opts: { kind, cwd: '/repo' }, }); return { ...h, sessionId: startedId(h.sent, 'r1'), adapter: nullthrow(h.adapters[0]) }; } @@ -147,6 +147,75 @@ describe('engine session input', () => { }); }); + it('refuses an image prompt on a text-only harness', async () => { + const h = await startedHarness('grok-build'); + + await h.inject({ + kind: 'agent.input', + clientReqId: 'input', + sessionId: h.sessionId, + input: { + type: 'prompt', + content: [{ type: 'image', mimeType: 'image/png', data: 'AA==' }], + }, + }); + + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'input', + code: 'unsupported_attachment', + message: 'Prompt attachments are not supported by this harness', + }); + expect(h.adapter.sentInputs).toEqual([]); + }); + + it('refuses a resource_link prompt before persist', async () => { + const h = await startedHarness(); + + await h.inject({ + kind: 'agent.input', + clientReqId: 'input', + sessionId: h.sessionId, + input: { + type: 'prompt', + content: [ + { type: 'text', text: 'look' }, + { type: 'resource_link', uri: 'attachment:att-1', name: 'shot.png' }, + ], + }, + }); + + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'input', + code: 'unsupported_attachment', + message: 'Editing a prompt attachment is not supported yet', + }); + expect(h.adapter.sentInputs).toEqual([]); + }); + + it('still names a non-attachment resource_link a file refusal', async () => { + const h = await startedHarness(); + + await h.inject({ + kind: 'agent.input', + clientReqId: 'input', + sessionId: h.sessionId, + input: { + type: 'prompt', + content: [{ type: 'resource_link', uri: 'file:///etc/hosts', name: 'hosts' }], + }, + }); + + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'input', + code: 'unsupported_attachment', + message: 'This harness does not accept file attachments', + }); + expect(h.adapter.sentInputs).toEqual([]); + }); + it('echoes command and shell inputs as the text the user typed', async () => { const { sent, inject, adapter, sessionId } = await startedHarness(); adapter.emit({ diff --git a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts index 02a6e05f1..db018d0ae 100644 --- a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts +++ b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts @@ -1217,7 +1217,7 @@ describe('turn.submit saga', () => { blocks: [{ type: 'attachment_ref', attachmentId: AttachmentIdSchema.parse('att-1') }], }, }); - expect(failure(h.sent, 's-attachment').code).toBe('unsupported'); + expect(failure(h.sent, 's-attachment').code).toBe('unsupported_attachment'); expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0); }); diff --git a/packages/host/engine/src/__tests__/failure.test.ts b/packages/host/engine/src/__tests__/failure.test.ts index 8a7c504e6..ced99de23 100644 --- a/packages/host/engine/src/__tests__/failure.test.ts +++ b/packages/host/engine/src/__tests__/failure.test.ts @@ -16,6 +16,20 @@ describe('engine request failures', () => { expect(failure).toEqual({ code: 'not_found', message: 'Workspace not found' }); }); + it('keeps unsupported_attachment as a typed request code', () => { + const failure = toRequestFailure( + new RequestError({ + code: 'unsupported_attachment', + message: 'This harness does not accept image attachments', + }), + ); + + expect(failure).toEqual({ + code: 'unsupported_attachment', + message: 'This harness does not accept image attachments', + }); + }); + it('exposes only the public message from an operation failure', () => { const failure = toRequestFailure( new OperationError({ diff --git a/packages/host/engine/src/__tests__/prompt-materializer.test.ts b/packages/host/engine/src/__tests__/prompt-materializer.test.ts new file mode 100644 index 000000000..505c873d3 --- /dev/null +++ b/packages/host/engine/src/__tests__/prompt-materializer.test.ts @@ -0,0 +1,207 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AttachmentCapability, PromptRecord } from '@linkcode/schema'; +import { + AttachmentIdSchema, + blobIdFromSha256, + effectiveAttachmentCapability, + MAX_ATTACHMENT_BYTES, + PromptIdSchema, + RunIdSchema, + SessionIdSchema, +} from '@linkcode/schema'; +import { Effect } from 'effect'; +import { nullthrow } from 'foxts/guard'; +import { afterEach, describe, expect, it } from 'vitest'; +import { InMemoryAttachmentStore } from '../attachment/attachment-store'; +import { FsBlobStore } from '../attachment/blob-store'; +import { PromptMaterializer } from '../attachment/materializer'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function fixture(bytes: Uint8Array = PNG_1X1): Promise<{ + materializer: PromptMaterializer; + store: InMemoryAttachmentStore; + blobs: FsBlobStore; + prompt: PromptRecord; + capability: AttachmentCapability; + root: string; +}> { + const root = await mkdtemp(join(tmpdir(), 'linkcode-materializer-')); + temporaryDirectories.push(root); + const blobs = new FsBlobStore(join(root, 'blobs')); + const store = new InMemoryAttachmentStore(); + const digest = sha256(bytes); + const blobId = blobIdFromSha256(digest); + const stage = await blobs.stage('up-1'); + await stage.write(0, bytes); + await stage.commit({ sha256: digest, sizeBytes: bytes.byteLength }); + const attachmentId = AttachmentIdSchema.parse('att-1'); + await store.commitAttachment({ + blob: { blobId, sizeBytes: bytes.byteLength, createdAt: 1 }, + attachment: { + attachmentId, + kind: 'image', + name: 'shot.png', + mimeType: 'image/png', + sizeBytes: bytes.byteLength, + metadata: {}, + createdAt: 1, + }, + }); + const prompt: PromptRecord = { + promptId: PromptIdSchema.parse('prompt-1'), + blocks: [ + { type: 'text', text: 'see' }, + { type: 'attachment_ref', attachmentId }, + ], + contextAttachmentIds: [], + createdAt: 1, + }; + const capability = nullthrow( + effectiveAttachmentCapability('claude-code'), + 'claude-code must declare images', + ); + return { + materializer: new PromptMaterializer(store, blobs, root), + store, + blobs, + prompt, + capability, + root, + }; +} + +describe('PromptMaterializer', () => { + it('turns a ready image ref into the same base64 ContentBlock adapters already consume', async () => { + const { materializer, prompt, capability } = await fixture(); + const prepared = await Effect.runPromise( + materializer.prepare( + SessionIdSchema.parse('sess-1'), + RunIdSchema.parse('run-1'), + prompt, + capability, + ), + ); + expect(materializer.toContentBlocks(prepared)).toEqual([ + { type: 'text', text: 'see' }, + { + type: 'image', + data: PNG_1X1.toString('base64'), + mimeType: 'image/png', + name: 'shot.png', + }, + ]); + }); + + it('hardlinks a readonly_file projection at mode 0444 and sweeps it', async () => { + const { materializer, prompt, store, blobs } = await fixture(); + const stored = await store.getAttachment(AttachmentIdSchema.parse('att-1')); + if (!stored) throw new Error('fixture attachment missing'); + const fileCapability: AttachmentCapability = { + kinds: { + file: { + mimeTypes: ['image/png'], + maxBytes: MAX_ATTACHMENT_BYTES, + maxCount: 1, + }, + }, + representations: ['readonly_file'], + }; + await store.commitAttachment({ + blob: { blobId: stored.blobId, sizeBytes: stored.sizeBytes, createdAt: 1 }, + attachment: { ...stored, kind: 'file' }, + }); + const filePrompt: PromptRecord = { + ...prompt, + blocks: [{ type: 'attachment_ref', attachmentId: stored.attachmentId }], + }; + const sessionId = SessionIdSchema.parse('sess-1'); + const runId = RunIdSchema.parse('run-1'); + const prepared = await Effect.runPromise( + materializer.prepare(sessionId, runId, filePrompt, fileCapability), + ); + const file = prepared.blocks[0]; + expect(file.type).toBe('readonly_file'); + if (file.type !== 'readonly_file') return; + expect((await stat(file.path)).nlink).toBeGreaterThanOrEqual(2); + expect((await stat(file.path)).mode & 0o222).toBe(0); + expect(await blobs.stat(stored.blobId)).toEqual({ sizeBytes: PNG_1X1.byteLength }); + + await materializer.cleanupRun(sessionId, runId); + await expect(stat(file.path)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await blobs.stat(stored.blobId)).toEqual({ sizeBytes: PNG_1X1.byteLength }); + + const again = await Effect.runPromise( + materializer.prepare(sessionId, runId, filePrompt, fileCapability), + ); + const againFile = again.blocks[0]; + expect(againFile.type).toBe('readonly_file'); + if (againFile.type !== 'readonly_file') return; + await materializer.bootSweep(); + await expect(stat(againFile.path)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('materializes a repeated readonly_file ref twice in one run', async () => { + const { materializer, prompt, store } = await fixture(); + const stored = await store.getAttachment(AttachmentIdSchema.parse('att-1')); + if (!stored) throw new Error('fixture attachment missing'); + const fileCapability: AttachmentCapability = { + kinds: { + file: { mimeTypes: ['image/png'], maxBytes: MAX_ATTACHMENT_BYTES, maxCount: 4 }, + }, + representations: ['readonly_file'], + }; + await store.commitAttachment({ + blob: { blobId: stored.blobId, sizeBytes: stored.sizeBytes, createdAt: 1 }, + attachment: { ...stored, kind: 'file' }, + }); + const repeated: PromptRecord = { + ...prompt, + blocks: [ + { type: 'attachment_ref', attachmentId: stored.attachmentId }, + { type: 'attachment_ref', attachmentId: stored.attachmentId }, + ], + }; + const prepared = await Effect.runPromise( + materializer.prepare( + SessionIdSchema.parse('sess-dup'), + RunIdSchema.parse('run-dup'), + repeated, + fileCapability, + ), + ); + const [first, second] = prepared.blocks; + if (first.type !== 'readonly_file' || second.type !== 'readonly_file') { + throw new Error('expected both refs to materialize'); + } + expect(second.path).toBe(first.path); + expect((await stat(first.path)).mode & 0o222).toBe(0); + }); + + it('does not traverse out of the materialized directory on cleanup', async () => { + const { materializer, root } = await fixture(); + const retained = join(root, 'retained.txt'); + await writeFile(retained, 'safe'); + await materializer.cleanupSession(SessionIdSchema.parse('..')); + await materializer.cleanupRun(SessionIdSchema.parse('..'), RunIdSchema.parse('..')); + expect(await readFile(retained, 'utf8')).toBe('safe'); + }); +}); diff --git a/packages/host/engine/src/attachment/admit.ts b/packages/host/engine/src/attachment/admit.ts new file mode 100644 index 000000000..0f715d09b --- /dev/null +++ b/packages/host/engine/src/attachment/admit.ts @@ -0,0 +1,182 @@ +import type { + AttachmentCapability, + AttachmentId, + ContentBlock, + PromptBlock, +} from '@linkcode/schema'; +import { attachmentIdFromUri, MAX_ATTACHMENT_TOTAL_BYTES } from '@linkcode/schema'; +import { RequestError } from '../failure'; +import type { StoredAttachment } from './attachment-store'; + +export function attachmentIdsFromBlocks(blocks: readonly PromptBlock[]): AttachmentId[] { + const ids: AttachmentId[] = []; + for (let i = 0, len = blocks.length; i < len; i++) { + const block = blocks[i]; + if (block.type === 'attachment_ref') ids.push(block.attachmentId); + } + return ids; +} + +/** Unique ids in first-seen order. */ +export function uniqueAttachmentIds(ids: readonly AttachmentId[]): AttachmentId[] { + const seen = new Set(); + const unique: AttachmentId[] = []; + for (let i = 0, len = ids.length; i < len; i++) { + const id = ids[i]; + if (seen.has(id)) continue; + seen.add(id); + unique.push(id); + } + return unique; +} + +function kindLimits(capability: AttachmentCapability, kind: string) { + if (kind === 'image') return capability.kinds.image; + return capability.kinds.file; +} + +function representationFor( + capability: AttachmentCapability, + kind: string, +): 'inline_image' | 'readonly_file' | undefined { + const representations = capability.representations; + if (kind === 'image' && representations.includes('inline_image')) return 'inline_image'; + if (representations.includes('readonly_file')) return 'readonly_file'; + return undefined; +} + +/** + * Admit a prompt's `attachment_ref`s against the effective capability. Throws `RequestError` + * (`unsupported_attachment` for missing/unknown/undeclared, `limit_exceeded` for size/count). + */ +export function admitPromptAttachments( + blocks: readonly PromptBlock[], + stored: readonly StoredAttachment[], + capability: AttachmentCapability | undefined, +): void { + // Accounting walks every occurrence, not the unique set: the materializer converts one block per + // ref, so a repeated id costs its bytes again and the caps must bound what actually leaves the store. + const ids = attachmentIdsFromBlocks(blocks); + if (ids.length === 0) return; + if (!capability) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'Prompt attachments are not supported by this harness', + }); + } + const byId = new Map(); + for (let i = 0, len = stored.length; i < len; i++) { + byId.set(stored[i].attachmentId, stored[i]); + } + let totalBytes = 0; + let imageCount = 0; + let fileCount = 0; + for (let i = 0, len = ids.length; i < len; i++) { + const id = ids[i]; + const attachment = byId.get(id); + if (!attachment) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'Unknown attachment', + }); + } + if (!representationFor(capability, attachment.kind)) { + throw new RequestError({ + code: 'unsupported_attachment', + message: `This harness does not accept ${attachment.kind} attachments`, + }); + } + const limits = kindLimits(capability, attachment.kind); + if (!limits) { + throw new RequestError({ + code: 'unsupported_attachment', + message: `This harness does not accept ${attachment.kind} attachments`, + }); + } + if (!limits.mimeTypes.includes(attachment.mimeType)) { + throw new RequestError({ + code: 'unsupported_attachment', + message: `Unsupported attachment type: ${attachment.mimeType}`, + }); + } + if (attachment.sizeBytes > limits.maxBytes) { + throw new RequestError({ + code: 'limit_exceeded', + message: 'Attachment exceeds the maximum allowed size', + }); + } + if (attachment.kind === 'image') imageCount += 1; + else fileCount += 1; + const maxCount = limits.maxCount; + if ( + (attachment.kind === 'image' && imageCount > maxCount) || + (attachment.kind === 'file' && fileCount > maxCount) + ) { + throw new RequestError({ + code: 'limit_exceeded', + message: 'Too many attachments', + }); + } + totalBytes += attachment.sizeBytes; + if (totalBytes > MAX_ATTACHMENT_TOTAL_BYTES) { + throw new RequestError({ + code: 'limit_exceeded', + message: 'Attachments exceed the maximum allowed total size', + }); + } + } +} + +/** Legacy `agent.input` images: size/mime already passed the inline guard; this is the capability gate. */ +export function assertInlineAttachmentsSupported( + content: ContentBlock[], + capability: AttachmentCapability | undefined, +): void { + for (let i = 0, len = content.length; i < len; i++) { + const block = content[i]; + if ( + block.type !== 'image' && + block.type !== 'audio' && + block.type !== 'resource' && + block.type !== 'resource_link' + ) { + continue; + } + if (!capability) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'Prompt attachments are not supported by this harness', + }); + } + if (block.type === 'image') { + const limits = capability.kinds.image; + if (limits === undefined || !capability.representations.includes('inline_image')) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'This harness does not accept image attachments', + }); + } + if (!limits.mimeTypes.includes(block.mimeType)) { + throw new RequestError({ + code: 'unsupported_attachment', + message: `Unsupported attachment type: ${block.mimeType}`, + }); + } + continue; + } + // A projected `attachment:` link is a stored attachment coming back in, not a file the harness + // was asked to read: refusing it as a "file attachment" would misname an image the harness takes. + if (block.type === 'resource_link' && attachmentIdFromUri(block.uri) !== undefined) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'Editing a prompt attachment is not supported yet', + }); + } + if (!capability.representations.includes('readonly_file')) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'This harness does not accept file attachments', + }); + } + } +} diff --git a/packages/host/engine/src/attachment/materializer.ts b/packages/host/engine/src/attachment/materializer.ts new file mode 100644 index 000000000..cfda3385d --- /dev/null +++ b/packages/host/engine/src/attachment/materializer.ts @@ -0,0 +1,272 @@ +import { Buffer } from 'node:buffer'; +import { chmod, link, mkdir, rm } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import type { + AttachmentCapability, + ContentBlock, + PromptRecord, + RunId, + SessionId, +} from '@linkcode/schema'; +import { Effect } from 'effect'; +import { OperationError, RequestError } from '../failure'; +import { admitPromptAttachments, attachmentIdsFromBlocks, uniqueAttachmentIds } from './admit'; +import type { AttachmentStore, StoredAttachment } from './attachment-store'; +import type { BlobStore } from './blob-store'; +import { AttachmentIoMutex } from './io-mutex'; + +const rPathSep = /[/\\]/g; +const rUnsafePathSegment = /[/\\]|\.\./; + +export type PreparedAttachment = + | { + readonly type: 'inline_image'; + readonly attachment: StoredAttachment; + readonly block: Extract; + } + | { + readonly type: 'readonly_file'; + readonly attachment: StoredAttachment; + readonly path: string; + }; + +export interface PreparedPrompt { + readonly blocks: Array<{ readonly type: 'text'; readonly text: string } | PreparedAttachment>; +} + +/** + * Last hop before the adapter: the only place attachment bytes leave the store. + * Path materialization assumes the harness shares this filesystem — that assumption stays here. + */ +export class PromptMaterializer { + constructor( + private readonly attachments: AttachmentStore, + private readonly blobs: BlobStore, + private readonly stateDir: string, + private readonly io: AttachmentIoMutex = new AttachmentIoMutex(), + ) {} + + prepare( + sessionId: SessionId, + runId: RunId, + prompt: PromptRecord, + capability: AttachmentCapability | undefined, + ): Effect.Effect { + const load = this.loadStored.bind(this); + const convert = this.convert.bind(this); + return Effect.gen(function* () { + const ids = uniqueAttachmentIds(attachmentIdsFromBlocks(prompt.blocks)); + const stored = yield* load(ids); + yield* Effect.try({ + try() { + admitPromptAttachments(prompt.blocks, stored, capability); + }, + catch(error) { + return error instanceof RequestError + ? error + : new OperationError({ + subsystem: 'store', + operation: 'attachments.admit', + publicMessage: 'Attachment validation failed', + cause: error, + }); + }, + }); + const byId = new Map(stored.map((attachment) => [attachment.attachmentId, attachment])); + const blocks: PreparedPrompt['blocks'] = []; + for (let i = 0, len = prompt.blocks.length; i < len; i++) { + const block = prompt.blocks[i]; + if (block.type === 'text') { + blocks.push(block); + continue; + } + const attachment = byId.get(block.attachmentId); + if (attachment === undefined) { + return yield* Effect.fail( + new RequestError({ + code: 'unsupported_attachment', + message: 'Unknown attachment', + }), + ); + } + blocks.push(yield* convert(sessionId, runId, attachment, capability)); + } + return { blocks }; + }); + } + + toContentBlocks(prepared: PreparedPrompt): ContentBlock[] { + const content: ContentBlock[] = []; + for (let i = 0, len = prepared.blocks.length; i < len; i++) { + const block = prepared.blocks[i]; + if (block.type === 'text') { + content.push({ type: 'text', text: block.text }); + continue; + } + if (block.type === 'inline_image') { + content.push(block.block); + continue; + } + throw new RequestError({ + code: 'unsupported_attachment', + message: 'This harness does not accept file attachments', + }); + } + return content; + } + + cleanupRun(sessionId: SessionId, runId: RunId): Promise { + const dir = this.runDir(sessionId, runId); + if (dir === undefined) return Promise.resolve(); + return this.io.run(() => rm(dir, { recursive: true, force: true })); + } + + cleanupSession(sessionId: SessionId): Promise { + const session = pathSegment(sessionId); + if (session === undefined) return Promise.resolve(); + return this.io.run(() => + rm(join(this.stateDir, 'materialized', session), { recursive: true, force: true }), + ); + } + + bootSweep(): Promise { + return this.io.run(() => + rm(join(this.stateDir, 'materialized'), { recursive: true, force: true }), + ); + } + + private runDir(sessionId: SessionId, runId: RunId): string | undefined { + const session = pathSegment(sessionId); + const run = pathSegment(runId); + if (session === undefined || run === undefined) return; + return join(this.stateDir, 'materialized', session, run); + } + + private loadStored( + ids: ReadonlyArray, + ): Effect.Effect { + if (ids.length === 0) return Effect.succeed([]); + return Effect.tryPromise({ + try: () => this.attachments.listAttachments(ids), + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'attachments.list', + publicMessage: 'Failed to load attachments', + cause, + }), + }); + } + + private convert( + sessionId: SessionId, + runId: RunId, + attachment: StoredAttachment, + capability: AttachmentCapability | undefined, + ): Effect.Effect { + const representations = capability?.representations ?? []; + if (attachment.kind === 'image' && representations.includes('inline_image')) { + return this.inlineImage(attachment); + } + if (representations.includes('readonly_file')) { + return this.readonlyFile(sessionId, runId, attachment); + } + return Effect.fail( + new RequestError({ + code: 'unsupported_attachment', + message: `This harness does not accept ${attachment.kind} attachments`, + }), + ); + } + + private inlineImage( + attachment: StoredAttachment, + ): Effect.Effect { + const { blobs } = this; + return Effect.tryPromise({ + async try() { + const bytes = await blobs.read(attachment.blobId, 0, attachment.sizeBytes); + if (bytes?.byteLength !== attachment.sizeBytes) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'Attachment is not ready', + }); + } + return { + type: 'inline_image' as const, + attachment, + block: { + type: 'image' as const, + data: Buffer.from(bytes).toString('base64'), + mimeType: attachment.mimeType, + name: attachment.name, + }, + }; + }, + catch(cause) { + return cause instanceof RequestError + ? cause + : new OperationError({ + subsystem: 'filesystem', + operation: 'attachments.materialize', + publicMessage: 'Failed to read attachment bytes', + cause, + }); + }, + }); + } + + private readonlyFile( + sessionId: SessionId, + runId: RunId, + attachment: StoredAttachment, + ): Effect.Effect { + const destDir = this.runDir(sessionId, runId); + if (destDir === undefined) { + return Effect.fail( + new RequestError({ + code: 'unsupported_attachment', + message: 'Failed to materialize attachment file', + }), + ); + } + const dest = join(destDir, destName(attachment)); + const source = this.blobs.pathOf(attachment.blobId); + const { io } = this; + return Effect.tryPromise({ + try() { + return io.run(async () => { + await mkdir(destDir, { recursive: true }); + // The destination is content-addressed, so an existing link is already the same bytes — + // a prompt may reference one attachment more than once within a run. + await link(source, dest).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'EEXIST') throw error; + }); + await chmod(dest, 0o444); + return { + type: 'readonly_file' as const, + attachment, + path: dest, + }; + }); + }, + catch(cause) { + return new OperationError({ + subsystem: 'filesystem', + operation: 'attachments.materialize', + publicMessage: 'Failed to materialize attachment file', + cause, + }); + }, + }); + } +} + +function destName(attachment: StoredAttachment): string { + return `${attachment.attachmentId}-${basename(attachment.name).replaceAll(rPathSep, '_')}`; +} + +function pathSegment(id: string): string | undefined { + if (id === '.' || id === '..' || rUnsafePathSegment.test(id)) return; + return id; +} diff --git a/packages/host/engine/src/conversation/turn-service.ts b/packages/host/engine/src/conversation/turn-service.ts index 492351eac..7a37cab62 100644 --- a/packages/host/engine/src/conversation/turn-service.ts +++ b/packages/host/engine/src/conversation/turn-service.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { HistoryCheckpoint } from '@linkcode/agent-adapter'; import type { + AttachmentId, ContentBlock, ConversationOperation, ConversationTurn, @@ -16,9 +17,12 @@ import type { TurnId, TurnInput, } from '@linkcode/schema'; +import { attachmentUri } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Effect } from 'effect'; +import type { AttachmentStore } from '../attachment/attachment-store'; +import { InMemoryAttachmentStore } from '../attachment/attachment-store'; import { OperationError, RequestError } from '../failure'; import type { SessionRecordRegistry } from '../session/session-record-registry'; import type { ConversationStore } from './conversation-store'; @@ -36,8 +40,7 @@ function mintPromptId(): PromptId { return `prompt-${randomUUID()}` as PromptId; } -/** Durable prompt blocks from legacy prompt content: text only for now — binary attachments - * become `attachment_ref`s once the attachment store lands. */ +/** Durable prompt blocks from legacy prompt content: text only. Inline images are not ingested. */ export function promptBlocksFromContent(content: ContentBlock[]): PromptBlock[] { return content.flatMap((block) => block.type === 'text' ? [{ type: 'text' as const, text: block.text }] : [], @@ -113,6 +116,7 @@ export class ConversationTurnService { private readonly records: SessionRecordRegistry, private readonly transport: Transport, private readonly runTask: (effect: Effect.Effect) => void, + private readonly attachments: AttachmentStore = new InMemoryAttachmentStore(), ) {} getOperation( @@ -158,12 +162,45 @@ export class ConversationTurnService { return Effect.succeed([{ type: 'text' as const, text: turnInputText(input) }]); } if (input.promptId === null) return Effect.undefined; + const { attachments } = this; return this.getPrompt(input.promptId).pipe( - Effect.map((prompt) => { - if (!prompt) return; - // attachment_ref blocks join the projection when the attachment store lands. - return prompt.blocks.flatMap((block) => - block.type === 'text' ? [{ type: 'text' as const, text: block.text }] : [], + Effect.flatMap((prompt) => { + if (!prompt) return Effect.undefined; + const ids: AttachmentId[] = []; + for (let i = 0, len = prompt.blocks.length; i < len; i++) { + const block = prompt.blocks[i]; + if (block.type === 'attachment_ref') ids.push(block.attachmentId); + } + const load = + ids.length === 0 + ? Effect.succeed([]) + : storeOperation('attachments.list', () => attachments.listAttachments(ids)); + return load.pipe( + Effect.map((stored) => { + const byId = new Map(stored.map((attachment) => [attachment.attachmentId, attachment])); + const content: ContentBlock[] = []; + for (let i = 0, len = prompt.blocks.length; i < len; i++) { + const block = prompt.blocks[i]; + if (block.type === 'text') { + content.push({ type: 'text', text: block.text }); + continue; + } + const attachment = byId.get(block.attachmentId); + content.push({ + type: 'resource_link', + uri: attachmentUri(block.attachmentId), + name: attachment?.name ?? block.attachmentId, + // `kind` rides `description`, never `title`: renderers prefer `title` over `name`, + // so putting it there labels every attachment chip "image" instead of its filename. + ...(attachment !== undefined && { + mimeType: attachment.mimeType, + size: attachment.sizeBytes, + description: attachment.kind, + }), + }); + } + return content; + }), ); }), ); diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 4599f9079..18230da04 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -21,6 +21,7 @@ import { InMemoryAttachmentStore } from './attachment/attachment-store'; import { FsBlobStore } from './attachment/blob-store'; import { AttachmentGc } from './attachment/gc'; import { AttachmentIoMutex } from './attachment/io-mutex'; +import { PromptMaterializer } from './attachment/materializer'; import { AttachmentRequestHandler } from './attachment/request-handler'; import { AttachmentUploadService } from './attachment/upload-service'; import { @@ -137,6 +138,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( ); const attachmentIo = new AttachmentIoMutex(); const attachmentGc = new AttachmentGc(attachmentStore, blobStore, Date.now, attachmentIo); + const materializer = new PromptMaterializer(attachmentStore, blobStore, stateDir, attachmentIo); const resources = new ResourceService( transport, resourceStore, @@ -195,6 +197,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( records, transport, runTask, + attachmentStore, ); const conversationJournals = new ConversationLiveJournals(); const sessions = new SessionOrchestrator( @@ -215,6 +218,11 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( deps.browserToolsEnabled ? () => new BrowserReplHost((op, args) => browserBroker.dispatch(op, args)) : undefined, + (sessionId, runId) => { + void materializer.cleanupRun(sessionId, runId).catch((error: unknown) => { + Effect.runFork(Effect.logWarning('Failed to clean up materialized attachments', error)); + }); + }, ); simulators?.setSessionValidator((id) => sessions.has(id)); terminals = deps.ptyBackend @@ -267,6 +275,8 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( worktrees, conversationTurns, conversationCheckpoints, + attachmentStore, + materializer, ); const sessionRequests = new SessionRequestHandler( transport, @@ -371,6 +381,14 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( 'Failed to sweep the attachment store', () => attachmentGc.bootSweep(), ).pipe(Effect.catch((error) => Effect.logWarning('Attachment boot sweep failed', error))); + yield* tryOperation( + 'filesystem', + 'attachments.materialized-sweep', + 'Failed to sweep materialized attachments', + () => materializer.bootSweep(), + ).pipe( + Effect.catch((error) => Effect.logWarning('Materialized attachment sweep failed', error)), + ); runTask(attachmentGc.cadence()); yield* worktrees.start(new Set(Array.from(records.values(), ({ sessionId }) => sessionId))); yield* tryOperation('store', 'workspaces.load', 'Failed to load workspaces', () => diff --git a/packages/host/engine/src/failure.ts b/packages/host/engine/src/failure.ts index 0a522b1db..c05fa1f1d 100644 --- a/packages/host/engine/src/failure.ts +++ b/packages/host/engine/src/failure.ts @@ -14,6 +14,8 @@ export type RequestErrorCode = /** The request was understood and refused — the user withheld consent, not a broken call. */ | 'forbidden' | 'unsupported' + /** A prompt attachment the harness did not declare, or that is missing/not ready. */ + | 'unsupported_attachment' | 'worktree_missing' | 'limit_exceeded' | 'cancelled'; diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 1e4ad86b1..8b3af5d33 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -17,8 +17,17 @@ import type { WorkspaceRecord, WorktreeRecord, } from '@linkcode/schema'; +import { effectiveAttachmentCapability } from '@linkcode/schema'; import { Effect, Exit, Semaphore } from 'effect'; import { nullthrow } from 'foxts/guard'; +import { + admitPromptAttachments, + assertInlineAttachmentsSupported, + attachmentIdsFromBlocks, + uniqueAttachmentIds, +} from '../attachment/admit'; +import type { AttachmentStore } from '../attachment/attachment-store'; +import type { PromptMaterializer } from '../attachment/materializer'; import type { SessionDriver } from '../automation'; import type { ConversationCheckpointService, ForkCut } from '../conversation/checkpoint-service'; import { hasHiddenPrefix, pathToLeaf } from '../conversation/lineage-attribution'; @@ -39,6 +48,7 @@ import { } from '../failure'; import type { WorkspaceRegistry } from '../workspace/workspace-registry'; import type { WorktreeService } from '../worktree/worktree-service'; +import { assertAttachmentContentAllowed } from './attachment-guard'; import type { HistoryService } from './history-service'; import { decodeLiveBranchCursor } from './live-session'; import type { SessionOrchestrator } from './orchestrator'; @@ -76,9 +86,24 @@ type TurnLaunch = | { readonly type: 'resume'; readonly historyId?: AgentHistoryId } | { readonly type: 'fork'; readonly cut: ForkCut }; +function caughtEngineFailure(error: unknown): EngineFailure { + if ( + error instanceof RequestError || + error instanceof OperationError || + error instanceof OperationTimeout + ) { + return error; + } + return new OperationError({ + subsystem: 'store', + operation: 'attachments.admit', + publicMessage: 'Attachment validation failed', + cause: error, + }); +} + function toAgentInput(input: TurnSubmitInput): AgentInput { if (input.type !== 'prompt') return input; - // attachment_ref blocks are refused at admit until the attachment store lands. return { type: 'prompt', content: input.blocks.flatMap((block) => @@ -103,6 +128,8 @@ export class SessionLifecycleService { private readonly worktrees: WorktreeService, private readonly turns: ConversationTurnService, private readonly checkpoints: ConversationCheckpointService, + private readonly attachments: AttachmentStore, + private readonly materializer: PromptMaterializer, ) { this.driver = { createSession: ({ signal, ...options }) => @@ -126,10 +153,15 @@ export class SessionLifecycleService { } deleteSession(sessionId: SessionId): Effect.Effect { - const { sessions, workspaces, worktrees } = this; + const { materializer, sessions, workspaces, worktrees } = this; return Effect.gen(function* () { const worktree = worktrees.get(sessionId); yield* sessions.delete(sessionId); + // Best-effort: a missed directory is removed at the next boot sweep. Do not await the + // unlink on the delete reply — `session.delete` of `..` must not block or traverse. + void materializer.cleanupSession(sessionId).catch((error: unknown) => { + Effect.runFork(Effect.logWarning('Failed to clean up materialized attachments', error)); + }); yield* worktrees.cleanupDeletedSession(sessionId); if (worktree && !worktrees.hasPath(worktree.worktreePath)) { const workspace = workspaces.findByCwd(worktree.worktreePath); @@ -314,6 +346,15 @@ export class SessionLifecycleService { const resolveForRecord = this.resolveForRecord.bind(this); const launchRun = this.launchRun.bind(this); return Effect.gen(function* () { + yield* Effect.try({ + try() { + assertAttachmentContentAllowed(content); + assertInlineAttachmentsSupported(content, effectiveAttachmentCapability(source.kind)); + }, + catch(error) { + return caughtEngineFailure(error); + }, + }); if (yield* turns.hasOpenOperation(sourceSessionId)) { return yield* Effect.fail( new RequestError({ @@ -414,6 +455,7 @@ export class SessionLifecycleService { const admitSubmit = this.admitSubmit.bind(this); const relaunch = this.relaunch.bind(this); const resumeSession = this.resumeSession.bind(this); + const materializeSubmitInput = this.materializeSubmitInput.bind(this); const { history } = this; return Effect.gen(function* () { // Replay before any validation: a reply lost to a disconnect must not duplicate a sibling. @@ -478,7 +520,9 @@ export class SessionLifecycleService { // the turn is visibly running is committed, not failed — pi-style send() spans the whole // turn. commitRunning completes before the race interrupts the losing send fiber, so its // exit backstop then sees an already-resolved operation and stands down. - yield* sessions.sendInput(request.sessionId, toAgentInput(request.input), intent).pipe( + const echoInput = toAgentInput(request.input); + const adapterInput = yield* materializeSubmitInput(request, intent); + yield* sessions.sendInput(request.sessionId, echoInput, intent, adapterInput).pipe( Effect.timeoutOrElse({ duration: TURN_SUBMIT_TIMEOUT_MS, orElse: (): Effect.Effect => @@ -559,20 +603,9 @@ export class SessionLifecycleService { new RequestError({ code: 'busy', message: `Session is busy: ${request.sessionId}` }), ); } - if ( - request.input.type === 'prompt' && - request.input.blocks.some((block) => block.type === 'attachment_ref') - ) { - // Seam: attachment existence/readiness/capability validation lands with the store. - return Effect.fail( - new RequestError({ - code: 'unsupported', - message: 'Prompt attachments are not supported yet', - }), - ); - } // Seam: the worktree co-leaseholder busy gate joins this critical section later. const { checkpoints, records, sessions, turns } = this; + const admitAttachments = this.admitPromptBlocks.bind(this); return Effect.gen(function* () { if (yield* turns.hasOpenOperation(request.sessionId)) { return yield* Effect.fail( @@ -699,6 +732,9 @@ export class SessionLifecycleService { const liveRunId = launch.type === 'continue' ? sessions.liveRunId(request.sessionId) : undefined; if (liveRunId === undefined && launch.type === 'continue') launch = { type: 'resume' }; + if (request.input.type === 'prompt') { + yield* admitAttachments(record.kind, request.input.blocks); + } const intent = yield* turns.persistIntent({ sessionId: request.sessionId, operationId: request.operationId, @@ -712,6 +748,76 @@ export class SessionLifecycleService { ); } + private admitPromptBlocks( + kind: AgentKind, + blocks: Extract['blocks'], + ): Effect.Effect { + const ids = uniqueAttachmentIds(attachmentIdsFromBlocks(blocks)); + if (ids.length === 0) return Effect.void; + const capability = effectiveAttachmentCapability(kind); + return Effect.tryPromise({ + try: () => this.attachments.listAttachments(ids), + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'attachments.list', + publicMessage: 'Failed to load attachments', + cause, + }), + }).pipe( + Effect.flatMap((stored) => + Effect.try({ + try: () => admitPromptAttachments(blocks, stored, capability), + catch: (error) => caughtEngineFailure(error), + }), + ), + ); + } + + private materializeSubmitInput( + request: TurnSubmitRequest, + intent: PersistedTurnIntent, + ): Effect.Effect { + if (request.input.type !== 'prompt') return Effect.succeed(request.input); + if (attachmentIdsFromBlocks(request.input.blocks).length === 0) { + return Effect.succeed(toAgentInput(request.input)); + } + const record = this.records.get(request.sessionId); + if (!record) { + return Effect.fail( + new RequestError({ + code: 'not_found', + message: `Unknown session: ${request.sessionId}`, + }), + ); + } + const promptId = intent.turn.input.type === 'prompt' ? intent.turn.input.promptId : null; + if (promptId === null) return Effect.succeed(toAgentInput(request.input)); + const { materializer } = this; + const capability = effectiveAttachmentCapability(record.kind); + return this.turns.getPrompt(promptId).pipe( + Effect.flatMap((prompt) => + prompt === undefined + ? Effect.fail( + new RequestError({ + code: 'not_found', + message: 'The prompt was not persisted', + }), + ) + : materializer.prepare(request.sessionId, intent.turn.runId, prompt, capability), + ), + Effect.flatMap((prepared) => + Effect.try({ + try: (): AgentInput => ({ + type: 'prompt', + content: materializer.toContentBlocks(prepared), + }), + catch: (error) => caughtEngineFailure(error), + }), + ), + ); + } + /** Replace the session's adapter under the same LinkCode id with one `start`ed on other provider * history: a fresh session (new root lineage), a fork at a checkpoint, or an inactive lineage's * own history resumed. The source adapter is stopped first — one live adapter per session, and diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index ebcf49251..09ab8980a 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -48,6 +48,7 @@ export class SessionOrchestrator { private readonly turns: ConversationTurnService, private readonly journals: ConversationLiveJournals, private readonly browserTools?: BrowserToolsetFactory, + private readonly onRunEnded?: (sessionId: SessionId, runId: RunId) => void, ) { this.events = new SessionEventProcessor( transport, @@ -130,11 +131,12 @@ export class SessionOrchestrator { sessionId: SessionId, input: AgentInput, prepared?: PersistedTurnIntent, + adapterInput?: AgentInput, ): Effect.Effect { return Effect.suspend(() => { const session = this.requireSession(sessionId); return session.run( - Effect.suspend(() => this.inputs.send(sessionId, session, input, prepared)), + Effect.suspend(() => this.inputs.send(sessionId, session, input, prepared, adapterInput)), ); }); } @@ -460,6 +462,7 @@ export class SessionOrchestrator { Effect.ensuring( Effect.suspend(() => { if (!this.remove(sessionId, session)) return Effect.void; + this.onRunEnded?.(sessionId, session.runId); if (releaseSession) this.onStopped(sessionId); // Teardown mid-turn kills the turn without a stop frame; settle it here. this.turns.settleStatus(sessionId, session.runId, 'stopped'); @@ -498,6 +501,7 @@ export class SessionOrchestrator { Effect.ensuring( Effect.suspend(() => { if (!this.remove(sessionId, session)) return Effect.void; + this.onRunEnded?.(sessionId, session.runId); // Release what a partial start may have reserved — notably the simulator MCP endpoint // token minted while resolving start options. Normal teardown does this via `onStopped`; // a discarded failed start must too, or that token leaks until daemon shutdown. diff --git a/packages/host/engine/src/session/session-input-dispatcher.ts b/packages/host/engine/src/session/session-input-dispatcher.ts index 1075303a9..2d220cffb 100644 --- a/packages/host/engine/src/session/session-input-dispatcher.ts +++ b/packages/host/engine/src/session/session-input-dispatcher.ts @@ -1,7 +1,13 @@ +import { UnsupportedAttachmentError } from '@linkcode/agent-adapter'; import type { AgentInput, SessionId } from '@linkcode/schema'; -import { agentCommandMatches, userRowMessageId } from '@linkcode/schema'; +import { + agentCommandMatches, + effectiveAttachmentCapability, + userRowMessageId, +} from '@linkcode/schema'; import { Cause, Effect, Exit } from 'effect'; import { nullthrow } from 'foxts/guard'; +import { assertInlineAttachmentsSupported } from '../attachment/admit'; import type { ConversationTurnService, PersistedTurnIntent } from '../conversation/turn-service'; import { mintOperationId, promptBlocksFromContent } from '../conversation/turn-service'; import { causeToRequestFailure, OperationError, RequestError } from '../failure'; @@ -28,6 +34,7 @@ export class SessionInputDispatcher { session: LiveSession, input: AgentInput, prepared?: PersistedTurnIntent, + adapterOverride?: AgentInput, ): Effect.Effect { const startsTurn = input.type === 'prompt' || input.type === 'command' || input.type === 'shell-command'; @@ -77,20 +84,29 @@ export class SessionInputDispatcher { events.rejectInput(sessionId, session, error.message); return yield* Effect.fail(error); } - let adapterInput: AgentInput = input; + let adapterInput: AgentInput = adapterOverride ?? input; if (input.type === 'prompt') { yield* Effect.try({ - try: () => assertAttachmentContentAllowed(input.content), - catch: (e) => e, + try() { + assertAttachmentContentAllowed(input.content); + assertInlineAttachmentsSupported( + input.content, + effectiveAttachmentCapability(records.get(sessionId)?.kind ?? session.adapter.kind), + ); + }, + catch(error) { + return error; + }, }); + const promptForAdapter = adapterInput.type === 'prompt' ? adapterInput : input; adapterInput = yield* resources.readySourceLocators(sessionId).pipe( Effect.map((locators) => locators.length === 0 - ? input + ? promptForAdapter : { - ...input, + ...promptForAdapter, content: [ - ...input.content, + ...promptForAdapter.content, { type: 'text' as const, text: `${RESOURCE_CONTEXT_SENTINEL}\n${locators.join('\n')}`, @@ -164,13 +180,19 @@ export class SessionInputDispatcher { yield* Effect.tryPromise({ try: () => session.adapter.send(adapterInput), catch: (cause) => - new OperationError({ - subsystem: 'agent', - operation: 'session.input', - publicMessage: 'Agent input was rejected', - cause, - ...(startsTurn && { reportedInConversation: true }), - }), + cause instanceof UnsupportedAttachmentError + ? new RequestError({ + code: 'unsupported_attachment', + message: cause.message, + reportedInConversation: true, + }) + : new OperationError({ + subsystem: 'agent', + operation: 'session.input', + publicMessage: 'Agent input was rejected', + cause, + ...(startsTurn && { reportedInConversation: true }), + }), }).pipe( Effect.tapError((error) => Effect.sync(() => { @@ -182,7 +204,13 @@ export class SessionInputDispatcher { ); } if (echoMessageId !== undefined) session.untrackPrompt(echoMessageId); - if (startsTurn) events.rejectInput(sessionId, session, error.publicMessage); + if (startsTurn) { + events.rejectInput( + sessionId, + session, + error instanceof RequestError ? error.message : error.publicMessage, + ); + } }), ), );