From eaf78e43f2e95b978622b97c1f656236c7cd8927 Mon Sep 17 00:00:00 2001 From: David de Regt Date: Fri, 17 Apr 2026 11:49:02 -0700 Subject: [PATCH] Add sorting/backwardsCursor to thread/list and new thread/turns/list api (#17305) To improve performance of UI loads from the app, add two main improvements: 1. The `thread/list` api now gets a `sortDirection` request field and a `backwardsCursor` to the response, which lets you paginate forwards and backwards from a window. This lets you fetch the first few items to display immediately while you paginate to fill in history, then can paginate "backwards" on future loads to catch up with any changes since the last UI load without a full reload of the entire data set. 2. Added a new `thread/turns/list` api which also has sortDirection and backwardsCursor for the same behavior as `thread/list`, allowing you the same small-fetch for immediate display followed by background fill-in and resync catchup. --- .../schema/json/ClientRequest.json | 80 + .../codex_app_server_protocol.schemas.json | 119 ++ .../codex_app_server_protocol.v2.schemas.json | 119 ++ .../schema/json/v2/ThreadListParams.json | 18 + .../schema/json/v2/ThreadListResponse.json | 7 + .../schema/json/v2/ThreadTurnsListParams.json | 49 + .../json/v2/ThreadTurnsListResponse.json | 1631 +++++++++++++++++ .../schema/typescript/ClientRequest.ts | 3 +- .../schema/typescript/v2/SortDirection.ts | 5 + .../schema/typescript/v2/ThreadListParams.ts | 5 + .../typescript/v2/ThreadListResponse.ts | 9 +- .../typescript/v2/ThreadTurnsListParams.ts | 18 + .../typescript/v2/ThreadTurnsListResponse.ts | 18 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 4 + .../src/protocol/thread_history.rs | 13 +- .../app-server-protocol/src/protocol/v2.rs | 47 + codex-rs/app-server-test-client/src/lib.rs | 1 + codex-rs/app-server/README.md | 25 +- .../app-server/src/codex_message_processor.rs | 429 ++++- .../app-server/tests/common/mcp_process.rs | 10 + .../app-server/tests/suite/v2/thread_fork.rs | 1 + .../app-server/tests/suite/v2/thread_list.rs | 113 ++ .../tests/suite/v2/thread_metadata_update.rs | 4 +- .../app-server/tests/suite/v2/thread_read.rs | 219 ++- .../tests/suite/v2/thread_resume.rs | 1 + .../tests/suite/v2/thread_shell_command.rs | 4 +- .../tests/suite/v2/thread_unsubscribe.rs | 2 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/personality_migration.rs | 1 + codex-rs/core/src/realtime_context.rs | 2 + codex-rs/core/src/rollout.rs | 1 + codex-rs/debug-client/src/client.rs | 1 + codex-rs/exec/src/lib.rs | 2 + codex-rs/rollout/src/lib.rs | 1 + codex-rs/rollout/src/list.rs | 52 +- codex-rs/rollout/src/recorder.rs | 312 +++- codex-rs/rollout/src/recorder_tests.rs | 4 + codex-rs/rollout/src/state_db.rs | 45 +- codex-rs/rollout/src/state_db_tests.rs | 6 +- codex-rs/rollout/src/tests.rs | 39 +- codex-rs/state/src/lib.rs | 2 + codex-rs/state/src/model/mod.rs | 1 + codex-rs/state/src/model/thread_metadata.rs | 13 +- codex-rs/state/src/runtime.rs | 1 + codex-rs/state/src/runtime/memories.rs | 26 +- codex-rs/state/src/runtime/threads.rs | 196 +- codex-rs/thread-store/src/lib.rs | 1 + .../thread-store/src/local/archive_thread.rs | 1 + .../thread-store/src/local/list_threads.rs | 23 +- .../thread-store/src/remote/list_threads.rs | 1 + codex-rs/thread-store/src/types.rs | 12 + codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/resume_picker.rs | 26 +- 54 files changed, 3510 insertions(+), 219 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SortDirection.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadTurnsListParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadTurnsListResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 113d82c94..ac9f480a8 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2645,6 +2645,13 @@ }, "type": "object" }, + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -2857,6 +2864,17 @@ "null" ] }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional sort direction; defaults to descending (newest first)." + }, "sortKey": { "anyOf": [ { @@ -3361,6 +3379,44 @@ ], "type": "string" }, + "ThreadTurnsListParams": { + "properties": { + "cursor": { + "description": "Opaque cursor to pass to the next call to continue after the last turn.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, "ThreadUnarchiveParams": { "properties": { "threadId": { @@ -4052,6 +4108,30 @@ "title": "Thread/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/turns/list" + ], + "title": "Thread/turns/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadTurnsListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/turns/listRequest", + "type": "object" + }, { "description": "Append raw Responses API items to the thread history without starting a user turn.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a532b66b2..7b7c29ac5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -578,6 +578,30 @@ "title": "Thread/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/turns/list" + ], + "title": "Thread/turns/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadTurnsListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/turns/listRequest", + "type": "object" + }, { "description": "Append raw Responses API items to the thread history without starting a user turn.", "properties": { @@ -12559,6 +12583,13 @@ "title": "SkillsListResponse", "type": "object" }, + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -13820,6 +13851,17 @@ "null" ] }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional sort direction; defaults to descending (newest first)." + }, "sortKey": { "anyOf": [ { @@ -13848,6 +13890,13 @@ "ThreadListResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "backwardsCursor": { + "description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one thread. Use it with the opposite `sortDirection`; for timestamp sorts it anchors at the start of the page timestamp so same-second updates are not skipped.", + "type": [ + "string", + "null" + ] + }, "data": { "items": { "$ref": "#/definitions/v2/Thread" @@ -14909,6 +14958,76 @@ "title": "ThreadTokenUsageUpdatedNotification", "type": "object" }, + "ThreadTurnsListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque cursor to pass to the next call to continue after the last turn.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadTurnsListParams", + "type": "object" + }, + "ThreadTurnsListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "backwardsCursor": { + "description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one turn. Use it with the opposite `sortDirection` to include the anchor turn again and catch updates to that turn.", + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/v2/Turn" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last turn. if None, there are no more turns to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadTurnsListResponse", + "type": "object" + }, "ThreadUnarchiveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 27a9779f1..c1f6dc628 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1160,6 +1160,30 @@ "title": "Thread/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/turns/list" + ], + "title": "Thread/turns/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadTurnsListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/turns/listRequest", + "type": "object" + }, { "description": "Append raw Responses API items to the thread history without starting a user turn.", "properties": { @@ -10403,6 +10427,13 @@ "title": "SkillsListResponse", "type": "object" }, + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -11664,6 +11695,17 @@ "null" ] }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional sort direction; defaults to descending (newest first)." + }, "sortKey": { "anyOf": [ { @@ -11692,6 +11734,13 @@ "ThreadListResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "backwardsCursor": { + "description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one thread. Use it with the opposite `sortDirection`; for timestamp sorts it anchors at the start of the page timestamp so same-second updates are not skipped.", + "type": [ + "string", + "null" + ] + }, "data": { "items": { "$ref": "#/definitions/Thread" @@ -12753,6 +12802,76 @@ "title": "ThreadTokenUsageUpdatedNotification", "type": "object" }, + "ThreadTurnsListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque cursor to pass to the next call to continue after the last turn.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadTurnsListParams", + "type": "object" + }, + "ThreadTurnsListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "backwardsCursor": { + "description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one turn. Use it with the opposite `sortDirection` to include the anchor turn again and catch updates to that turn.", + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last turn. if None, there are no more turns to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadTurnsListResponse", + "type": "object" + }, "ThreadUnarchiveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index c5cf1364c..62a7dc9c5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -1,6 +1,13 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, "ThreadSortKey": { "enum": [ "created_at", @@ -72,6 +79,17 @@ "null" ] }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional sort direction; defaults to descending (newest first)." + }, "sortKey": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 8ebebd39f..2db329651 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1953,6 +1953,13 @@ } }, "properties": { + "backwardsCursor": { + "description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one thread. Use it with the opposite `sortDirection`; for timestamp sorts it anchors at the start of the page timestamp so same-second updates are not skipped.", + "type": [ + "string", + "null" + ] + }, "data": { "items": { "$ref": "#/definitions/Thread" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListParams.json new file mode 100644 index 000000000..ed58a4546 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListParams.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + "properties": { + "cursor": { + "description": "Opaque cursor to pass to the next call to continue after the last turn.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadTurnsListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json new file mode 100644 index 000000000..2c1dfb14f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -0,0 +1,1631 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "usageLimitExceeded", + "serverOverloaded", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentState": { + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "read" + ], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "name", + "path", + "type" + ], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, + "CommandExecutionStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": [ + "diff", + "kind", + "path" + ], + "type": "object" + }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, + "McpToolCallError": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "McpToolCallResult": { + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "structuredContent": true + }, + "required": [ + "content" + ], + "type": "object" + }, + "McpToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, + "PatchApplyStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "byteRange" + ], + "type": "object" + }, + "ThreadItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "content", + "id", + "type" + ], + "title": "UserMessageThreadItem", + "type": "object" + }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "AgentMessageThreadItem", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "plan" + ], + "title": "PlanThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "PlanThreadItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ReasoningThreadItem", + "type": "object" + }, + { + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": "array" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "exitCode": { + "description": "The command's exit code.", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType", + "type": "string" + } + }, + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "title": "CommandExecutionThreadItem", + "type": "object" + }, + { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType", + "type": "string" + } + }, + "required": [ + "changes", + "id", + "status", + "type" + ], + "title": "FileChangeThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentsStates": { + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + }, + "description": "Last known status of the target agents, when available.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ], + "description": "Current status of the collab tool call." + }, + "tool": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ], + "description": "Name of the collab tool that was invoked." + }, + "type": { + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "title": "CollabAgentToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "type": { + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "query", + "type" + ], + "title": "WebSearchThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "path", + "type" + ], + "title": "ImageViewThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "result", + "status", + "type" + ], + "title": "ImageGenerationThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "EnteredReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "Turn": { + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ], + "description": "Only populated when the Turn's status is failed." + }, + "id": { + "type": "string" + }, + "items": { + "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "items": { + "$ref": "#/definitions/ThreadItem" + }, + "type": "array" + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + }, + "required": [ + "id", + "items", + "status" + ], + "type": "object" + }, + "TurnError": { + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "TurnStatus": { + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ], + "type": "string" + }, + "UserInput": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": [ + "text" + ], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "image" + ], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalImageUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "skill" + ], + "title": "SkillUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "SkillUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "mention" + ], + "title": "MentionUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "MentionUserInput", + "type": "object" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherWebSearchAction", + "type": "object" + } + ] + } + }, + "properties": { + "backwardsCursor": { + "description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one turn. Use it with the opposite `sortDirection` to include the anchor turn again and catch updates to that turn.", + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last turn. if None, there are no more turns to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadTurnsListResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 9d9a82340..d21f95145 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -58,6 +58,7 @@ import type { ThreadRollbackParams } from "./v2/ThreadRollbackParams"; import type { ThreadSetNameParams } from "./v2/ThreadSetNameParams"; import type { ThreadShellCommandParams } from "./v2/ThreadShellCommandParams"; import type { ThreadStartParams } from "./v2/ThreadStartParams"; +import type { ThreadTurnsListParams } from "./v2/ThreadTurnsListParams"; import type { ThreadUnarchiveParams } from "./v2/ThreadUnarchiveParams"; import type { ThreadUnsubscribeParams } from "./v2/ThreadUnsubscribeParams"; import type { TurnInterruptParams } from "./v2/TurnInterruptParams"; @@ -68,4 +69,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SortDirection.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SortDirection.ts new file mode 100644 index 000000000..d8597a46e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SortDirection.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SortDirection = "asc" | "desc"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts index 87857b7f7..a2add1768 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SortDirection } from "./SortDirection"; import type { ThreadSortKey } from "./ThreadSortKey"; import type { ThreadSourceKind } from "./ThreadSourceKind"; @@ -17,6 +18,10 @@ limit?: number | null, * Optional sort key; defaults to created_at. */ sortKey?: ThreadSortKey | null, +/** + * Optional sort direction; defaults to descending (newest first). + */ +sortDirection?: SortDirection | null, /** * Optional provider filter; when set, only sessions recorded under these * providers are returned. When present but empty, includes all providers. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListResponse.ts index 3e4d4f57a..51757e24c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListResponse.ts @@ -8,4 +8,11 @@ export type ThreadListResponse = { data: Array, * Opaque cursor to pass to the next call to continue after the last item. * if None, there are no more items to return. */ -nextCursor: string | null, }; +nextCursor: string | null, +/** + * Opaque cursor to pass as `cursor` when reversing `sortDirection`. + * This is only populated when the page contains at least one thread. + * Use it with the opposite `sortDirection`; for timestamp sorts it anchors + * at the start of the page timestamp so same-second updates are not skipped. + */ +backwardsCursor: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadTurnsListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadTurnsListParams.ts new file mode 100644 index 000000000..2c507bc9c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadTurnsListParams.ts @@ -0,0 +1,18 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SortDirection } from "./SortDirection"; + +export type ThreadTurnsListParams = { threadId: string, +/** + * Opaque cursor to pass to the next call to continue after the last turn. + */ +cursor?: string | null, +/** + * Optional turn page size. + */ +limit?: number | null, +/** + * Optional turn pagination direction; defaults to descending. + */ +sortDirection?: SortDirection | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadTurnsListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadTurnsListResponse.ts new file mode 100644 index 000000000..1dbed91a7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadTurnsListResponse.ts @@ -0,0 +1,18 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Turn } from "./Turn"; + +export type ThreadTurnsListResponse = { data: Array, +/** + * Opaque cursor to pass to the next call to continue after the last turn. + * if None, there are no more turns to return. + */ +nextCursor: string | null, +/** + * Opaque cursor to pass as `cursor` when reversing `sortDirection`. + * This is only populated when the page contains at least one turn. + * Use it with the opposite `sortDirection` to include the anchor turn again + * and catch updates to that turn. + */ +backwardsCursor: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 31371b712..5c53d1ec3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -274,6 +274,7 @@ export type { SkillsListEntry } from "./SkillsListEntry"; export type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd"; export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; +export type { SortDirection } from "./SortDirection"; export type { TerminalInteractionNotification } from "./TerminalInteractionNotification"; export type { TextElement } from "./TextElement"; export type { TextPosition } from "./TextPosition"; @@ -329,6 +330,8 @@ export type { ThreadStatus } from "./ThreadStatus"; export type { ThreadStatusChangedNotification } from "./ThreadStatusChangedNotification"; export type { ThreadTokenUsage } from "./ThreadTokenUsage"; export type { ThreadTokenUsageUpdatedNotification } from "./ThreadTokenUsageUpdatedNotification"; +export type { ThreadTurnsListParams } from "./ThreadTurnsListParams"; +export type { ThreadTurnsListResponse } from "./ThreadTurnsListResponse"; export type { ThreadUnarchiveParams } from "./ThreadUnarchiveParams"; export type { ThreadUnarchiveResponse } from "./ThreadUnarchiveResponse"; export type { ThreadUnarchivedNotification } from "./ThreadUnarchivedNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index db4284324..c29dbccd5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -327,6 +327,10 @@ client_request_definitions! { params: v2::ThreadReadParams, response: v2::ThreadReadResponse, }, + ThreadTurnsList => "thread/turns/list" { + params: v2::ThreadTurnsListParams, + response: v2::ThreadTurnsListResponse, + }, /// Append raw Responses API items to the thread history without starting a user turn. ThreadInjectItems => "thread/inject_items" { params: v2::ThreadInjectItemsParams, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 5c327187e..ef6593d09 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -991,8 +991,15 @@ impl ThreadHistoryBuilder { } fn new_turn(&mut self, id: Option) -> PendingTurn { + let id = id.unwrap_or_else(|| { + if self.next_rollout_index == 0 { + Uuid::now_v7().to_string() + } else { + format!("rollout-{}", self.current_rollout_index) + } + }); PendingTurn { - id: id.unwrap_or_else(|| Uuid::now_v7().to_string()), + id, items: Vec::new(), error: None, status: TurnStatus::Completed, @@ -1640,8 +1647,8 @@ mod tests { .collect::>(); let turns = build_turns_from_rollout_items(&items); assert_eq!(turns.len(), 2); - assert!(Uuid::parse_str(&turns[0].id).is_ok()); - assert!(Uuid::parse_str(&turns[1].id).is_ok()); + assert_eq!(turns[0].id, "rollout-0"); + assert_eq!(turns[1].id, "rollout-5"); assert_ne!(turns[0].id, turns[1].id); assert_eq!(turns[0].status, TurnStatus::Completed); assert_eq!(turns[1].status, TurnStatus::Completed); diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f10cf7d59..b89e48106 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3230,6 +3230,9 @@ pub struct ThreadListParams { /// Optional sort key; defaults to created_at. #[ts(optional = nullable)] pub sort_key: Option, + /// Optional sort direction; defaults to descending (newest first). + #[ts(optional = nullable)] + pub sort_direction: Option, /// Optional provider filter; when set, only sessions recorded under these /// providers are returned. When present but empty, includes all providers. #[ts(optional = nullable)] @@ -3277,6 +3280,14 @@ pub enum ThreadSortKey { UpdatedAt, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SortDirection { + Asc, + Desc, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3285,6 +3296,11 @@ pub struct ThreadListResponse { /// Opaque cursor to pass to the next call to continue after the last item. /// if None, there are no more items to return. pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one thread. + /// Use it with the opposite `sortDirection`; for timestamp sorts it anchors + /// at the start of the page timestamp so same-second updates are not skipped. + pub backwards_cursor: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] @@ -3350,6 +3366,37 @@ pub struct ThreadReadResponse { pub thread: Thread, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsListParams { + pub thread_id: String, + /// Opaque cursor to pass to the next call to continue after the last turn. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional turn page size. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional turn pagination direction; defaults to descending. + #[ts(optional = nullable)] + pub sort_direction: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last turn. + /// if None, there are no more turns to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one turn. + /// Use it with the opposite `sortDirection` to include the anchor turn again + /// and catch updates to that turn. + pub backwards_cursor: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index dd548012a..6f6e0dfc8 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1124,6 +1124,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3 cursor: None, limit: Some(limit), sort_key: None, + sort_direction: None, model_providers: None, source_kinds: None, archived: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8c1be6083..88156f553 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -142,6 +142,7 @@ Example with notification opt-out: - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. +- `thread/turns/list` — page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `nextCursor`, and `backwardsCursor`. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. @@ -281,11 +282,13 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per - `cursor` — opaque string from a prior response; omit for the first page. - `limit` — server defaults to a reasonable page size if unset. - `sortKey` — `created_at` (default) or `updated_at`. +- `sortDirection` — `desc` (default) or `asc`. - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). - `cwd` — restrict results to threads whose session cwd exactly matches this path. Relative paths are resolved against the app-server process cwd before matching. - `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive). +- Responses include `nextCursor` to continue in the same direction and `backwardsCursor` to pass as `cursor` when reversing `sortDirection`. - Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. Example: @@ -301,7 +304,8 @@ Example: { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } } ], - "nextCursor": "opaque-token-or-null" + "nextCursor": "opaque-token-or-null", + "backwardsCursor": "opaque-token-or-null" } } ``` @@ -363,7 +367,7 @@ Later, after the idle unload timeout: ### Example: Read a thread -Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. +Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the full rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. ```json { "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } } @@ -379,6 +383,23 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl } } ``` +### Example: List thread turns + +Use `thread/turns/list` to page a stored thread’s turn history without resuming it. By default, results are sorted descending so clients can start at the present and fetch older turns with `nextCursor`. The response also includes `backwardsCursor`; pass it as `cursor` on a later request with `sortDirection: "asc"` to fetch turns newer than the first item from the earlier page. + +```json +{ "method": "thread/turns/list", "id": 24, "params": { + "threadId": "thr_123", + "limit": 50, + "sortDirection": "desc" +} } +{ "id": 24, "result": { + "data": [ ... ], + "nextCursor": "older-turns-cursor-or-null", + "backwardsCursor": "newer-turns-cursor-or-null" +} } +``` + ### Example: Update stored thread metadata Use `thread/metadata/update` to patch sqlite-backed metadata for a thread without resuming it. Today this supports persisted `gitInfo`; omitted fields are left unchanged, while explicit `null` clears a stored value. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 04a066e88..35249be45 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -19,6 +19,7 @@ use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::thread_status::ThreadWatchManager; use crate::thread_status::resolve_thread_status; use chrono::DateTime; +use chrono::Duration as ChronoDuration; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; @@ -123,6 +124,7 @@ use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; @@ -177,6 +179,8 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; use codex_app_server_protocol::ThreadUnarchiveParams; use codex_app_server_protocol::ThreadUnarchiveResponse; use codex_app_server_protocol::ThreadUnarchivedNotification; @@ -325,6 +329,7 @@ use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams; use codex_thread_store::ListThreadsParams as StoreListThreadsParams; use codex_thread_store::LocalThreadStore; use codex_thread_store::ReadThreadParams as StoreReadThreadParams; +use codex_thread_store::SortDirection as StoreSortDirection; use codex_thread_store::StoredThread; use codex_thread_store::ThreadSortKey as StoreThreadSortKey; use codex_thread_store::ThreadStore; @@ -377,6 +382,8 @@ use token_usage_replay::send_thread_token_usage_update_to_connection; const THREAD_LIST_DEFAULT_LIMIT: usize = 25; const THREAD_LIST_MAX_LIMIT: usize = 100; +const THREAD_TURNS_DEFAULT_LIMIT: usize = 25; +const THREAD_TURNS_MAX_LIMIT: usize = 100; struct ThreadListFilters { model_providers: Option>, @@ -951,6 +958,10 @@ impl CodexMessageProcessor { self.thread_read(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadTurnsList { request_id, params } => { + self.thread_turns_list(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ThreadShellCommand { request_id, params } => { self.thread_shell_command(to_connection_request_id(request_id), params) .await; @@ -3641,6 +3652,7 @@ impl CodexMessageProcessor { cursor, limit, sort_key, + sort_direction, model_providers, source_kinds, archived, @@ -3663,11 +3675,13 @@ impl CodexMessageProcessor { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, }; - let (summaries, next_cursor) = match self + let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); + let list_result = self .list_threads_common( requested_page_size, cursor, store_sort_key, + sort_direction, ThreadListFilters { model_providers, source_kinds, @@ -3676,14 +3690,17 @@ impl CodexMessageProcessor { search_term, }, ) - .await - { + .await; + let (summaries, next_cursor) = match list_result { Ok(r) => r, Err(error) => { self.outgoing.send_error(request_id, error).await; return; } }; + let backwards_cursor = summaries.first().and_then(|summary| { + thread_backwards_cursor_for_sort_key(summary, store_sort_key, sort_direction) + }); let mut threads = Vec::with_capacity(summaries.len()); let mut thread_ids = HashSet::with_capacity(summaries.len()); let mut status_ids = Vec::with_capacity(summaries.len()); @@ -3704,7 +3721,7 @@ impl CodexMessageProcessor { .loaded_statuses_for_threads(status_ids) .await; - let data = threads + let data: Vec<_> = threads .into_iter() .map(|(conversation_id, mut thread)| { if let Some(title) = names.get(&conversation_id).cloned() { @@ -3716,7 +3733,11 @@ impl CodexMessageProcessor { thread }) .collect(); - let response = ThreadListResponse { data, next_cursor }; + let response = ThreadListResponse { + data, + next_cursor, + backwards_cursor, + }; self.outgoing.send_response(request_id, response).await; } @@ -3832,7 +3853,7 @@ impl CodexMessageProcessor { ))); }; - let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread { + let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread.as_ref() { matches!(loaded_thread.agent_status().await, AgentStatus::Running) } else { false @@ -3959,6 +3980,157 @@ impl CodexMessageProcessor { Ok(()) } + async fn thread_turns_list( + &self, + request_id: ConnectionRequestId, + params: ThreadTurnsListParams, + ) { + let ThreadTurnsListParams { + thread_id, + cursor, + limit, + sort_direction, + } = params; + + let thread_uuid = match ThreadId::from_string(&thread_id) { + Ok(id) => id, + Err(err) => { + self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) + .await; + return; + } + }; + + let state_db_ctx = get_state_db(&self.config).await; + let mut rollout_path = self + .resolve_rollout_path(thread_uuid, state_db_ctx.as_ref()) + .await; + if rollout_path.is_none() { + rollout_path = + match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string()) + .await + { + Ok(Some(path)) => Some(path), + Ok(None) => match find_archived_thread_path_by_id_str( + &self.config.codex_home, + &thread_uuid.to_string(), + ) + .await + { + Ok(path) => path, + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate archived thread id {thread_uuid}: {err}"), + ) + .await; + return; + } + }, + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate thread id {thread_uuid}: {err}"), + ) + .await; + return; + } + }; + } + + if rollout_path.is_none() { + match self.thread_manager.get_thread(thread_uuid).await { + Ok(thread) => { + rollout_path = thread.rollout_path(); + if rollout_path.is_none() { + self.send_invalid_request_error( + request_id, + "ephemeral threads do not support thread/turns/list".to_string(), + ) + .await; + return; + } + } + Err(_) => { + self.send_invalid_request_error( + request_id, + format!("thread not loaded: {thread_uuid}"), + ) + .await; + return; + } + } + } + + let Some(rollout_path) = rollout_path.as_ref() else { + self.send_internal_error( + request_id, + format!("failed to locate rollout for thread {thread_uuid}"), + ) + .await; + return; + }; + + match read_rollout_items_from_rollout(rollout_path).await { + Ok(items) => { + // This API optimizes network transfer by letting clients page through a + // thread's turns incrementally, but it still replays the entire rollout on + // every request. Rollback and compaction events can change earlier turns, so + // the server has to rebuild the full turn list until turn metadata is indexed + // separately. + let has_live_in_progress_turn = + match self.thread_manager.get_thread(thread_uuid).await { + Ok(thread) => matches!(thread.agent_status().await, AgentStatus::Running), + Err(_) => false, + }; + let turns = reconstruct_thread_turns_from_rollout_items( + &items, + self.thread_watch_manager + .loaded_status_for_thread(&thread_uuid.to_string()) + .await, + has_live_in_progress_turn, + ); + let page = match paginate_thread_turns( + turns, + cursor.as_deref(), + limit, + sort_direction.unwrap_or(SortDirection::Desc), + ) { + Ok(page) => page, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let response = ThreadTurnsListResponse { + data: page.turns, + next_cursor: page.next_cursor, + backwards_cursor: page.backwards_cursor, + }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + self.send_invalid_request_error( + request_id, + format!( + "thread {thread_uuid} is not materialized yet; thread/turns/list is unavailable before first user message" + ), + ) + .await; + } + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load rollout `{}` for thread {thread_uuid}: {err}", + rollout_path.display() + ), + ) + .await; + } + } + } + pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { self.thread_manager.subscribe_thread_created() } @@ -4980,6 +5152,7 @@ impl CodexMessageProcessor { requested_page_size: usize, cursor: Option, sort_key: StoreThreadSortKey, + sort_direction: SortDirection, filters: ThreadListFilters, ) -> Result<(Vec, Option), JSONRPCErrorError> { let ThreadListFilters { @@ -5008,6 +5181,10 @@ impl CodexMessageProcessor { let fallback_provider = self.config.model_provider_id.clone(); let (allowed_sources_vec, source_kind_filter) = compute_source_filters(source_kinds); let allowed_sources = allowed_sources_vec.as_slice(); + let store_sort_direction = match sort_direction { + SortDirection::Asc => StoreSortDirection::Asc, + SortDirection::Desc => StoreSortDirection::Desc, + }; while remaining > 0 { let page_size = remaining.min(THREAD_LIST_MAX_LIMIT); @@ -5017,6 +5194,7 @@ impl CodexMessageProcessor { page_size, cursor: cursor_obj.clone(), sort_key, + sort_direction: store_sort_direction, allowed_sources: allowed_sources.to_vec(), model_providers: model_provider_filter.clone(), archived, @@ -5025,12 +5203,17 @@ impl CodexMessageProcessor { .await .map_err(thread_store_list_error)?; - let mut filtered = Vec::with_capacity(page.items.len()); + let mut candidate_summaries = Vec::with_capacity(page.items.len()); for it in page.items { let Some(summary) = summary_from_stored_thread(it, fallback_provider.as_str()) else { continue; }; + candidate_summaries.push(summary); + } + + let mut filtered = Vec::with_capacity(candidate_summaries.len()); + for summary in candidate_summaries { if source_kind_filter .as_ref() .is_none_or(|filter| source_kind_matches(&summary.source, filter)) @@ -9339,8 +9522,18 @@ fn summary_from_stored_thread( conversation_id: thread.thread_id, path, preview: thread.first_user_message.unwrap_or(thread.preview), - timestamp: Some(thread.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), - updated_at: Some(thread.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true)), + // Preserve millisecond precision from the thread store so thread/list cursors + // round-trip the same ordering key used by pagination queries. + timestamp: Some( + thread + .created_at + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + updated_at: Some( + thread + .updated_at + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), model_provider: if thread.model_provider.is_empty() { fallback_provider.to_string() } else { @@ -9764,17 +9957,188 @@ pub(crate) fn summary_to_thread( } } +fn thread_backwards_cursor_for_sort_key( + summary: &ConversationSummary, + sort_key: StoreThreadSortKey, + sort_direction: SortDirection, +) -> Option { + let timestamp = match sort_key { + StoreThreadSortKey::CreatedAt => summary.timestamp.as_deref(), + StoreThreadSortKey::UpdatedAt => summary + .updated_at + .as_deref() + .or(summary.timestamp.as_deref()), + }; + let timestamp = parse_datetime(timestamp)?; + // The state DB stores unique millisecond timestamps. Offset the reverse cursor by one + // millisecond so the opposite-direction query includes the page anchor. + let timestamp = match sort_direction { + SortDirection::Asc => timestamp.checked_add_signed(ChronoDuration::milliseconds(1))?, + SortDirection::Desc => timestamp.checked_sub_signed(ChronoDuration::milliseconds(1))?, + }; + Some(timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)) +} + +struct ThreadTurnsPage { + turns: Vec, + next_cursor: Option, + backwards_cursor: Option, +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ThreadTurnsCursor { + turn_id: String, + include_anchor: bool, +} + +fn paginate_thread_turns( + turns: Vec, + cursor: Option<&str>, + limit: Option, + sort_direction: SortDirection, +) -> Result { + if turns.is_empty() { + return Ok(ThreadTurnsPage { + turns: Vec::new(), + next_cursor: None, + backwards_cursor: None, + }); + } + + let anchor = cursor.map(parse_thread_turns_cursor).transpose()?; + let page_size = limit + .map(|value| value as usize) + .unwrap_or(THREAD_TURNS_DEFAULT_LIMIT) + .clamp(1, THREAD_TURNS_MAX_LIMIT); + + let anchor_index = anchor + .as_ref() + .and_then(|anchor| turns.iter().position(|turn| turn.id == anchor.turn_id)); + if anchor.is_some() && anchor_index.is_none() { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "invalid cursor: anchor turn is no longer present".to_string(), + data: None, + }); + } + + let mut keyed_turns: Vec<_> = turns.into_iter().enumerate().collect(); + match sort_direction { + SortDirection::Asc => { + if let (Some(anchor), Some(anchor_index)) = (anchor.as_ref(), anchor_index) { + keyed_turns.retain(|(index, _)| { + if anchor.include_anchor { + *index >= anchor_index + } else { + *index > anchor_index + } + }); + } + } + SortDirection::Desc => { + keyed_turns.reverse(); + if let (Some(anchor), Some(anchor_index)) = (anchor.as_ref(), anchor_index) { + keyed_turns.retain(|(index, _)| { + if anchor.include_anchor { + *index <= anchor_index + } else { + *index < anchor_index + } + }); + } + } + } + + let more_turns_available = keyed_turns.len() > page_size; + keyed_turns.truncate(page_size); + let backwards_cursor = keyed_turns + .first() + .map(|(_, turn)| serialize_thread_turns_cursor(&turn.id, /*include_anchor*/ true)) + .transpose()?; + let next_cursor = if more_turns_available { + keyed_turns + .last() + .map(|(_, turn)| serialize_thread_turns_cursor(&turn.id, /*include_anchor*/ false)) + .transpose()? + } else { + None + }; + let turns = keyed_turns.into_iter().map(|(_, turn)| turn).collect(); + + Ok(ThreadTurnsPage { + turns, + next_cursor, + backwards_cursor, + }) +} + +fn serialize_thread_turns_cursor( + turn_id: &str, + include_anchor: bool, +) -> Result { + serde_json::to_string(&ThreadTurnsCursor { + turn_id: turn_id.to_string(), + include_anchor, + }) + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to serialize cursor: {err}"), + data: None, + }) +} + +fn parse_thread_turns_cursor(cursor: &str) -> Result { + serde_json::from_str(cursor).map_err(|_| JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid cursor: {cursor}"), + data: None, + }) +} + +fn reconstruct_thread_turns_from_rollout_items( + items: &[RolloutItem], + loaded_status: ThreadStatus, + has_live_in_progress_turn: bool, +) -> Vec { + let mut turns = build_turns_from_rollout_items(items); + normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn); + turns +} + +fn normalize_thread_turns_status( + turns: &mut [Turn], + loaded_status: ThreadStatus, + has_live_in_progress_turn: bool, +) { + let status = resolve_thread_status(loaded_status, has_live_in_progress_turn); + if matches!(status, ThreadStatus::Active { .. }) { + return; + } + for turn in turns { + if matches!(turn.status, TurnStatus::InProgress) { + turn.status = TurnStatus::Interrupted; + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingMessage; use anyhow::Result; + use chrono::DateTime; + use chrono::Utc; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; + use codex_thread_store::StoredThread; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -9824,6 +10188,53 @@ mod tests { validate_dynamic_tools(&tools).expect("valid schema"); } + #[test] + fn summary_from_stored_thread_preserves_millisecond_precision() { + let created_at = + DateTime::parse_from_rfc3339("2025-01-02T03:04:05.678Z").expect("valid timestamp"); + let updated_at = + DateTime::parse_from_rfc3339("2025-01-02T03:04:06.789Z").expect("valid timestamp"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread"); + let stored_thread = StoredThread { + thread_id, + rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")), + forked_from_id: None, + preview: "preview".to_string(), + name: None, + model_provider: "openai".to_string(), + model: None, + reasoning_effort: None, + created_at: created_at.with_timezone(&Utc), + updated_at: updated_at.with_timezone(&Utc), + archived_at: None, + cwd: PathBuf::from("/tmp"), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, + agent_path: None, + git_info: None, + approval_mode: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + token_usage: None, + first_user_message: Some("first user message".to_string()), + history: None, + }; + + let summary = + summary_from_stored_thread(stored_thread, "fallback").expect("summary should exist"); + + assert_eq!( + summary.timestamp.as_deref(), + Some("2025-01-02T03:04:05.678Z") + ); + assert_eq!( + summary.updated_at.as_deref(), + Some("2025-01-02T03:04:06.789Z") + ); + } + #[test] fn config_load_error_marks_cloud_requirements_failures_for_relogin() { let err = std::io::Error::other(CloudRequirementsLoadError::new( diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 22225c7c9..27bacbcd2 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -79,6 +79,7 @@ use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadTurnsListParams; use codex_app_server_protocol::ThreadUnarchiveParams; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::TurnCompletedNotification; @@ -464,6 +465,15 @@ impl McpProcess { self.send_request("thread/read", params).await } + /// Send a `thread/turns/list` JSON-RPC request. + pub async fn send_thread_turns_list_request( + &mut self, + params: ThreadTurnsListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/turns/list", params).await + } + /// Send a `model/list` JSON-RPC request. pub async fn send_list_models_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index f5bc5a73c..89980c7c6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -543,6 +543,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() cursor: None, limit: Some(10), sort_key: None, + sort_direction: None, model_providers: None, source_kinds: None, archived: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 8fd4e4730..247bbb412 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; @@ -84,6 +85,7 @@ async fn list_threads_with_sort( cursor, limit, sort_key, + sort_direction: None, model_providers: providers, source_kinds, archived, @@ -357,6 +359,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { let ThreadListResponse { data: data1, next_cursor: cursor1, + .. } = list_threads( &mut mcp, /*cursor*/ None, @@ -384,6 +387,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { let ThreadListResponse { data: data2, next_cursor: cursor2, + .. } = list_threads( &mut mcp, Some(cursor1), @@ -498,6 +502,7 @@ async fn thread_list_respects_cwd_filter() -> Result<()> { cursor: None, limit: Some(10), sort_key: None, + sort_direction: None, model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, @@ -584,6 +589,7 @@ sqlite = true /*page_size*/ 10, /*cursor*/ None, codex_core::ThreadSortKey::CreatedAt, + codex_core::SortDirection::Desc, &[], /*model_providers*/ None, "mock_provider", @@ -598,6 +604,7 @@ sqlite = true cursor: None, limit: Some(10), sort_key: None, + sort_direction: None, model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, @@ -1252,6 +1259,111 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_old = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_watermark = create_fake_rollout( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_old).as_path(), + "2025-02-02T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_watermark).as_path(), + "2025-02-03T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { + data: page1, + backwards_cursor, + .. + } = { + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(1), + sort_key: Some(ThreadSortKey::UpdatedAt), + sort_direction: Some(SortDirection::Desc), + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response::(resp)? + }; + let ids_page1: Vec<_> = page1.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids_page1, vec![id_watermark.as_str()]); + let backwards_cursor = backwards_cursor.expect("expected backwardsCursor on first page"); + assert_eq!(backwards_cursor, "2025-02-02T23:59:59.999Z"); + + let id_new = create_fake_rollout( + codex_home.path(), + "2025-02-01T12-00-00", + "2025-02-01T12:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T12-00-00", &id_new).as_path(), + "2025-02-04T00:00:00Z", + )?; + + let ThreadListResponse { + data: delta_page, .. + } = { + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: Some(backwards_cursor), + limit: Some(10), + sort_key: Some(ThreadSortKey::UpdatedAt), + sort_direction: Some(SortDirection::Asc), + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response::(resp)? + }; + let ids_delta: Vec<_> = delta_page.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids_delta, vec![id_watermark.as_str(), id_new.as_str()]); + + Ok(()) +} + #[tokio::test] async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> { let codex_home = TempDir::new()?; @@ -1468,6 +1580,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> { cursor: Some("not-a-cursor".to_string()), limit: Some(2), sort_key: None, + sort_direction: None, model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index 8bf9a8a9a..d06b22edc 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -109,7 +109,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; - let ThreadReadResponse { thread: read } = to_response::(read_resp)?; + let ThreadReadResponse { thread: read, .. } = to_response::(read_resp)?; assert_eq!( read.git_info, @@ -421,7 +421,7 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; - let ThreadReadResponse { thread: read } = to_response::(read_resp)?; + let ThreadReadResponse { thread: read, .. } = to_response::(read_resp)?; assert_eq!(read.git_info, None); diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 37fd07d56..7cee2f099 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -9,6 +9,7 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadItem; @@ -24,6 +25,8 @@ use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; @@ -34,6 +37,8 @@ use codex_protocol::user_input::TextElement; use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; +use std::io::Write; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -81,7 +86,7 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; - let ThreadReadResponse { thread } = to_response::(read_resp)?; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.id, conversation_id); assert_eq!(thread.preview, preview); @@ -136,7 +141,7 @@ async fn thread_read_can_include_turns() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; - let ThreadReadResponse { thread } = to_response::(read_resp)?; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.turns.len(), 1); let turn = &thread.turns[0]; @@ -159,6 +164,88 @@ async fn thread_read_can_include_turns() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "first", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; + append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id.clone(), + cursor: None, + limit: Some(2), + sort_direction: Some(SortDirection::Desc), + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadTurnsListResponse { + data, + next_cursor, + backwards_cursor, + } = to_response::(read_resp)?; + assert_eq!(turn_user_texts(&data), vec!["third", "second"]); + let next_cursor = next_cursor.expect("expected nextCursor for older turns"); + let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn"); + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id.clone(), + cursor: Some(next_cursor), + limit: Some(10), + sort_direction: Some(SortDirection::Desc), + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadTurnsListResponse { data, .. } = to_response::(read_resp)?; + assert_eq!(turn_user_texts(&data), vec!["first"]); + + append_user_message(rollout_path.as_path(), "2025-01-05T12:03:00Z", "fourth")?; + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id, + cursor: Some(backwards_cursor), + limit: Some(10), + sort_direction: Some(SortDirection::Asc), + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadTurnsListResponse { data, .. } = to_response::(read_resp)?; + assert_eq!(turn_user_texts(&data), vec!["third", "fourth"]); + + Ok(()) +} + #[tokio::test] async fn thread_read_can_return_archived_threads_by_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -207,6 +294,75 @@ async fn thread_read_can_return_archived_threads_by_id() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "first", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; + append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id.clone(), + cursor: None, + limit: Some(2), + sort_direction: Some(SortDirection::Desc), + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadTurnsListResponse { + backwards_cursor, .. + } = to_response::(read_resp)?; + let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn"); + + append_thread_rollback( + rollout_path.as_path(), + "2025-01-05T12:03:00Z", + /*num_turns*/ 1, + )?; + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id, + cursor: Some(backwards_cursor), + limit: Some(10), + sort_direction: Some(SortDirection::Asc), + }) + .await?; + let read_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(read_id)), + ) + .await??; + + assert_eq!( + read_err.error.message, + "invalid cursor: anchor turn is no longer present" + ); + + Ok(()) +} + #[tokio::test] async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -250,7 +406,7 @@ async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; - let ThreadReadResponse { thread } = to_response::(read_resp)?; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.forked_from_id, Some(conversation_id)); @@ -295,7 +451,7 @@ async fn thread_read_loaded_thread_returns_precomputed_path_before_materializati mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; - let ThreadReadResponse { thread: read } = to_response::(read_resp)?; + let ThreadReadResponse { thread: read, .. } = to_response::(read_resp)?; assert_eq!(read.id, thread.id); assert_eq!(read.path, Some(thread_path)); @@ -363,7 +519,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { ) .await??; let read_result = read_resp.result.clone(); - let ThreadReadResponse { thread } = to_response::(read_resp)?; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.id, conversation_id); assert_eq!(thread.name.as_deref(), Some(new_name)); let thread_json = read_result @@ -387,6 +543,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { cursor: None, limit: Some(50), sort_key: None, + sort_direction: None, model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, @@ -572,13 +729,63 @@ async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Resul mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; - let ThreadReadResponse { thread } = to_response::(read_resp)?; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.status, ThreadStatus::SystemError,); Ok(()) } +fn append_user_message(path: &Path, timestamp: &str, text: &str) -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new().append(true).open(path)?; + writeln!( + file, + "{}", + json!({ + "timestamp": timestamp, + "type":"event_msg", + "payload": { + "type":"user_message", + "message": text, + "text_elements": [], + "local_images": [] + } + }) + ) +} + +fn append_thread_rollback(path: &Path, timestamp: &str, num_turns: u32) -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new().append(true).open(path)?; + writeln!( + file, + "{}", + json!({ + "timestamp": timestamp, + "type":"event_msg", + "payload": { + "type":"thread_rolled_back", + "num_turns": num_turns + } + }) + ) +} + +fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { + turns + .iter() + .filter_map(|turn| match turn.items.first()? { + ThreadItem::UserMessage { content, .. } => match content.first()? { + UserInput::Text { text, .. } => Some(text.as_str()), + UserInput::Image { .. } + | UserInput::LocalImage { .. } + | UserInput::Skill { .. } + | UserInput::Mention { .. } => None, + }, + _ => None, + }) + .collect() +} + // Helper to create a config.toml pointing at the mock model server. fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index edf792ca1..18988872b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -844,6 +844,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is .await??; let ThreadReadResponse { thread: read_thread, + .. } = to_response::(read_resp)?; assert_eq!(read_thread.status, ThreadStatus::Idle); diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index f44beb9a2..4580c1879 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -135,7 +135,7 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; - let ThreadReadResponse { thread } = to_response::(read_resp)?; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.turns.len(), 1); let ThreadItem::CommandExecution { source, @@ -305,7 +305,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; - let ThreadReadResponse { thread } = to_response::(read_resp)?; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.turns.len(), 1); assert!( thread.turns[0].items.iter().any(|item| { diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index e91a8654c..303e16687 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -288,7 +288,7 @@ async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Resu mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; - let ThreadReadResponse { thread } = to_response::(read_resp)?; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.status, ThreadStatus::SystemError); let unsubscribe_id = mcp diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index c41a49867..18d1a3029 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -157,6 +157,7 @@ pub use rollout::RolloutRecorder; pub use rollout::RolloutRecorderParams; pub use rollout::SESSIONS_SUBDIR; pub use rollout::SessionMeta; +pub use rollout::SortDirection; pub use rollout::ThreadItem; pub use rollout::ThreadSortKey; pub use rollout::ThreadsPage; diff --git a/codex-rs/core/src/personality_migration.rs b/codex-rs/core/src/personality_migration.rs index 4b53a2629..ae24499e0 100644 --- a/codex-rs/core/src/personality_migration.rs +++ b/codex-rs/core/src/personality_migration.rs @@ -79,6 +79,7 @@ async fn has_threads(store: &LocalThreadStore, archived: bool) -> io::Result Vec { page_size: MAX_RECENT_THREADS, cursor: None, sort_key: ThreadSortKey::UpdatedAt, + sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, archived: false, diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 4913f957e..2405b83fc 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -7,6 +7,7 @@ pub use codex_rollout::RolloutRecorder; pub use codex_rollout::RolloutRecorderParams; pub use codex_rollout::SESSIONS_SUBDIR; pub use codex_rollout::SessionMeta; +pub use codex_rollout::SortDirection; pub use codex_rollout::ThreadItem; pub use codex_rollout::ThreadSortKey; pub use codex_rollout::ThreadsPage; diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 8962a1f02..c8cf0dd72 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -178,6 +178,7 @@ impl AppServerClient { cursor, limit: None, sort_key: None, + sort_direction: None, model_providers: None, source_kinds: None, archived: None, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 123038ffa..35f2ae49a 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1234,6 +1234,7 @@ async fn resolve_resume_thread_id( cursor, limit: Some(100), sort_key: Some(ThreadSortKey::UpdatedAt), + sort_direction: None, model_providers: model_providers.clone(), source_kinds: Some(all_thread_source_kinds()), archived: Some(false), @@ -1296,6 +1297,7 @@ async fn resolve_resume_thread_id( cursor, limit: Some(100), sort_key: Some(ThreadSortKey::UpdatedAt), + sort_direction: None, model_providers: model_providers.clone(), source_kinds: Some(all_thread_source_kinds()), archived: Some(false), diff --git a/codex-rs/rollout/src/lib.rs b/codex-rs/rollout/src/lib.rs index de169db1f..4046beb63 100644 --- a/codex-rs/rollout/src/lib.rs +++ b/codex-rs/rollout/src/lib.rs @@ -34,6 +34,7 @@ pub use config::Config; pub use config::RolloutConfig; pub use config::RolloutConfigView; pub use list::Cursor; +pub use list::SortDirection; pub use list::ThreadItem; pub use list::ThreadListConfig; pub use list::ThreadListLayout; diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index 640f0b027..bf09d15e8 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -111,6 +111,12 @@ pub enum ThreadSortKey { UpdatedAt, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortDirection { + Asc, + Desc, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThreadListLayout { NestedByDate, @@ -124,26 +130,28 @@ pub struct ThreadListConfig<'a> { pub layout: ThreadListLayout, } -/// Pagination cursor identifying a file by timestamp and UUID. +/// Pagination cursor identifying the timestamp of the last item in a page. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cursor { ts: OffsetDateTime, - id: Uuid, } impl Cursor { - fn new(ts: OffsetDateTime, id: Uuid) -> Self { - Self { ts, id } + fn new(ts: OffsetDateTime) -> Self { + Self { ts } + } + + pub(crate) fn timestamp(&self) -> OffsetDateTime { + self.ts } } /// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest, -/// it ignores everything until it reaches the last seen item from the previous page, then +/// it ignores everything until it passes the last seen timestamp from the previous page, then /// starts returning results after that. This makes paging stable even if new files show up during /// pagination. struct AnchorState { ts: OffsetDateTime, - id: Uuid, passed: bool, } @@ -152,22 +160,20 @@ impl AnchorState { match anchor { Some(cursor) => Self { ts: cursor.ts, - id: cursor.id, passed: false, }, None => Self { ts: OffsetDateTime::UNIX_EPOCH, - id: Uuid::nil(), passed: true, }, } } - fn should_skip(&mut self, ts: OffsetDateTime, id: Uuid) -> bool { + fn should_skip(&mut self, ts: OffsetDateTime, _id: Uuid) -> bool { if self.passed { return false; } - if ts < self.ts || (ts == self.ts && id < self.id) { + if ts < self.ts { self.passed = true; false } else { @@ -275,7 +281,7 @@ impl serde::Serialize for Cursor { .ts .format(&Rfc3339) .map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?; - serializer.serialize_str(&format!("{ts_str}|{}", self.id)) + serializer.serialize_str(&ts_str) } } @@ -296,14 +302,14 @@ impl From for Cursor { .timestamp_nanos_opt() .and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok()) .unwrap_or(OffsetDateTime::UNIX_EPOCH); - Self::new(ts, anchor.id) + Self::new(ts) } } /// Retrieve recorded thread file paths with token pagination. The returned `next_cursor` /// can be supplied on the next call to resume after the last returned item, resilient to /// concurrent new sessions being appended. Ordering is stable by the requested sort key -/// (timestamp desc, then UUID desc). +/// (timestamp desc). pub async fn get_threads( codex_home: &Path, page_size: usize, @@ -657,31 +663,27 @@ async fn traverse_flat_paths_updated( }) } -/// Pagination cursor token format: "|" where `ts` uses -/// YYYY-MM-DDThh-mm-ss (UTC, second precision). -/// The cursor orders files by the requested sort key (timestamp desc, then UUID desc). +/// Pagination cursor token format: an RFC3339 timestamp. pub fn parse_cursor(token: &str) -> Option { - let (file_ts, uuid_str) = token.split_once('|')?; - - let Ok(uuid) = Uuid::parse_str(uuid_str) else { + if token.contains('|') { return None; - }; + } - let ts = OffsetDateTime::parse(file_ts, &Rfc3339).ok().or_else(|| { + let ts = OffsetDateTime::parse(token, &Rfc3339).ok().or_else(|| { let format: &[FormatItem] = format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); - PrimitiveDateTime::parse(file_ts, format) + PrimitiveDateTime::parse(token, format) .ok() .map(PrimitiveDateTime::assume_utc) })?; - Some(Cursor::new(ts, uuid)) + Some(Cursor::new(ts)) } fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option { let last = items.last()?; let file_name = last.path.file_name()?.to_string_lossy(); - let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; + let (created_ts, _id) = parse_timestamp_uuid_from_filename(&file_name)?; let ts = match sort_key { ThreadSortKey::CreatedAt => created_ts, ThreadSortKey::UpdatedAt => { @@ -689,7 +691,7 @@ fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option, sort_key: ThreadSortKey, + sort_direction: SortDirection, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, default_provider: &str, @@ -229,6 +233,7 @@ impl RolloutRecorder { page_size, cursor, sort_key, + sort_direction, allowed_sources, model_providers, default_provider, @@ -245,6 +250,7 @@ impl RolloutRecorder { page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, + sort_direction: SortDirection, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, default_provider: &str, @@ -255,6 +261,7 @@ impl RolloutRecorder { page_size, cursor, sort_key, + sort_direction, allowed_sources, model_providers, default_provider, @@ -270,6 +277,7 @@ impl RolloutRecorder { page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, + sort_direction: SortDirection, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, default_provider: &str, @@ -290,6 +298,7 @@ impl RolloutRecorder { page_size, cursor, sort_key, + sort_direction, allowed_sources, model_providers, archived, @@ -304,38 +313,44 @@ impl RolloutRecorder { // Filesystem-first listing intentionally overfetches so we can repair stale/missing // SQLite rollout paths before the final DB-backed page is returned. let fs_page_size = page_size.saturating_mul(2).max(page_size); - let fs_page = if archived { - let root = codex_home.join(ARCHIVED_SESSIONS_SUBDIR); - get_threads_in_root( - root, - fs_page_size, - cursor, - sort_key, - ThreadListConfig { + let fs_page = match sort_direction { + SortDirection::Asc => { + list_threads_from_files_asc( + codex_home, + page_size, + cursor, + sort_key, allowed_sources, model_providers, default_provider, - layout: ThreadListLayout::Flat, - }, - ) - .await? - } else { - get_threads( - codex_home, - fs_page_size, - cursor, - sort_key, - allowed_sources, - model_providers, - default_provider, - ) - .await? + archived, + search_term, + ) + .await? + } + SortDirection::Desc => { + list_threads_from_files_desc( + codex_home, + fs_page_size, + cursor, + sort_key, + allowed_sources, + model_providers, + default_provider, + archived, + search_term, + ) + .await? + } }; if state_db_ctx.is_none() { // Keep legacy behavior when SQLite is unavailable: return filesystem results // at the requested page size. - return Ok(truncate_fs_page(fs_page, page_size, sort_key)); + return Ok(match sort_direction { + SortDirection::Asc => fs_page, + SortDirection::Desc => truncate_fs_page(fs_page, page_size, sort_key), + }); } // Warm the DB by repairing every filesystem hit before querying SQLite. @@ -355,6 +370,7 @@ impl RolloutRecorder { page_size, cursor, sort_key, + sort_direction, allowed_sources, model_providers, archived, @@ -367,7 +383,10 @@ impl RolloutRecorder { // If SQLite listing still fails, return the filesystem page rather than failing the list. tracing::error!("Falling back on rollout system"); tracing::warn!("state db discrepancy during list_threads_with_db_fallback: falling_back"); - Ok(truncate_fs_page(fs_page, page_size, sort_key)) + Ok(match sort_direction { + SortDirection::Asc => fs_page, + SortDirection::Desc => truncate_fs_page(fs_page, page_size, sort_key), + }) } /// Find the newest recorded thread path, optionally filtering to a matching cwd. @@ -393,6 +412,7 @@ impl RolloutRecorder { page_size, db_cursor.as_ref(), sort_key, + SortDirection::Desc, allowed_sources, model_providers, /*archived*/ false, @@ -766,16 +786,252 @@ fn truncate_fs_page( page.items.truncate(page_size); page.next_cursor = page.items.last().and_then(|item| { let file_name = item.path.file_name()?.to_str()?; - let (created_at, id) = parse_timestamp_uuid_from_filename(file_name)?; + let (created_at, _id) = parse_timestamp_uuid_from_filename(file_name)?; let cursor_token = match sort_key { - ThreadSortKey::CreatedAt => format!("{}|{id}", created_at.format(&Rfc3339).ok()?), - ThreadSortKey::UpdatedAt => format!("{}|{id}", item.updated_at.as_deref()?), + ThreadSortKey::CreatedAt => created_at.format(&Rfc3339).ok()?, + ThreadSortKey::UpdatedAt => item.updated_at.as_deref()?.to_string(), }; parse_cursor(cursor_token.as_str()) }); page } +#[allow(clippy::too_many_arguments)] +async fn list_threads_from_files_desc( + codex_home: &Path, + page_size: usize, + cursor: Option<&Cursor>, + sort_key: ThreadSortKey, + allowed_sources: &[SessionSource], + model_providers: Option<&[String]>, + default_provider: &str, + archived: bool, + search_term: Option<&str>, +) -> std::io::Result { + if let Some(search_term) = search_term { + let mut matching_items = Vec::new(); + let mut scanned_files = 0usize; + let mut reached_scan_cap = false; + let mut page_cursor = cursor.cloned(); + let scan_page_size = page_size.saturating_mul(8).clamp(256, 2048); + + loop { + let mut page = list_threads_from_files_desc_unfiltered( + codex_home, + scan_page_size, + page_cursor.as_ref(), + sort_key, + allowed_sources, + model_providers, + default_provider, + archived, + ) + .await?; + scanned_files = scanned_files.saturating_add(page.num_scanned_files); + reached_scan_cap |= page.reached_scan_cap; + filter_thread_items_by_search_term(codex_home, &mut page.items, Some(search_term)) + .await?; + matching_items.extend(page.items); + page_cursor = page.next_cursor; + if matching_items.len() > page_size || page_cursor.is_none() { + break; + } + } + + let more_matches_available = + matching_items.len() > page_size || page_cursor.is_some() || reached_scan_cap; + matching_items.truncate(page_size); + let next_cursor = if more_matches_available { + matching_items + .last() + .and_then(|item| cursor_from_thread_item(item, sort_key)) + } else { + None + }; + + return Ok(ThreadsPage { + items: matching_items, + next_cursor, + num_scanned_files: scanned_files, + reached_scan_cap, + }); + } + + list_threads_from_files_desc_unfiltered( + codex_home, + page_size, + cursor, + sort_key, + allowed_sources, + model_providers, + default_provider, + archived, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn list_threads_from_files_desc_unfiltered( + codex_home: &Path, + page_size: usize, + cursor: Option<&Cursor>, + sort_key: ThreadSortKey, + allowed_sources: &[SessionSource], + model_providers: Option<&[String]>, + default_provider: &str, + archived: bool, +) -> std::io::Result { + if archived { + let root = codex_home.join(ARCHIVED_SESSIONS_SUBDIR); + get_threads_in_root( + root, + page_size, + cursor, + sort_key, + ThreadListConfig { + allowed_sources, + model_providers, + default_provider, + layout: ThreadListLayout::Flat, + }, + ) + .await + } else { + get_threads( + codex_home, + page_size, + cursor, + sort_key, + allowed_sources, + model_providers, + default_provider, + ) + .await + } +} + +#[allow(clippy::too_many_arguments)] +async fn list_threads_from_files_asc( + codex_home: &Path, + page_size: usize, + cursor: Option<&Cursor>, + sort_key: ThreadSortKey, + allowed_sources: &[SessionSource], + model_providers: Option<&[String]>, + default_provider: &str, + archived: bool, + search_term: Option<&str>, +) -> std::io::Result { + let mut all_items = Vec::new(); + let mut scanned_files = 0usize; + let mut reached_scan_cap = false; + let mut page_cursor = None; + let scan_page_size = page_size.saturating_mul(8).clamp(256, 2048); + loop { + let page = list_threads_from_files_desc( + codex_home, + scan_page_size, + page_cursor.as_ref(), + sort_key, + allowed_sources, + model_providers, + default_provider, + archived, + /*search_term*/ None, + ) + .await?; + scanned_files = scanned_files.saturating_add(page.num_scanned_files); + reached_scan_cap |= page.reached_scan_cap; + all_items.extend(page.items); + page_cursor = page.next_cursor; + if page_cursor.is_none() { + break; + } + } + + filter_thread_items_by_search_term(codex_home, &mut all_items, search_term).await?; + + let mut keyed_items = all_items + .into_iter() + .filter_map(|item| thread_item_sort_key(&item, sort_key).map(|key| (key, item))) + .collect::>(); + keyed_items.sort_by_key(|(key, _)| *key); + let mut all_items = keyed_items + .into_iter() + .map(|(_, item)| item) + .collect::>(); + + if let Some(cursor) = cursor { + let anchor = cursor.timestamp(); + all_items + .retain(|item| thread_item_sort_key(item, sort_key).is_some_and(|key| key.0 > anchor)); + } + + let more_matches_available = all_items.len() > page_size || reached_scan_cap; + all_items.truncate(page_size); + let next_cursor = if more_matches_available { + all_items + .last() + .and_then(|item| cursor_from_thread_item(item, sort_key)) + } else { + None + }; + + Ok(ThreadsPage { + items: all_items, + next_cursor, + num_scanned_files: scanned_files, + reached_scan_cap, + }) +} + +async fn filter_thread_items_by_search_term( + codex_home: &Path, + items: &mut Vec, + search_term: Option<&str>, +) -> std::io::Result<()> { + let Some(search_term) = search_term else { + return Ok(()); + }; + + // The file-backed fallback only has the thread title in the sidecar session index. + // Match the SQLite path's title substring filter so search pagination behaves the same + // whether the state DB is available or not. + let thread_ids = items + .iter() + .filter_map(|item| item.thread_id) + .collect::>(); + let thread_names = find_thread_names_by_ids(codex_home, &thread_ids).await?; + items.retain(|item| { + item.thread_id + .and_then(|thread_id| thread_names.get(&thread_id)) + .is_some_and(|title| title.contains(search_term)) + }); + Ok(()) +} + +fn thread_item_sort_key( + item: &ThreadItem, + sort_key: ThreadSortKey, +) -> Option<(OffsetDateTime, uuid::Uuid)> { + let file_name = item.path.file_name()?.to_str()?; + let (created_at, id) = parse_timestamp_uuid_from_filename(file_name)?; + let timestamp = match sort_key { + ThreadSortKey::CreatedAt => created_at, + ThreadSortKey::UpdatedAt => { + let updated_at = item.updated_at.as_deref().or(item.created_at.as_deref())?; + OffsetDateTime::parse(updated_at, &Rfc3339).ok()? + } + }; + Some((timestamp, id)) +} + +fn cursor_from_thread_item(item: &ThreadItem, sort_key: ThreadSortKey) -> Option { + let (timestamp, _id) = thread_item_sort_key(item, sort_key)?; + let cursor_token = timestamp.format(&Rfc3339).ok()?; + parse_cursor(cursor_token.as_str()) +} + struct LogFileInfo { /// Full path to the rollout file. path: PathBuf, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index e3d82c445..fa789d3a7 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -372,6 +372,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re /*page_size*/ 1, /*cursor*/ None, ThreadSortKey::CreatedAt, + SortDirection::Desc, &[], /*model_providers*/ None, default_provider.as_str(), @@ -387,6 +388,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re /*page_size*/ 1, Some(&cursor), ThreadSortKey::CreatedAt, + SortDirection::Desc, &[], /*model_providers*/ None, default_provider.as_str(), @@ -444,6 +446,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul /*page_size*/ 10, /*cursor*/ None, ThreadSortKey::CreatedAt, + SortDirection::Desc, &[], /*model_providers*/ None, default_provider.as_str(), @@ -506,6 +509,7 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul /*page_size*/ 1, /*cursor*/ None, ThreadSortKey::CreatedAt, + SortDirection::Desc, &[], /*model_providers*/ None, default_provider.as_str(), diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index 7f609bf26..7d72429c9 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -1,10 +1,10 @@ use crate::config::RolloutConfig; use crate::config::RolloutConfigView; use crate::list::Cursor; +use crate::list::SortDirection; use crate::list::ThreadSortKey; use crate::metadata; use chrono::DateTime; -use chrono::NaiveDateTime; use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; @@ -18,7 +18,6 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use tracing::warn; -use uuid::Uuid; /// Core-facing handle to the SQLite-backed state runtime. pub type StateDbHandle = Arc; @@ -117,21 +116,10 @@ async fn require_backfill_complete( fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option { let cursor = cursor?; - let value = serde_json::to_value(cursor).ok()?; - let cursor_str = value.as_str()?; - let (ts_str, id_str) = cursor_str.split_once('|')?; - if id_str.contains('|') { - return None; - } - let id = Uuid::parse_str(id_str).ok()?; - let ts = if let Ok(naive) = NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%dT%H-%M-%S") { - DateTime::::from_naive_utc_and_offset(naive, Utc) - } else if let Ok(dt) = DateTime::parse_from_rfc3339(ts_str) { - dt.with_timezone(&Utc) - } else { - return None; - }; - Some(codex_state::Anchor { ts, id }) + let millis = cursor.timestamp().unix_timestamp_nanos() / 1_000_000; + let millis = i64::try_from(millis).ok()?; + let ts = chrono::DateTime::::from_timestamp_millis(millis)?; + Some(codex_state::Anchor { ts }) } pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { @@ -200,6 +188,7 @@ pub async fn list_threads_db( page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, + sort_direction: SortDirection, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, archived: bool, @@ -227,15 +216,21 @@ pub async fn list_threads_db( match ctx .list_threads( page_size, - anchor.as_ref(), - match sort_key { - ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, - ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + codex_state::ThreadFilterOptions { + archived_only: archived, + allowed_sources: allowed_sources.as_slice(), + model_providers: model_providers.as_deref(), + anchor: anchor.as_ref(), + sort_key: match sort_key { + ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, + ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + }, + sort_direction: match sort_direction { + SortDirection::Asc => codex_state::SortDirection::Asc, + SortDirection::Desc => codex_state::SortDirection::Desc, + }, + search_term, }, - allowed_sources.as_slice(), - model_providers.as_deref(), - archived, - search_term, ) .await { diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index e5e321176..a4e59db9d 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -7,14 +7,11 @@ use chrono::NaiveDateTime; use chrono::Timelike; use chrono::Utc; use pretty_assertions::assert_eq; -use uuid::Uuid; #[test] fn cursor_to_anchor_normalizes_timestamp_format() { - let uuid = Uuid::new_v4(); let ts_str = "2026-01-27T12-34-56"; - let token = format!("{ts_str}|{uuid}"); - let cursor = parse_cursor(token.as_str()).expect("cursor should parse"); + let cursor = parse_cursor(ts_str).expect("cursor should parse"); let anchor = cursor_to_anchor(Some(&cursor)).expect("anchor should parse"); let naive = @@ -23,6 +20,5 @@ fn cursor_to_anchor_normalizes_timestamp_format() { .with_nanosecond(0) .expect("nanosecond"); - assert_eq!(anchor.id, uuid); assert_eq!(anchor.ts, expected_ts); } diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index e647a54cd..d9619a598 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -707,8 +707,7 @@ async fn test_pagination_cursor() { .join(format!("rollout-2025-03-04T09-00-00-{u4}.jsonl")); let updated_page1: Vec> = page1.items.iter().map(|i| i.updated_at.clone()).collect(); - let expected_cursor1: Cursor = - serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap(); + let expected_cursor1: Cursor = serde_json::from_str("\"2025-03-04T09-00-00\"").unwrap(); let expected_page1 = ThreadsPage { items: vec![ ThreadItem { @@ -775,8 +774,7 @@ async fn test_pagination_cursor() { .join(format!("rollout-2025-03-02T09-00-00-{u2}.jsonl")); let updated_page2: Vec> = page2.items.iter().map(|i| i.updated_at.clone()).collect(); - let expected_cursor2: Cursor = - serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap(); + let expected_cursor2: Cursor = serde_json::from_str("\"2025-03-02T09-00-00\"").unwrap(); let expected_page2 = ThreadsPage { items: vec![ ThreadItem { @@ -1207,7 +1205,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { } #[tokio::test] -async fn test_stable_ordering_same_second_pagination() { +async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { let temp = TempDir::new().unwrap(); let home = temp.path(); @@ -1268,7 +1266,7 @@ async fn test_stable_ordering_same_second_pagination() { .join(format!("rollout-2025-07-01T00-00-00-{u2}.jsonl")); let updated_page1: Vec> = page1.items.iter().map(|i| i.updated_at.clone()).collect(); - let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap(); + let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}\"")).unwrap(); let expected_page1 = ThreadsPage { items: vec![ ThreadItem { @@ -1321,33 +1319,12 @@ async fn test_stable_ordering_same_second_pagination() { ) .await .unwrap(); - let p1 = home - .join("sessions") - .join("2025") - .join("07") - .join("01") - .join(format!("rollout-2025-07-01T00-00-00-{u1}.jsonl")); - let updated_page2: Vec> = - page2.items.iter().map(|i| i.updated_at.clone()).collect(); + // The filesystem fallback only has second-precision timestamps in filenames. The primary + // SQLite-backed listing uses unique millisecond timestamps and does not have this tie. let expected_page2 = ThreadsPage { - items: vec![ThreadItem { - path: p1, - thread_id: Some(thread_id_from_uuid(u1)), - first_user_message: Some("Hello from user".to_string()), - cwd: Some(Path::new(".").to_path_buf()), - git_branch: None, - git_sha: None, - git_origin_url: None, - source: Some(SessionSource::VSCode), - agent_nickname: None, - agent_role: None, - model_provider: Some(TEST_PROVIDER.to_string()), - cli_version: Some("test_version".to_string()), - created_at: Some(ts.to_string()), - updated_at: updated_page2.first().cloned().flatten(), - }], + items: Vec::new(), next_cursor: None, - num_scanned_files: 3, // scanned u3, u2 (anchor), u1 + num_scanned_files: 3, reached_scan_cap: false, }; assert_eq!(page2, expected_page2); diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index efad7651f..36676d5a4 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -37,6 +37,7 @@ pub use model::BackfillStats; pub use model::BackfillStatus; pub use model::DirectionalThreadSpawnEdgeStatus; pub use model::ExtractionOutcome; +pub use model::SortDirection; pub use model::SortKey; pub use model::Stage1JobClaim; pub use model::Stage1JobClaimOutcome; @@ -47,6 +48,7 @@ pub use model::ThreadMetadata; pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; pub use runtime::RemoteControlEnrollmentRecord; +pub use runtime::ThreadFilterOptions; pub use runtime::logs_db_filename; pub use runtime::logs_db_path; pub use runtime::state_db_filename; diff --git a/codex-rs/state/src/model/mod.rs b/codex-rs/state/src/model/mod.rs index af4b30a8f..a5f9531aa 100644 --- a/codex-rs/state/src/model/mod.rs +++ b/codex-rs/state/src/model/mod.rs @@ -28,6 +28,7 @@ pub use memories::Stage1StartupClaimParams; pub use thread_metadata::Anchor; pub use thread_metadata::BackfillStats; pub use thread_metadata::ExtractionOutcome; +pub use thread_metadata::SortDirection; pub use thread_metadata::SortKey; pub use thread_metadata::ThreadMetadata; pub use thread_metadata::ThreadMetadataBuilder; diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index db4cba27f..bddb2fb36 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -9,7 +9,6 @@ use codex_protocol::protocol::SessionSource; use sqlx::Row; use sqlx::sqlite::SqliteRow; use std::path::PathBuf; -use uuid::Uuid; /// The sort key to use when listing threads. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -20,13 +19,18 @@ pub enum SortKey { UpdatedAt, } +/// Sort direction to use when listing threads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortDirection { + Asc, + Desc, +} + /// A pagination anchor used for keyset pagination. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Anchor { /// The timestamp component of the anchor. pub ts: DateTime, - /// The UUID component of the anchor. - pub id: Uuid, } /// A single page of thread metadata results. @@ -414,12 +418,11 @@ impl TryFrom for ThreadMetadata { } pub(crate) fn anchor_from_item(item: &ThreadMetadata, sort_key: SortKey) -> Option { - let id = Uuid::parse_str(&item.id.to_string()).ok()?; let ts = match sort_key { SortKey::CreatedAt => item.created_at, SortKey::UpdatedAt => item.updated_at, }; - Some(Anchor { ts, id }) + Some(Anchor { ts }) } pub(crate) fn datetime_to_epoch_millis(dt: DateTime) -> i64 { diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 054ad6ff2..da2010b31 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -63,6 +63,7 @@ mod test_support; mod threads; pub use remote_control::RemoteControlEnrollmentRecord; +pub use threads::ThreadFilterOptions; // "Partition" is the retained-log-content bucket we cap at 10 MiB: // - one bucket per non-null thread_id diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 4817d5db6..eee3a2c7b 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -1,6 +1,8 @@ +use super::threads::ThreadFilterOptions; use super::threads::push_thread_filters; use super::threads::push_thread_order_and_limit; use super::*; +use crate::SortDirection; use crate::model::Phase2InputSelection; use crate::model::Phase2JobClaimOutcome; use crate::model::Stage1JobClaim; @@ -101,7 +103,7 @@ WHERE thread_id = ? /// - excludes the current thread id /// - keeps only threads whose millisecond `updated_at` is in the age window /// - keeps only threads whose memory is stale compared to millisecond `updated_at` - /// - orders by `updated_at_ms DESC, id DESC` and applies `scan_limit` + /// - orders by `updated_at_ms DESC` and applies `scan_limit` /// /// For each selected thread, this function calls [`Self::try_claim_stage1_job`] /// with `source_updated_at = thread.updated_at.timestamp()` and returns up to @@ -169,12 +171,15 @@ LEFT JOIN jobs ); push_thread_filters( &mut builder, - /*archived_only*/ false, - allowed_sources, - /*model_providers*/ None, - /*anchor*/ None, - SortKey::UpdatedAt, - /*search_term*/ None, + ThreadFilterOptions { + archived_only: false, + allowed_sources, + model_providers: None, + anchor: None, + sort_key: SortKey::UpdatedAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, ); builder.push(" AND threads.memory_mode = 'enabled'"); builder @@ -195,7 +200,12 @@ LEFT JOIN jobs builder.push(updated_at); builder.push(" AND ((COALESCE(jobs.last_success_watermark, -1) + 1) * 1000) <= "); builder.push(updated_at); - push_thread_order_and_limit(&mut builder, SortKey::UpdatedAt, scan_limit); + push_thread_order_and_limit( + &mut builder, + SortKey::UpdatedAt, + SortDirection::Desc, + scan_limit, + ); let items = builder .build() diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index efd5b9c19..eb7494a03 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1,4 +1,5 @@ use super::*; +use crate::SortDirection; use codex_protocol::protocol::SessionSource; use std::sync::atomic::Ordering; @@ -342,12 +343,15 @@ ON CONFLICT(child_thread_id) DO NOTHING builder.push(" FROM threads"); push_thread_filters( &mut builder, - archived_only, - allowed_sources, - model_providers, - /*anchor*/ None, - crate::SortKey::UpdatedAt, - /*search_term*/ None, + ThreadFilterOptions { + archived_only, + allowed_sources, + model_providers, + anchor: None, + sort_key: crate::SortKey::UpdatedAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, ); builder.push(" AND threads.title = "); builder.push_bind(title); @@ -355,7 +359,12 @@ ON CONFLICT(child_thread_id) DO NOTHING builder.push(" AND threads.cwd = "); builder.push_bind(cwd.display().to_string()); } - push_thread_order_and_limit(&mut builder, crate::SortKey::UpdatedAt, /*limit*/ 1); + push_thread_order_and_limit( + &mut builder, + crate::SortKey::UpdatedAt, + SortDirection::Desc, + /*limit*/ 1, + ); let row = builder.build().fetch_optional(self.pool.as_ref()).await?; row.map(|row| ThreadRow::try_from_row(&row).and_then(crate::ThreadMetadata::try_from)) @@ -363,32 +372,20 @@ ON CONFLICT(child_thread_id) DO NOTHING } /// List threads using the underlying database. - #[allow(clippy::too_many_arguments)] pub async fn list_threads( &self, page_size: usize, - anchor: Option<&crate::Anchor>, - sort_key: crate::SortKey, - allowed_sources: &[String], - model_providers: Option<&[String]>, - archived_only: bool, - search_term: Option<&str>, + filters: ThreadFilterOptions<'_>, ) -> anyhow::Result { let limit = page_size.saturating_add(1); + let sort_key = filters.sort_key; + let sort_direction = filters.sort_direction; let mut builder = QueryBuilder::::new(""); push_thread_select_columns(&mut builder); builder.push(" FROM threads"); - push_thread_filters( - &mut builder, - archived_only, - allowed_sources, - model_providers, - anchor, - sort_key, - search_term, - ); - push_thread_order_and_limit(&mut builder, sort_key, limit); + push_thread_filters(&mut builder, filters); + push_thread_order_and_limit(&mut builder, sort_key, sort_direction, limit); let rows = builder.build().fetch_all(self.pool.as_ref()).await?; let mut items = rows @@ -424,14 +421,17 @@ ON CONFLICT(child_thread_id) DO NOTHING let mut builder = QueryBuilder::::new("SELECT threads.id FROM threads"); push_thread_filters( &mut builder, - archived_only, - allowed_sources, - model_providers, - anchor, - sort_key, - /*search_term*/ None, + ThreadFilterOptions { + archived_only, + allowed_sources, + model_providers, + anchor, + sort_key, + sort_direction: SortDirection::Desc, + search_term: None, + }, ); - push_thread_order_and_limit(&mut builder, sort_key, limit); + push_thread_order_and_limit(&mut builder, sort_key, SortDirection::Desc, limit); let rows = builder.build().fetch_all(self.pool.as_ref()).await?; rows.into_iter() @@ -983,15 +983,30 @@ fn thread_spawn_parent_thread_id_from_source_str(source: &str) -> Option { + pub archived_only: bool, + pub allowed_sources: &'a [String], + pub model_providers: Option<&'a [String]>, + pub anchor: Option<&'a crate::Anchor>, + pub sort_key: SortKey, + pub sort_direction: SortDirection, + pub search_term: Option<&'a str>, +} + pub(super) fn push_thread_filters<'a>( builder: &mut QueryBuilder<'a, Sqlite>, - archived_only: bool, - allowed_sources: &'a [String], - model_providers: Option<&'a [String]>, - anchor: Option<&crate::Anchor>, - sort_key: SortKey, - search_term: Option<&'a str>, + options: ThreadFilterOptions<'a>, ) { + let ThreadFilterOptions { + archived_only, + allowed_sources, + model_providers, + anchor, + sort_key, + sort_direction, + search_term, + } = options; builder.push(" WHERE 1 = 1"); if archived_only { builder.push(" AND threads.archived = 1"); @@ -1028,32 +1043,38 @@ pub(super) fn push_thread_filters<'a>( SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", }; + let operator = match sort_direction { + SortDirection::Asc => ">", + SortDirection::Desc => "<", + }; builder.push(" AND ("); builder.push(column); - builder.push(" < "); + builder.push(" "); + builder.push(operator); + builder.push(" "); builder.push_bind(anchor_ts); - builder.push(" OR ("); - builder.push(column); - builder.push(" = "); - builder.push_bind(anchor_ts); - builder.push(" AND id < "); - builder.push_bind(anchor.id.to_string()); - builder.push("))"); + builder.push(")"); } } pub(super) fn push_thread_order_and_limit( builder: &mut QueryBuilder<'_, Sqlite>, sort_key: SortKey, + sort_direction: SortDirection, limit: usize, ) { let order_column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", }; + let order_direction = match sort_direction { + SortDirection::Asc => "ASC", + SortDirection::Desc => "DESC", + }; builder.push(" ORDER BY "); builder.push(order_column); - builder.push(" DESC, id DESC"); + builder.push(" "); + builder.push(order_direction); builder.push(" LIMIT "); builder.push_bind(limit as i64); } @@ -1061,6 +1082,7 @@ pub(super) fn push_thread_order_and_limit( #[cfg(test)] mod tests { use super::*; + use crate::Anchor; use crate::DirectionalThreadSpawnEdgeStatus; use crate::runtime::test_support::test_thread_metadata; use crate::runtime::test_support::unique_temp_dir; @@ -1110,6 +1132,88 @@ mod tests { assert_eq!(memory_mode, "disabled"); } + #[tokio::test] + async fn list_threads_updated_after_returns_oldest_changes_first() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let older_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread id"); + let middle_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread id"); + let newer_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000003").expect("valid thread id"); + let older_updated_at = + DateTime::::from_timestamp(1_700_000_100, 0).expect("valid older timestamp"); + let newer_updated_at = + DateTime::::from_timestamp(1_700_000_200, 0).expect("valid newer timestamp"); + + for (thread_id, updated_at) in [ + (older_id, older_updated_at), + (newer_id, newer_updated_at), + (middle_id, newer_updated_at), + ] { + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.updated_at = updated_at; + metadata.first_user_message = Some("hello".to_string()); + runtime + .upsert_thread(&metadata) + .await + .expect("thread insert should succeed"); + } + + let anchor = Anchor { + ts: older_updated_at, + }; + let model_providers = ["test-provider".to_string()]; + let page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: Some(&model_providers), + anchor: Some(&anchor), + sort_key: SortKey::UpdatedAt, + sort_direction: SortDirection::Asc, + search_term: None, + }, + ) + .await + .expect("list should succeed"); + + let ids = page.items.iter().map(|item| item.id).collect::>(); + assert_eq!(ids, vec![newer_id]); + assert_eq!( + page.next_anchor, + Some(Anchor { + ts: DateTime::::from_timestamp_millis(1_700_000_200_000) + .expect("valid timestamp"), + }) + ); + + let page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: Some(&model_providers), + anchor: page.next_anchor.as_ref(), + sort_key: SortKey::UpdatedAt, + sort_direction: SortDirection::Asc, + search_term: None, + }, + ) + .await + .expect("second page should succeed"); + + let ids = page.items.iter().map(|item| item.id).collect::>(); + assert_eq!(ids, vec![middle_id]); + assert_eq!(page.next_anchor, None); + } + #[tokio::test] async fn apply_rollout_items_restores_memory_mode_from_session_meta() { let codex_home = unique_temp_dir(); diff --git a/codex-rs/thread-store/src/lib.rs b/codex-rs/thread-store/src/lib.rs index f8dc94908..8c3d035fe 100644 --- a/codex-rs/thread-store/src/lib.rs +++ b/codex-rs/thread-store/src/lib.rs @@ -27,6 +27,7 @@ pub use types::OptionalStringPatch; pub use types::ReadThreadParams; pub use types::ResumeThreadRecorderParams; pub use types::SetThreadNameParams; +pub use types::SortDirection; pub use types::StoredThread; pub use types::StoredThreadHistory; pub use types::ThreadEventPersistenceMode; diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index 4fcea6dba..dc07e4962 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -100,6 +100,7 @@ mod tests { page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, + sort_direction: crate::SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, archived: true, diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index 35c6f3de4..a0c2d4131 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -5,6 +5,7 @@ use codex_rollout::parse_cursor; use super::LocalThreadStore; use super::helpers::stored_thread_from_rollout_item; use crate::ListThreadsParams; +use crate::SortDirection; use crate::ThreadPage; use crate::ThreadSortKey; use crate::ThreadStoreError; @@ -27,7 +28,18 @@ pub(super) async fn list_threads( ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, }; - let page = list_rollout_threads(&store.config, ¶ms, cursor.as_ref(), sort_key).await?; + let sort_direction = match params.sort_direction { + SortDirection::Asc => codex_rollout::SortDirection::Asc, + SortDirection::Desc => codex_rollout::SortDirection::Desc, + }; + let page = list_rollout_threads( + &store.config, + ¶ms, + cursor.as_ref(), + sort_key, + sort_direction, + ) + .await?; let next_cursor = page .next_cursor @@ -54,6 +66,7 @@ async fn list_rollout_threads( params: &ListThreadsParams, cursor: Option<&codex_rollout::Cursor>, sort_key: codex_rollout::ThreadSortKey, + sort_direction: codex_rollout::SortDirection, ) -> ThreadStoreResult { let page = if params.archived { RolloutRecorder::list_archived_threads( @@ -61,6 +74,7 @@ async fn list_rollout_threads( params.page_size, cursor, sort_key, + sort_direction, params.allowed_sources.as_slice(), params.model_providers.as_deref(), config.model_provider_id.as_str(), @@ -73,6 +87,7 @@ async fn list_rollout_threads( params.page_size, cursor, sort_key, + sort_direction, params.allowed_sources.as_slice(), params.model_providers.as_deref(), config.model_provider_id.as_str(), @@ -122,6 +137,7 @@ mod tests { page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, + sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, archived: false, @@ -177,6 +193,7 @@ mod tests { page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, + sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, archived: false, @@ -213,6 +230,7 @@ mod tests { page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, + sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, archived: false, @@ -225,6 +243,7 @@ mod tests { page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, + sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, archived: true, @@ -273,6 +292,7 @@ mod tests { page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, + sort_direction: SortDirection::Desc, allowed_sources: vec![SessionSource::Cli], model_providers: Some(vec!["test-provider".to_string()]), archived: false, @@ -306,6 +326,7 @@ mod tests { page_size: 10, cursor: Some("not-a-cursor".to_string()), sort_key: ThreadSortKey::CreatedAt, + sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, archived: false, diff --git a/codex-rs/thread-store/src/remote/list_threads.rs b/codex-rs/thread-store/src/remote/list_threads.rs index b94db9dc8..0ed1f44a1 100644 --- a/codex-rs/thread-store/src/remote/list_threads.rs +++ b/codex-rs/thread-store/src/remote/list_threads.rs @@ -161,6 +161,7 @@ mod tests { page_size: 2, cursor: Some("cursor-1".to_string()), sort_key: ThreadSortKey::UpdatedAt, + sort_direction: crate::SortDirection::Desc, allowed_sources: vec![SessionSource::Cli], model_providers: Some(vec!["openai".to_string()]), archived: true, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 8d489ea6c..68a84a6fd 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -101,6 +101,16 @@ pub enum ThreadSortKey { UpdatedAt, } +/// The direction to use when listing stored threads. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SortDirection { + /// Return older threads before newer threads. + Asc, + /// Return newer threads before older threads. + #[default] + Desc, +} + /// Parameters for listing threads. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ListThreadsParams { @@ -110,6 +120,8 @@ pub struct ListThreadsParams { pub cursor: Option, /// Sort order requested by the caller. pub sort_key: ThreadSortKey, + /// Sort direction requested by the caller. + pub sort_direction: SortDirection, /// Allowed session sources. Empty means implementation default. pub allowed_sources: Vec, /// Optional model provider filter. `None` means implementation default, while an empty vector diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a5efcd1ea..b67dd419f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -517,6 +517,7 @@ async fn lookup_session_target_by_name_with_app_server( cursor: cursor.clone(), limit: Some(100), sort_key: Some(AppServerThreadSortKey::UpdatedAt), + sort_direction: None, model_providers: None, source_kinds: Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]), archived: Some(false), @@ -612,6 +613,7 @@ fn latest_session_lookup_params( cursor: None, limit: Some(1), sort_key: Some(AppServerThreadSortKey::UpdatedAt), + sort_direction: None, model_providers: if is_remote { None } else { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 93828afe8..1198308c3 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -336,6 +336,7 @@ fn spawn_rollout_page_loader( PAGE_SIZE, cursor, request.sort_key, + codex_rollout::SortDirection::Desc, INTERACTIVE_SESSION_SOURCES.as_slice(), default_provider.as_ref().map(std::slice::from_ref), default_provider.as_deref().unwrap_or_default(), @@ -1179,6 +1180,7 @@ fn thread_list_params( ThreadSortKey::CreatedAt => AppServerThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => AppServerThreadSortKey::UpdatedAt, }), + sort_direction: None, model_providers: match provider_filter { ProviderFilter::Any => None, ProviderFilter::MatchDefault(default_provider) => Some(vec![default_provider]), @@ -2420,9 +2422,7 @@ mod tests { make_item("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "third"), make_item("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "second"), ], - Some(cursor_from_str( - "2025-01-02T00-00-00|00000000-0000-0000-0000-000000000000", - )), + Some(cursor_from_str("2025-01-02T00:00:00Z")), /*num_scanned_files*/ 2, /*reached_scan_cap*/ false, )); @@ -2432,9 +2432,7 @@ mod tests { make_item("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "duplicate"), make_item("/tmp/c.jsonl", "2025-01-01T00:00:00Z", "first"), ], - Some(cursor_from_str( - "2025-01-01T00-00-00|00000000-0000-0000-0000-000000000001", - )), + Some(cursor_from_str("2025-01-01T00:00:00Z")), /*num_scanned_files*/ 2, /*reached_scan_cap*/ false, )); @@ -2488,9 +2486,7 @@ mod tests { make_item("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"), make_item("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"), ], - Some(cursor_from_str( - "2025-01-03T00-00-00|00000000-0000-0000-0000-000000000000", - )), + Some(cursor_from_str("2025-01-03T00:00:00Z")), /*num_scanned_files*/ 2, /*reached_scan_cap*/ false, )); @@ -2806,9 +2802,7 @@ mod tests { "2025-01-01T00:00:00Z", "alpha", )], - Some(cursor_from_str( - "2025-01-02T00-00-00|00000000-0000-0000-0000-000000000000", - )), + Some(cursor_from_str("2025-01-02T00:00:00Z")), /*num_scanned_files*/ 1, /*reached_scan_cap*/ false, )); @@ -2827,9 +2821,7 @@ mod tests { search_token: first_request.search_token, page: Ok(page( vec![make_item("/tmp/beta.jsonl", "2025-01-02T00:00:00Z", "beta")], - Some(cursor_from_str( - "2025-01-03T00-00-00|00000000-0000-0000-0000-000000000001", - )), + Some(cursor_from_str("2025-01-03T00:00:00Z")), /*num_scanned_files*/ 5, /*reached_scan_cap*/ false, )), @@ -2855,9 +2847,7 @@ mod tests { "2025-01-03T00:00:00Z", "target log", )], - Some(cursor_from_str( - "2025-01-04T00-00-00|00000000-0000-0000-0000-000000000002", - )), + Some(cursor_from_str("2025-01-04T00:00:00Z")), /*num_scanned_files*/ 7, /*reached_scan_cap*/ false, )),