1import * as Schema from "effect/Schema";
2
3import { NonNegativeInt, ThreadId, TrimmedNonEmptyString, TurnItemId } from "./baseSchemas.ts";
4import {
5 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
6 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
7 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
8} from "./chatAttachment.ts";
9import { ProjectFaviconPath } from "./project.ts";
10import { ToolActivityNativeAppReference } from "./providerRuntime.ts";
11
12const ASSET_PATH_MAX_LENGTH = 1024;
13
14export const AssetResource = Schema.Union([
15 Schema.TaggedStruct("workspace-file", {
16 threadId: ThreadId,
17 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
18 }),
19 // One file served in place from anywhere the environment host can read:
20 // images, videos, HTML, and PDF. An absolute path may lie outside the
21 // workspace; a relative one resolves against the thread's workspace.
22 Schema.TaggedStruct("media-file", {
23 threadId: ThreadId,
24 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
25 }),
26 // A workspace file named by a draft that has no thread yet. The draft names
27 // its workspace root explicitly instead of resolving one from a thread.
28 Schema.TaggedStruct("draft-workspace-file", {
29 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
30 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
31 }),
32 Schema.TaggedStruct("attachment", {
33 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
34 /** Display name and mime from the `ChatAttachment` the caller holds. The
35 server bakes both into the signed URL so downloads carry the real
36 filename and Content-Type. Absent on older clients, which fall back to
37 an octet-stream download without a filename. */
38 fileName: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(255))),
39 mimeType: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(100))),
40 /** Generic attachments download by default. Document viewers opt into an
41 inline response after deciding the file type is safe to preview. */
42 disposition: Schema.optionalKey(Schema.Literals(["inline", "attachment"])),
43 /** Render an MCP App snapshot with the host theme bootstrap. */
44 renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")),
45 }),
46 // An image a tool returned inline, such as a device screenshot, by its order
47 // in the stored output. The timeline never carries these bytes.
48 Schema.TaggedStruct("tool-output-image", {
49 threadId: ThreadId,
50 itemId: TurnItemId,
51 index: NonNegativeInt,
52 }),
53 Schema.TaggedStruct("project-favicon", {
54 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
55 // A cache-key hint only. The server reads the authoritative path from the
56 // project projection before it issues the signed URL.
57 path: Schema.optional(ProjectFaviconPath),
58 }),
59 Schema.TaggedStruct("native-app-icon", {
60 app: ToolActivityNativeAppReference,
61 }),
62 // An upload a pull request body points at on GitHub. A private repository serves these only
63 // to a request that carries a credential, which the client has none of, so the server fetches
64 // them with the `gh` credential the repository at `cwd` authenticates with.
65 Schema.TaggedStruct("github-media", {
66 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
67 url: TrimmedNonEmptyString.check(Schema.isMaxLength(2048)),
68 }),
69]);
70export type AssetResource = typeof AssetResource.Type;
71
72export const AssetCreateUrlInput = Schema.Struct({
73 resource: AssetResource,
74});
75export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type;
76
77export const AssetImageDimensions = Schema.Struct({
78 width: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)),
79 height: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)),
80});
81export type AssetImageDimensions = typeof AssetImageDimensions.Type;
82
83export const AssetCreateUrlResult = Schema.Struct({
84 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
85 expiresAt: Schema.Number,
86 sourcePath: Schema.optional(
87 TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
88 ),
89 /** Pixel size read from the image header, so a client can reserve the exact box before the bytes arrive. */
90 imageDimensions: Schema.optional(AssetImageDimensions),
91});
92export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
93
94export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000;
95
96const ImageAttachmentCreateUploadUrlInput = Schema.Struct({
97 type: Schema.optionalKey(Schema.Literal("image")),
98 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
99 mimeType: Schema.Literals(PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES),
100 sizeBytes: NonNegativeInt.check(
101 Schema.isGreaterThanOrEqualTo(1),
102 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES),
103 ),
104});
105
106const FileAttachmentCreateUploadUrlInput = Schema.Struct({
107 type: Schema.Literal("file"),
108 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
109 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
110 sizeBytes: NonNegativeInt.check(
111 Schema.isGreaterThanOrEqualTo(1),
112 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES),
113 ),
114});
115
116export const AttachmentCreateUploadUrlInput = Schema.Union([
117 ImageAttachmentCreateUploadUrlInput,
118 FileAttachmentCreateUploadUrlInput,
119]);
120export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type;
121
122export const AttachmentCreateUploadUrlResult = Schema.Struct({
123 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
124 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
125 expiresAt: Schema.Number,
126});
127export type AttachmentCreateUploadUrlResult = typeof AttachmentCreateUploadUrlResult.Type;
128
129export const AttachmentDeleteInput = Schema.Struct({
130 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
131});
132export type AttachmentDeleteInput = typeof AttachmentDeleteInput.Type;
133
134export class AttachmentUploadSigningKeyError extends Schema.TaggedError<AttachmentUploadSigningKeyError>()(
135 "AttachmentUploadSigningKeyError",
136 {
137 cause: Schema.Defect(),
138 },
139) {
140 override get message(): string {
141 return "Failed to load the attachment upload signing key.";
142 }
143}
144
145export class AssetWorkspaceContextNotFoundError extends Schema.TaggedError<AssetWorkspaceContextNotFoundError>()(
146 "AssetWorkspaceContextNotFoundError",
147 {
148 resource: AssetResource,
149 },
150) {
151 override get message(): string {
152 return "Workspace context was not found.";
153 }
154}
155
156export class AssetWorkspaceContextResolutionError extends Schema.TaggedError<AssetWorkspaceContextResolutionError>()(
157 "AssetWorkspaceContextResolutionError",
158 {
159 resource: AssetResource,
160 cause: Schema.Defect(),
161 },
162) {
163 override get message(): string {
164 return "Failed to resolve workspace context.";
165 }
166}
167
168export class AssetWorkspaceRootNormalizationError extends Schema.TaggedError<AssetWorkspaceRootNormalizationError>()(
169 "AssetWorkspaceRootNormalizationError",
170 {
171 resource: AssetResource,
172 cause: Schema.Defect(),
173 },
174) {
175 override get message(): string {
176 return "Failed to normalize the workspace root.";
177 }
178}
179
180export class AssetWorkspacePathValidationError extends Schema.TaggedError<AssetWorkspacePathValidationError>()(
181 "AssetWorkspacePathValidationError",
182 {
183 resource: AssetResource,
184 cause: Schema.Defect(),
185 },
186) {
187 override get message(): string {
188 return "Workspace file path must be relative to the project root.";
189 }
190}
191
192export class AssetPreviewTypeValidationError extends Schema.TaggedError<AssetPreviewTypeValidationError>()(
193 "AssetPreviewTypeValidationError",
194 {
195 resource: AssetResource,
196 },
197) {
198 override get message(): string {
199 // Draft resources serve absolute paths through the same host-media
200 // validation as media files, so they share its message.
201 return this.resource._tag === "media-file" || this.resource._tag === "draft-workspace-file"
202 ? "Only images, videos, audio, HTML, and PDF files can be previewed."
203 : "Only browser documents and images can be previewed.";
204 }
205}
206
207export class AssetWorkspaceAssetInspectionError extends Schema.TaggedError<AssetWorkspaceAssetInspectionError>()(
208 "AssetWorkspaceAssetInspectionError",
209 {
210 resource: AssetResource,
211 cause: Schema.Defect(),
212 },
213) {
214 override get message(): string {
215 return this.resource._tag === "media-file"
216 ? "Failed to inspect the media file."
217 : "Failed to inspect the workspace asset.";
218 }
219}
220
221export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedError<AssetWorkspaceAssetNotFoundError>()(
222 "AssetWorkspaceAssetNotFoundError",
223 {
224 resource: AssetResource,
225 },
226) {
227 override get message(): string {
228 return this.resource._tag === "media-file"
229 ? "Media file was not found."
230 : this.resource._tag === "tool-output-image"
231 ? "Tool output image was not found."
232 : "Workspace asset was not found.";
233 }
234}
235
236export class AssetWorkspaceResolutionError extends Schema.TaggedError<AssetWorkspaceResolutionError>()(
237 "AssetWorkspaceResolutionError",
238 {
239 resource: AssetResource,
240 cause: Schema.Defect(),
241 },
242) {
243 override get message(): string {
244 return "Failed to resolve workspace.";
245 }
246}
247
248export class AssetAttachmentNotFoundError extends Schema.TaggedError<AssetAttachmentNotFoundError>()(
249 "AssetAttachmentNotFoundError",
250 {
251 resource: AssetResource,
252 },
253) {
254 override get message(): string {
255 return "Attachment was not found.";
256 }
257}
258
259export class AssetProjectFaviconResolutionError extends Schema.TaggedError<AssetProjectFaviconResolutionError>()(
260 "AssetProjectFaviconResolutionError",
261 {
262 resource: AssetResource,
263 cause: Schema.Defect(),
264 },
265) {
266 override get message(): string {
267 return "Failed to resolve project favicon.";
268 }
269}
270
271export class AssetProjectFaviconInspectionError extends Schema.TaggedError<AssetProjectFaviconInspectionError>()(
272 "AssetProjectFaviconInspectionError",
273 {
274 resource: AssetResource,
275 cause: Schema.Defect(),
276 },
277) {
278 override get message(): string {
279 return "Failed to inspect the project favicon.";
280 }
281}
282
283export class AssetProjectFaviconNotFoundError extends Schema.TaggedError<AssetProjectFaviconNotFoundError>()(
284 "AssetProjectFaviconNotFoundError",
285 {
286 resource: AssetResource,
287 },
288) {
289 override get message(): string {
290 return "Project favicon was not found.";
291 }
292}
293
294export class AssetSigningKeyLoadError extends Schema.TaggedError<AssetSigningKeyLoadError>()(
295 "AssetSigningKeyLoadError",
296 {
297 resource: AssetResource,
298 cause: Schema.Defect(),
299 },
300) {
301 override get message(): string {
302 return "Failed to load the asset signing key.";
303 }
304}
305
306export class AssetGitHubMediaUrlValidationError extends Schema.TaggedError<AssetGitHubMediaUrlValidationError>()(
307 "AssetGitHubMediaUrlValidationError",
308 {},
309) {
310 override get message(): string {
311 return "Only media hosted by GitHub can be fetched with a GitHub credential.";
312 }
313}
314
315export const AssetAccessError = Schema.Union([
316 AssetWorkspaceContextNotFoundError,
317 AssetWorkspaceContextResolutionError,
318 AssetWorkspaceRootNormalizationError,
319 AssetWorkspacePathValidationError,
320 AssetPreviewTypeValidationError,
321 AssetWorkspaceAssetInspectionError,
322 AssetWorkspaceAssetNotFoundError,
323 AssetWorkspaceResolutionError,
324 AssetAttachmentNotFoundError,
325 AssetProjectFaviconResolutionError,
326 AssetProjectFaviconInspectionError,
327 AssetProjectFaviconNotFoundError,
328 AssetGitHubMediaUrlValidationError,
329 AssetSigningKeyLoadError,
330]);
331export type AssetAccessError = typeof AssetAccessError.Type;