Skip to content
Merged
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
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export * from "./lib/workflow-display-flow.js";
export * from "./lib/workflow-run.js";
export * from "./lib/workflow-run-error.js";
export * from "./lib/workflow-run-progress.js";
export * from "./lib/conversation.js";
export * from "./lib/conversation-activity.js";
export * from "./lib/media-kind.js";
export * from "./lib/attachment-limits.js";
export * from "./ui/agent-turn.js";
export * from "./ui/animated-number.js";
export * from "./ui/approval-card.js";
Expand Down
115 changes: 115 additions & 0 deletions src/lib/attachment-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";

import { checkAttachments } from "./attachment-limits.js";

const file = (name: string, size = 10) => ({ name, size });

describe("checkAttachments", () => {
test("accepts everything when no limit applies", () => {
const incoming = [file("a"), file("b")];

expect(checkAttachments([], incoming, {})).toEqual({
accepted: incoming,
rejected: [],
});
});

test("rejects past the count limit, counting what is already attached", () => {
const result = checkAttachments(
[file("held")],
[file("a"), file("b"), file("c")],
{ maxFiles: 3 },
);

expect(result.accepted).toEqual([file("a"), file("b")]);
expect(result.rejected).toEqual([{ file: file("c"), reason: "too-many" }]);
});

test("rejects everything when the draft is already over the count limit", () => {
const result = checkAttachments([file("x"), file("y")], [file("a")], {
maxFiles: 1,
});

expect(result.accepted).toEqual([]);
expect(result.rejected).toEqual([{ file: file("a"), reason: "too-many" }]);
});

test("rejects past the size limit", () => {
const result = checkAttachments(
[],
[file("small", 100), file("exact", 200), file("big", 201)],
{ maxFileBytes: 200 },
);

expect(result.accepted).toEqual([file("small", 100), file("exact", 200)]);
expect(result.rejected).toEqual([
{ file: file("big", 201), reason: "too-large" },
]);
});

test("rejects duplicates by name plus size, against held and incoming files", () => {
const result = checkAttachments(
[file("held", 5)],
[file("held", 5), file("held", 6), file("new"), file("new")],
{},
);

expect(result.accepted).toEqual([file("held", 6), file("new")]);
expect(result.rejected).toEqual([
{ file: file("held", 5), reason: "duplicate" },
{ file: file("new"), reason: "duplicate" },
]);
});

test("a rejected file does not use up room or count as seen", () => {
const result = checkAttachments(
[],
[file("big", 999), file("a"), file("big", 999)],
{ maxFiles: 1, maxFileBytes: 100 },
);

expect(result.accepted).toEqual([file("a")]);
expect(result.rejected.map((rejection) => rejection.reason)).toEqual([
"too-large",
"too-large",
]);
});

test("keeps incoming order in both lists", () => {
const incoming = [
file("one"),
file("huge", 500),
file("two"),
file("one"),
file("three"),
file("four"),
];
const result = checkAttachments([], incoming, {
maxFiles: 3,
maxFileBytes: 100,
});

expect(result.accepted.map((accepted) => accepted.name)).toEqual([
"one",
"two",
"three",
]);
expect(
result.rejected.map(({ file: rejected, reason }) => [
rejected.name,
reason,
]),
).toEqual([
["huge", "too-large"],
["one", "duplicate"],
["four", "too-many"],
]);
});

test("returns the caller's own file objects", () => {
const blob = new File(["hi"], "note.txt", { type: "text/plain" });
const result = checkAttachments([], [blob], {});

expect(result.accepted[0]).toBe(blob);
});
});
65 changes: 65 additions & 0 deletions src/lib/attachment-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** What an attachment check needs to know about a file. `File` satisfies it. */
export type AttachmentCandidate = {
readonly name: string;
readonly size: number;
};

/** An absent limit does not apply. */
export type AttachmentLimits = {
/** Most files held at once, counting those already attached. */
readonly maxFiles?: number;
/** Largest single file, in bytes. */
readonly maxFileBytes?: number;
};

export type AttachmentRejectionReason = "too-many" | "too-large" | "duplicate";

export type AttachmentRejection<T extends AttachmentCandidate> = {
readonly file: T;
readonly reason: AttachmentRejectionReason;
};

export type AttachmentCheck<T extends AttachmentCandidate> = {
readonly accepted: readonly T[];
readonly rejected: readonly AttachmentRejection<T>[];
};

function identity(file: AttachmentCandidate): string {
return `${file.size}\u0000${file.name}`;
}

/**
* Which of `incoming` may join the `current` attachments. Each file is checked
* in turn: too large, then already attached (same name and size, against
* `current` and the files accepted before it), then past the count limit.
* Both lists keep the order `incoming` arrived in.
*/
export function checkAttachments<T extends AttachmentCandidate>(
current: readonly AttachmentCandidate[],
incoming: readonly T[],
limits: AttachmentLimits,
): AttachmentCheck<T> {
const seen = new Set(current.map(identity));
const room =
limits.maxFiles === undefined
? Number.POSITIVE_INFINITY
: Math.max(0, limits.maxFiles - current.length);
const accepted: T[] = [];
const rejected: AttachmentRejection<T>[] = [];

for (const file of incoming) {
const key = identity(file);
if (limits.maxFileBytes !== undefined && file.size > limits.maxFileBytes) {
rejected.push({ file, reason: "too-large" });
} else if (seen.has(key)) {
rejected.push({ file, reason: "duplicate" });
} else if (accepted.length >= room) {
rejected.push({ file, reason: "too-many" });
} else {
seen.add(key);
accepted.push(file);
}
}

return { accepted, rejected };
}
Loading
Loading