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, )),