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
133 changes: 126 additions & 7 deletions packages/contracts/src/chatAttachment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,128 @@ import {
PROVIDER_SEND_TURN_MAX_FILE_BYTES,
SnapShotAccessibility,
} from "./chatAttachment.ts";
import {
OrchestrationV2Command,
OrchestrationV2ConversationMessageJson,
OrchestrationV2ThreadLaunchInput,
} from "./orchestrationV2.ts";
import { ProviderRespondToUserInputInput, ProviderSendTurnInput } from "./provider.ts";
import { UserInputAttachmentAnswerPayload } from "./providerPolicy.ts";

const decodeAttachment = Schema.decodeUnknownEffect(ChatAttachment);
const decodeSnapShotAccessibility = Schema.decodeUnknownEffect(SnapShotAccessibility);
const decodeQuestionAnswer = Schema.decodeUnknownSync(UserInputAttachmentAnswerPayload);
const decodeMessage = Schema.decodeUnknownSync(OrchestrationV2ConversationMessageJson);

it.each([
["provider turn", ProviderSendTurnInput, {}],
[
"provider question response",
ProviderRespondToUserInputInput,
{ requestId: "request-1", answers: {} },
],
[
"message dispatch",
OrchestrationV2Command,
{
type: "message.dispatch",
createdBy: "user",
creationSource: "web",
messageId: "message-1",
text: "hello",
dispatchMode: { type: "start_immediately" },
},
],
[
"queued message edit",
OrchestrationV2Command,
{ type: "queued-run.edit", runId: "run-1", text: "hello" },
],
[
"runtime question response",
OrchestrationV2Command,
{ type: "runtime-request.respond", requestId: "request-1", answers: {} },
],
[
"thread launch",
OrchestrationV2ThreadLaunchInput,
{
projectId: "project-1",
title: "Thread",
modelSelection: { instanceId: "codex", model: "gpt-5.4" },
runtimeMode: "full-access",
interactionMode: "default",
workspaceStrategy: { type: "root" },
},
],
] as const)("caps new file attachments in %s", (name, schema, fields) => {
const input = (sizeBytes: number) => {
const attachments = [
{ type: "file", id: "file-1", name: "report.pdf", mimeType: "application/pdf", sizeBytes },
];
return {
commandId: "command-1",
threadId: "thread-1",
...fields,
...(name === "thread launch"
? { initialMessage: { text: "hello", attachments } }
: name.includes("question response")
? { attachmentsByQuestionId: { question: attachments } }
: { attachments }),
};
};
assert.strictEqual(Schema.is(schema)(input(PROVIDER_SEND_TURN_MAX_FILE_BYTES)), true);
assert.strictEqual(Schema.is(schema)(input(PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1)), false);
});

it("reads historical question replies with files above the send limit", () => {
const payload = decodeQuestionAnswer({
requestId: "request-1",
answers: {},
attachmentsByQuestionId: {
question: [
{
type: "file",
id: "file-1",
name: "report.pdf",
mimeType: "application/pdf",
sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1,
},
],
},
});
assert.strictEqual(
payload.attachmentsByQuestionId.question?.[0]?.sizeBytes,
PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1,
);
});

it("reads complete historical messages with files above the send limit", () => {
const message = decodeMessage({
id: "message-1",
threadId: "thread-1",
runId: null,
nodeId: null,
createdBy: "user",
creationSource: "web",
role: "user",
text: "hello",
streaming: false,
createdAt: "2026-10-03T00:00:00.000Z",
updatedAt: "2026-10-03T00:00:00.000Z",
attachments: [
{
type: "file",
id: "file-1",
name: "report.pdf",
mimeType: "application/pdf",
sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1,
},
],
});
assert.strictEqual(message.attachments[0]?.sizeBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1);
assert.strictEqual(message.text, "hello");
});

// Attachments ride on persisted events and thread streams with no client
// version negotiation. A type this build does not know must decode instead of
Expand All @@ -29,20 +148,20 @@ it.effect("tolerates attachment types from newer builds", () =>
}),
);

