feat: stream files as binary frames over WebSocket (#659)
Replace base64-in-JSON file transfers with binary WebSocket frames to
unblock the JS thread when assistant markdown messages contain images.
Receive cost (JSON.parse over multi-MB base64) and persist cost (base64
re-encode to disk) both removed.
- Split daemon-client `exploreFileSystem` into `listDirectory` + `readFile`;
`readFile` returns `{ bytes, mime, size, modifiedAt }`. Encoding stays
private to the seam.
- New `packages/server/src/shared/binary-frames/` module hosts terminal
frames (moved from `terminal-stream-protocol.ts`) and new file-transfer
opcodes (`FileBegin` / `FileChunk` / `FileEnd`) with request-id correlation.
- Add optional `acceptBinary` to `FileExplorerRequestSchema`. New daemons
emit binary frames when set; legacy JSON path remains for old clients.
Compat fallback lives only inside `readFile`.
- New `persistAttachmentFromBytes` writes bytes directly: native via
`expo-file-system` `File.write(Uint8Array)`, web via Blob -> IndexedDB,
desktop via new `write_attachment_bytes` IPC. `persistAttachmentFromBase64`
removed.
This commit is contained in:
@@ -14,6 +14,21 @@ const fileSystemMock = vi.hoisted(() => ({
|
|||||||
readAsStringAsync: vi.fn(async () => "AAECAw=="),
|
readAsStringAsync: vi.fn(async () => "AAECAw=="),
|
||||||
deleteAsync: vi.fn(async () => {}),
|
deleteAsync: vi.fn(async () => {}),
|
||||||
readDirectoryAsync: vi.fn(async () => []),
|
readDirectoryAsync: vi.fn(async () => []),
|
||||||
|
fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("expo-file-system", () => ({
|
||||||
|
File: class {
|
||||||
|
uri: string;
|
||||||
|
|
||||||
|
constructor(uri: string) {
|
||||||
|
this.uri = uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
write(content: Uint8Array) {
|
||||||
|
fileSystemMock.fileWrite(this.uri, content);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("expo-file-system/legacy", () => ({
|
vi.mock("expo-file-system/legacy", () => ({
|
||||||
@@ -37,9 +52,10 @@ describe("local file attachment store", () => {
|
|||||||
fileSystemMock.readAsStringAsync.mockClear();
|
fileSystemMock.readAsStringAsync.mockClear();
|
||||||
fileSystemMock.deleteAsync.mockClear();
|
fileSystemMock.deleteAsync.mockClear();
|
||||||
fileSystemMock.readDirectoryAsync.mockClear();
|
fileSystemMock.readDirectoryAsync.mockClear();
|
||||||
|
fileSystemMock.fileWrite.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes raw base64 sources directly to the managed file path", async () => {
|
it("writes raw byte sources directly to the managed file path", async () => {
|
||||||
const store = createLocalFileAttachmentStore({
|
const store = createLocalFileAttachmentStore({
|
||||||
storageType: "native-file",
|
storageType: "native-file",
|
||||||
baseDirectoryName: "preview-assets",
|
baseDirectoryName: "preview-assets",
|
||||||
@@ -50,14 +66,14 @@ describe("local file attachment store", () => {
|
|||||||
id: "preview_8_test",
|
id: "preview_8_test",
|
||||||
mimeType: "image/png",
|
mimeType: "image/png",
|
||||||
fileName: "result.png",
|
fileName: "result.png",
|
||||||
source: { kind: "base64", base64: "AAECAw==" },
|
source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
|
expect(fileSystemMock.fileWrite).toHaveBeenCalledWith(
|
||||||
"file:///cache/preview-assets/preview_8_test.png",
|
"file:///cache/preview-assets/preview_8_test.png",
|
||||||
"AAECAw==",
|
new Uint8Array([0, 1, 2, 3]),
|
||||||
{ encoding: "base64" },
|
|
||||||
);
|
);
|
||||||
|
expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled();
|
||||||
expect(attachment).toMatchObject({
|
expect(attachment).toMatchObject({
|
||||||
id: "preview_8_test",
|
id: "preview_8_test",
|
||||||
mimeType: "image/png",
|
mimeType: "image/png",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { File } from "expo-file-system";
|
||||||
import * as FileSystem from "expo-file-system/legacy";
|
import * as FileSystem from "expo-file-system/legacy";
|
||||||
import {
|
import {
|
||||||
type AttachmentStore,
|
type AttachmentStore,
|
||||||
@@ -6,7 +7,6 @@ import {
|
|||||||
type SaveAttachmentInput,
|
type SaveAttachmentInput,
|
||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import {
|
import {
|
||||||
blobToBase64,
|
|
||||||
fileUriToPath,
|
fileUriToPath,
|
||||||
generateAttachmentId,
|
generateAttachmentId,
|
||||||
getFileExtensionFromName,
|
getFileExtensionFromName,
|
||||||
@@ -45,6 +45,15 @@ async function ensureDirectory(uri: string): Promise<void> {
|
|||||||
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
|
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
|
||||||
|
const response = await fetch(dataUrl);
|
||||||
|
return new Uint8Array(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToBytes(blob: Blob): Promise<Uint8Array> {
|
||||||
|
return new Uint8Array(await blob.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
async function writeFromSource(input: {
|
async function writeFromSource(input: {
|
||||||
source: SaveAttachmentInput["source"];
|
source: SaveAttachmentInput["source"];
|
||||||
targetUri: string;
|
targetUri: string;
|
||||||
@@ -59,30 +68,16 @@ async function writeFromSource(input: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bytes: Uint8Array;
|
||||||
if (input.source.kind === "data_url") {
|
if (input.source.kind === "data_url") {
|
||||||
const parsed = parseDataUrl(input.source.dataUrl);
|
bytes = await dataUrlToBytes(input.source.dataUrl);
|
||||||
const mimeType = normalizeMimeType(parsed.mimeType || input.mimeType);
|
} else if (input.source.kind === "blob") {
|
||||||
const base64 = parsed.base64;
|
bytes = await blobToBytes(input.source.blob);
|
||||||
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
} else {
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
bytes = input.source.bytes;
|
||||||
});
|
|
||||||
if (mimeType !== input.mimeType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.source.kind === "base64") {
|
new File(input.targetUri).write(bytes);
|
||||||
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
|
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base64 = await blobToBase64(input.source.blob);
|
|
||||||
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachmentUri(metadata: AttachmentMetadata): string {
|
function attachmentUri(metadata: AttachmentMetadata): string {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
|
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
|
||||||
import { __setAttachmentStoreForTests } from "./store";
|
import { __setAttachmentStoreForTests } from "./store";
|
||||||
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
|
import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service";
|
||||||
|
|
||||||
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
|
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
|
||||||
return {
|
return {
|
||||||
@@ -54,19 +54,20 @@ describe("attachment service", () => {
|
|||||||
__setAttachmentStoreForTests(null);
|
__setAttachmentStoreForTests(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists raw base64 without requiring a data URL wrapper", async () => {
|
it("persists raw bytes without requiring a base64 wrapper", async () => {
|
||||||
const store = createRecordingStore();
|
const store = createRecordingStore();
|
||||||
__setAttachmentStoreForTests(store);
|
__setAttachmentStoreForTests(store);
|
||||||
|
const bytes = new Uint8Array([0, 1, 2, 3]);
|
||||||
|
|
||||||
const attachment = await persistAttachmentFromBase64({
|
const attachment = await persistAttachmentFromBytes({
|
||||||
id: "att_base64",
|
id: "att_bytes",
|
||||||
base64: "AAECAw==",
|
bytes,
|
||||||
mimeType: "image/png",
|
mimeType: "image/png",
|
||||||
fileName: "image.png",
|
fileName: "image.png",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(attachment).toEqual({
|
expect(attachment).toEqual({
|
||||||
id: "att_base64",
|
id: "att_bytes",
|
||||||
mimeType: "image/png",
|
mimeType: "image/png",
|
||||||
storageType: "web-indexeddb",
|
storageType: "web-indexeddb",
|
||||||
storageKey: "att_1",
|
storageKey: "att_1",
|
||||||
@@ -76,10 +77,10 @@ describe("attachment service", () => {
|
|||||||
});
|
});
|
||||||
expect(store.savedSources).toEqual([
|
expect(store.savedSources).toEqual([
|
||||||
{
|
{
|
||||||
id: "att_base64",
|
id: "att_bytes",
|
||||||
mimeType: "image/png",
|
mimeType: "image/png",
|
||||||
fileName: "image.png",
|
fileName: "image.png",
|
||||||
source: { kind: "base64", base64: "AAECAw==" },
|
source: { kind: "bytes", bytes },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export async function persistAttachmentFromDataUrl(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function persistAttachmentFromBase64(input: {
|
export async function persistAttachmentFromBytes(input: {
|
||||||
base64: string;
|
bytes: Uint8Array;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
fileName?: string | null;
|
fileName?: string | null;
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -42,7 +42,7 @@ export async function persistAttachmentFromBase64(input: {
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
mimeType: input.mimeType,
|
mimeType: input.mimeType,
|
||||||
fileName: input.fileName,
|
fileName: input.fileName,
|
||||||
source: { kind: "base64", base64: input.base64 },
|
source: { kind: "bytes", bytes: input.bytes },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export type UserComposerAttachment = Exclude<ComposerAttachment, { kind: "review
|
|||||||
export type WorkspaceComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
|
export type WorkspaceComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
|
||||||
|
|
||||||
export type AttachmentDataSource =
|
export type AttachmentDataSource =
|
||||||
| { kind: "base64"; base64: string }
|
| { kind: "bytes"; bytes: Uint8Array }
|
||||||
| { kind: "blob"; blob: Blob }
|
| { kind: "blob"; blob: Blob }
|
||||||
| { kind: "data_url"; dataUrl: string }
|
| { kind: "data_url"; dataUrl: string }
|
||||||
| { kind: "file_uri"; uri: string };
|
| { kind: "file_uri"; uri: string };
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createIndexedDbAttachmentStore } from "./indexeddb-attachment-store";
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
class FakeRequest<T = unknown> {
|
||||||
|
result!: T;
|
||||||
|
error: Error | null = null;
|
||||||
|
private listeners = new Map<string, Listener[]>();
|
||||||
|
|
||||||
|
addEventListener(event: string, listener: Listener): void {
|
||||||
|
this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string): void {
|
||||||
|
for (const listener of this.listeners.get(event) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeObjectStore {
|
||||||
|
constructor(private readonly onPut: (record: unknown) => void) {}
|
||||||
|
|
||||||
|
put(record: unknown): FakeRequest<unknown> {
|
||||||
|
this.onPut(record);
|
||||||
|
const request = new FakeRequest<unknown>();
|
||||||
|
queueMicrotask(() => request.emit("success"));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeTransaction {
|
||||||
|
error: Error | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly store: FakeObjectStore) {}
|
||||||
|
|
||||||
|
objectStore(): FakeObjectStore {
|
||||||
|
return this.store;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDatabase {
|
||||||
|
readonly objectStoreNames = {
|
||||||
|
contains: () => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private readonly store: FakeObjectStore) {}
|
||||||
|
|
||||||
|
createObjectStore(): void {}
|
||||||
|
|
||||||
|
transaction(): FakeTransaction {
|
||||||
|
return new FakeTransaction(this.store);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("indexeddb attachment store", () => {
|
||||||
|
let storedRecord: unknown;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storedRecord = null;
|
||||||
|
Object.defineProperty(globalThis, "indexedDB", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
open: () => {
|
||||||
|
const store = new FakeObjectStore((record) => {
|
||||||
|
storedRecord = record;
|
||||||
|
});
|
||||||
|
const request = new FakeRequest<FakeDatabase>();
|
||||||
|
request.result = new FakeDatabase(store);
|
||||||
|
queueMicrotask(() => request.emit("success"));
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
} as unknown as IDBFactory,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, "indexedDB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores raw byte sources as a Blob", async () => {
|
||||||
|
const store = createIndexedDbAttachmentStore();
|
||||||
|
const bytes = new Uint8Array([0, 1, 2, 3]);
|
||||||
|
|
||||||
|
const attachment = await store.save({
|
||||||
|
id: "att_bytes",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "image.png",
|
||||||
|
source: { kind: "bytes", bytes },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(storedRecord).toEqual({
|
||||||
|
id: "att_bytes",
|
||||||
|
blob: new Blob([bytes], { type: "image/png" }),
|
||||||
|
createdAt: expect.any(Number),
|
||||||
|
fileName: "image.png",
|
||||||
|
});
|
||||||
|
expect(attachment).toMatchObject({
|
||||||
|
id: "att_bytes",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "att_bytes",
|
||||||
|
fileName: "image.png",
|
||||||
|
byteSize: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,17 +74,18 @@ function runTx<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64ToBlob(input: { base64: string; mimeType: string }): Blob {
|
|
||||||
const binary = atob(input.base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let index = 0; index < binary.length; index += 1) {
|
|
||||||
bytes[index] = binary.charCodeAt(index);
|
|
||||||
}
|
|
||||||
return new Blob([bytes], { type: input.mimeType });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
|
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
|
||||||
const source = input.source;
|
const source = input.source;
|
||||||
|
if (source.kind === "bytes") {
|
||||||
|
const mimeType = normalizeMimeType(input.mimeType);
|
||||||
|
const buffer = new ArrayBuffer(source.bytes.byteLength);
|
||||||
|
new Uint8Array(buffer).set(source.bytes);
|
||||||
|
return {
|
||||||
|
blob: new Blob([buffer], { type: mimeType }),
|
||||||
|
mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (source.kind === "blob") {
|
if (source.kind === "blob") {
|
||||||
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
|
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
|
||||||
const blob =
|
const blob =
|
||||||
@@ -105,14 +106,6 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source.kind === "base64") {
|
|
||||||
const mimeType = normalizeMimeType(input.mimeType);
|
|
||||||
return {
|
|
||||||
blob: base64ToBlob({ base64: source.base64, mimeType }),
|
|
||||||
mimeType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(source.uri);
|
const response = await fetch(source.uri);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
|
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, useRef } from "react";
|
import React, { useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { FileReadResult } from "@server/client/daemon-client";
|
||||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -27,8 +28,9 @@ import { isWeb } from "@/constants/platform";
|
|||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { persistAttachmentFromBase64 } from "@/attachments/service";
|
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||||
|
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -65,30 +67,34 @@ function formatFileSize({ size }: { size: number }): string {
|
|||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createFilePanePreview(file: ExplorerFile | null): Promise<{
|
async function createFilePanePreview(file: FileReadResult | null): Promise<{
|
||||||
file: ExplorerFile | null;
|
file: ExplorerFile | null;
|
||||||
imageAttachment: AttachmentMetadata | null;
|
imageAttachment: AttachmentMetadata | null;
|
||||||
}> {
|
}> {
|
||||||
if (!file || file.kind !== "image" || !file.content) {
|
if (!file) {
|
||||||
return { file, imageAttachment: null };
|
return { file: null, imageAttachment: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content: _content, ...imageFile } = file;
|
const explorerFile = explorerFileFromReadResult(file);
|
||||||
const imageAttachment = await persistAttachmentFromBase64({
|
if (file.kind !== "image") {
|
||||||
|
return { file: explorerFile, imageAttachment: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageAttachment = await persistAttachmentFromBytes({
|
||||||
id: createPreviewAttachmentId({
|
id: createPreviewAttachmentId({
|
||||||
mimeType: file.mimeType ?? "image/png",
|
mimeType: file.mime,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
modifiedAt: file.modifiedAt,
|
modifiedAt: file.modifiedAt,
|
||||||
contentLength: file.content.length,
|
contentLength: file.bytes.byteLength,
|
||||||
}),
|
}),
|
||||||
base64: file.content,
|
bytes: file.bytes,
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mime,
|
||||||
fileName: getFileNameFromPath(file.path),
|
fileName: getFileNameFromPath(file.path),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
file: imageFile,
|
file: explorerFile,
|
||||||
imageAttachment,
|
imageAttachment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -358,17 +364,21 @@ export function FilePane({
|
|||||||
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
|
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
|
||||||
return { file: null as ExplorerFile | null, error: "Host is not connected" };
|
return { file: null as ExplorerFile | null, error: "Host is not connected" };
|
||||||
}
|
}
|
||||||
const payload = await client.exploreFileSystem(
|
try {
|
||||||
normalizedWorkspaceRoot,
|
const file = await client.readFile(normalizedWorkspaceRoot, normalizedFilePath);
|
||||||
normalizedFilePath,
|
const preview = await createFilePanePreview(file);
|
||||||
"file",
|
return {
|
||||||
);
|
file: preview.file,
|
||||||
const preview = await createFilePanePreview(payload.file ?? null);
|
imageAttachment: preview.imageAttachment,
|
||||||
return {
|
error: null,
|
||||||
file: preview.file,
|
};
|
||||||
imageAttachment: preview.imageAttachment,
|
} catch (error) {
|
||||||
error: payload.error ?? null,
|
return {
|
||||||
};
|
file: null,
|
||||||
|
imageAttachment: null,
|
||||||
|
error: error instanceof Error ? error.message : "Failed to load file",
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ import { PlanCard } from "./plan-card";
|
|||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
|
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
@@ -760,25 +760,21 @@ function AssistantMarkdownImage({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file");
|
const file = await client.readFile(resolution.cwd, resolution.path);
|
||||||
if (payload.error) {
|
if (file.kind !== "image") {
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
const file = payload.file;
|
|
||||||
if (!file || file.kind !== "image" || !file.content) {
|
|
||||||
throw new Error("Image preview unavailable.");
|
throw new Error("Image preview unavailable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await persistAttachmentFromBase64({
|
return await persistAttachmentFromBytes({
|
||||||
id: createPreviewAttachmentId({
|
id: createPreviewAttachmentId({
|
||||||
mimeType: file.mimeType ?? "image/png",
|
mimeType: file.mime,
|
||||||
path: file.path || resolution.path,
|
path: file.path || resolution.path,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
modifiedAt: file.modifiedAt,
|
modifiedAt: file.modifiedAt,
|
||||||
contentLength: file.content.length,
|
contentLength: file.bytes.byteLength,
|
||||||
}),
|
}),
|
||||||
base64: file.content,
|
bytes: file.bytes,
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mime,
|
||||||
fileName: getFileNameFromPath(file.path || resolution.path),
|
fileName: getFileNameFromPath(file.path || resolution.path),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createDesktopAttachmentStore } from "./desktop-attachment-store";
|
|||||||
const {
|
const {
|
||||||
copyDesktopAttachmentFileMock,
|
copyDesktopAttachmentFileMock,
|
||||||
writeDesktopAttachmentBase64Mock,
|
writeDesktopAttachmentBase64Mock,
|
||||||
|
writeDesktopAttachmentBytesMock,
|
||||||
deleteDesktopAttachmentFileMock,
|
deleteDesktopAttachmentFileMock,
|
||||||
garbageCollectDesktopAttachmentFilesMock,
|
garbageCollectDesktopAttachmentFilesMock,
|
||||||
readDesktopFileBase64Mock,
|
readDesktopFileBase64Mock,
|
||||||
@@ -18,6 +19,10 @@ const {
|
|||||||
path: "/managed/att_2.png",
|
path: "/managed/att_2.png",
|
||||||
byteSize: 4,
|
byteSize: 4,
|
||||||
})),
|
})),
|
||||||
|
writeDesktopAttachmentBytesMock: vi.fn(async () => ({
|
||||||
|
path: "/managed/att_bytes.png",
|
||||||
|
byteSize: 4,
|
||||||
|
})),
|
||||||
deleteDesktopAttachmentFileMock: vi.fn(async () => true),
|
deleteDesktopAttachmentFileMock: vi.fn(async () => true),
|
||||||
garbageCollectDesktopAttachmentFilesMock: vi.fn(async () => 0),
|
garbageCollectDesktopAttachmentFilesMock: vi.fn(async () => 0),
|
||||||
readDesktopFileBase64Mock: vi.fn(async () => "AAECAw=="),
|
readDesktopFileBase64Mock: vi.fn(async () => "AAECAw=="),
|
||||||
@@ -28,6 +33,7 @@ const {
|
|||||||
vi.mock("./desktop-file-commands", () => ({
|
vi.mock("./desktop-file-commands", () => ({
|
||||||
copyDesktopAttachmentFile: copyDesktopAttachmentFileMock,
|
copyDesktopAttachmentFile: copyDesktopAttachmentFileMock,
|
||||||
writeDesktopAttachmentBase64: writeDesktopAttachmentBase64Mock,
|
writeDesktopAttachmentBase64: writeDesktopAttachmentBase64Mock,
|
||||||
|
writeDesktopAttachmentBytes: writeDesktopAttachmentBytesMock,
|
||||||
deleteDesktopAttachmentFile: deleteDesktopAttachmentFileMock,
|
deleteDesktopAttachmentFile: deleteDesktopAttachmentFileMock,
|
||||||
garbageCollectDesktopAttachmentFiles: garbageCollectDesktopAttachmentFilesMock,
|
garbageCollectDesktopAttachmentFiles: garbageCollectDesktopAttachmentFilesMock,
|
||||||
}));
|
}));
|
||||||
@@ -42,6 +48,7 @@ describe("desktop attachment store", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
copyDesktopAttachmentFileMock.mockClear();
|
copyDesktopAttachmentFileMock.mockClear();
|
||||||
writeDesktopAttachmentBase64Mock.mockClear();
|
writeDesktopAttachmentBase64Mock.mockClear();
|
||||||
|
writeDesktopAttachmentBytesMock.mockClear();
|
||||||
deleteDesktopAttachmentFileMock.mockClear();
|
deleteDesktopAttachmentFileMock.mockClear();
|
||||||
garbageCollectDesktopAttachmentFilesMock.mockClear();
|
garbageCollectDesktopAttachmentFilesMock.mockClear();
|
||||||
readDesktopFileBase64Mock.mockClear();
|
readDesktopFileBase64Mock.mockClear();
|
||||||
@@ -86,28 +93,30 @@ describe("desktop attachment store", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves raw base64 sources via desktop filesystem writes", async () => {
|
it("saves raw byte sources via desktop filesystem writes", async () => {
|
||||||
const store = createDesktopAttachmentStore();
|
const store = createDesktopAttachmentStore();
|
||||||
|
const bytes = new Uint8Array([0, 1, 2, 3]);
|
||||||
const attachment = await store.save({
|
const attachment = await store.save({
|
||||||
id: "att_base64",
|
id: "att_bytes",
|
||||||
mimeType: "image/png",
|
mimeType: "image/png",
|
||||||
fileName: "inline.png",
|
fileName: "inline.png",
|
||||||
source: {
|
source: {
|
||||||
kind: "base64",
|
kind: "bytes",
|
||||||
base64: "AAECAw==",
|
bytes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({
|
expect(writeDesktopAttachmentBytesMock).toHaveBeenCalledWith({
|
||||||
attachmentId: "att_base64",
|
attachmentId: "att_bytes",
|
||||||
base64: "AAECAw==",
|
bytes,
|
||||||
extension: ".png",
|
extension: ".png",
|
||||||
});
|
});
|
||||||
|
expect(writeDesktopAttachmentBase64Mock).not.toHaveBeenCalled();
|
||||||
expect(attachment).toMatchObject({
|
expect(attachment).toMatchObject({
|
||||||
id: "att_base64",
|
id: "att_bytes",
|
||||||
mimeType: "image/png",
|
mimeType: "image/png",
|
||||||
storageType: "desktop-file",
|
storageType: "desktop-file",
|
||||||
storageKey: "/managed/att_2.png",
|
storageKey: "/managed/att_bytes.png",
|
||||||
fileName: "inline.png",
|
fileName: "inline.png",
|
||||||
byteSize: 4,
|
byteSize: 4,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
deleteDesktopAttachmentFile,
|
deleteDesktopAttachmentFile,
|
||||||
garbageCollectDesktopAttachmentFiles,
|
garbageCollectDesktopAttachmentFiles,
|
||||||
writeDesktopAttachmentBase64,
|
writeDesktopAttachmentBase64,
|
||||||
|
writeDesktopAttachmentBytes,
|
||||||
} from "@/desktop/attachments/desktop-file-commands";
|
} from "@/desktop/attachments/desktop-file-commands";
|
||||||
import {
|
import {
|
||||||
readDesktopFileBase64,
|
readDesktopFileBase64,
|
||||||
@@ -132,6 +133,32 @@ async function saveDesktopAttachmentFromBase64(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveDesktopAttachmentFromBytes(input: {
|
||||||
|
id: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
mimeType: string;
|
||||||
|
fileName?: string | null;
|
||||||
|
}): Promise<AttachmentMetadata> {
|
||||||
|
const extension = extensionForAttachment({
|
||||||
|
fileName: input.fileName,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await writeDesktopAttachmentBytes({
|
||||||
|
attachmentId: input.id,
|
||||||
|
bytes: input.bytes,
|
||||||
|
extension,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toDesktopMetadata({
|
||||||
|
id: input.id,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
storagePath: result.path,
|
||||||
|
byteSize: result.byteSize,
|
||||||
|
fileName: input.fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function assertDesktopAttachment(attachment: AttachmentMetadata): void {
|
function assertDesktopAttachment(attachment: AttachmentMetadata): void {
|
||||||
if (attachment.storageType !== "desktop-file") {
|
if (attachment.storageType !== "desktop-file") {
|
||||||
throw new Error(`Unsupported desktop attachment storage type '${attachment.storageType}'.`);
|
throw new Error(`Unsupported desktop attachment storage type '${attachment.storageType}'.`);
|
||||||
@@ -166,11 +193,11 @@ export function createDesktopAttachmentStore(): AttachmentStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.source.kind === "base64") {
|
if (input.source.kind === "bytes") {
|
||||||
const mimeType = normalizeMimeType(input.mimeType);
|
const mimeType = normalizeMimeType(input.mimeType);
|
||||||
return await saveDesktopAttachmentFromBase64({
|
return await saveDesktopAttachmentFromBytes({
|
||||||
id,
|
id,
|
||||||
base64: input.source.base64,
|
bytes: input.source.bytes,
|
||||||
mimeType,
|
mimeType,
|
||||||
fileName,
|
fileName,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ export async function writeDesktopAttachmentBase64(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function writeDesktopAttachmentBytes(input: {
|
||||||
|
attachmentId: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
extension?: string | null;
|
||||||
|
}): Promise<AttachmentFileResult> {
|
||||||
|
return await invokeDesktopCommand<AttachmentFileResult>("write_attachment_bytes", {
|
||||||
|
attachmentId: input.attachmentId,
|
||||||
|
bytes: input.bytes,
|
||||||
|
extension: input.extension ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function copyDesktopAttachmentFile(input: {
|
export async function copyDesktopAttachmentFile(input: {
|
||||||
attachmentId: string;
|
attachmentId: string;
|
||||||
sourcePath: string;
|
sourcePath: string;
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { fileUriToPath } from "@/attachments/utils";
|
import { fileUriToPath } from "@/attachments/utils";
|
||||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||||
|
import { Buffer } from "buffer";
|
||||||
|
|
||||||
function base64ToUint8Array(base64: string): Uint8Array {
|
function base64ToUint8Array(base64: string): Uint8Array {
|
||||||
const binary = atob(base64);
|
return new Uint8Array(Buffer.from(base64, "base64"));
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i += 1) {
|
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeObjectUrls = new Set<string>();
|
const activeObjectUrls = new Set<string>();
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { FileReadResult } from "@server/client/daemon-client";
|
||||||
|
import type { ExplorerFile } from "@/stores/session-store";
|
||||||
|
|
||||||
|
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
|
||||||
|
const isText = file.kind === "text";
|
||||||
|
return {
|
||||||
|
path: file.path,
|
||||||
|
kind: file.kind,
|
||||||
|
encoding: isText ? "utf-8" : "none",
|
||||||
|
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
|
||||||
|
mimeType: file.mime,
|
||||||
|
size: file.size,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
|
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
|
||||||
|
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||||
|
|
||||||
function createExplorerState(): AgentFileExplorerState {
|
function createExplorerState(): AgentFileExplorerState {
|
||||||
return {
|
return {
|
||||||
@@ -130,26 +131,20 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await client.exploreFileSystem(
|
const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath);
|
||||||
normalizedWorkspaceRoot,
|
|
||||||
normalizedPath,
|
|
||||||
"list",
|
|
||||||
);
|
|
||||||
updateExplorerState((state) => {
|
updateExplorerState((state) => {
|
||||||
const nextState: AgentFileExplorerState = {
|
const nextState: AgentFileExplorerState = {
|
||||||
...state,
|
...state,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
lastError: payload.error ?? null,
|
lastError: null,
|
||||||
pendingRequest: null,
|
pendingRequest: null,
|
||||||
directories: state.directories,
|
directories: state.directories,
|
||||||
files: state.files,
|
files: state.files,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!payload.error && payload.directory) {
|
const directories = new Map(state.directories);
|
||||||
const directories = new Map(state.directories);
|
directories.set(directory.path, directory);
|
||||||
directories.set(payload.directory.path, payload.directory);
|
nextState.directories = directories;
|
||||||
nextState.directories = directories;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextState;
|
return nextState;
|
||||||
});
|
});
|
||||||
@@ -201,34 +196,29 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await client.exploreFileSystem(
|
const file = await client.readFile(normalizedWorkspaceRoot, normalizedPath);
|
||||||
normalizedWorkspaceRoot,
|
|
||||||
normalizedPath,
|
|
||||||
"file",
|
|
||||||
);
|
|
||||||
updateExplorerState((state) => {
|
updateExplorerState((state) => {
|
||||||
const nextState: AgentFileExplorerState = {
|
const nextState: AgentFileExplorerState = {
|
||||||
...state,
|
...state,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
lastError: null,
|
||||||
pendingRequest: null,
|
pendingRequest: null,
|
||||||
directories: state.directories,
|
directories: state.directories,
|
||||||
files: state.files,
|
files: state.files,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!payload.error && payload.file) {
|
const files = new Map(state.files);
|
||||||
const files = new Map(state.files);
|
const explorerFile = explorerFileFromReadResult(file);
|
||||||
files.set(payload.file.path, payload.file);
|
files.set(explorerFile.path, explorerFile);
|
||||||
nextState.files = files;
|
nextState.files = files;
|
||||||
} else if (payload.error) {
|
|
||||||
nextState.lastError = payload.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextState;
|
return nextState;
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
updateExplorerState((state) => ({
|
updateExplorerState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
lastError: error instanceof Error ? error.message : "Failed to load file preview",
|
||||||
pendingRequest: null,
|
pendingRequest: null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function createTestStore(): AttachmentStore {
|
|||||||
} else if (input.source.kind === "file_uri") {
|
} else if (input.source.kind === "file_uri") {
|
||||||
byteSize = input.source.uri.length;
|
byteSize = input.source.uri.length;
|
||||||
} else {
|
} else {
|
||||||
byteSize = input.source.base64.length;
|
byteSize = input.source.bytes.byteLength;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
import type { DaemonClient } from "@getpaseo/server";
|
||||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
||||||
import type {
|
import type {
|
||||||
CommandOptions,
|
CommandOptions,
|
||||||
@@ -55,7 +56,7 @@ export async function runArchiveCommand(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
let client;
|
let client: DaemonClient;
|
||||||
try {
|
try {
|
||||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import type { DaemonClient } from "@getpaseo/server";
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export async function runDeleteCommand(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
let client;
|
let client: DaemonClient;
|
||||||
try {
|
try {
|
||||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
import type { DaemonClient } from "@getpaseo/server";
|
||||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
||||||
import type {
|
import type {
|
||||||
CommandOptions,
|
CommandOptions,
|
||||||
@@ -50,7 +51,7 @@ export async function runReloadCommand(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
let client;
|
let client: DaemonClient;
|
||||||
try {
|
try {
|
||||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
import type { DaemonClient } from "@getpaseo/server";
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -53,7 +54,7 @@ export async function runStopCommand(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
let client;
|
let client: DaemonClient;
|
||||||
try {
|
try {
|
||||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ export interface ModelListItem {
|
|||||||
thinkingOptions: string;
|
thinkingOptions: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProviderModelOption {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderModel {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
thinkingOptions?: ProviderModelOption[];
|
||||||
|
defaultThinkingOptionId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Schema for provider models output */
|
/** Schema for provider models output */
|
||||||
export const providerModelsSchema: OutputSchema<ModelListItem> = {
|
export const providerModelsSchema: OutputSchema<ModelListItem> = {
|
||||||
idField: "id",
|
idField: "id",
|
||||||
@@ -61,7 +73,7 @@ export async function runModelsCommand(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const models: ModelListItem[] = (result.models ?? []).map((m) => ({
|
const models: ModelListItem[] = (result.models ?? []).map((m: ProviderModel) => ({
|
||||||
model: m.label,
|
model: m.label,
|
||||||
id: m.id,
|
id: m.id,
|
||||||
description: m.description ?? "",
|
description: m.description ?? "",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js";
|
import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js";
|
||||||
|
|
||||||
|
type TerminalListEntry = Parameters<typeof toTerminalRow>[0];
|
||||||
|
|
||||||
export interface TerminalLsOptions extends TerminalCommandOptions {
|
export interface TerminalLsOptions extends TerminalCommandOptions {
|
||||||
all?: boolean;
|
all?: boolean;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -24,7 +26,9 @@ export async function runLsCommand(
|
|||||||
cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd);
|
cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd);
|
||||||
return {
|
return {
|
||||||
type: "list",
|
type: "list",
|
||||||
data: payload.terminals.map((terminal) => toTerminalRow(terminal, payload.cwd ?? cwd)),
|
data: payload.terminals.map((terminal: TerminalListEntry) =>
|
||||||
|
toTerminalRow(terminal, payload.cwd ?? cwd),
|
||||||
|
),
|
||||||
schema: terminalSchema,
|
schema: terminalSchema,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import type { DaemonClient } from "@getpaseo/server";
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
import type {
|
import type {
|
||||||
CommandOptions,
|
CommandOptions,
|
||||||
@@ -51,7 +52,7 @@ export async function runArchiveCommand(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
let client;
|
let client: DaemonClient;
|
||||||
try {
|
try {
|
||||||
client = await connectToDaemon({ host: options.host });
|
client = await connectToDaemon({ host: options.host });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { basename, join, sep } from "node:path";
|
import { basename, join, sep } from "node:path";
|
||||||
|
import type { DaemonClient } from "@getpaseo/server";
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ export async function runLsCommand(
|
|||||||
): Promise<WorktreeLsResult> {
|
): Promise<WorktreeLsResult> {
|
||||||
const host = getDaemonHost({ host: options.host });
|
const host = getDaemonHost({ host: options.host });
|
||||||
|
|
||||||
let client;
|
let client: DaemonClient;
|
||||||
try {
|
try {
|
||||||
client = await connectToDaemon({ host: options.host });
|
client = await connectToDaemon({ host: options.host });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
garbageCollectManagedAttachmentFiles,
|
garbageCollectManagedAttachmentFiles,
|
||||||
readManagedFileBase64,
|
readManagedFileBase64,
|
||||||
writeAttachmentBase64,
|
writeAttachmentBase64,
|
||||||
|
writeAttachmentBytes,
|
||||||
} from "../features/attachments.js";
|
} from "../features/attachments.js";
|
||||||
import {
|
import {
|
||||||
checkForAppUpdate,
|
checkForAppUpdate,
|
||||||
@@ -539,6 +540,7 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
|||||||
desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000,
|
desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000,
|
||||||
cli_daemon_status: () => getCliDaemonStatus(),
|
cli_daemon_status: () => getCliDaemonStatus(),
|
||||||
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
|
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
|
||||||
|
write_attachment_bytes: (args) => writeAttachmentBytes(args ?? {}),
|
||||||
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
|
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
|
||||||
read_file_base64: (args) => readManagedFileBase64(args ?? {}),
|
read_file_base64: (args) => readManagedFileBase64(args ?? {}),
|
||||||
delete_attachment_file: (args) => deleteManagedAttachmentFile(args ?? {}),
|
delete_attachment_file: (args) => deleteManagedAttachmentFile(args ?? {}),
|
||||||
|
|||||||
@@ -90,6 +90,37 @@ export async function writeAttachmentBase64(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBytes(value: unknown): Uint8Array {
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return Uint8Array.from(value);
|
||||||
|
}
|
||||||
|
throw new Error("Attachment byte payload is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAttachmentBytes(input: {
|
||||||
|
attachmentId?: unknown;
|
||||||
|
bytes?: unknown;
|
||||||
|
extension?: unknown;
|
||||||
|
}): Promise<AttachmentFileResult> {
|
||||||
|
const bytes = normalizeBytes(input.bytes);
|
||||||
|
const targetPath = await buildManagedAttachmentPath({
|
||||||
|
attachmentId: input.attachmentId,
|
||||||
|
extension: input.extension,
|
||||||
|
});
|
||||||
|
await writeFile(targetPath, bytes);
|
||||||
|
const fileInfo = await stat(targetPath);
|
||||||
|
return {
|
||||||
|
path: targetPath,
|
||||||
|
byteSize: fileInfo.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function copyAttachmentFileToManagedStorage(input: {
|
export async function copyAttachmentFileToManagedStorage(input: {
|
||||||
attachmentId?: unknown;
|
attachmentId?: unknown;
|
||||||
sourcePath?: unknown;
|
sourcePath?: unknown;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
||||||
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
||||||
|
import { encodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js";
|
||||||
import {
|
import {
|
||||||
asUint8Array,
|
asUint8Array,
|
||||||
decodeTerminalResizePayload,
|
decodeTerminalResizePayload,
|
||||||
@@ -13,6 +14,9 @@ expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTy
|
|||||||
expectTypeOf<
|
expectTypeOf<
|
||||||
"getHighlightedDiff" extends keyof DaemonClient ? true : false
|
"getHighlightedDiff" extends keyof DaemonClient ? true : false
|
||||||
>().toEqualTypeOf<false>();
|
>().toEqualTypeOf<false>();
|
||||||
|
expectTypeOf<
|
||||||
|
"exploreFileSystem" extends keyof DaemonClient ? true : false
|
||||||
|
>().toEqualTypeOf<false>();
|
||||||
|
|
||||||
function createMockLogger() {
|
function createMockLogger() {
|
||||||
return {
|
return {
|
||||||
@@ -235,6 +239,209 @@ test("does not reconnect after close when ensureConnected is called", async () =
|
|||||||
expect(client.getConnectionState().status).toBe("disposed");
|
expect(client.getConnectionState().status).toBe("disposed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("listDirectory sends a list file explorer request and returns directory entries", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const responsePromise = client.listDirectory("/tmp/project", "src", "req-list");
|
||||||
|
|
||||||
|
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "src",
|
||||||
|
mode: "list",
|
||||||
|
requestId: "req-list",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "src",
|
||||||
|
mode: "list",
|
||||||
|
directory: {
|
||||||
|
path: "src",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: "index.ts",
|
||||||
|
path: "src/index.ts",
|
||||||
|
kind: "file",
|
||||||
|
size: 12,
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
file: null,
|
||||||
|
error: null,
|
||||||
|
requestId: "req-list",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(responsePromise).resolves.toEqual({
|
||||||
|
path: "src",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: "index.ts",
|
||||||
|
path: "src/index.ts",
|
||||||
|
kind: "file",
|
||||||
|
size: 12,
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readFile hides legacy base64 behind bytes", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-file");
|
||||||
|
|
||||||
|
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "logo.png",
|
||||||
|
mode: "file",
|
||||||
|
acceptBinary: true,
|
||||||
|
requestId: "req-file",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "logo.png",
|
||||||
|
mode: "file",
|
||||||
|
directory: null,
|
||||||
|
file: {
|
||||||
|
path: "logo.png",
|
||||||
|
kind: "image",
|
||||||
|
encoding: "base64",
|
||||||
|
content: "aGVsbG8=",
|
||||||
|
mimeType: "image/png",
|
||||||
|
size: 5,
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
requestId: "req-file",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await responsePromise;
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
mime: "image/png",
|
||||||
|
size: 5,
|
||||||
|
path: "logo.png",
|
||||||
|
kind: "image",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readFile resolves from binary file frames when the daemon supports them", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-binary");
|
||||||
|
|
||||||
|
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "logo.png",
|
||||||
|
mode: "file",
|
||||||
|
acceptBinary: true,
|
||||||
|
requestId: "req-binary",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileBegin,
|
||||||
|
requestId: "req-binary",
|
||||||
|
metadata: {
|
||||||
|
mime: "image/png",
|
||||||
|
size: 5,
|
||||||
|
encoding: "binary",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileChunk,
|
||||||
|
requestId: "req-binary",
|
||||||
|
payload: new TextEncoder().encode("hello"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileEnd,
|
||||||
|
requestId: "req-binary",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await responsePromise;
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
mime: "image/png",
|
||||||
|
size: 5,
|
||||||
|
path: "logo.png",
|
||||||
|
kind: "image",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
|
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -82,13 +82,16 @@ import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "../shared/me
|
|||||||
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
|
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
|
||||||
import {
|
import {
|
||||||
asUint8Array,
|
asUint8Array,
|
||||||
|
decodeFileTransferFrame,
|
||||||
decodeTerminalSnapshotPayload,
|
decodeTerminalSnapshotPayload,
|
||||||
decodeTerminalStreamFrame,
|
decodeTerminalStreamFrame,
|
||||||
|
FileTransferOpcode,
|
||||||
encodeTerminalResizePayload,
|
encodeTerminalResizePayload,
|
||||||
encodeTerminalStreamFrame,
|
encodeTerminalStreamFrame,
|
||||||
TerminalStreamOpcode,
|
TerminalStreamOpcode,
|
||||||
|
type FileTransferFrame,
|
||||||
type TerminalStreamFrame,
|
type TerminalStreamFrame,
|
||||||
} from "../shared/terminal-stream-protocol.js";
|
} from "../shared/binary-frames/index.js";
|
||||||
import {
|
import {
|
||||||
createRelayE2eeTransportFactory,
|
createRelayE2eeTransportFactory,
|
||||||
createWebSocketTransportFactory,
|
createWebSocketTransportFactory,
|
||||||
@@ -283,6 +286,16 @@ type CreatePaseoWorktreePayload = Extract<
|
|||||||
{ type: "create_paseo_worktree_response" }
|
{ type: "create_paseo_worktree_response" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||||
|
export type FileExplorerDirectoryPayload = NonNullable<FileExplorerPayload["directory"]>;
|
||||||
|
type LegacyFileExplorerFilePayload = NonNullable<FileExplorerPayload["file"]>;
|
||||||
|
export interface FileReadResult {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
path: string;
|
||||||
|
kind: LegacyFileExplorerFilePayload["kind"];
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||||
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
|
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
|
||||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||||
@@ -569,6 +582,22 @@ interface WaitHandle<T> {
|
|||||||
cancel: (error: Error) => void;
|
cancel: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingBinaryFileRead {
|
||||||
|
cwd: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BinaryFileTransferState extends PendingBinaryFileRead {
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
encoding: Extract<
|
||||||
|
FileTransferFrame,
|
||||||
|
{ opcode: typeof FileTransferOpcode.FileBegin }
|
||||||
|
>["metadata"]["encoding"];
|
||||||
|
modifiedAt: string;
|
||||||
|
chunks: Uint8Array[];
|
||||||
|
}
|
||||||
|
|
||||||
type RpcWaitResult<T> = { kind: "ok"; value: T } | { kind: "error"; error: DaemonRpcError };
|
type RpcWaitResult<T> = { kind: "ok"; value: T } | { kind: "error"; error: DaemonRpcError };
|
||||||
type GetDaemonConfigResponse = Extract<
|
type GetDaemonConfigResponse = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
@@ -627,6 +656,55 @@ function normalizeClientId(value: unknown): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeBase64ToBytes(base64: string): Uint8Array {
|
||||||
|
const binary = globalThis.atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyExplorerFileToBytes(file: LegacyFileExplorerFilePayload): FileReadResult {
|
||||||
|
let bytes: Uint8Array;
|
||||||
|
if (file.encoding === "base64" && file.content) {
|
||||||
|
bytes = decodeBase64ToBytes(file.content);
|
||||||
|
} else if (file.encoding === "utf-8" && file.content) {
|
||||||
|
bytes = new TextEncoder().encode(file.content);
|
||||||
|
} else {
|
||||||
|
bytes = new Uint8Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
mime: file.mimeType ?? "application/octet-stream",
|
||||||
|
size: file.size,
|
||||||
|
path: file.path,
|
||||||
|
kind: file.kind,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryFileKind(mime: string, encoding: string): FileReadResult["kind"] {
|
||||||
|
if (mime.startsWith("image/")) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if (encoding === "utf-8" || mime.startsWith("text/") || mime === "application/json") {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
return "binary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatByteChunks(chunks: Uint8Array[], size: number): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(size);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
function hashForLog(value: string): string {
|
function hashForLog(value: string): string {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
@@ -697,6 +775,9 @@ export class DaemonClient {
|
|||||||
private terminalDirectorySubscriptions = new Set<string>();
|
private terminalDirectorySubscriptions = new Set<string>();
|
||||||
private terminalSlots = new Map<string, number>();
|
private terminalSlots = new Map<string, number>();
|
||||||
private slotTerminals = new Map<number, string>();
|
private slotTerminals = new Map<number, string>();
|
||||||
|
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
||||||
|
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
||||||
|
private completedBinaryFileReads = new Map<string, FileReadResult>();
|
||||||
private readonly terminalStreamListeners = new Set<(event: TerminalStreamEvent) => void>();
|
private readonly terminalStreamListeners = new Set<(event: TerminalStreamEvent) => void>();
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private pendingSendQueue: PendingSend[] = [];
|
private pendingSendQueue: PendingSend[] = [];
|
||||||
@@ -2865,11 +2946,12 @@ export class DaemonClient {
|
|||||||
// File Explorer
|
// File Explorer
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async exploreFileSystem(
|
private async requestFileExplorer(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
path: string,
|
path: string,
|
||||||
mode: "list" | "file" = "list",
|
mode: "list" | "file",
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
|
acceptBinary = false,
|
||||||
): Promise<FileExplorerPayload> {
|
): Promise<FileExplorerPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
@@ -2878,12 +2960,51 @@ export class DaemonClient {
|
|||||||
cwd,
|
cwd,
|
||||||
path,
|
path,
|
||||||
mode,
|
mode,
|
||||||
|
...(acceptBinary ? { acceptBinary: true } : {}),
|
||||||
},
|
},
|
||||||
responseType: "file_explorer_response",
|
responseType: "file_explorer_response",
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listDirectory(
|
||||||
|
cwd: string,
|
||||||
|
path: string,
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<FileExplorerDirectoryPayload> {
|
||||||
|
const payload = await this.requestFileExplorer(cwd, path, "list", requestId);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
if (!payload.directory) {
|
||||||
|
throw new Error("Directory listing unavailable.");
|
||||||
|
}
|
||||||
|
return payload.directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(cwd: string, path: string, requestId?: string): Promise<FileReadResult> {
|
||||||
|
const resolvedRequestId = this.createRequestId(requestId);
|
||||||
|
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path });
|
||||||
|
try {
|
||||||
|
const payload = await this.requestFileExplorer(cwd, path, "file", resolvedRequestId, true);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
const binaryResult = this.completedBinaryFileReads.get(resolvedRequestId);
|
||||||
|
if (binaryResult) {
|
||||||
|
this.completedBinaryFileReads.delete(resolvedRequestId);
|
||||||
|
return binaryResult;
|
||||||
|
}
|
||||||
|
if (!payload.file) {
|
||||||
|
throw new Error("File unavailable.");
|
||||||
|
}
|
||||||
|
return legacyExplorerFileToBytes(payload.file);
|
||||||
|
} finally {
|
||||||
|
this.pendingBinaryFileReads.delete(resolvedRequestId);
|
||||||
|
this.activeBinaryFileTransfers.delete(resolvedRequestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async requestDownloadToken(
|
async requestDownloadToken(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -3933,6 +4054,13 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
|
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
|
||||||
|
const fileFrame = decodeFileTransferFrame(rawBytes);
|
||||||
|
if (fileFrame) {
|
||||||
|
this.handleFileTransferFrame(fileFrame);
|
||||||
|
this.runtimeMetrics?.recordBinaryFrame("other", rawBytes.byteLength, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const frame = decodeTerminalStreamFrame(rawBytes);
|
const frame = decodeTerminalStreamFrame(rawBytes);
|
||||||
if (!frame) {
|
if (!frame) {
|
||||||
return false;
|
return false;
|
||||||
@@ -3953,6 +4081,57 @@ export class DaemonClient {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleFileTransferFrame(frame: FileTransferFrame): void {
|
||||||
|
if (frame.opcode === FileTransferOpcode.FileBegin) {
|
||||||
|
const pending = this.pendingBinaryFileReads.get(frame.requestId);
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.activeBinaryFileTransfers.set(frame.requestId, {
|
||||||
|
...pending,
|
||||||
|
mime: frame.metadata.mime,
|
||||||
|
size: frame.metadata.size,
|
||||||
|
encoding: frame.metadata.encoding,
|
||||||
|
modifiedAt: frame.metadata.modifiedAt,
|
||||||
|
chunks: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transfer = this.activeBinaryFileTransfers.get(frame.requestId);
|
||||||
|
if (!transfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.opcode === FileTransferOpcode.FileChunk) {
|
||||||
|
transfer.chunks.push(frame.payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
||||||
|
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||||
|
this.completedBinaryFileReads.set(frame.requestId, {
|
||||||
|
bytes,
|
||||||
|
mime: transfer.mime,
|
||||||
|
size: transfer.size,
|
||||||
|
path: transfer.path,
|
||||||
|
kind: binaryFileKind(transfer.mime, transfer.encoding),
|
||||||
|
modifiedAt: transfer.modifiedAt,
|
||||||
|
});
|
||||||
|
this.handleSessionMessage({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: {
|
||||||
|
cwd: transfer.cwd,
|
||||||
|
path: transfer.path,
|
||||||
|
mode: "file",
|
||||||
|
directory: null,
|
||||||
|
file: null,
|
||||||
|
error: null,
|
||||||
|
requestId: frame.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private handleBinaryFrame(frame: TerminalStreamFrame): void {
|
private handleBinaryFrame(frame: TerminalStreamFrame): void {
|
||||||
const terminalId = this.slotTerminals.get(frame.slot);
|
const terminalId = this.slotTerminals.get(frame.slot);
|
||||||
if (!terminalId) {
|
if (!terminalId) {
|
||||||
|
|||||||
@@ -1291,11 +1291,9 @@ test("supports git and file operations", async () => {
|
|||||||
return unsubscribeList;
|
return unsubscribeList;
|
||||||
});
|
});
|
||||||
|
|
||||||
const listResult = await ctx.client.exploreFileSystem(cwd, ".", "list", listRequestId);
|
const listResult = await ctx.client.listDirectory(cwd, ".", listRequestId);
|
||||||
const listMessage = await listMessagePromise;
|
const listMessage = await listMessagePromise;
|
||||||
expect(listResult.error).toBeNull();
|
expect(listResult.entries.some((entry) => entry.name === "download.txt")).toBe(true);
|
||||||
expect(listResult.directory).toBeTruthy();
|
|
||||||
expect(listResult.requestId).toBe(listRequestId);
|
|
||||||
expect(listMessage.payload.mode).toBe("list");
|
expect(listMessage.payload.mode).toBe("list");
|
||||||
expect(listMessage.payload.requestId).toBe(listRequestId);
|
expect(listMessage.payload.requestId).toBe(listRequestId);
|
||||||
|
|
||||||
@@ -1319,11 +1317,9 @@ test("supports git and file operations", async () => {
|
|||||||
return unsubscribeFile;
|
return unsubscribeFile;
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileResult = await ctx.client.exploreFileSystem(cwd, "download.txt", "file", fileRequestId);
|
const fileResult = await ctx.client.readFile(cwd, "download.txt", fileRequestId);
|
||||||
const fileMessage = await fileMessagePromise;
|
const fileMessage = await fileMessagePromise;
|
||||||
expect(fileResult.error).toBeNull();
|
expect(new TextDecoder().decode(fileResult.bytes)).toBe(downloadContents);
|
||||||
expect(fileResult.file?.content).toBe(downloadContents);
|
|
||||||
expect(fileResult.requestId).toBe(fileRequestId);
|
|
||||||
expect(fileMessage.payload.mode).toBe("file");
|
expect(fileMessage.payload.mode).toBe("file");
|
||||||
expect(fileMessage.payload.requestId).toBe(fileRequestId);
|
expect(fileMessage.payload.requestId).toBe(fileRequestId);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ afterEach(async () => {
|
|||||||
await ctx.cleanup();
|
await ctx.cleanup();
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
describe("exploreFileSystem", () => {
|
describe("file explorer", () => {
|
||||||
test("lists directory contents", async () => {
|
test("lists directory contents", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
@@ -45,16 +45,13 @@ describe("exploreFileSystem", () => {
|
|||||||
expect(agent.status).toBe("idle");
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
// List directory contents
|
// List directory contents
|
||||||
const result = await ctx.client.exploreFileSystem(cwd, ".", "list");
|
const directory = await ctx.client.listDirectory(cwd, ".");
|
||||||
|
|
||||||
// Verify listing returned without error
|
// Verify listing returned without error
|
||||||
expect(result.error).toBeNull();
|
expect(directory.entries).toBeTruthy();
|
||||||
expect(result.mode).toBe("list");
|
|
||||||
expect(result.directory).toBeTruthy();
|
|
||||||
expect(result.directory!.entries).toBeTruthy();
|
|
||||||
|
|
||||||
// Find expected entries
|
// Find expected entries
|
||||||
const entries = result.directory!.entries;
|
const entries = directory.entries;
|
||||||
const testTxt = entries.find((e) => e.name === "test.txt");
|
const testTxt = entries.find((e) => e.name === "test.txt");
|
||||||
const dataJson = entries.find((e) => e.name === "data.json");
|
const dataJson = entries.find((e) => e.name === "data.json");
|
||||||
const subdir = entries.find((e) => e.name === "subdir");
|
const subdir = entries.find((e) => e.name === "subdir");
|
||||||
@@ -92,17 +89,14 @@ describe("exploreFileSystem", () => {
|
|||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
// Read file contents
|
// Read file contents
|
||||||
const result = await ctx.client.exploreFileSystem(cwd, "readme.txt", "file");
|
const result = await ctx.client.readFile(cwd, "readme.txt");
|
||||||
|
|
||||||
// Verify file read
|
// Verify file read
|
||||||
expect(result.error).toBeNull();
|
|
||||||
expect(result.mode).toBe("file");
|
|
||||||
expect(result.file).toBeTruthy();
|
|
||||||
// Server may return basename or full path
|
// Server may return basename or full path
|
||||||
expect(result.file!.path).toContain("readme.txt");
|
expect(result.path).toContain("readme.txt");
|
||||||
expect(result.file!.kind).toBe("text");
|
expect(result.kind).toBe("text");
|
||||||
expect(result.file!.content).toBe(testContent);
|
expect(new TextDecoder().decode(result.bytes)).toBe(testContent);
|
||||||
expect(result.file!.size).toBe(testContent.length);
|
expect(result.size).toBe(testContent.length);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await ctx.client.deleteAgent(agent.id);
|
await ctx.client.deleteAgent(agent.id);
|
||||||
@@ -124,10 +118,7 @@ describe("exploreFileSystem", () => {
|
|||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
// Try to list non-existent path
|
// Try to list non-existent path
|
||||||
const result = await ctx.client.exploreFileSystem(cwd, "does-not-exist", "list");
|
await expect(ctx.client.listDirectory(cwd, "does-not-exist")).rejects.toThrow();
|
||||||
|
|
||||||
// Should return error
|
|
||||||
expect(result.error).toBeTruthy();
|
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await ctx.client.deleteAgent(agent.id);
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ export interface FileExplorerFile {
|
|||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileExplorerFileBytes {
|
||||||
|
path: string;
|
||||||
|
kind: ExplorerFileKind;
|
||||||
|
encoding: "utf-8" | "binary";
|
||||||
|
bytes: Uint8Array;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
const TEXT_MIME_TYPES: Record<string, string> = {
|
const TEXT_MIME_TYPES: Record<string, string> = {
|
||||||
".json": "application/json",
|
".json": "application/json",
|
||||||
};
|
};
|
||||||
@@ -120,6 +130,46 @@ export async function readExplorerFile({
|
|||||||
root,
|
root,
|
||||||
relativePath,
|
relativePath,
|
||||||
}: ReadFileParams): Promise<FileExplorerFile> {
|
}: ReadFileParams): Promise<FileExplorerFile> {
|
||||||
|
const file = await readExplorerFileBytes({ root, relativePath });
|
||||||
|
|
||||||
|
if (file.kind === "image") {
|
||||||
|
return {
|
||||||
|
path: file.path,
|
||||||
|
kind: file.kind,
|
||||||
|
encoding: "base64",
|
||||||
|
content: Buffer.from(file.bytes).toString("base64"),
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
size: file.size,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.kind === "binary") {
|
||||||
|
return {
|
||||||
|
path: file.path,
|
||||||
|
kind: file.kind,
|
||||||
|
encoding: "none",
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
size: file.size,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: file.path,
|
||||||
|
kind: file.kind,
|
||||||
|
encoding: "utf-8",
|
||||||
|
content: Buffer.from(file.bytes).toString("utf-8"),
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
size: file.size,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readExplorerFileBytes({
|
||||||
|
root,
|
||||||
|
relativePath,
|
||||||
|
}: ReadFileParams): Promise<FileExplorerFileBytes> {
|
||||||
const filePath = await resolveScopedPath({ root, relativePath });
|
const filePath = await resolveScopedPath({ root, relativePath });
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
@@ -139,8 +189,8 @@ export async function readExplorerFile({
|
|||||||
return {
|
return {
|
||||||
...basePayload,
|
...basePayload,
|
||||||
kind: "image",
|
kind: "image",
|
||||||
encoding: "base64",
|
encoding: "binary",
|
||||||
content: buffer.toString("base64"),
|
bytes: buffer,
|
||||||
mimeType: IMAGE_MIME_TYPES[ext],
|
mimeType: IMAGE_MIME_TYPES[ext],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -150,7 +200,8 @@ export async function readExplorerFile({
|
|||||||
return {
|
return {
|
||||||
...basePayload,
|
...basePayload,
|
||||||
kind: "binary",
|
kind: "binary",
|
||||||
encoding: "none",
|
encoding: "binary",
|
||||||
|
bytes: buffer,
|
||||||
mimeType: "application/octet-stream",
|
mimeType: "application/octet-stream",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -159,7 +210,7 @@ export async function readExplorerFile({
|
|||||||
...basePayload,
|
...basePayload,
|
||||||
kind: "text",
|
kind: "text",
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
content: buffer.toString("utf-8"),
|
bytes: buffer,
|
||||||
mimeType: textMimeTypeForExtension(ext),
|
mimeType: textMimeTypeForExtension(ext),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
|||||||
|
|
||||||
import { CheckoutPrStatusSchema } from "../shared/messages.js";
|
import { CheckoutPrStatusSchema } from "../shared/messages.js";
|
||||||
import type { WorkspaceDescriptorPayload } from "../shared/messages.js";
|
import type { WorkspaceDescriptorPayload } from "../shared/messages.js";
|
||||||
|
import { decodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js";
|
||||||
import { normalizeCheckoutPrStatusPayload, Session } from "./session.js";
|
import { normalizeCheckoutPrStatusPayload, Session } from "./session.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
@@ -47,6 +48,17 @@ function asSessionInternals(session: Session): SessionHandlerInternals {
|
|||||||
return session as unknown as SessionHandlerInternals;
|
return session as unknown as SessionHandlerInternals;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createBinaryMessageHandler(
|
||||||
|
binaryMessages: Uint8Array[] | undefined,
|
||||||
|
): ((frame: Uint8Array) => void) | undefined {
|
||||||
|
if (!binaryMessages) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return (frame) => {
|
||||||
|
binaryMessages.push(frame);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const checkoutGitMocks = vi.hoisted(() => ({
|
const checkoutGitMocks = vi.hoisted(() => ({
|
||||||
checkoutResolvedBranch: vi.fn(),
|
checkoutResolvedBranch: vi.fn(),
|
||||||
commitChanges: vi.fn(),
|
commitChanges: vi.fn(),
|
||||||
@@ -195,7 +207,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function createSessionForTest(options?: {
|
interface SessionForTestOptions {
|
||||||
github?: {
|
github?: {
|
||||||
invalidate: ReturnType<typeof vi.fn>;
|
invalidate: ReturnType<typeof vi.fn>;
|
||||||
isAuthenticated?: ReturnType<typeof vi.fn>;
|
isAuthenticated?: ReturnType<typeof vi.fn>;
|
||||||
@@ -223,17 +235,20 @@ function createSessionForTest(options?: {
|
|||||||
getDaemonTcpHost?: () => string | null;
|
getDaemonTcpHost?: () => string | null;
|
||||||
providerSnapshotManager?: ProviderSnapshotManager;
|
providerSnapshotManager?: ProviderSnapshotManager;
|
||||||
messages?: unknown[];
|
messages?: unknown[];
|
||||||
}): Session {
|
binaryMessages?: Uint8Array[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
const github = options?.github ?? {
|
const github = options.github ?? {
|
||||||
invalidate: vi.fn(),
|
invalidate: vi.fn(),
|
||||||
searchIssuesAndPrs: vi.fn(),
|
searchIssuesAndPrs: vi.fn(),
|
||||||
createPullRequest: vi.fn(),
|
createPullRequest: vi.fn(),
|
||||||
};
|
};
|
||||||
const checkoutDiffManager = options?.checkoutDiffManager ?? {
|
const checkoutDiffManager = options.checkoutDiffManager ?? {
|
||||||
scheduleRefreshForCwd: vi.fn(),
|
scheduleRefreshForCwd: vi.fn(),
|
||||||
};
|
};
|
||||||
const workspaceGitService = options?.workspaceGitService ?? {
|
const workspaceGitService = options.workspaceGitService ?? {
|
||||||
getCheckoutDiff: vi.fn(),
|
getCheckoutDiff: vi.fn(),
|
||||||
getSnapshot: vi.fn(),
|
getSnapshot: vi.fn(),
|
||||||
suggestBranchesForCwd: vi.fn(),
|
suggestBranchesForCwd: vi.fn(),
|
||||||
@@ -244,11 +259,12 @@ function createSessionForTest(options?: {
|
|||||||
resolveRepoRemoteUrl: vi.fn(),
|
resolveRepoRemoteUrl: vi.fn(),
|
||||||
getWorkspaceGitMetadata: vi.fn(),
|
getWorkspaceGitMetadata: vi.fn(),
|
||||||
};
|
};
|
||||||
const messages = options?.messages ?? [];
|
const messages = options.messages ?? [];
|
||||||
|
|
||||||
return new Session({
|
return new Session({
|
||||||
clientId: "test-client",
|
clientId: "test-client",
|
||||||
onMessage: (message) => messages.push(message),
|
onMessage: (message) => messages.push(message),
|
||||||
|
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
|
||||||
logger,
|
logger,
|
||||||
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"],
|
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"],
|
||||||
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
|
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
|
||||||
@@ -260,7 +276,7 @@ function createSessionForTest(options?: {
|
|||||||
agentStorage: {
|
agentStorage: {
|
||||||
list: vi.fn().mockResolvedValue([]),
|
list: vi.fn().mockResolvedValue([]),
|
||||||
} as unknown as SessionOptions["agentStorage"],
|
} as unknown as SessionOptions["agentStorage"],
|
||||||
projectRegistry: (options?.projectRegistry ?? {
|
projectRegistry: (options.projectRegistry ?? {
|
||||||
list: vi.fn().mockResolvedValue([]),
|
list: vi.fn().mockResolvedValue([]),
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
upsert: vi.fn(),
|
upsert: vi.fn(),
|
||||||
@@ -269,7 +285,7 @@ function createSessionForTest(options?: {
|
|||||||
initialize: vi.fn(),
|
initialize: vi.fn(),
|
||||||
existsOnDisk: vi.fn(),
|
existsOnDisk: vi.fn(),
|
||||||
}) as unknown as SessionOptions["projectRegistry"],
|
}) as unknown as SessionOptions["projectRegistry"],
|
||||||
workspaceRegistry: (options?.workspaceRegistry ?? {
|
workspaceRegistry: (options.workspaceRegistry ?? {
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
list: vi.fn().mockResolvedValue([]),
|
list: vi.fn().mockResolvedValue([]),
|
||||||
}) as unknown as SessionOptions["workspaceRegistry"],
|
}) as unknown as SessionOptions["workspaceRegistry"],
|
||||||
@@ -288,15 +304,112 @@ function createSessionForTest(options?: {
|
|||||||
} as unknown as SessionOptions["daemonConfigStore"],
|
} as unknown as SessionOptions["daemonConfigStore"],
|
||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
terminalManager: (options?.terminalManager ?? null) as SessionOptions["terminalManager"],
|
terminalManager: (options.terminalManager ?? null) as SessionOptions["terminalManager"],
|
||||||
providerSnapshotManager: options?.providerSnapshotManager,
|
providerSnapshotManager: options.providerSnapshotManager,
|
||||||
scriptRouteStore: options?.scriptRouteStore as SessionOptions["scriptRouteStore"],
|
scriptRouteStore: options.scriptRouteStore as SessionOptions["scriptRouteStore"],
|
||||||
scriptRuntimeStore: options?.scriptRuntimeStore as SessionOptions["scriptRuntimeStore"],
|
scriptRuntimeStore: options.scriptRuntimeStore as SessionOptions["scriptRuntimeStore"],
|
||||||
getDaemonTcpPort: options?.getDaemonTcpPort,
|
getDaemonTcpPort: options.getDaemonTcpPort,
|
||||||
getDaemonTcpHost: options?.getDaemonTcpHost,
|
getDaemonTcpHost: options.getDaemonTcpHost,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("file explorer binary responses", () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeRoot(): string {
|
||||||
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "file-explorer-session-test-")));
|
||||||
|
tempDirs.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("old clients get legacy JSON file content from a new daemon", async () => {
|
||||||
|
const cwd = makeRoot();
|
||||||
|
writeFileSync(join(cwd, "logo.png"), "hello");
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const binaryMessages: Uint8Array[] = [];
|
||||||
|
const session = createSessionForTest({ messages, binaryMessages });
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd,
|
||||||
|
path: "logo.png",
|
||||||
|
mode: "file",
|
||||||
|
requestId: "req-old-client",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(binaryMessages).toEqual([]);
|
||||||
|
expect(messages).toEqual([
|
||||||
|
{
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
cwd,
|
||||||
|
path: "logo.png",
|
||||||
|
mode: "file",
|
||||||
|
directory: null,
|
||||||
|
error: null,
|
||||||
|
requestId: "req-old-client",
|
||||||
|
file: expect.objectContaining({
|
||||||
|
kind: "image",
|
||||||
|
encoding: "base64",
|
||||||
|
content: "aGVsbG8=",
|
||||||
|
mimeType: "image/png",
|
||||||
|
size: 5,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new clients get binary file frames without legacy JSON content", async () => {
|
||||||
|
const cwd = makeRoot();
|
||||||
|
writeFileSync(join(cwd, "logo.png"), "hello");
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const binaryMessages: Uint8Array[] = [];
|
||||||
|
const session = createSessionForTest({ messages, binaryMessages });
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd,
|
||||||
|
path: "logo.png",
|
||||||
|
mode: "file",
|
||||||
|
requestId: "req-new-client",
|
||||||
|
acceptBinary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
expect(binaryMessages).toHaveLength(3);
|
||||||
|
|
||||||
|
const frames = binaryMessages.map((frame) => decodeFileTransferFrame(frame));
|
||||||
|
expect(frames[0]).toEqual({
|
||||||
|
opcode: FileTransferOpcode.FileBegin,
|
||||||
|
requestId: "req-new-client",
|
||||||
|
metadata: {
|
||||||
|
mime: "image/png",
|
||||||
|
size: 5,
|
||||||
|
encoding: "binary",
|
||||||
|
modifiedAt: expect.any(String),
|
||||||
|
},
|
||||||
|
payload: new Uint8Array(),
|
||||||
|
});
|
||||||
|
expect(frames[1]).toEqual({
|
||||||
|
opcode: FileTransferOpcode.FileChunk,
|
||||||
|
requestId: "req-new-client",
|
||||||
|
payload: new TextEncoder().encode("hello"),
|
||||||
|
});
|
||||||
|
expect(frames[2]).toEqual({
|
||||||
|
opcode: FileTransferOpcode.FileEnd,
|
||||||
|
requestId: "req-new-client",
|
||||||
|
payload: new Uint8Array(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function createProjectRecord(rootPath: string, archivedAt: string | null = null) {
|
function createProjectRecord(rootPath: string, archivedAt: string | null = null) {
|
||||||
return {
|
return {
|
||||||
projectId: `project:${rootPath}`,
|
projectId: `project:${rootPath}`,
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ import {
|
|||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import { TerminalSessionController } from "../terminal/terminal-session-controller.js";
|
import { TerminalSessionController } from "../terminal/terminal-session-controller.js";
|
||||||
import type { TerminalStreamFrame } from "../shared/terminal-stream-protocol.js";
|
import {
|
||||||
|
encodeFileTransferFrame,
|
||||||
|
FileTransferOpcode,
|
||||||
|
type TerminalStreamFrame,
|
||||||
|
} from "../shared/binary-frames/index.js";
|
||||||
import { CursorError } from "./pagination/cursor.js";
|
import { CursorError } from "./pagination/cursor.js";
|
||||||
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
|
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
|
||||||
import { TTSManager } from "./agent/tts-manager.js";
|
import { TTSManager } from "./agent/tts-manager.js";
|
||||||
@@ -149,6 +153,7 @@ import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
|||||||
import {
|
import {
|
||||||
listDirectoryEntries,
|
listDirectoryEntries,
|
||||||
readExplorerFile,
|
readExplorerFile,
|
||||||
|
readExplorerFileBytes,
|
||||||
getDownloadableFileInfo,
|
getDownloadableFileInfo,
|
||||||
} from "./file-explorer/service.js";
|
} from "./file-explorer/service.js";
|
||||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
@@ -5475,23 +5480,56 @@ export class Session {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const file = await readExplorerFile({
|
if (request.acceptBinary && this.onBinaryMessage) {
|
||||||
root: cwd,
|
const file = await readExplorerFileBytes({
|
||||||
relativePath: requestedPath,
|
root: cwd,
|
||||||
});
|
relativePath: requestedPath,
|
||||||
|
});
|
||||||
|
|
||||||
this.emit({
|
this.emitBinary(
|
||||||
type: "file_explorer_response",
|
encodeFileTransferFrame({
|
||||||
payload: {
|
opcode: FileTransferOpcode.FileBegin,
|
||||||
cwd,
|
requestId,
|
||||||
path: file.path,
|
metadata: {
|
||||||
mode,
|
mime: file.mimeType,
|
||||||
directory: null,
|
size: file.size,
|
||||||
file,
|
encoding: file.encoding,
|
||||||
error: null,
|
modifiedAt: file.modifiedAt,
|
||||||
requestId,
|
},
|
||||||
},
|
}),
|
||||||
});
|
);
|
||||||
|
this.emitBinary(
|
||||||
|
encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileChunk,
|
||||||
|
requestId,
|
||||||
|
payload: file.bytes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.emitBinary(
|
||||||
|
encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileEnd,
|
||||||
|
requestId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const file = await readExplorerFile({
|
||||||
|
root: cwd,
|
||||||
|
relativePath: requestedPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
path: file.path,
|
||||||
|
mode,
|
||||||
|
directory: null,
|
||||||
|
file,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
type WSOutboundMessage,
|
type WSOutboundMessage,
|
||||||
wrapSessionMessage,
|
wrapSessionMessage,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js";
|
import { asUint8Array, decodeTerminalStreamFrame } from "../shared/binary-frames/index.js";
|
||||||
import type { HostnamesConfig } from "./hostnames.js";
|
import type { HostnamesConfig } from "./hostnames.js";
|
||||||
import { isHostnameAllowed } from "./hostnames.js";
|
import { isHostnameAllowed } from "./hostnames.js";
|
||||||
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileTransferOpcode,
|
||||||
|
TerminalStreamOpcode,
|
||||||
|
decodeFileTransferFrame,
|
||||||
|
decodeTerminalStreamFrame,
|
||||||
|
encodeFileTransferFrame,
|
||||||
|
} from "./index.js";
|
||||||
|
|
||||||
|
describe("file transfer binary frames", () => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
it("uses non-terminal opcodes", () => {
|
||||||
|
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileBegin);
|
||||||
|
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileChunk);
|
||||||
|
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes FileBegin as opcode plus request id prefix plus JSON metadata", () => {
|
||||||
|
const encoded = encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileBegin,
|
||||||
|
requestId: "req-1",
|
||||||
|
metadata: {
|
||||||
|
mime: "image/png",
|
||||||
|
size: 6,
|
||||||
|
encoding: "binary",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const requestId = encoder.encode("req-1");
|
||||||
|
const metadata = encoder.encode(
|
||||||
|
JSON.stringify({
|
||||||
|
mime: "image/png",
|
||||||
|
size: 6,
|
||||||
|
encoding: "binary",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(decodeTerminalStreamFrame(encoded)).toBeNull();
|
||||||
|
expect(encoded[0]).toBe(FileTransferOpcode.FileBegin);
|
||||||
|
expect(encoded[1]).toBe(requestId.byteLength);
|
||||||
|
expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId);
|
||||||
|
expect(
|
||||||
|
new DataView(encoded.buffer, encoded.byteOffset).getUint16(2 + requestId.byteLength),
|
||||||
|
).toBe(metadata.byteLength);
|
||||||
|
expect(encoded.subarray(4 + requestId.byteLength)).toEqual(metadata);
|
||||||
|
|
||||||
|
expect(decodeFileTransferFrame(encoded)).toEqual({
|
||||||
|
opcode: FileTransferOpcode.FileBegin,
|
||||||
|
requestId: "req-1",
|
||||||
|
metadata: {
|
||||||
|
mime: "image/png",
|
||||||
|
size: 6,
|
||||||
|
encoding: "binary",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
payload: new Uint8Array(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes FileChunk as opcode plus request id prefix plus binary payload", () => {
|
||||||
|
const payload = new Uint8Array([0, 1, 2, 253, 254, 255]);
|
||||||
|
const encoded = encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileChunk,
|
||||||
|
requestId: "req-1",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
const requestId = encoder.encode("req-1");
|
||||||
|
|
||||||
|
expect(encoded[0]).toBe(FileTransferOpcode.FileChunk);
|
||||||
|
expect(encoded[1]).toBe(requestId.byteLength);
|
||||||
|
expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId);
|
||||||
|
expect(encoded.subarray(2 + requestId.byteLength)).toEqual(payload);
|
||||||
|
const decoded = decodeFileTransferFrame(encoded);
|
||||||
|
|
||||||
|
expect(decoded).toEqual({
|
||||||
|
opcode: FileTransferOpcode.FileChunk,
|
||||||
|
requestId: "req-1",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(decoded?.payload).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(decoded?.payload).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes FileEnd as opcode plus request id prefix only", () => {
|
||||||
|
const encoded = encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileEnd,
|
||||||
|
requestId: "req-1",
|
||||||
|
});
|
||||||
|
const requestId = encoder.encode("req-1");
|
||||||
|
|
||||||
|
expect(encoded).toEqual(
|
||||||
|
new Uint8Array([FileTransferOpcode.FileEnd, requestId.byteLength, ...requestId]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(decodeFileTransferFrame(encoded)).toEqual({
|
||||||
|
opcode: FileTransferOpcode.FileEnd,
|
||||||
|
requestId: "req-1",
|
||||||
|
payload: new Uint8Array(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed metadata and unknown metadata fields", () => {
|
||||||
|
expect(
|
||||||
|
decodeFileTransferFrame(
|
||||||
|
encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileBegin,
|
||||||
|
requestId: "req-1",
|
||||||
|
metadata: {
|
||||||
|
mime: "image/png",
|
||||||
|
size: -1,
|
||||||
|
encoding: "binary",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
const json = encoder.encode(
|
||||||
|
JSON.stringify({
|
||||||
|
mime: "image/png",
|
||||||
|
size: 1,
|
||||||
|
encoding: "binary",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
extra: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const requestId = encoder.encode("req-1");
|
||||||
|
const encoded = new Uint8Array(4 + requestId.byteLength + json.byteLength);
|
||||||
|
encoded[0] = FileTransferOpcode.FileBegin;
|
||||||
|
encoded[1] = requestId.byteLength;
|
||||||
|
encoded.set(requestId, 2);
|
||||||
|
new DataView(encoded.buffer).setUint16(2 + requestId.byteLength, json.byteLength);
|
||||||
|
encoded.set(json, 4 + requestId.byteLength);
|
||||||
|
|
||||||
|
expect(decodeFileTransferFrame(encoded)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed request id prefixes and frame tails", () => {
|
||||||
|
expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 0]))).toBeNull();
|
||||||
|
expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 6, 1]))).toBeNull();
|
||||||
|
|
||||||
|
const requestId = encoder.encode("req-1");
|
||||||
|
const encoded = new Uint8Array([
|
||||||
|
FileTransferOpcode.FileEnd,
|
||||||
|
requestId.byteLength,
|
||||||
|
...requestId,
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
expect(decodeFileTransferFrame(encoded)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { asUint8Array } from "./terminal.js";
|
||||||
|
|
||||||
|
export const FileTransferOpcode = {
|
||||||
|
FileBegin: 0x10,
|
||||||
|
FileChunk: 0x11,
|
||||||
|
FileEnd: 0x12,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type FileTransferOpcode = (typeof FileTransferOpcode)[keyof typeof FileTransferOpcode];
|
||||||
|
|
||||||
|
export const FileBeginMetadataSchema = z
|
||||||
|
.object({
|
||||||
|
mime: z.string().min(1),
|
||||||
|
size: z.number().int().nonnegative(),
|
||||||
|
encoding: z.enum(["utf-8", "binary"]),
|
||||||
|
modifiedAt: z.string(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export interface FileBegin {
|
||||||
|
opcode: typeof FileTransferOpcode.FileBegin;
|
||||||
|
requestId: string;
|
||||||
|
metadata: z.infer<typeof FileBeginMetadataSchema>;
|
||||||
|
payload: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileChunk {
|
||||||
|
opcode: typeof FileTransferOpcode.FileChunk;
|
||||||
|
requestId: string;
|
||||||
|
payload: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileEnd {
|
||||||
|
opcode: typeof FileTransferOpcode.FileEnd;
|
||||||
|
requestId: string;
|
||||||
|
payload: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileTransferFrame = FileBegin | FileChunk | FileEnd;
|
||||||
|
|
||||||
|
type FileTransferFrameInput =
|
||||||
|
| {
|
||||||
|
opcode: typeof FileTransferOpcode.FileBegin;
|
||||||
|
requestId: string;
|
||||||
|
metadata: z.infer<typeof FileBeginMetadataSchema>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
opcode: typeof FileTransferOpcode.FileChunk;
|
||||||
|
requestId: string;
|
||||||
|
payload?: Uint8Array | ArrayBuffer | string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
opcode: typeof FileTransferOpcode.FileEnd;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function encodeFileTransferFrame(input: FileTransferFrameInput): Uint8Array {
|
||||||
|
const requestId = encodeRequestId(input.requestId);
|
||||||
|
|
||||||
|
if (input.opcode === FileTransferOpcode.FileBegin) {
|
||||||
|
const metadata = encodeJsonPayload(input.metadata);
|
||||||
|
if (metadata.byteLength > 0xffff) {
|
||||||
|
throw new RangeError("FileBegin metadata is too long");
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(4 + requestId.byteLength + metadata.byteLength);
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
bytes[0] = input.opcode;
|
||||||
|
bytes[1] = requestId.byteLength;
|
||||||
|
bytes.set(requestId, 2);
|
||||||
|
view.setUint16(2 + requestId.byteLength, metadata.byteLength);
|
||||||
|
bytes.set(metadata, 4 + requestId.byteLength);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload =
|
||||||
|
input.opcode === FileTransferOpcode.FileChunk
|
||||||
|
? (asUint8Array(input.payload ?? new Uint8Array()) ?? new Uint8Array())
|
||||||
|
: new Uint8Array();
|
||||||
|
const bytes = new Uint8Array(2 + requestId.byteLength + payload.byteLength);
|
||||||
|
bytes[0] = input.opcode;
|
||||||
|
bytes[1] = requestId.byteLength;
|
||||||
|
bytes.set(requestId, 2);
|
||||||
|
bytes.set(payload, 2 + requestId.byteLength);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeFileTransferFrame(bytes: Uint8Array): FileTransferFrame | null {
|
||||||
|
if (bytes.byteLength < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const opcode = bytes[0];
|
||||||
|
if (!isFileTransferOpcode(opcode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const requestIdLength = bytes[1];
|
||||||
|
if (requestIdLength === 0 || requestIdLength > bytes.byteLength - 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = decodeRequestId(bytes.subarray(2, 2 + requestIdLength));
|
||||||
|
const body = bytes.subarray(2 + requestIdLength);
|
||||||
|
|
||||||
|
if (opcode === FileTransferOpcode.FileBegin) {
|
||||||
|
if (body.byteLength < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const view = new DataView(body.buffer, body.byteOffset, body.byteLength);
|
||||||
|
const metadataLength = view.getUint16(0);
|
||||||
|
if (metadataLength !== body.byteLength - 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const metadataBytes = body.subarray(2);
|
||||||
|
const result = FileBeginMetadataSchema.safeParse(decodeJsonPayload(metadataBytes));
|
||||||
|
return result.success
|
||||||
|
? { opcode, requestId, metadata: result.data, payload: new Uint8Array() }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opcode === FileTransferOpcode.FileChunk) {
|
||||||
|
return { opcode, requestId, payload: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.byteLength !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { opcode, requestId, payload: new Uint8Array() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileTransferOpcode(value: number): value is FileTransferOpcode {
|
||||||
|
return (
|
||||||
|
value === FileTransferOpcode.FileBegin ||
|
||||||
|
value === FileTransferOpcode.FileChunk ||
|
||||||
|
value === FileTransferOpcode.FileEnd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeJsonPayload(value: unknown): Uint8Array {
|
||||||
|
return new TextEncoder().encode(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeRequestId(requestId: string): Uint8Array {
|
||||||
|
const bytes = new TextEncoder().encode(requestId);
|
||||||
|
if (bytes.byteLength === 0) {
|
||||||
|
throw new RangeError("File transfer requestId is required");
|
||||||
|
}
|
||||||
|
if (bytes.byteLength > 0xff) {
|
||||||
|
throw new RangeError("File transfer requestId is too long");
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeRequestId(bytes: Uint8Array): string {
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./file-transfer.js";
|
||||||
|
export * from "./terminal.js";
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TerminalStreamOpcode,
|
||||||
|
decodeTerminalResizePayload,
|
||||||
|
decodeTerminalSnapshotPayload,
|
||||||
|
decodeTerminalStreamFrame,
|
||||||
|
encodeTerminalResizePayload,
|
||||||
|
encodeTerminalSnapshotPayload,
|
||||||
|
encodeTerminalStreamFrame,
|
||||||
|
} from "./index.js";
|
||||||
|
|
||||||
|
describe("terminal binary frames", () => {
|
||||||
|
it("encodes output frames as opcode plus slot plus payload", () => {
|
||||||
|
const payload = new TextEncoder().encode("hello");
|
||||||
|
const encoded = encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Output,
|
||||||
|
slot: 7,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
|
||||||
|
expect(encoded[1]).toBe(7);
|
||||||
|
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
|
||||||
|
|
||||||
|
const decoded = decodeTerminalStreamFrame(encoded);
|
||||||
|
expect(decoded).toEqual({
|
||||||
|
opcode: TerminalStreamOpcode.Output,
|
||||||
|
slot: 7,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips resize payloads", () => {
|
||||||
|
const payload = encodeTerminalResizePayload({
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decodeTerminalResizePayload(payload)).toEqual({
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips snapshot payloads", () => {
|
||||||
|
const state = {
|
||||||
|
rows: 1,
|
||||||
|
cols: 2,
|
||||||
|
grid: [[{ char: "A" }, { char: "B" }]],
|
||||||
|
scrollback: [],
|
||||||
|
cursor: { row: 0, col: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = encodeTerminalSnapshotPayload(state);
|
||||||
|
expect(decodeTerminalSnapshotPayload(payload)).toEqual(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown opcodes", () => {
|
||||||
|
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01, 0x02]))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects frames without a slot byte", () => {
|
||||||
|
expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed JSON payloads", () => {
|
||||||
|
const malformed = new TextEncoder().encode("{");
|
||||||
|
|
||||||
|
expect(decodeTerminalResizePayload(malformed)).toBeNull();
|
||||||
|
expect(decodeTerminalSnapshotPayload(malformed)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid resize and snapshot shapes", () => {
|
||||||
|
expect(
|
||||||
|
decodeTerminalResizePayload(
|
||||||
|
new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
decodeTerminalSnapshotPayload(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
rows: 1,
|
||||||
|
cols: 1,
|
||||||
|
grid: [[{ char: "A" }]],
|
||||||
|
scrollback: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown fields in resize and snapshot payloads", () => {
|
||||||
|
expect(
|
||||||
|
decodeTerminalResizePayload(
|
||||||
|
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
decodeTerminalSnapshotPayload(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
rows: 1,
|
||||||
|
cols: 1,
|
||||||
|
grid: [[{ char: "A", extra: true }]],
|
||||||
|
scrollback: [],
|
||||||
|
cursor: { row: 0, col: 1 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { TerminalStateSchema } from "../messages.js";
|
||||||
|
|
||||||
|
export const TerminalStreamResizeSchema = z
|
||||||
|
.object({
|
||||||
|
rows: z.number().int().positive(),
|
||||||
|
cols: z.number().int().positive(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const TerminalStreamOpcode = {
|
||||||
|
Output: 0x01,
|
||||||
|
Input: 0x02,
|
||||||
|
Resize: 0x03,
|
||||||
|
Snapshot: 0x04,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
|
||||||
|
|
||||||
|
export interface TerminalStreamFrame {
|
||||||
|
opcode: TerminalStreamOpcode;
|
||||||
|
slot: number;
|
||||||
|
payload: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asUint8Array(data: unknown): Uint8Array | null {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
if (typeof TextEncoder !== "undefined") {
|
||||||
|
return new TextEncoder().encode(data);
|
||||||
|
}
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
return new Uint8Array(Buffer.from(data, "utf8"));
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(data.length);
|
||||||
|
for (let index = 0; index < data.length; index += 1) {
|
||||||
|
out[index] = data.charCodeAt(index) & 0xff;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (data instanceof Uint8Array) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(data);
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
}
|
||||||
|
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
|
||||||
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
||||||
|
return (
|
||||||
|
value === TerminalStreamOpcode.Output ||
|
||||||
|
value === TerminalStreamOpcode.Input ||
|
||||||
|
value === TerminalStreamOpcode.Resize ||
|
||||||
|
value === TerminalStreamOpcode.Snapshot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTerminalStreamFrame(input: {
|
||||||
|
opcode: TerminalStreamOpcode;
|
||||||
|
slot: number;
|
||||||
|
payload?: Uint8Array | ArrayBuffer | string;
|
||||||
|
}): Uint8Array {
|
||||||
|
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
|
||||||
|
const bytes = new Uint8Array(2 + payload.byteLength);
|
||||||
|
bytes[0] = input.opcode;
|
||||||
|
bytes[1] = input.slot & 0xff;
|
||||||
|
bytes.set(payload, 2);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
|
||||||
|
if (bytes.byteLength < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const opcode = bytes[0];
|
||||||
|
if (!isTerminalStreamOpcode(opcode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
opcode,
|
||||||
|
slot: bytes[1],
|
||||||
|
payload: bytes.subarray(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTerminalSnapshotPayload(
|
||||||
|
state: z.infer<typeof TerminalStateSchema>,
|
||||||
|
): Uint8Array {
|
||||||
|
return encodeJsonPayload(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTerminalSnapshotPayload(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): z.infer<typeof TerminalStateSchema> | null {
|
||||||
|
const parsed = decodeJsonPayload(bytes);
|
||||||
|
const result = TerminalStateSchema.safeParse(parsed);
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTerminalResizePayload(
|
||||||
|
input: z.infer<typeof TerminalStreamResizeSchema>,
|
||||||
|
): Uint8Array {
|
||||||
|
return encodeJsonPayload(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTerminalResizePayload(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): z.infer<typeof TerminalStreamResizeSchema> | null {
|
||||||
|
const parsed = decodeJsonPayload(bytes);
|
||||||
|
const result = TerminalStreamResizeSchema.safeParse(parsed);
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeJsonPayload(value: unknown): Uint8Array {
|
||||||
|
return new TextEncoder().encode(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
||||||
|
try {
|
||||||
|
const text = new TextDecoder().decode(bytes);
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { SessionOutboundMessageSchema } from "./messages.js";
|
import { FileExplorerRequestSchema, SessionOutboundMessageSchema } from "./messages.js";
|
||||||
|
|
||||||
function workspaceDescriptor(overrides: Record<string, unknown> = {}) {
|
function workspaceDescriptor(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -126,3 +126,38 @@ describe("workspace descriptor message compatibility", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("file explorer request compatibility", () => {
|
||||||
|
test("acceptBinary is optional for old clients and accepted for new clients", () => {
|
||||||
|
expect(
|
||||||
|
FileExplorerRequestSchema.parse({
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd: "/repo/app",
|
||||||
|
path: "image.png",
|
||||||
|
mode: "file",
|
||||||
|
requestId: "req-old",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd: "/repo/app",
|
||||||
|
path: "image.png",
|
||||||
|
mode: "file",
|
||||||
|
requestId: "req-old",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
FileExplorerRequestSchema.parse({
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd: "/repo/app",
|
||||||
|
path: "image.png",
|
||||||
|
mode: "file",
|
||||||
|
requestId: "req-new",
|
||||||
|
acceptBinary: true,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: "file_explorer_request",
|
||||||
|
requestId: "req-new",
|
||||||
|
acceptBinary: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1505,6 +1505,7 @@ export const FileExplorerRequestSchema = z.object({
|
|||||||
path: z.string().optional(),
|
path: z.string().optional(),
|
||||||
mode: z.enum(["list", "file"]),
|
mode: z.enum(["list", "file"]),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
|
acceptBinary: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ProjectIconRequestSchema = z.object({
|
export const ProjectIconRequestSchema = z.object({
|
||||||
|
|||||||
@@ -1,114 +1,36 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import * as terminalBinaryFrames from "./binary-frames/index.js";
|
||||||
TerminalStreamOpcode,
|
import * as legacyTerminalStreamProtocol from "./terminal-stream-protocol.js";
|
||||||
decodeTerminalResizePayload,
|
|
||||||
decodeTerminalSnapshotPayload,
|
|
||||||
decodeTerminalStreamFrame,
|
|
||||||
encodeTerminalResizePayload,
|
|
||||||
encodeTerminalSnapshotPayload,
|
|
||||||
encodeTerminalStreamFrame,
|
|
||||||
} from "./terminal-stream-protocol.js";
|
|
||||||
|
|
||||||
describe("terminal stream protocol", () => {
|
describe("terminal stream protocol", () => {
|
||||||
it("encodes output frames as opcode plus slot plus payload", () => {
|
it("keeps the old import path compatible with terminal binary frames", () => {
|
||||||
|
expect(legacyTerminalStreamProtocol.TerminalStreamOpcode).toBe(
|
||||||
|
terminalBinaryFrames.TerminalStreamOpcode,
|
||||||
|
);
|
||||||
|
expect(legacyTerminalStreamProtocol.encodeTerminalStreamFrame).toBe(
|
||||||
|
terminalBinaryFrames.encodeTerminalStreamFrame,
|
||||||
|
);
|
||||||
|
expect(legacyTerminalStreamProtocol.decodeTerminalStreamFrame).toBe(
|
||||||
|
terminalBinaryFrames.decodeTerminalStreamFrame,
|
||||||
|
);
|
||||||
|
|
||||||
const payload = new TextEncoder().encode("hello");
|
const payload = new TextEncoder().encode("hello");
|
||||||
const encoded = encodeTerminalStreamFrame({
|
const encoded = legacyTerminalStreamProtocol.encodeTerminalStreamFrame({
|
||||||
opcode: TerminalStreamOpcode.Output,
|
opcode: legacyTerminalStreamProtocol.TerminalStreamOpcode.Output,
|
||||||
slot: 7,
|
slot: 7,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
|
expect(encoded[0]).toBe(terminalBinaryFrames.TerminalStreamOpcode.Output);
|
||||||
expect(encoded[1]).toBe(7);
|
expect(encoded[1]).toBe(7);
|
||||||
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
|
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
|
||||||
|
|
||||||
const decoded = decodeTerminalStreamFrame(encoded);
|
const decoded = terminalBinaryFrames.decodeTerminalStreamFrame(encoded);
|
||||||
expect(decoded).toEqual({
|
expect(decoded).toEqual({
|
||||||
opcode: TerminalStreamOpcode.Output,
|
opcode: legacyTerminalStreamProtocol.TerminalStreamOpcode.Output,
|
||||||
slot: 7,
|
slot: 7,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips resize payloads", () => {
|
|
||||||
const payload = encodeTerminalResizePayload({
|
|
||||||
rows: 24,
|
|
||||||
cols: 80,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(decodeTerminalResizePayload(payload)).toEqual({
|
|
||||||
rows: 24,
|
|
||||||
cols: 80,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips snapshot payloads", () => {
|
|
||||||
const state = {
|
|
||||||
rows: 1,
|
|
||||||
cols: 2,
|
|
||||||
grid: [[{ char: "A" }, { char: "B" }]],
|
|
||||||
scrollback: [],
|
|
||||||
cursor: { row: 0, col: 2 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const payload = encodeTerminalSnapshotPayload(state);
|
|
||||||
expect(decodeTerminalSnapshotPayload(payload)).toEqual(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown opcodes", () => {
|
|
||||||
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01, 0x02]))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects frames without a slot byte", () => {
|
|
||||||
expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects malformed JSON payloads", () => {
|
|
||||||
const malformed = new TextEncoder().encode("{");
|
|
||||||
|
|
||||||
expect(decodeTerminalResizePayload(malformed)).toBeNull();
|
|
||||||
expect(decodeTerminalSnapshotPayload(malformed)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid resize and snapshot shapes", () => {
|
|
||||||
expect(
|
|
||||||
decodeTerminalResizePayload(
|
|
||||||
new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })),
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
decodeTerminalSnapshotPayload(
|
|
||||||
new TextEncoder().encode(
|
|
||||||
JSON.stringify({
|
|
||||||
rows: 1,
|
|
||||||
cols: 1,
|
|
||||||
grid: [[{ char: "A" }]],
|
|
||||||
scrollback: [],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown fields in resize and snapshot payloads", () => {
|
|
||||||
expect(
|
|
||||||
decodeTerminalResizePayload(
|
|
||||||
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
decodeTerminalSnapshotPayload(
|
|
||||||
new TextEncoder().encode(
|
|
||||||
JSON.stringify({
|
|
||||||
rows: 1,
|
|
||||||
cols: 1,
|
|
||||||
grid: [[{ char: "A", extra: true }]],
|
|
||||||
scrollback: [],
|
|
||||||
cursor: { row: 0, col: 1 },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,131 +1 @@
|
|||||||
import { z } from "zod";
|
export * from "./binary-frames/terminal.js";
|
||||||
import { TerminalStateSchema } from "./messages.js";
|
|
||||||
|
|
||||||
export const TerminalStreamResizeSchema = z
|
|
||||||
.object({
|
|
||||||
rows: z.number().int().positive(),
|
|
||||||
cols: z.number().int().positive(),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const TerminalStreamOpcode = {
|
|
||||||
Output: 0x01,
|
|
||||||
Input: 0x02,
|
|
||||||
Resize: 0x03,
|
|
||||||
Snapshot: 0x04,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
|
|
||||||
|
|
||||||
export interface TerminalStreamFrame {
|
|
||||||
opcode: TerminalStreamOpcode;
|
|
||||||
slot: number;
|
|
||||||
payload: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asUint8Array(data: unknown): Uint8Array | null {
|
|
||||||
if (typeof data === "string") {
|
|
||||||
if (typeof TextEncoder !== "undefined") {
|
|
||||||
return new TextEncoder().encode(data);
|
|
||||||
}
|
|
||||||
if (typeof Buffer !== "undefined") {
|
|
||||||
return new Uint8Array(Buffer.from(data, "utf8"));
|
|
||||||
}
|
|
||||||
const out = new Uint8Array(data.length);
|
|
||||||
for (let index = 0; index < data.length; index += 1) {
|
|
||||||
out[index] = data.charCodeAt(index) & 0xff;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
if (data instanceof Uint8Array) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
|
|
||||||
return new Uint8Array(data);
|
|
||||||
}
|
|
||||||
if (ArrayBuffer.isView(data)) {
|
|
||||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
||||||
}
|
|
||||||
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
|
|
||||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
|
||||||
return (
|
|
||||||
value === TerminalStreamOpcode.Output ||
|
|
||||||
value === TerminalStreamOpcode.Input ||
|
|
||||||
value === TerminalStreamOpcode.Resize ||
|
|
||||||
value === TerminalStreamOpcode.Snapshot
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeTerminalStreamFrame(input: {
|
|
||||||
opcode: TerminalStreamOpcode;
|
|
||||||
slot: number;
|
|
||||||
payload?: Uint8Array | ArrayBuffer | string;
|
|
||||||
}): Uint8Array {
|
|
||||||
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
|
|
||||||
const bytes = new Uint8Array(2 + payload.byteLength);
|
|
||||||
bytes[0] = input.opcode;
|
|
||||||
bytes[1] = input.slot & 0xff;
|
|
||||||
bytes.set(payload, 2);
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
|
|
||||||
if (bytes.byteLength < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const opcode = bytes[0];
|
|
||||||
if (!isTerminalStreamOpcode(opcode)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
opcode,
|
|
||||||
slot: bytes[1],
|
|
||||||
payload: bytes.subarray(2),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeTerminalSnapshotPayload(
|
|
||||||
state: z.infer<typeof TerminalStateSchema>,
|
|
||||||
): Uint8Array {
|
|
||||||
return encodeJsonPayload(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeTerminalSnapshotPayload(
|
|
||||||
bytes: Uint8Array,
|
|
||||||
): z.infer<typeof TerminalStateSchema> | null {
|
|
||||||
const parsed = decodeJsonPayload(bytes);
|
|
||||||
const result = TerminalStateSchema.safeParse(parsed);
|
|
||||||
return result.success ? result.data : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeTerminalResizePayload(
|
|
||||||
input: z.infer<typeof TerminalStreamResizeSchema>,
|
|
||||||
): Uint8Array {
|
|
||||||
return encodeJsonPayload(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeTerminalResizePayload(
|
|
||||||
bytes: Uint8Array,
|
|
||||||
): z.infer<typeof TerminalStreamResizeSchema> | null {
|
|
||||||
const parsed = decodeJsonPayload(bytes);
|
|
||||||
const result = TerminalStreamResizeSchema.safeParse(parsed);
|
|
||||||
return result.success ? result.data : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeJsonPayload(value: unknown): Uint8Array {
|
|
||||||
return new TextEncoder().encode(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
|
||||||
try {
|
|
||||||
const text = new TextDecoder().decode(bytes);
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
encodeTerminalSnapshotPayload,
|
encodeTerminalSnapshotPayload,
|
||||||
encodeTerminalStreamFrame,
|
encodeTerminalStreamFrame,
|
||||||
type TerminalStreamFrame,
|
type TerminalStreamFrame,
|
||||||
} from "../shared/terminal-stream-protocol.js";
|
} from "../shared/binary-frames/index.js";
|
||||||
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
|
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
|
||||||
import type { TerminalSession } from "./terminal.js";
|
import type { TerminalSession } from "./terminal.js";
|
||||||
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
|
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user