Prepare prompt file attachments for future UI (#1376)

* Support file uploads to agents

* Harden prompt file upload handling

* Fix duplicate upload retry handling

* Update relay reconnect tests for queued messages

* Make upload timeout track idle progress

* Clean failed upload directories
This commit is contained in:
Mohamed Boudra
2026-06-06 20:37:38 +08:00
committed by GitHub
parent 9a8912b3ef
commit db4376d17a
16 changed files with 917 additions and 21 deletions

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
decodeBinaryFrame,
encodeFileTransferFrame,
encodeTerminalStreamFrame,
FileTransferOpcode,
TerminalStreamOpcode,
} from "./index.js";
describe("binary frame demux", () => {
it("routes terminal frames by opcode", () => {
expect(
decodeBinaryFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
slot: 7,
payload: "ls",
}),
),
).toEqual({
kind: "terminal",
frame: {
opcode: TerminalStreamOpcode.Input,
slot: 7,
payload: new TextEncoder().encode("ls"),
},
});
});
it("routes file-transfer frames by opcode", () => {
expect(
decodeBinaryFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("hello"),
}),
),
).toEqual({
kind: "file_transfer",
frame: {
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("hello"),
},
});
});
it("rejects unknown binary opcodes", () => {
expect(decodeBinaryFrame(new Uint8Array([0xff, 0]))).toBeNull();
});
});

View File

@@ -0,0 +1,35 @@
import {
decodeFileTransferFrame,
FileTransferOpcode,
type FileTransferFrame,
} from "./file-transfer.js";
import {
decodeTerminalStreamFrame,
TerminalStreamOpcode,
type TerminalStreamFrame,
} from "./terminal.js";
export type BinaryFrame =
| { kind: "terminal"; frame: TerminalStreamFrame }
| { kind: "file_transfer"; frame: FileTransferFrame };
export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
switch (bytes[0]) {
case TerminalStreamOpcode.Output:
case TerminalStreamOpcode.Input:
case TerminalStreamOpcode.Resize:
case TerminalStreamOpcode.Snapshot:
case TerminalStreamOpcode.Restore: {
const frame = decodeTerminalStreamFrame(bytes);
return frame ? { kind: "terminal", frame } : null;
}
case FileTransferOpcode.FileBegin:
case FileTransferOpcode.FileChunk:
case FileTransferOpcode.FileEnd: {
const frame = decodeFileTransferFrame(bytes);
return frame ? { kind: "file_transfer", frame } : null;
}
default:
return null;
}
}

View File

@@ -14,6 +14,7 @@ export const FileBeginMetadataSchema = z.object({
size: z.number().int().nonnegative(),
encoding: z.enum(["utf-8", "binary"]),
modifiedAt: z.string(),
fileName: z.string().optional(),
});
export interface FileBegin {

View File

@@ -1,2 +1,3 @@
export * from "./demux.js";
export * from "./file-transfer.js";
export * from "./terminal.js";

View File

@@ -839,11 +839,21 @@ export const ReviewAttachmentSchema = z.object({
comments: z.array(ReviewAttachmentCommentSchema),
});
export const UploadedFileAttachmentSchema = z.object({
type: z.literal("uploaded_file"),
id: z.string(),
fileName: z.string(),
mimeType: z.string(),
size: z.number().int().nonnegative(),
path: z.string(),
});
export const AgentAttachmentSchema = z.discriminatedUnion("type", [
GitHubPrAttachmentSchema,
GitHubIssueAttachmentSchema,
TextAttachmentSchema,
ReviewAttachmentSchema,
UploadedFileAttachmentSchema,
]);
function normalizeAgentAttachments(input: unknown): AgentAttachment[] {
@@ -1680,6 +1690,15 @@ export const FileDownloadTokenRequestSchema = z.object({
requestId: z.string(),
});
export const FileUploadRequestSchema = z.object({
type: z.literal("file.upload.request"),
fileName: z.string().min(1),
mimeType: z.string().min(1),
size: z.number().int().nonnegative(),
modifiedAt: z.string(),
requestId: z.string(),
});
export const ClearAgentAttentionMessageSchema = z.object({
type: z.literal("clear_agent_attention"),
agentId: z.union([z.string(), z.array(z.string())]),
@@ -1911,6 +1930,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
FileExplorerRequestSchema,
ProjectIconRequestSchema,
FileDownloadTokenRequestSchema,
FileUploadRequestSchema,
ClearAgentAttentionMessageSchema,
ClientHeartbeatMessageSchema,
PingMessageSchema,
@@ -3436,6 +3456,15 @@ export const FileDownloadTokenResponseSchema = z.object({
}),
});
export const FileUploadResponseSchema = z.object({
type: z.literal("file.upload.response"),
payload: z.object({
requestId: z.string(),
file: UploadedFileAttachmentSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ListProviderModelsResponseMessageSchema = z.object({
type: z.literal("list_provider_models_response"),
payload: z.object({
@@ -3756,6 +3785,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FileExplorerResponseSchema,
ProjectIconResponseSchema,
FileDownloadTokenResponseSchema,
FileUploadResponseSchema,
ListProviderModelsResponseMessageSchema,
ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema,
@@ -4056,6 +4086,8 @@ export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
export type FileUploadRequest = z.infer<typeof FileUploadRequestSchema>;
export type FileUploadResponse = z.infer<typeof FileUploadResponseSchema>;
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
export type ShutdownServerRequestMessage = z.infer<typeof ShutdownServerRequestMessageSchema>;
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;