// The tolerant member must not catch malformed known attachments: a file over
// the size cap or an image with a bad mime has to fail its own schema, not
// slide through the open one with those constraints unchecked.
it.effect("rejects malformed known attachment types instead of tolerating them", () =>
Effect.gen(function* () {
const base = {
id: "thread-1-00000000-0000-4000-8000-000000000003-pdf",
name: "report.pdf",
mimeType: "application/pdf",
};
const oversizedFile = yield* Effect.exit(
decodeAttachment({ ...base, type: "file", sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 }),
);
assert.strictEqual(Exit.isFailure(oversizedFile), true);
// A newer build may raise the upload cap; this build must still read those files.
const aboveUploadCap = PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1;
const largeFile = yield* decodeAttachment({ ...base, type: "file", sizeBytes: aboveUploadCap });
assert.strictEqual(largeFile.sizeBytes, aboveUploadCap);

const emptyFile = yield* Effect.exit(decodeAttachment({ ...base, type: "file", sizeBytes: 0 }));
assert.strictEqual(Exit.isFailure(emptyFile), true);
const badMimeImage = yield* Effect.exit(
decodeAttachment({ ...base, type: "image", mimeType: "application/pdf", sizeBytes: 12 }),
);
Expand Down
20 changes: 16 additions & 4 deletions packages/contracts/src/chatAttachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,22 @@ export const ChatFileAttachment = Schema.Struct({
id: ChatAttachmentId,
name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
sizeBytes: NonNegativeInt.check(
Schema.isGreaterThanOrEqualTo(1),
Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES),
),
sizeBytes: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)),
Comment thread
Bil0000 marked this conversation as resolved.
/** Clipboard text folded by a client. Providers keep these path-only so the
agent can inspect the file selectively instead of eagerly spending the
same context the fold is intended to preserve. */
source: Schema.optional(PastedTextAttachmentSource),
});
export type ChatFileAttachment = typeof ChatFileAttachment.Type;

export const ChatFileAttachmentInput = Schema.Struct({
...ChatFileAttachment.fields,
sizeBytes: ChatFileAttachment.fields.sizeBytes.check(
Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES),
),
});
export type ChatFileAttachmentInput = typeof ChatFileAttachmentInput.Type;

