diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts index a1d79f6a9c..ecf1d6aac4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts @@ -21,7 +21,7 @@ isSymlink: boolean, /** * File size in bytes when available. */ -sizeBytes?: number, +sizeBytes: number | null, /** * File creation time in Unix milliseconds when available, otherwise `0`. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/fs.rs b/codex-rs/app-server-protocol/src/protocol/v2/fs.rs index 7726d3debd..082c8b3509 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/fs.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/fs.rs @@ -12,11 +12,9 @@ pub struct FsReadFileParams { /// Absolute path to read. pub path: AbsolutePathBuf, /// Optional byte offset for a bounded read. - #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable, type = "number")] pub offset: Option, /// Optional maximum byte length for a bounded read. - #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable, type = "number")] pub length: Option, } @@ -86,8 +84,7 @@ pub struct FsGetMetadataResponse { /// Whether the path itself is a symbolic link. pub is_symlink: bool, /// File size in bytes when available. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable, type = "number")] + #[ts(type = "number | null")] pub size_bytes: Option, /// File creation time in Unix milliseconds when available, otherwise `0`. #[ts(type = "number")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index db19b9ccc2..29ab6d9c13 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -717,6 +717,41 @@ fn fs_get_metadata_response_round_trips_minimal_fields() { assert_eq!(decoded, response); } +#[test] +fn fs_get_metadata_response_serializes_unavailable_size_as_null() { + let response = FsGetMetadataResponse { + is_directory: false, + is_file: true, + is_symlink: false, + size_bytes: None, + created_at_ms: 123, + modified_at_ms: 456, + }; + + let value = serde_json::to_value(&response).expect("serialize fs/getMetadata response"); + assert_eq!( + value, + json!({ + "isDirectory": false, + "isFile": true, + "isSymlink": false, + "sizeBytes": null, + "createdAtMs": 123, + "modifiedAtMs": 456, + }) + ); + + let decoded = serde_json::from_value::(json!({ + "isDirectory": false, + "isFile": true, + "isSymlink": false, + "createdAtMs": 123, + "modifiedAtMs": 456, + })) + .expect("deserialize fs/getMetadata response without sizeBytes"); + assert_eq!(decoded, response); +} + #[test] fn fs_read_file_response_round_trips_base64_data() { let response = FsReadFileResponse { @@ -759,6 +794,31 @@ fn fs_read_file_params_round_trip() { assert_eq!(decoded, params); } +#[test] +fn fs_read_file_params_serializes_unbounded_read_with_null_range() { + let params = FsReadFileParams { + path: absolute_path("tmp/example.txt"), + offset: None, + length: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example.txt"), + "offset": null, + "length": null, + }) + ); + + let decoded = serde_json::from_value::(json!({ + "path": absolute_path_string("tmp/example.txt"), + })) + .expect("deserialize fs/readFile params without range"); + assert_eq!(decoded, params); +} + #[test] fn fs_create_directory_params_round_trip_with_default_recursive() { let params = FsCreateDirectoryParams { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 88edc22f9e..258108bb1c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -178,10 +178,10 @@ Example with notification opt-out: - `process/kill` — experimental; terminate a running `process/spawn` session by `processHandle`; returns `{}`. - `process/outputDelta` — experimental; notification emitted for base64-encoded stdout/stderr chunks from a streaming `process/spawn` session. - `process/exited` — experimental; notification emitted when a `process/spawn` session exits. -- `fs/readFile` — read an absolute file path and return `{ dataBase64 }`. +- `fs/readFile` — read an absolute file path and return `{ dataBase64 }`; provide both `offset` and `length` for a bounded byte range. - `fs/writeFile` — write an absolute file path from base64-encoded `{ dataBase64 }`; returns `{}`. - `fs/createDirectory` — create an absolute directory path; `recursive` defaults to `true`. -- `fs/getMetadata` — return metadata for an absolute path: `isDirectory`, `isFile`, `isSymlink`, `createdAtMs`, and `modifiedAtMs`. +- `fs/getMetadata` — return metadata for an absolute path: `isDirectory`, `isFile`, `isSymlink`, `sizeBytes`, `createdAtMs`, and `modifiedAtMs`. - `fs/readDirectory` — list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path. - `fs/remove` — remove an absolute file or directory tree; `recursive` and `force` default to `true`. - `fs/copy` — copy between absolute paths; directory copies require `recursive: true`. @@ -1116,6 +1116,7 @@ All filesystem paths in this section must be absolute. "isDirectory": false, "isFile": true, "isSymlink": false, + "sizeBytes": 5, "createdAtMs": 1730910000000, "modifiedAtMs": 1730910000000 } } @@ -1125,12 +1126,20 @@ All filesystem paths in this section must be absolute. { "id": 43, "result": { "dataBase64": "aGVsbG8=" } } +{ "method": "fs/readFile", "id": 44, "params": { + "path": "/tmp/example/nested/note.txt", + "offset": 1, + "length": 3 +} } +{ "id": 44, "result": { + "dataBase64": "ZWxs" +} } ``` -- `fs/getMetadata` returns whether the path resolves to a directory or regular file, whether the path itself is a symlink, plus `createdAtMs` and `modifiedAtMs` in Unix milliseconds. If a timestamp is unavailable on the current platform, that field is `0`. +- `fs/getMetadata` returns whether the path resolves to a directory or regular file, whether the path itself is a symlink, `sizeBytes` when the current platform can report it, plus `createdAtMs` and `modifiedAtMs` in Unix milliseconds. If a size is unavailable, `sizeBytes` is `null`; if a timestamp is unavailable, that timestamp field is `0`. - `fs/createDirectory` defaults `recursive` to `true` when omitted. - `fs/remove` defaults both `recursive` and `force` to `true` when omitted. -- `fs/readFile` always returns base64 bytes via `dataBase64`, and `fs/writeFile` always expects base64 bytes in `dataBase64`. +- `fs/readFile` always returns base64 bytes via `dataBase64`. Omit both `offset` and `length` to read the whole file, or provide both to read at most `length` bytes starting at `offset`; providing only one is invalid. `fs/writeFile` always expects base64 bytes in `dataBase64`. - `fs/copy` handles both file copies and directory-tree copies; it requires `recursive: true` when `sourcePath` is a directory. Recursive copies traverse regular files, directories, and symlinks; other entry types are skipped. ### Example: Filesystem watch @@ -1138,11 +1147,11 @@ All filesystem paths in this section must be absolute. `fs/watch` accepts absolute file or directory paths. Watching a file emits `fs/changed` for that file path, including updates delivered via replace or rename operations. ```json -{ "method": "fs/watch", "id": 44, "params": { +{ "method": "fs/watch", "id": 45, "params": { "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", "path": "/Users/me/project/.git/HEAD" } } -{ "id": 44, "result": { +{ "id": 45, "result": { "path": "/Users/me/project/.git/HEAD" } } { "method": "fs/changed", "params": {