/**
* Catch-all for attachment types this build does not know. Attachments ride on
* persisted events and thread streams, so a newer server or client must be able
Expand Down Expand Up @@ -217,6 +222,13 @@ export const ChatAttachment = Schema.Union([
]);
export type ChatAttachment = typeof ChatAttachment.Type;

export const ChatAttachmentInput = Schema.Union([
ChatImageAttachment,
ChatFileAttachmentInput,
ChatUnknownAttachment,
]);
export type ChatAttachmentInput = typeof ChatAttachmentInput.Type;

export function getProviderAttachmentLimitError(
attachments: ReadonlyArray<Pick<ChatAttachment, "type" | "mimeType" | "sizeBytes">>,
): string | undefined {
Expand Down
12 changes: 6 additions & 6 deletions packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
TrimmedNonEmptyString,
TurnItemId,
} from "./baseSchemas.ts";
import { ChatAttachment } from "./chatAttachment.ts";
import { ChatAttachment, ChatAttachmentInput } from "./chatAttachment.ts";
import {
OrchestrationGetFullThreadDiffInput,
OrchestrationGetFullThreadDiffResult,
Expand All @@ -54,7 +54,7 @@ import {
ProviderInteractionMode,
ProviderRequestKind,
ProviderUserInputAnswers,
UserInputAttachments,
UserInputAttachmentsInput,
UserInputAttachmentAnswerPayload,
RuntimeMode,
} from "./providerPolicy.ts";
Expand Down Expand Up @@ -2672,7 +2672,7 @@ export const OrchestrationV2Command = Schema.Union([
messageId: MessageId,
text: Schema.String,
context: Schema.optional(OrchestrationMessageContext),
attachments: Schema.Array(ChatAttachment),
attachments: Schema.Array(ChatAttachmentInput),
/** Seed the temporary title and generate a durable replacement for the first message. */
titleSeed: Schema.optional(TrimmedNonEmptyString),
modelSelection: Schema.optional(ModelSelection),
Expand Down Expand Up @@ -2767,7 +2767,7 @@ export const OrchestrationV2Command = Schema.Union([
text: Schema.String,
// Full replacement list. Absent = leave the message's attachments as-is,
// so pre-attachment clients editing text keep the original attachments.
attachments: Schema.optional(Schema.Array(ChatAttachment)),
attachments: Schema.optional(Schema.Array(ChatAttachmentInput)),
}),
Schema.Struct({
type: Schema.Literal("runtime-request.respond"),
Expand All @@ -2776,7 +2776,7 @@ export const OrchestrationV2Command = Schema.Union([
requestId: RuntimeRequestId,
decision: Schema.optional(ProviderApprovalDecision),
answers: Schema.optional(ProviderUserInputAnswers),
attachmentsByQuestionId: Schema.optional(UserInputAttachments),
attachmentsByQuestionId: Schema.optional(UserInputAttachmentsInput),
}),
Schema.Struct({
type: Schema.Literal("thread.user-input.dismiss"),
Expand Down Expand Up @@ -2998,7 +2998,7 @@ export const OrchestrationV2ThreadLaunchInput = Schema.Struct({
messageId: Schema.optional(MessageId),
text: Schema.String,
context: Schema.optional(OrchestrationMessageContext),
attachments: Schema.Array(ChatAttachment),
attachments: Schema.Array(ChatAttachmentInput),
}),
),
});
Expand Down
8 changes: 4 additions & 4 deletions packages/contracts/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import {
getProviderAttachmentLimitError,
PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
ChatAttachment,
ChatAttachmentInput,
} from "./chatAttachment.ts";
import { ModelSelection } from "./modelSelection.ts";
import {
Expand All @@ -21,7 +21,7 @@ import {
ProviderRequestKind,
ProviderSandboxMode,
ProviderUserInputAnswers,
UserInputAttachments,
UserInputAttachmentsInput,
RuntimeMode,
} from "./providerPolicy.ts";
import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
Expand Down Expand Up @@ -77,7 +77,7 @@ export const ProviderSendTurnInput = Schema.Struct({
TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),
),
attachments: Schema.optional(
Schema.Array(ChatAttachment).check(
Schema.Array(ChatAttachmentInput).check(
Schema.makeFilter((attachments) => getProviderAttachmentLimitError(attachments) ?? true),
),
),
Expand Down Expand Up @@ -115,7 +115,7 @@ export const ProviderRespondToUserInputInput = Schema.Struct({
threadId: ThreadId,
requestId: ApprovalRequestId,
answers: ProviderUserInputAnswers,
attachmentsByQuestionId: Schema.optional(UserInputAttachments),
attachmentsByQuestionId: Schema.optional(UserInputAttachmentsInput),
});
export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type;

Expand Down
9 changes: 9 additions & 0 deletions packages/contracts/src/providerPolicy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ChatImageAttachment,
ChatFileAttachment,
ChatFileAttachmentInput,
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
} from "./chatAttachment.ts";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -75,6 +76,14 @@ export const UserInputAttachments = Schema.Record(
);
export type UserInputAttachments = typeof UserInputAttachments.Type;

export const UserInputAttachmentsInput = Schema.Record(
Schema.String,
Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachmentInput])).pipe(
Schema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
),
);
export type UserInputAttachmentsInput = typeof UserInputAttachmentsInput.Type;

export const UserInputAttachmentAnswerPayload = Schema.Struct({
requestId: TrimmedNonEmptyString,
questionTextById: Schema.optional(Schema.Record(Schema.String, Schema.String)),
Expand Down
Loading