From ad6fc66b6dad21a474e43486c9839631ac4f882c Mon Sep 17 00:00:00 2001 From: joeytrasatti-openai Date: Wed, 29 Jul 2026 18:27:59 +0000 Subject: [PATCH] Add persisted manual ordering for thread sections (#36007) ## What changed - Add `thread/section/move` to atomically move a thread into, within, or out of a section. Threads can be inserted before an existing member or appended, and moves within a section preserve `sectionEnteredAt`. - Add `section_position` sorting to `thread/list`, with ascending order as its default, and expose `sectionEnteredAt` in thread responses. - Persist section positions and entry times in SQLite, including migration of existing section members into recency order. Section membership is no longer updated through `thread/metadata/update`. ## Testing - Cover section moves, reordering, pagination, persistence across restarts and rollout reconciliation, concurrent updates, and rank renumbering. GitOrigin-RevId: aec6d7ddedca5277029b5caf5c074975397e956c --- .../analytics/src/analytics_client_tests.rs | 1 + codex-rs/analytics/src/client_tests.rs | 1 + .../schema/json/ClientRequest.json | 70 +- .../schema/json/ServerNotification.json | 9 + .../codex_app_server_protocol.schemas.json | 86 ++- .../codex_app_server_protocol.v2.schemas.json | 86 ++- .../schema/json/v2/ThreadForkResponse.json | 9 + .../schema/json/v2/ThreadListParams.json | 3 +- .../schema/json/v2/ThreadListResponse.json | 9 + .../json/v2/ThreadMetadataUpdateParams.json | 7 - .../json/v2/ThreadMetadataUpdateResponse.json | 9 + .../schema/json/v2/ThreadReadResponse.json | 9 + .../schema/json/v2/ThreadResumeResponse.json | 9 + .../json/v2/ThreadRollbackResponse.json | 9 + .../json/v2/ThreadSectionMoveParams.json | 30 + .../json/v2/ThreadSectionMoveResponse.json | 5 + .../schema/json/v2/ThreadStartResponse.json | 9 + .../json/v2/ThreadStartedNotification.json | 9 + .../json/v2/ThreadUnarchiveResponse.json | 9 + .../schema/typescript/ClientRequest.ts | 3 +- .../schema/typescript/v2/Thread.ts | 3 + .../v2/ThreadMetadataUpdateParams.ts | 6 +- .../typescript/v2/ThreadSearchSortKey.ts | 5 + .../typescript/v2/ThreadSectionMoveParams.ts | 20 + .../v2/ThreadSectionMoveResponse.ts | 5 + .../schema/typescript/v2/ThreadSortKey.ts | 2 +- .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 93 +++ .../src/protocol/serde_helpers.rs | 6 + .../src/protocol/v2/account.rs | 11 +- .../src/protocol/v2/tests.rs | 43 +- .../src/protocol/v2/thread.rs | 46 +- .../src/protocol/v2/thread_data.rs | 4 + codex-rs/app-server/README.md | 38 +- .../app-server/src/bespoke_event_handling.rs | 2 + codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/request_processors.rs | 1 + .../request_processors/thread_processor.rs | 77 ++- .../thread_processor_tests.rs | 2 + .../thread_resume_redaction.rs | 1 + .../src/request_processors/thread_summary.rs | 1 + .../tests/common/test_app_server.rs | 10 + .../app-server/tests/suite/v2/thread_list.rs | 89 ++- .../tests/suite/v2/thread_metadata_update.rs | 259 +++++++- .../tests/suite/v2/thread_resume.rs | 1 - .../tests/suite/v2/thread_unarchive.rs | 35 +- codex-rs/core/src/realtime_context_tests.rs | 2 + codex-rs/core/src/thread_manager.rs | 27 + codex-rs/exec/src/lib_tests.rs | 2 + .../migrations/0046_threads_section_order.sql | 20 + codex-rs/state/src/extract.rs | 2 + codex-rs/state/src/migrations_tests.rs | 118 ++++ codex-rs/state/src/model/thread_metadata.rs | 33 +- codex-rs/state/src/runtime.rs | 1 + codex-rs/state/src/runtime/memories.rs | 4 + codex-rs/state/src/runtime/test_support.rs | 2 + .../state/src/runtime/thread_section_order.rs | 284 ++++++++ .../src/runtime/thread_section_order_tests.rs | 623 ++++++++++++++++++ codex-rs/state/src/runtime/threads.rs | 404 ++++-------- codex-rs/thread-store/src/in_memory.rs | 140 +++- codex-rs/thread-store/src/lib.rs | 1 + codex-rs/thread-store/src/local/helpers.rs | 16 + .../thread-store/src/local/list_threads.rs | 245 ++++++- codex-rs/thread-store/src/local/mod.rs | 9 + .../src/local/move_thread_to_section.rs | 58 ++ .../thread-store/src/local/read_thread.rs | 60 +- .../thread-store/src/local/search_threads.rs | 25 + .../src/local/update_thread_metadata.rs | 121 ++-- codex-rs/thread-store/src/store.rs | 13 + codex-rs/thread-store/src/types.rs | 33 +- codex-rs/tui/src/app/loaded_threads.rs | 1 + codex-rs/tui/src/app/tests.rs | 3 + codex-rs/tui/src/app/thread_session_state.rs | 1 + codex-rs/tui/src/app_server_session.rs | 2 +- codex-rs/tui/src/resume_picker.rs | 20 +- 75 files changed, 2877 insertions(+), 541 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadSectionMoveParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadSectionMoveResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadSearchSortKey.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionMoveParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionMoveResponse.ts create mode 100644 codex-rs/state/migrations/0046_threads_section_order.sql create mode 100644 codex-rs/state/src/runtime/thread_section_order.rs create mode 100644 codex-rs/state/src/runtime/thread_section_order_tests.rs create mode 100644 codex-rs/thread-store/src/local/move_thread_to_section.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 22d97bd8a99a..ba9d172353cb 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -205,6 +205,7 @@ fn sample_thread_with_metadata( preview: "first prompt".to_string(), ephemeral, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index ecce99b019ae..1dc49fa802e7 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -480,6 +480,7 @@ fn sample_thread(thread_id: &str) -> Thread { preview: "first prompt".to_string(), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3582754110de..b2cbff661bd5 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4310,13 +4310,6 @@ ], "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." }, - "sectionId": { - "description": "Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID.", - "type": [ - "string", - "null" - ] - }, "threadId": { "type": "string" } @@ -4589,6 +4582,14 @@ ], "type": "object" }, + "ThreadSearchSortKey": { + "enum": [ + "created_at", + "updated_at", + "recency_at" + ], + "type": "string" + }, "ThreadSectionListParams": { "description": "Parameters for listing independently persisted thread sections.", "properties": { @@ -4611,6 +4612,34 @@ }, "type": "object" }, + "ThreadSectionMoveParams": { + "description": "Parameters for moving a thread within a server-owned section ordering.", + "properties": { + "beforeThreadId": { + "description": "Existing thread to insert before; omission or null appends to the section.", + "type": [ + "string", + "null" + ] + }, + "sectionId": { + "description": "Destination section, or `null` to remove the thread from its section.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "description": "Thread to move into, within, or out of a section.", + "type": "string" + } + }, + "required": [ + "sectionId", + "threadId" + ], + "type": "object" + }, "ThreadSetNameParams": { "properties": { "name": { @@ -4646,7 +4675,8 @@ "enum": [ "created_at", "updated_at", - "recency_at" + "recency_at", + "section_position" ], "type": "string" }, @@ -5529,6 +5559,30 @@ "title": "Thread/metadata/updateRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/section/move" + ], + "title": "Thread/section/moveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSectionMoveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/section/moveRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 839f6bcd311b..ab02038e4190 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3816,6 +3816,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" 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 4f3f01e79c97..8050d9dbd6fd 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 @@ -492,6 +492,30 @@ "title": "Thread/metadata/updateRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/section/move" + ], + "title": "Thread/section/moveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadSectionMoveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/section/moveRequest", + "type": "object" + }, { "properties": { "id": { @@ -18283,6 +18307,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -19903,13 +19936,6 @@ ], "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." }, - "sectionId": { - "description": "Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID.", - "type": [ - "string", - "null" - ] - }, "threadId": { "type": "string" } @@ -20498,6 +20524,14 @@ ], "type": "object" }, + "ThreadSearchSortKey": { + "enum": [ + "created_at", + "updated_at", + "recency_at" + ], + "type": "string" + }, "ThreadSection": { "description": "An independently persisted, user-visible thread section.", "properties": { @@ -20564,6 +20598,41 @@ "title": "ThreadSectionListResponse", "type": "object" }, + "ThreadSectionMoveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for moving a thread within a server-owned section ordering.", + "properties": { + "beforeThreadId": { + "description": "Existing thread to insert before; omission or null appends to the section.", + "type": [ + "string", + "null" + ] + }, + "sectionId": { + "description": "Destination section, or `null` to remove the thread from its section.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "description": "Thread to move into, within, or out of a section.", + "type": "string" + } + }, + "required": [ + "sectionId", + "threadId" + ], + "title": "ThreadSectionMoveParams", + "type": "object" + }, + "ThreadSectionMoveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadSectionMoveResponse", + "type": "object" + }, "ThreadSetNameParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -20711,7 +20780,8 @@ "enum": [ "created_at", "updated_at", - "recency_at" + "recency_at", + "section_position" ], "type": "string" }, 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 f998192a83b1..f275f7fc58b2 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 @@ -1642,6 +1642,30 @@ "title": "Thread/metadata/updateRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/section/move" + ], + "title": "Thread/section/moveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSectionMoveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/section/moveRequest", + "type": "object" + }, { "properties": { "id": { @@ -16047,6 +16071,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -17667,13 +17700,6 @@ ], "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." }, - "sectionId": { - "description": "Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID.", - "type": [ - "string", - "null" - ] - }, "threadId": { "type": "string" } @@ -18262,6 +18288,14 @@ ], "type": "object" }, + "ThreadSearchSortKey": { + "enum": [ + "created_at", + "updated_at", + "recency_at" + ], + "type": "string" + }, "ThreadSection": { "description": "An independently persisted, user-visible thread section.", "properties": { @@ -18328,6 +18362,41 @@ "title": "ThreadSectionListResponse", "type": "object" }, + "ThreadSectionMoveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for moving a thread within a server-owned section ordering.", + "properties": { + "beforeThreadId": { + "description": "Existing thread to insert before; omission or null appends to the section.", + "type": [ + "string", + "null" + ] + }, + "sectionId": { + "description": "Destination section, or `null` to remove the thread from its section.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "description": "Thread to move into, within, or out of a section.", + "type": "string" + } + }, + "required": [ + "sectionId", + "threadId" + ], + "title": "ThreadSectionMoveParams", + "type": "object" + }, + "ThreadSectionMoveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadSectionMoveResponse", + "type": "object" + }, "ThreadSetNameParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -18475,7 +18544,8 @@ "enum": [ "created_at", "updated_at", - "recency_at" + "recency_at", + "section_position" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index ead666369d90..f3410c14603b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1159,6 +1159,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" 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 5f6b62011585..a4afb3028061 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -25,7 +25,8 @@ "enum": [ "created_at", "updated_at", - "recency_at" + "recency_at", + "section_position" ], "type": "string" }, 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 b2c44e214a5a..154d698066ef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -950,6 +950,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json index 7866be89b81b..c6679568ea5e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json @@ -40,13 +40,6 @@ ], "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." }, - "sectionId": { - "description": "Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID.", - "type": [ - "string", - "null" - ] - }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 8e3de74501f7..84893b059dee 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -950,6 +950,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 73449d46e759..c035ef827172 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -950,6 +950,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index d3ffee1deff5..fab346d49dc3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1159,6 +1159,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 63982aa3025c..68471bcdd8dd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -950,6 +950,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionMoveParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionMoveParams.json new file mode 100644 index 000000000000..3d8a3ca0ad88 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionMoveParams.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for moving a thread within a server-owned section ordering.", + "properties": { + "beforeThreadId": { + "description": "Existing thread to insert before; omission or null appends to the section.", + "type": [ + "string", + "null" + ] + }, + "sectionId": { + "description": "Destination section, or `null` to remove the thread from its section.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "description": "Thread to move into, within, or out of a section.", + "type": "string" + } + }, + "required": [ + "sectionId", + "threadId" + ], + "title": "ThreadSectionMoveParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionMoveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionMoveResponse.json new file mode 100644 index 000000000000..f6982622c806 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionMoveResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadSectionMoveResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 14569d764fda..6145520bbb64 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1159,6 +1159,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 850d62c101f0..e3c26033cfde 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -950,6 +950,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 46d0ee41caa2..b37937c3957c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -950,6 +950,15 @@ "default": null, "description": "The independently persisted section selected for this thread, if any." }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index cb9841890689..adf9118c1f35 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -79,6 +79,7 @@ import type { ThreadReadParams } from "./v2/ThreadReadParams"; import type { ThreadResumeParams } from "./v2/ThreadResumeParams"; import type { ThreadRollbackParams } from "./v2/ThreadRollbackParams"; import type { ThreadSectionListParams } from "./v2/ThreadSectionListParams"; +import type { ThreadSectionMoveParams } from "./v2/ThreadSectionMoveParams"; import type { ThreadSetNameParams } from "./v2/ThreadSetNameParams"; import type { ThreadShellCommandParams } from "./v2/ThreadShellCommandParams"; import type { ThreadStartParams } from "./v2/ThreadStartParams"; @@ -92,4 +93,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/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "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/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "threadSection/list", id: RequestId, params: ThreadSectionListParams, } | { "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": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/read", id: RequestId, params: AppsReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "app/installed", id: RequestId, params: AppsInstalledParams, } | { "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": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "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": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "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": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "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": "externalAgentConfig/import/recordHistory", id: RequestId, params: ExternalAgentConfigImportHistoryRecordParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "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/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/section/move", id: RequestId, params: ThreadSectionMoveParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "threadSection/list", id: RequestId, params: ThreadSectionListParams, } | { "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": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/read", id: RequestId, params: AppsReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "app/installed", id: RequestId, params: AppsInstalledParams, } | { "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": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "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": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "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": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "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": "externalAgentConfig/import/recordHistory", id: RequestId, params: ExternalAgentConfigImportHistoryRecordParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "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/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 61fda97efb58..cad1d9465c96 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -31,6 +31,9 @@ ephemeral: boolean, /** * The independently persisted section selected for this thread, if any. */ section: ThreadSection | null, /** + * Unix timestamp in seconds when the thread entered its current section. + */ +sectionEnteredAt: number | null, /** * Model provider used for this thread (for example, 'openai'). */ modelProvider: string, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts index 829c46ba23c2..bec4bc1284d0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts @@ -9,8 +9,4 @@ export type ThreadMetadataUpdateParams = { threadId: string, * Omit a field to leave it unchanged, set it to `null` to clear it, or * provide a string to replace the stored value. */ -gitInfo?: ThreadMetadataGitInfoUpdateParams | null, -/** - * Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID. - */ -sectionId?: string | null, }; +gitInfo?: ThreadMetadataGitInfoUpdateParams | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSearchSortKey.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSearchSortKey.ts new file mode 100644 index 000000000000..4abf27135179 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSearchSortKey.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 ThreadSearchSortKey = "created_at" | "updated_at" | "recency_at"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionMoveParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionMoveParams.ts new file mode 100644 index 000000000000..b3b70cb68b09 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionMoveParams.ts @@ -0,0 +1,20 @@ +// 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. + +/** + * Parameters for moving a thread within a server-owned section ordering. + */ +export type ThreadSectionMoveParams = { +/** + * Thread to move into, within, or out of a section. + */ +threadId: string, +/** + * Destination section, or `null` to remove the thread from its section. + */ +sectionId: string | null, +/** + * Existing thread to insert before; omission or null appends to the section. + */ +beforeThreadId?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionMoveResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionMoveResponse.ts new file mode 100644 index 000000000000..e9e0f43900ba --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionMoveResponse.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 ThreadSectionMoveResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts index d93f1c47bfe9..21eae4e755f6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadSortKey = "created_at" | "updated_at" | "recency_at"; +export type ThreadSortKey = "created_at" | "updated_at" | "recency_at" | "section_position"; 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 36f51b235b2a..7a06b5248959 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -470,9 +470,12 @@ export type { ThreadResumeResponse } from "./ThreadResumeResponse"; export type { ThreadRollbackParams } from "./ThreadRollbackParams"; export type { ThreadRollbackResponse } from "./ThreadRollbackResponse"; export type { ThreadSearchResult } from "./ThreadSearchResult"; +export type { ThreadSearchSortKey } from "./ThreadSearchSortKey"; export type { ThreadSection } from "./ThreadSection"; export type { ThreadSectionListParams } from "./ThreadSectionListParams"; export type { ThreadSectionListResponse } from "./ThreadSectionListResponse"; +export type { ThreadSectionMoveParams } from "./ThreadSectionMoveParams"; +export type { ThreadSectionMoveResponse } from "./ThreadSectionMoveResponse"; export type { ThreadSetNameParams } from "./ThreadSetNameParams"; export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; export type { ThreadSettings } from "./ThreadSettings"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 0ae2fcb860fe..983c97b807a3 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -546,6 +546,11 @@ client_request_definitions! { serialization: thread_id(params.thread_id), response: v2::ThreadMetadataUpdateResponse, }, + ThreadSectionMove => "thread/section/move" { + params: v2::ThreadSectionMoveParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadSectionMoveResponse, + }, #[experimental("thread/settings/update")] ThreadSettingsUpdate => "thread/settings/update" { params: v2::ThreadSettingsUpdateParams, @@ -1873,6 +1878,92 @@ mod tests { } } + #[test] + fn thread_section_move_round_trips_and_serializes_by_thread() -> Result<()> { + assert_eq!( + serde_json::to_value(v2::ThreadSortKey::SectionPosition)?, + json!("section_position") + ); + let request = ClientRequest::ThreadSectionMove { + request_id: request_id(), + params: v2::ThreadSectionMoveParams { + thread_id: "thread-1".to_string(), + section_id: Some("01984de2-8f74-7c91-a3b2-5c5e937cf318".to_string()), + before_thread_id: Some("thread-2".to_string()), + }, + }; + assert_eq!( + serde_json::to_value(&request)?, + json!({ + "method": "thread/section/move", + "id": 1, + "params": { + "threadId": "thread-1", + "sectionId": "01984de2-8f74-7c91-a3b2-5c5e937cf318", + "beforeThreadId": "thread-2" + } + }) + ); + assert_eq!( + request.serialization_scope(), + Some(ClientRequestSerializationScope::Thread { + thread_id: "thread-1".to_string() + }) + ); + + let append_request = ClientRequest::try_from(JSONRPCRequest { + id: request_id(), + method: "thread/section/move".to_string(), + params: Some(json!({ + "threadId": "thread-1", + "sectionId": "01984de2-8f74-7c91-a3b2-5c5e937cf318" + })), + trace: None, + })?; + assert_eq!( + append_request, + ClientRequest::ThreadSectionMove { + request_id: request_id(), + params: v2::ThreadSectionMoveParams { + thread_id: "thread-1".to_string(), + section_id: Some("01984de2-8f74-7c91-a3b2-5c5e937cf318".to_string()), + before_thread_id: None, + }, + } + ); + + let clear_request = ClientRequest::try_from(JSONRPCRequest { + id: request_id(), + method: "thread/section/move".to_string(), + params: Some(json!({ + "threadId": "thread-1", + "sectionId": null + })), + trace: None, + })?; + assert_eq!( + clear_request, + ClientRequest::ThreadSectionMove { + request_id: request_id(), + params: v2::ThreadSectionMoveParams { + thread_id: "thread-1".to_string(), + section_id: None, + before_thread_id: None, + }, + } + ); + assert!( + ClientRequest::try_from(JSONRPCRequest { + id: request_id(), + method: "thread/section/move".to_string(), + params: Some(json!({ "threadId": "thread-1" })), + trace: None, + }) + .is_err() + ); + Ok(()) + } + #[test] fn client_request_serialization_scope_covers_keyed_families() { let thread_id = "thread-1".to_string(); @@ -2717,6 +2808,7 @@ mod tests { preview: "first prompt".to_string(), ephemeral: true, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, @@ -2770,6 +2862,7 @@ mod tests { "preview": "first prompt", "ephemeral": true, "section": null, + "sectionEnteredAt": null, "historyMode": "legacy", "modelProvider": "openai", "createdAt": 1, diff --git a/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs b/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs index 6d6747a5cc3a..8d3cdbea6137 100644 --- a/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs +++ b/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs @@ -5,6 +5,12 @@ use serde::Deserializer; use serde::Serialize; use serde::Serializer; +pub(crate) fn nullable_string_schema( + generator: &mut schemars::r#gen::SchemaGenerator, +) -> schemars::schema::Schema { + generator.subschema_for::>() +} + pub fn deserialize_empty_path_as_none<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 1ef94f9d818b..1e7ac5088fa1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -25,7 +25,10 @@ pub enum Account { #[serde(rename = "chatgpt", rename_all = "camelCase")] #[ts(rename = "chatgpt", rename_all = "camelCase")] Chatgpt { - #[schemars(required, schema_with = "nullable_string_schema")] + #[schemars( + required, + schema_with = "crate::protocol::serde_helpers::nullable_string_schema" + )] email: Option, plan_type: PlanType, }, @@ -38,12 +41,6 @@ pub enum Account { }, } -fn nullable_string_schema( - generator: &mut schemars::r#gen::SchemaGenerator, -) -> schemars::schema::Schema { - generator.subschema_for::>() -} - impl From for Account { fn from(account: ProviderAccount) -> Self { match account { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 501d5ef75201..4984ca4788fc 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -240,6 +240,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { id: "01984de2-8f74-7c91-a3b2-5c5e937cf318".to_string(), name: "Pinned".to_string(), }), + section_entered_at: Some(1), history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, @@ -287,15 +288,18 @@ fn thread_resume_response_round_trips_initial_turns_page() { "name": "Pinned", }) ); + assert_eq!(value["thread"]["sectionEnteredAt"], json!(1)); let mut legacy_thread = value["thread"].clone(); - legacy_thread + let legacy_thread_fields = legacy_thread .as_object_mut() - .expect("serialized thread should be an object") - .remove("section"); + .expect("serialized thread should be an object"); + legacy_thread_fields.remove("section"); + legacy_thread_fields.remove("sectionEnteredAt"); let legacy_thread = serde_json::from_value::(legacy_thread).expect("deserialize legacy thread"); assert_eq!(legacy_thread.section, None); + assert_eq!(legacy_thread.section_entered_at, None); assert_eq!( value.get("initialTurnsPage"), @@ -474,39 +478,6 @@ fn thread_list_params_accepts_section_id_filter() { ); } -#[test] -fn thread_metadata_update_params_distinguish_section_id_set_clear_and_omission() { - for section_id in [ - "01984de2-8f74-7c91-a3b2-5c5e937cf318", - "01984de2-8f74-7c91-a3b2-5c5e937cf319", - ] { - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - "sectionId": section_id, - })) - .expect("section ID metadata patch should deserialize"); - - assert_eq!( - params.section_id.as_ref().map(|section| section.as_deref()), - Some(Some(section_id)) - ); - assert_eq!(params.git_info, None); - } - - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - "sectionId": null, - })) - .expect("cleared section ID metadata patch should deserialize"); - assert_eq!(params.section_id, Some(None)); - - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - })) - .expect("omitted section ID metadata patch should deserialize"); - assert_eq!(params.section_id, None); -} - #[test] fn thread_section_list_params_and_response_round_trip() { let params = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index e0c70778e3cd..d8b02ac6509b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -866,15 +866,6 @@ pub struct ThreadMetadataUpdateParams { /// provide a string to replace the stored value. #[ts(optional = nullable)] pub git_info: Option, - /// Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID. - #[serde( - default, - skip_serializing_if = "Option::is_none", - serialize_with = "crate::protocol::serde_helpers::serialize_double_option", - deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" - )] - #[ts(optional = nullable, type = "string | null")] - pub section_id: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -920,6 +911,31 @@ pub struct ThreadMetadataUpdateResponse { pub thread: Thread, } +/// Parameters for moving a thread within a server-owned section ordering. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSectionMoveParams { + /// Thread to move into, within, or out of a section. + pub thread_id: String, + /// Destination section, or `null` to remove the thread from its section. + #[serde(deserialize_with = "Option::deserialize")] + #[schemars( + required, + schema_with = "crate::protocol::serde_helpers::nullable_string_schema" + )] + #[ts(type = "string | null")] + pub section_id: Option, + /// Existing thread to insert before; omission or null appends to the section. + #[ts(optional = nullable)] + pub before_thread_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSectionMoveResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[ts(rename_all = "lowercase")] @@ -1196,7 +1212,7 @@ pub struct ThreadSearchParams { pub limit: Option, /// Optional sort key; defaults to created_at. #[ts(optional = nullable)] - pub sort_key: Option, + pub sort_key: Option, /// Optional sort direction; defaults to descending (newest first). #[ts(optional = nullable)] pub sort_direction: Option, @@ -1244,6 +1260,16 @@ pub enum ThreadSortKey { CreatedAt, UpdatedAt, RecencyAt, + SectionPosition, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum ThreadSearchSortKey { + CreatedAt, + UpdatedAt, + RecencyAt, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 064fd8243566..3b63fc3c87e1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -197,6 +197,10 @@ pub struct Thread { /// The independently persisted section selected for this thread, if any. #[serde(default)] pub section: Option, + /// Unix timestamp in seconds when the thread entered its current section. + #[serde(default)] + #[ts(type = "number | null")] + pub section_entered_at: Option, /// Persisted thread history contract selected when this thread was created. #[experimental("thread.historyMode")] #[serde(default)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 83e1a16c29bf..d6002d947d86 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -143,14 +143,15 @@ Example with notification opt-out: - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; pass an optional `lastTurnId` to copy history only through that turn, inclusive, and drop later turns from the fork. An in-progress `lastTurnId` boundary is rejected. Experimental `beforeTurnId` instead copies history strictly before the referenced turn, including when that turn is in progress, and cannot be combined with `lastTurnId`. If both boundaries are null while the source thread is mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately, or `deferGoalContinuation: true` to carry the source thread's current goal into the fork and run an explicit turn before automatic continuation resumes. Deferred goal continuation is persisted until that turn starts and cannot be combined with `ephemeral: true`. Accepts the same permission override rules as `thread/start`. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior. -- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `sectionId`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. +- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `sectionId`, `cwd`, and `searchTerm` filters. Set `sortKey` to `"section_position"` when listing a section in its persisted manual order. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `threadSection/list` — page through independently persisted thread sections and their display names, including sections that do not currently contain any threads. - `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. For loaded threads, experimental clients can use `canAcceptDirectInput` to determine whether `turn/start` and `turn/steer` are accepted; unloaded stored threads report `null` when that capability is unavailable. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/items/list` — experimental; page through persisted thread items without resuming the thread. Pass `turnId` to restrict results to one turn, or omit it to page items across the thread. The active thread store must support item pagination. - `thread/searchOccurrences` — experimental; find literal, case-insensitive matches in visible user messages and summary-selected final assistant messages within one paginated thread. -- `thread/metadata/update` — patch stored thread metadata in sqlite; supports updating persisted `gitInfo` fields and `sectionId`, and returns the refreshed `thread`. Set `sectionId` to the ID of an existing section, pass `null` to clear it, or omit it to leave the current section unchanged. The returned `thread.section` contains the section's `id` and display `name`, or `null` when the thread has no section. +- `thread/metadata/update` — patch stored thread metadata in sqlite; supports updating persisted `gitInfo` fields and returns the refreshed `thread`. +- `thread/section/move` — atomically move a thread into the section identified by `sectionId`, before another thread or at the end when `beforeThreadId` is `null`. Reordering within the same section preserves `sectionEnteredAt`; entering a different section resets it. Set `sectionId` to `null` to remove the thread from its section. Returns `{}` on success. - `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; deprecated `multiAgentMode` is ignored, while Ultra reasoning effort enables proactive multi-agent behavior; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. - `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. @@ -396,9 +397,9 @@ Pass any combination of: - `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), `updated_at`, or `recency_at`. +- `sortKey` — `created_at` (default), `updated_at`, `recency_at`, or `section_position` for a section's persisted manual order. - `recencyAt` is initialized when the thread is created and advances when a turn starts. Unlike `updatedAt`, background output and other persisted mutations do not advance it. -- `sortDirection` — `desc` (default) or `asc`. +- `sortDirection` — `desc` (default for timestamp sorts) or `asc` (default for `section_position`). - `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). @@ -590,7 +591,7 @@ to load the containing turn. ### 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. +Use `thread/metadata/update` to patch sqlite-backed `gitInfo` without resuming a thread. Omitted fields are left unchanged, while explicit `null` clears a stored value. Use `thread/section/move` to enter, reorder, or leave a section; section positions remain server-owned, and `thread/list` returns threads in their manual order when `sortKey` is `section_position`. ```json { "method": "thread/metadata/update", "id": 24, "params": { @@ -614,6 +615,33 @@ Use `thread/metadata/update` to patch sqlite-backed metadata for a thread withou "gitInfo": null } } } + +{ "method": "thread/section/move", "id": 26, "params": { + "threadId": "thr_123", + "sectionId": "01984de2-8f74-7c91-a3b2-5c5e937cf318", + "beforeThreadId": null +} } +{ "id": 26, "result": {} } + +{ "method": "thread/list", "id": 27, "params": { + "sectionId": "01984de2-8f74-7c91-a3b2-5c5e937cf318", + "sortKey": "section_position", + "limit": 100 +} } + +{ "method": "thread/section/move", "id": 28, "params": { + "threadId": "thr_123", + "sectionId": "01984de2-8f74-7c91-a3b2-5c5e937cf318", + "beforeThreadId": "thr_456" +} } +{ "id": 28, "result": {} } + +{ "method": "thread/section/move", "id": 29, "params": { + "threadId": "thr_123", + "sectionId": null, + "beforeThreadId": null +} } +{ "id": 29, "result": {} } ``` Experimental: use `thread/memoryMode/set` to change whether a thread remains eligible for future memory generation. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 58f4d8d40159..e5a24f20ca20 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2218,6 +2218,8 @@ mod tests { recency_at: created_at, archived_at: None, section: None, + section_position: None, + section_entered_at: None, cwd: test_path_buf("/tmp").abs().into(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 854ef5b6b9ed..3f2447e9d4c8 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1114,6 +1114,9 @@ impl MessageProcessor { ClientRequest::ThreadMetadataUpdate { params, .. } => { self.thread_processor.thread_metadata_update(params).await } + ClientRequest::ThreadSectionMove { params, .. } => { + self.thread_processor.thread_section_move(params).await + } ClientRequest::ThreadSectionList { params, .. } => { self.thread_processor.thread_section_list(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 1c4f2feda2f1..2cf28de75c57 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -265,6 +265,7 @@ use codex_app_server_protocol::ThreadSearchOccurrencesResponse; use codex_app_server_protocol::ThreadSearchParams; use codex_app_server_protocol::ThreadSearchResponse; use codex_app_server_protocol::ThreadSearchResult; +use codex_app_server_protocol::ThreadSearchSortKey; use codex_app_server_protocol::ThreadSearchTextRange; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 283a7ba1d327..35548b3e1edc 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -7,6 +7,8 @@ use codex_app_server_protocol::SelectedCapabilityRoot; use codex_app_server_protocol::ThreadSection; use codex_app_server_protocol::ThreadSectionListParams; use codex_app_server_protocol::ThreadSectionListResponse; +use codex_app_server_protocol::ThreadSectionMoveParams; +use codex_app_server_protocol::ThreadSectionMoveResponse; use codex_extension_api::ExtensionDataInit; use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErrorDetails; @@ -589,6 +591,48 @@ impl ThreadRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn thread_section_move( + &self, + params: ThreadSectionMoveParams, + ) -> Result, JSONRPCErrorError> { + let ThreadSectionMoveParams { + thread_id, + section_id, + before_thread_id, + } = params; + let thread_uuid = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + if section_id + .as_deref() + .is_some_and(|section| section.trim().is_empty()) + { + return Err(invalid_request("sectionId must not be empty")); + } + if section_id.is_none() && before_thread_id.is_some() { + return Err(invalid_request( + "beforeThreadId requires a non-null sectionId", + )); + } + let before_thread_uuid = before_thread_id + .map(|thread_id| { + ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid before thread id: {err}"))) + }) + .transpose()?; + + { + let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; + self.thread_manager + .move_thread_to_section(thread_uuid, section_id.as_deref(), before_thread_uuid) + .await + .map_err(|err| core_thread_write_error("move thread in section", err))?; + } + + Ok(Some(ClientResponsePayload::ThreadSectionMove( + ThreadSectionMoveResponse {}, + ))) + } + pub(crate) async fn thread_memory_mode_set( &self, params: ThreadMemoryModeSetParams, @@ -1675,13 +1719,12 @@ impl ThreadRequestProcessor { let ThreadMetadataUpdateParams { thread_id, git_info, - section_id, } = params; let thread_uuid = ThreadId::from_string(&thread_id) .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - if git_info.is_none() && section_id.is_none() { + if git_info.is_none() { return Err(invalid_request( "thread metadata update must include at least one field", )); @@ -1717,7 +1760,6 @@ impl ThreadRequestProcessor { let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; let patch = StoreThreadMetadataPatch { git_info, - section: section_id, ..Default::default() }; self.thread_manager @@ -2040,8 +2082,14 @@ impl ThreadRequestProcessor { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, + ThreadSortKey::SectionPosition => StoreThreadSortKey::SectionPosition, }; - let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); + let sort_direction = sort_direction.unwrap_or(match store_sort_key { + StoreThreadSortKey::SectionPosition => SortDirection::Asc, + StoreThreadSortKey::CreatedAt + | StoreThreadSortKey::UpdatedAt + | StoreThreadSortKey::RecencyAt => SortDirection::Desc, + }); let (stored_threads, next_cursor) = self .list_threads_common( requested_page_size, @@ -2110,10 +2158,10 @@ impl ThreadRequestProcessor { .map(|value| value as usize) .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) .clamp(1, THREAD_LIST_MAX_LIMIT); - let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { - ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, - ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, - ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, + let store_sort_key = match sort_key.unwrap_or(ThreadSearchSortKey::CreatedAt) { + ThreadSearchSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, + ThreadSearchSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + ThreadSearchSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let store_sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (allowed_sources, source_kind_filter) = compute_source_filters(source_kinds); @@ -4630,10 +4678,19 @@ fn thread_backwards_cursor_for_sort_key( sort_key: StoreThreadSortKey, sort_direction: SortDirection, ) -> Option { + if sort_key == StoreThreadSortKey::SectionPosition { + let position = match sort_direction { + SortDirection::Asc => thread.section_position?.checked_add(1)?, + SortDirection::Desc => thread.section_position?.checked_sub(1)?, + }; + return Some(format!("{position}|{}", thread.thread_id)); + } + let timestamp = match sort_key { StoreThreadSortKey::CreatedAt => thread.created_at, StoreThreadSortKey::UpdatedAt => thread.updated_at, StoreThreadSortKey::RecencyAt => thread.recency_at, + StoreThreadSortKey::SectionPosition => unreachable!("section positions use rank cursors"), }; // The state DB stores unique millisecond timestamps. Offset the reverse cursor by one // millisecond so the opposite-direction query includes the page anchor. @@ -5134,6 +5191,9 @@ pub(crate) fn thread_from_stored_thread( id: section.id, name: section.name, }), + section_entered_at: thread + .section_entered_at + .map(|entered_at| entered_at.timestamp()), history_mode: thread.history_mode.into(), model_provider: if thread.model_provider.is_empty() { fallback_provider.to_string() @@ -5346,6 +5406,7 @@ fn build_thread_from_snapshot( preview: String::new(), ephemeral: config_snapshot.ephemeral, section: None, + section_entered_at: None, history_mode: config_snapshot.history_mode.into(), model_provider: config_snapshot.model_provider_id.clone(), created_at: now, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 3d8a78e89c22..9f6dd8d9e27b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -481,6 +481,8 @@ mod thread_processor_behavior_tests { recency_at: updated_at.with_timezone(&Utc), archived_at: None, section: None, + section_position: None, + section_entered_at: None, cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index e580616e6633..c0fb1d0a6c19 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -195,6 +195,7 @@ mod tests { preview: "preview".to_string(), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "mock_provider".to_string(), created_at: 0, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index b72b08f2106c..3dc1625ef8f1 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -308,6 +308,7 @@ pub(crate) fn summary_to_thread( preview, ephemeral: false, section: None, + section_entered_at: None, history_mode: ThreadHistoryMode::Legacy, model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 63dcc844f765..ff04657a18a7 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -103,6 +103,7 @@ use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSearchOccurrencesParams; use codex_app_server_protocol::ThreadSearchParams; +use codex_app_server_protocol::ThreadSectionMoveParams; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadShellCommandParams; @@ -538,6 +539,15 @@ impl TestAppServer { self.send_request("thread/metadata/update", params).await } + /// Send a `thread/section/move` JSON-RPC request. + pub async fn send_thread_section_move_request( + &mut self, + params: ThreadSectionMoveParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/section/move", params).await + } + /// Send a `thread/settings/update` JSON-RPC request. pub async fn send_thread_settings_update_request( &mut self, 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 956a048754ce..ac91f1e11771 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -19,9 +19,13 @@ use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSearchResponse; +use codex_app_server_protocol::ThreadSectionMoveParams; +use codex_app_server_protocol::ThreadSectionMoveResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; @@ -696,6 +700,14 @@ async fn thread_search_returns_content_matches() -> Result<()> { Some("mock_provider"), /*git_info*/ None, )?; + let unsectioned_match = create_fake_rollout( + codex_home.path(), + "2025-01-02T11-30-00", + "2025-01-02T11:30:00Z", + "unsectioned needle", + Some("mock_provider"), + /*git_info*/ None, + )?; let newer_match = create_fake_rollout( codex_home.path(), "2025-01-02T12-00-00", @@ -726,9 +738,84 @@ async fn thread_search_returns_content_matches() -> Result<()> { .iter() .map(|result| result.thread.id.as_str()) .collect(); - assert_eq!(ids, vec![newer_match, older_match]); + assert_eq!( + ids, + vec![ + newer_match.as_str(), + unsectioned_match.as_str(), + older_match.as_str(), + ] + ); assert_eq!(data[0].snippet, "mixed NEEDLE suffix"); + let mut pinned_threads = Vec::new(); + for thread_id in [&older_match, &newer_match] { + let request_id = mcp + .send_thread_section_move_request(ThreadSectionMoveParams { + thread_id: thread_id.clone(), + section_id: Some(codex_state::PINNED_THREAD_SECTION_ID.to_string()), + before_thread_id: None, + }) + .await?; + let _: ThreadSectionMoveResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; + let request_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }) + .await?; + let ThreadReadResponse { thread } = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; + pinned_threads.push(thread); + } + let [older_pinned, newer_pinned] = pinned_threads.as_slice() else { + unreachable!("two matching threads were pinned"); + }; + + let request_id = mcp + .send_thread_search_request(codex_app_server_protocol::ThreadSearchParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + source_kinds: None, + archived: None, + search_term: "needle".to_string(), + }) + .await?; + let ThreadSearchResponse { + data, next_cursor, .. + } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; + + let actual = data + .iter() + .map(|result| { + ( + result.thread.id.as_str(), + result.thread.section.clone(), + result.thread.section_entered_at, + ) + }) + .collect::>(); + assert_eq!( + actual, + vec![ + ( + newer_match.as_str(), + newer_pinned.section.clone(), + newer_pinned.section_entered_at, + ), + (unsectioned_match.as_str(), None, None), + ( + older_match.as_str(), + older_pinned.section.clone(), + older_pinned.section_entered_at, + ), + ] + ); + assert_eq!(next_cursor, None); + Ok(()) } 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 8fbe8f321af0..a88d38209fbb 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 @@ -21,6 +21,8 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSection; use codex_app_server_protocol::ThreadSectionListParams; use codex_app_server_protocol::ThreadSectionListResponse; +use codex_app_server_protocol::ThreadSectionMoveParams; +use codex_app_server_protocol::ThreadSectionMoveResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -47,7 +49,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] -async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination() -> Result<()> { +async fn thread_section_move_pins_and_unpins_with_filtered_recency_pagination() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; mock_responses_config(&server.uri()).write(codex_home.path())?; @@ -117,10 +119,10 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination let unknown_section_id = "01984de2-8f74-7c91-a3b2-5c5e937cf319"; let unknown_section_request_id = mcp - .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + .send_thread_section_move_request(ThreadSectionMoveParams { thread_id: initially_unpinned.clone(), - git_info: None, - section_id: Some(Some(unknown_section_id.to_string())), + section_id: Some(unknown_section_id.to_string()), + before_thread_id: None, }) .await?; let unknown_section_error: JSONRPCError = timeout( @@ -131,15 +133,15 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination assert_eq!(unknown_section_error.error.code, INVALID_REQUEST_ERROR_CODE); assert_eq!( unknown_section_error.error.message, - format!("thread section not found: {unknown_section_id}") + format!("section {unknown_section_id} does not exist") ); for thread_id in [older_pinned, newer_pinned] { let request_id = mcp - .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + .send_thread_section_move_request(ThreadSectionMoveParams { thread_id: thread_id.clone(), - git_info: None, - section_id: Some(Some(PINNED_THREAD_SECTION_ID.to_string())), + section_id: Some(PINNED_THREAD_SECTION_ID.to_string()), + before_thread_id: None, }) .await?; let response = timeout( @@ -147,15 +149,21 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - let wire_section = response - .result - .get("thread") - .and_then(|thread| thread.get("section")) - .cloned(); - let ThreadMetadataUpdateResponse { thread } = to_response(response)?; - assert_eq!(thread.id, *thread_id); - assert_eq!(thread.section, Some(pinned_section.clone())); - assert_eq!(wire_section, Some(serde_json::to_value(&pinned_section)?)); + assert_eq!( + to_response::(response)?, + ThreadSectionMoveResponse {} + ); + let thread = state_db + .get_thread(ThreadId::from_string(thread_id)?) + .await? + .expect("pinned thread should remain persisted"); + assert_eq!( + thread.section, + Some(codex_state::ThreadSection { + id: pinned_section.id.clone(), + name: pinned_section.name.clone(), + }) + ); } let list_params = ThreadListParams { @@ -201,10 +209,10 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination assert_eq!(second_page.data[0].section, Some(pinned_section.clone())); let request_id = mcp - .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + .send_thread_section_move_request(ThreadSectionMoveParams { thread_id: newer_pinned.clone(), - git_info: None, - section_id: Some(None), + section_id: None, + before_thread_id: None, }) .await?; let response = timeout( @@ -212,8 +220,22 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - let ThreadMetadataUpdateResponse { thread } = to_response(response)?; - assert_eq!(thread.section, None); + assert_eq!( + to_response::(response)?, + ThreadSectionMoveResponse {} + ); + let thread = state_db + .get_thread(ThreadId::from_string(newer_pinned)?) + .await? + .expect("unpinned thread should remain persisted"); + assert_eq!( + ( + thread.section, + thread.section_position, + thread.section_entered_at, + ), + (None, None, None) + ); let request_id = mcp .send_thread_list_request(ThreadListParams { @@ -260,6 +282,184 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination Ok(()) } +#[tokio::test] +async fn thread_sections_preserve_server_owned_manual_order_across_moves_and_restarts() -> Result<()> +{ + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + mock_responses_config(&server.uri()).write(codex_home.path())?; + let state_db = init_state_db(codex_home.path()).await?; + + let mut thread_ids = Vec::new(); + for (filename_timestamp, timestamp, preview) in [ + ( + "2025-01-06T08-00-00", + "2025-01-06T08:00:00Z", + "First pinned", + ), + ( + "2025-01-06T09-00-00", + "2025-01-06T09:00:00Z", + "Second pinned", + ), + ( + "2025-01-06T10-00-00", + "2025-01-06T10:00:00Z", + "Third pinned", + ), + ] { + let thread_id = create_fake_rollout( + codex_home.path(), + filename_timestamp, + timestamp, + preview, + Some("mock_provider"), + /*git_info*/ None, + )?; + reconcile_rollout( + Some(&state_db), + rollout_path(codex_home.path(), filename_timestamp, &thread_id).as_path(), + "mock_provider", + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + thread_ids.push(thread_id); + } + let [first_pinned, second_pinned, third_pinned] = thread_ids.as_slice() else { + unreachable!("three fake rollouts were created"); + }; + + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + + for thread_id in [first_pinned, second_pinned, third_pinned] { + let request_id = mcp + .send_thread_section_move_request(ThreadSectionMoveParams { + thread_id: thread_id.clone(), + section_id: Some(PINNED_THREAD_SECTION_ID.to_string()), + before_thread_id: None, + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + to_response::(response)?, + ThreadSectionMoveResponse {} + ); + } + + let list_params = ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: Some(ThreadSortKey::SectionPosition), + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + section_id: Some(Some(PINNED_THREAD_SECTION_ID.to_string())), + cwd: None, + use_state_db_only: false, + search_term: None, + parent_thread_id: None, + ancestor_thread_id: None, + }; + let request_id = mcp.send_thread_list_request(list_params.clone()).await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let initial: ThreadListResponse = to_response(response)?; + assert_eq!( + initial + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect::>(), + [ + first_pinned.as_str(), + second_pinned.as_str(), + third_pinned.as_str(), + ] + ); + let third_entered_at = initial.data[2].section_entered_at; + assert!(third_entered_at.is_some()); + + let request_id = mcp + .send_thread_section_move_request(ThreadSectionMoveParams { + thread_id: third_pinned.clone(), + section_id: Some(PINNED_THREAD_SECTION_ID.to_string()), + before_thread_id: Some(first_pinned.clone()), + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + to_response::(response)?, + ThreadSectionMoveResponse {} + ); + + let request_id = mcp.send_thread_list_request(list_params.clone()).await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let reordered: ThreadListResponse = to_response(response)?; + assert_eq!( + reordered + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect::>(), + [ + third_pinned.as_str(), + first_pinned.as_str(), + second_pinned.as_str() + ] + ); + assert_eq!(reordered.data[0].section_entered_at, third_entered_at); + + drop(mcp); + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let request_id = mcp.send_thread_list_request(list_params).await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let persisted: ThreadListResponse = to_response(response)?; + assert_eq!( + persisted + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect::>(), + [ + third_pinned.as_str(), + first_pinned.as_str(), + second_pinned.as_str() + ] + ); + assert_eq!(persisted.data[0].section_entered_at, third_entered_at); + + Ok(()) +} + #[tokio::test] async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -288,7 +488,6 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread.id.clone(), - section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/sidebar-pr".to_string())), @@ -387,7 +586,6 @@ async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> { let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread.id, - section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: None, @@ -438,7 +636,6 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread.id.clone(), - section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/ephemeral".to_string())), @@ -462,10 +659,10 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { ); let clear_section_id = mcp - .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + .send_thread_section_move_request(ThreadSectionMoveParams { thread_id: thread.id.clone(), - section_id: Some(None), - git_info: None, + section_id: None, + before_thread_id: None, }) .await?; let clear_section_err: JSONRPCError = timeout( @@ -478,7 +675,7 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { assert_eq!( clear_section_err.error.message, format!( - "ephemeral thread does not support metadata updates: {}", + "ephemeral thread does not support section moves: {}", thread.id ) ); @@ -513,7 +710,6 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() - let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), - section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/stored-thread".to_string())), @@ -598,7 +794,6 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary( let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), - section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/loaded-thread".to_string())), @@ -666,7 +861,6 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread() let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), - section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/archived-thread".to_string())), @@ -727,7 +921,6 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> { let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), - section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: Some(None), branch: Some(None), 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 2d680d1ec618..eb3a68f2a156 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2693,7 +2693,6 @@ async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Res let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), - section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/pr-branch".to_string())), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 8f87f34df85d..bca6f110028d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -13,9 +13,11 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; -use codex_app_server_protocol::ThreadMetadataUpdateParams; -use codex_app_server_protocol::ThreadMetadataUpdateResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadSection; +use codex_app_server_protocol::ThreadSectionMoveParams; +use codex_app_server_protocol::ThreadSectionMoveResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; @@ -105,16 +107,27 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result name: PINNED_THREAD_SECTION_NAME.to_string(), }; let pin_id = mcp - .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + .send_thread_section_move_request(ThreadSectionMoveParams { thread_id: thread.id.clone(), - git_info: None, - section_id: Some(Some(PINNED_THREAD_SECTION_ID.to_string())), + section_id: Some(PINNED_THREAD_SECTION_ID.to_string()), + before_thread_id: None, }) .await?; - let ThreadMetadataUpdateResponse { + let _: ThreadSectionMoveResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(pin_id)).await??; + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id.clone(), + include_turns: false, + }) + .await?; + let ThreadReadResponse { thread: pinned_thread, - } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(pin_id)).await??; + } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(pinned_thread.section, Some(pinned_section.clone())); + let pinned_entered_at = pinned_thread + .section_entered_at + .expect("pinned thread should have a section entry timestamp"); let found_rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) @@ -174,6 +187,10 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result .await??; assert_eq!(unarchived_notification.thread_id, thread.id); assert_eq!(unarchived_thread.section, Some(pinned_section.clone())); + assert_eq!( + unarchived_thread.section_entered_at, + Some(pinned_entered_at) + ); assert!( unarchived_thread.updated_at > old_timestamp, "expected updated_at to be bumped on unarchive" @@ -190,6 +207,10 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result thread_json.get("section"), Some(&serde_json::to_value(&pinned_section)?) ); + assert_eq!( + thread_json.get("sectionEnteredAt"), + Some(&Value::from(pinned_entered_at)) + ); assert_eq!( thread_json.get("name"), Some(&Value::Null), diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index b86f90145e4b..0ec90884cb97 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -53,6 +53,8 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre .expect("valid timestamp"), archived_at: None, section: None, + section_position: None, + section_entered_at: None, cwd: PathBuf::from(cwd), cli_version: "test".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 502fae8e4e2f..0fdb9fbb26ae 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -72,6 +72,7 @@ use codex_thread_store::InMemoryThreadStore; use codex_thread_store::LoadThreadHistoryParams; use codex_thread_store::LocalThreadStore; use codex_thread_store::LocalThreadStoreConfig; +use codex_thread_store::MoveThreadToSectionParams; use codex_thread_store::PreparedFork; use codex_thread_store::ReadThreadByRolloutPathParams; use codex_thread_store::ReadThreadParams; @@ -730,6 +731,32 @@ impl ThreadManager { }) } + /// Moves a persisted thread to, within, or out of a server-ordered section. + pub async fn move_thread_to_section( + &self, + thread_id: ThreadId, + section: Option<&str>, + before_thread_id: Option, + ) -> CodexResult<()> { + if let Ok(thread) = self.get_thread(thread_id).await + && thread.config_snapshot().await.ephemeral + { + return Err(CodexErr::InvalidRequest(format!( + "ephemeral thread does not support section moves: {thread_id}" + ))); + } + + self.state + .thread_store + .move_thread_to_section(MoveThreadToSectionParams { + thread_id, + section: section.map(ToOwned::to_owned), + before_thread_id, + }) + .await + .map_err(|err| thread_store_metadata_update_error(thread_id, err)) + } + /// List `thread_id` plus all known descendants in its spawn subtree. pub async fn list_agent_subtree_thread_ids( &self, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index b6bc6a61973c..d2c89d703502 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -336,6 +336,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { preview: String::new(), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 0, @@ -804,6 +805,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { preview: String::new(), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 0, diff --git a/codex-rs/state/migrations/0046_threads_section_order.sql b/codex-rs/state/migrations/0046_threads_section_order.sql new file mode 100644 index 000000000000..ad16a06272fb --- /dev/null +++ b/codex-rs/state/migrations/0046_threads_section_order.sql @@ -0,0 +1,20 @@ +ALTER TABLE threads ADD COLUMN section_position INTEGER; +ALTER TABLE threads ADD COLUMN section_entered_at_ms INTEGER; + +UPDATE threads +SET section_position = ranked.position, + section_entered_at_ms = threads.recency_at_ms +FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY thread_section_id + ORDER BY recency_at_ms DESC, id DESC + ) * 1000000 AS position + FROM threads + WHERE thread_section_id IS NOT NULL +) AS ranked +WHERE threads.id = ranked.id; + +CREATE INDEX idx_threads_section_position + ON threads(archived, thread_section_id, section_position ASC, id ASC) + WHERE thread_section_id IS NOT NULL AND preview <> ''; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 2d1a91fb1d40..9780ce3507b1 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -696,6 +696,8 @@ mod tests { first_user_message: None, archived_at: None, section: None, + section_position: None, + section_entered_at: None, git_sha: None, git_branch: None, git_origin_url: None, diff --git a/codex-rs/state/src/migrations_tests.rs b/codex-rs/state/src/migrations_tests.rs index a01b2685b481..1167731ef2ec 100644 --- a/codex-rs/state/src/migrations_tests.rs +++ b/codex-rs/state/src/migrations_tests.rs @@ -186,6 +186,124 @@ INSERT INTO threads ( pool.close().await; } +#[tokio::test] +async fn thread_section_order_migration_backfills_stably() { + let sqlite_home = crate::runtime::test_support::unique_temp_dir(); + tokio::fs::create_dir_all(&sqlite_home) + .await + .expect("sqlite home should be created"); + let _cleanup = scopeguard::guard(sqlite_home.clone(), |sqlite_home| { + let _ = std::fs::remove_dir_all(sqlite_home); + }); + let sqlite = crate::SqliteConfig::new_for_testing(sqlite_home.as_path().abs()); + let pool = sqlite + .open_read_write_pool(&sqlite.state_db_path()) + .await + .expect("sqlite database should open"); + migrator_through(/*version*/ 45) + .run(&pool) + .await + .expect("pre-ordering migrations should apply"); + + sqlx::query("INSERT INTO thread_sections (id, name) VALUES (?, ?)") + .bind(CUSTOM_THREAD_SECTION_ID) + .bind("Custom section") + .execute(&pool) + .await + .expect("custom section should exist before threads reference it"); + + let older = "00000000-0000-0000-0000-000000000071"; + let newer = "00000000-0000-0000-0000-000000000072"; + let pinned = "00000000-0000-0000-0000-000000000073"; + let unsectioned = "00000000-0000-0000-0000-000000000074"; + for (thread_id, recency_at_ms, section) in [ + (older, 1_700_000_001_000_i64, Some(CUSTOM_THREAD_SECTION_ID)), + (newer, 1_700_000_002_000, Some(CUSTOM_THREAD_SECTION_ID)), + (pinned, 1_700_000_003_000, Some(PINNED_THREAD_SECTION_ID)), + (unsectioned, 1_700_000_004_000, None), + ] { + sqlx::query( + r#" +INSERT INTO threads ( + id, rollout_path, created_at, updated_at, recency_at, + created_at_ms, updated_at_ms, recency_at_ms, source, + model_provider, cwd, title, preview, sandbox_policy, approval_mode, thread_section_id +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(thread_id) + .bind("/tmp/legacy.jsonl") + .bind(recency_at_ms / 1000) + .bind(recency_at_ms / 1000) + .bind(recency_at_ms / 1000) + .bind(recency_at_ms) + .bind(recency_at_ms) + .bind(recency_at_ms) + .bind("cli") + .bind("openai") + .bind("/tmp") + .bind("") + .bind("preview") + .bind("read-only") + .bind("on-request") + .bind(section) + .execute(&pool) + .await + .expect("legacy section row should insert"); + } + + STATE_MIGRATOR + .run(&pool) + .await + .expect("section ordering migration should apply"); + let custom_order = sqlx::query_scalar::<_, String>( + "SELECT id FROM threads WHERE thread_section_id = ? ORDER BY section_position, id", + ) + .bind(CUSTOM_THREAD_SECTION_ID) + .fetch_all(&pool) + .await + .expect("backfilled custom order should load"); + assert_eq!(custom_order, vec![newer.to_string(), older.to_string()]); + let positions = + sqlx::query_scalar::<_, Option>("SELECT section_position FROM threads ORDER BY id") + .fetch_all(&pool) + .await + .expect("section positions should load"); + assert_eq!( + positions, + vec![Some(2_000_000), Some(1_000_000), Some(1_000_000), None] + ); + let entered = sqlx::query_scalar::<_, Option>( + "SELECT section_entered_at_ms FROM threads ORDER BY id", + ) + .fetch_all(&pool) + .await + .expect("section entry timestamps should load"); + assert_eq!( + entered, + vec![ + Some(1_700_000_001_000), + Some(1_700_000_002_000), + Some(1_700_000_003_000), + None, + ] + ); + + let section_position_index = sqlx::query_scalar::<_, String>( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?", + ) + .bind("idx_threads_section_position") + .fetch_optional(&pool) + .await + .expect("section position index should remain inspectable"); + assert_eq!( + section_position_index, + Some("idx_threads_section_position".to_string()) + ); + + pool.close().await; +} + #[tokio::test] async fn thread_item_update_ordinals_allow_older_writers() { let sqlite_home = crate::runtime::test_support::unique_temp_dir(); diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index 22e4d3cea1c5..2502558f9c70 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -24,6 +24,8 @@ pub enum SortKey { UpdatedAt, /// Sort by the thread's product recency timestamp. RecencyAt, + /// Sort by the thread's stable position within its user-selected section. + SectionPosition, } /// Sort direction to use when listing threads. @@ -146,6 +148,10 @@ pub struct ThreadMetadata { pub archived_at: Option>, /// The user-selected section for this thread, if any. pub section: Option, + /// The stable sparse ordering rank within the user-selected section. + pub section_position: Option, + /// The time when the thread most recently entered its current section. + pub section_entered_at: Option>, /// The git commit SHA, if known. pub git_sha: Option, /// The git branch name, if known. @@ -277,6 +283,8 @@ impl ThreadMetadataBuilder { first_user_message: None, archived_at: self.archived_at.map(canonicalize_datetime), section: None, + section_position: None, + section_entered_at: None, git_sha: self.git_sha.clone(), git_branch: self.git_branch.clone(), git_origin_url: self.git_origin_url.clone(), @@ -394,6 +402,12 @@ impl ThreadMetadata { if self.section != other.section { diffs.push("section"); } + if self.section_position != other.section_position { + diffs.push("section_position"); + } + if self.section_entered_at != other.section_entered_at { + diffs.push("section_entered_at"); + } if self.git_sha != other.git_sha { diffs.push("git_sha"); } @@ -439,6 +453,8 @@ pub(crate) struct ThreadRow { archived_at: Option, section: Option, section_name: Option, + section_position: Option, + section_entered_at_ms: Option, git_sha: Option, git_branch: Option, git_origin_url: Option, @@ -473,6 +489,8 @@ impl ThreadRow { archived_at: row.try_get("archived_at")?, section: row.try_get("section")?, section_name: row.try_get("section_name")?, + section_position: row.try_get("section_position")?, + section_entered_at_ms: row.try_get("section_entered_at_ms")?, git_sha: row.try_get("git_sha")?, git_branch: row.try_get("git_branch")?, git_origin_url: row.try_get("git_origin_url")?, @@ -511,6 +529,8 @@ impl TryFrom for ThreadMetadata { archived_at, section, section_name, + section_position, + section_entered_at_ms, git_sha, git_branch, git_origin_url, @@ -561,6 +581,10 @@ impl TryFrom for ThreadMetadata { first_user_message: (!first_user_message.is_empty()).then_some(first_user_message), archived_at: archived_at.map(epoch_seconds_to_datetime).transpose()?, section, + section_position, + section_entered_at: section_entered_at_ms + .map(epoch_millis_to_datetime) + .transpose()?, git_sha, git_branch, git_origin_url, @@ -577,10 +601,13 @@ pub(crate) fn anchor_from_item( SortKey::CreatedAt => item.created_at, SortKey::UpdatedAt => item.updated_at, SortKey::RecencyAt => item.recency_at, + SortKey::SectionPosition => DateTime::::from_timestamp_millis(item.section_position?)?, }; Some(Anchor { ts, - id: (include_thread_id_tiebreaker || sort_key == SortKey::RecencyAt).then_some(item.id), + id: (include_thread_id_tiebreaker + || matches!(sort_key, SortKey::RecencyAt | SortKey::SectionPosition)) + .then_some(item.id), }) } @@ -661,6 +688,8 @@ mod tests { archived_at: None, section: None, section_name: None, + section_position: None, + section_entered_at_ms: None, git_sha: None, git_branch: None, git_origin_url: None, @@ -695,6 +724,8 @@ mod tests { first_user_message: None, archived_at: None, section: None, + section_position: None, + section_entered_at: None, git_sha: None, git_branch: None, git_origin_url: None, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 79f8e96f2153..ed7b97f7ede2 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -47,6 +47,7 @@ mod recovery; mod remote_control; #[cfg(test)] pub(crate) mod test_support; +mod thread_section_order; mod threads; pub use external_agent_config_imports::ExternalAgentConfigImportDetailsRecord; diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index a1d16dd28e8c..f80bb6fdc806 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -201,6 +201,8 @@ SELECT FROM thread_sections WHERE thread_sections.id = threads.thread_section_id ) AS section_name, + threads.section_position, + threads.section_entered_at_ms, threads.git_sha, threads.git_branch, threads.git_origin_url @@ -582,6 +584,8 @@ SELECT FROM thread_sections WHERE thread_sections.id = threads.thread_section_id ) AS section_name, + threads.section_position, + threads.section_entered_at_ms, threads.git_sha, threads.git_branch, threads.git_origin_url diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index 409cba75aa64..2c5ef65440c5 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -70,6 +70,8 @@ pub(super) fn test_thread_metadata( first_user_message: Some("hello".to_string()), archived_at: None, section: None, + section_position: None, + section_entered_at: None, git_sha: None, git_branch: None, git_origin_url: None, diff --git a/codex-rs/state/src/runtime/thread_section_order.rs b/codex-rs/state/src/runtime/thread_section_order.rs new file mode 100644 index 000000000000..15ce881ea113 --- /dev/null +++ b/codex-rs/state/src/runtime/thread_section_order.rs @@ -0,0 +1,284 @@ +use super::StateRuntime; +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::ThreadId; +use sqlx::QueryBuilder; +use sqlx::Sqlite; +use std::collections::HashMap; + +const SECTION_POSITION_GAP: i64 = 1_000_000; + +impl StateRuntime { + /// Read persisted section ordering for multiple threads in one SQLite query. + pub async fn get_thread_section_ordering( + &self, + thread_ids: &[ThreadId], + ) -> anyhow::Result, Option>)>> { + if thread_ids.is_empty() { + return Ok(HashMap::new()); + } + + let mut builder = QueryBuilder::::new( + "SELECT id, section_position, section_entered_at_ms FROM threads WHERE id IN (", + ); + let mut separated = builder.separated(", "); + for thread_id in thread_ids { + separated.push_bind(thread_id.to_string()); + } + separated.push_unseparated(")"); + + let rows = builder + .build_query_as::<(String, Option, Option)>() + .fetch_all(self.pool.as_ref()) + .await?; + rows.into_iter() + .map(|(thread_id, section_position, section_entered_at_ms)| { + let thread_id = ThreadId::try_from(thread_id)?; + let section_entered_at = section_entered_at_ms + .map(|millis| { + DateTime::::from_timestamp_millis(millis).ok_or_else(|| { + anyhow::anyhow!("invalid unix timestamp millis: {millis}") + }) + }) + .transpose()?; + Ok((thread_id, (section_position, section_entered_at))) + }) + .collect() + } + + /// Read an independently persisted thread section by its opaque identifier. + pub async fn get_thread_section( + &self, + id: &str, + ) -> anyhow::Result> { + let row = sqlx::query_as::<_, (String, String)>( + "SELECT id, name FROM thread_sections WHERE id = ?", + ) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await?; + Ok(row.map(|(id, name)| crate::ThreadSection { id, name })) + } + + /// List independently persisted sections in stable, cursor-paginated identifier order. + pub async fn list_thread_sections( + &self, + cursor: Option<&str>, + limit: usize, + ) -> anyhow::Result { + let page_size = limit.max(1); + let fetch_limit = i64::try_from(page_size.saturating_add(1))?; + let rows = sqlx::query_as::<_, (String, String)>( + r#" +SELECT id, name +FROM thread_sections +WHERE (? IS NULL OR id > ?) +ORDER BY id +LIMIT ? + "#, + ) + .bind(cursor) + .bind(cursor) + .bind(fetch_limit) + .fetch_all(self.pool.as_ref()) + .await?; + let mut sections = rows + .into_iter() + .map(|(id, name)| crate::ThreadSection { id, name }) + .collect::>(); + let next_cursor = if sections.len() > page_size { + sections.pop(); + sections.last().map(|section| section.id.clone()) + } else { + None + }; + Ok(crate::ThreadSectionsPage { + sections, + next_cursor, + }) + } + + /// Move a thread into or within a section, or clear its section. + /// + /// Omitting `before_thread_id` appends the thread to its destination section. + pub async fn move_thread_to_section( + &self, + thread_id: ThreadId, + section: Option<&str>, + before_thread_id: Option, + ) -> anyhow::Result { + if section.is_none() && before_thread_id.is_some() { + return Err(anyhow::anyhow!( + "before thread cannot be specified without a section" + )); + } + + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let thread_id = thread_id.to_string(); + let current_section = sqlx::query_scalar::<_, Option>( + "SELECT thread_section_id FROM threads WHERE id = ?", + ) + .bind(&thread_id) + .fetch_optional(&mut *tx) + .await?; + let Some(current_section) = current_section else { + return Ok(false); + }; + let Some(section) = section else { + sqlx::query( + "UPDATE threads SET thread_section_id = NULL, section_position = NULL, section_entered_at_ms = NULL WHERE id = ?", + ) + .bind(&thread_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + return Ok(true); + }; + + if sqlx::query_scalar::<_, i64>("SELECT 1 FROM thread_sections WHERE id = ?") + .bind(section) + .fetch_optional(&mut *tx) + .await? + .is_none() + { + return Err(anyhow::anyhow!("section {section} does not exist")); + } + + let before_thread_id = before_thread_id.map(|id| id.to_string()); + if before_thread_id.as_deref() == Some(thread_id.as_str()) { + return Err(anyhow::anyhow!( + "thread {thread_id} cannot be moved before itself" + )); + } + + if let Some(before_thread_id) = before_thread_id.as_deref() { + let before_section = sqlx::query_scalar::<_, Option>( + "SELECT thread_section_id FROM threads WHERE id = ?", + ) + .bind(before_thread_id) + .fetch_optional(&mut *tx) + .await?; + if before_section.flatten().as_deref() != Some(section) { + return Err(anyhow::anyhow!( + "before thread {before_thread_id} is not in section {section}" + )); + } + } + + let position = + section_move_position(&mut tx, section, &thread_id, before_thread_id.as_deref()) + .await?; + if current_section.as_deref() == Some(section) { + sqlx::query("UPDATE threads SET section_position = ? WHERE id = ?") + .bind(position) + .bind(&thread_id) + .execute(&mut *tx) + .await?; + } else { + sqlx::query( + "UPDATE threads SET thread_section_id = ?, section_position = ?, section_entered_at_ms = ? WHERE id = ?", + ) + .bind(section) + .bind(position) + .bind(Utc::now().timestamp_millis()) + .bind(&thread_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(true) + } +} + +async fn section_move_position( + tx: &mut sqlx::Transaction<'_, Sqlite>, + section: &str, + thread_id: &str, + before_thread_id: Option<&str>, +) -> anyhow::Result { + let mut renumbered = false; + loop { + let position = if let Some(before_thread_id) = before_thread_id { + let upper = sqlx::query_scalar::<_, Option>( + "SELECT section_position FROM threads WHERE id = ? AND thread_section_id = ?", + ) + .bind(before_thread_id) + .bind(section) + .fetch_optional(&mut **tx) + .await? + .flatten() + .ok_or_else(|| { + anyhow::anyhow!("before thread {before_thread_id} is not in section {section}") + })?; + let lower = sqlx::query_scalar::<_, Option>( + "SELECT MAX(section_position) FROM threads WHERE thread_section_id = ? AND section_position < ? AND id <> ?", + ) + .bind(section) + .bind(upper) + .bind(thread_id) + .fetch_one(&mut **tx) + .await?; + match lower { + Some(lower) if i128::from(upper) - i128::from(lower) > 1 => Some(i64::try_from( + i128::from(lower) + (i128::from(upper) - i128::from(lower)) / 2, + )?), + Some(_) => None, + None if upper > 1 => Some(upper / 2), + None => None, + } + } else { + let max_position = sqlx::query_scalar::<_, Option>( + "SELECT MAX(section_position) FROM threads WHERE thread_section_id = ? AND id <> ?", + ) + .bind(section) + .bind(thread_id) + .fetch_one(&mut **tx) + .await?; + max_position + .unwrap_or_default() + .checked_add(SECTION_POSITION_GAP) + }; + + if let Some(position) = position { + return Ok(position); + } + if renumbered { + return Err(anyhow::anyhow!( + "section {section} has no remaining thread positions" + )); + } + renumber_section_positions(tx, section, Some(thread_id)).await?; + renumbered = true; + } +} + +async fn renumber_section_positions( + tx: &mut sqlx::Transaction<'_, Sqlite>, + section: &str, + excluded_thread_id: Option<&str>, +) -> anyhow::Result<()> { + sqlx::query( + r#" +UPDATE threads +SET section_position = ranked.position +FROM ( + SELECT id, + ROW_NUMBER() OVER (ORDER BY section_position ASC, id ASC) * ? AS position + FROM threads + WHERE thread_section_id = ? AND (? IS NULL OR id <> ?) +) AS ranked +WHERE threads.id = ranked.id + "#, + ) + .bind(SECTION_POSITION_GAP) + .bind(section) + .bind(excluded_thread_id) + .bind(excluded_thread_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +#[cfg(test)] +#[path = "thread_section_order_tests.rs"] +mod tests; diff --git a/codex-rs/state/src/runtime/thread_section_order_tests.rs b/codex-rs/state/src/runtime/thread_section_order_tests.rs new file mode 100644 index 000000000000..80618bb222f5 --- /dev/null +++ b/codex-rs/state/src/runtime/thread_section_order_tests.rs @@ -0,0 +1,623 @@ +use super::StateRuntime; +use crate::runtime::test_support::test_thread_metadata; +use crate::runtime::test_support::unique_temp_dir; +use anyhow::Result; +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::ThreadId; +use codex_utils_absolute_path::test_support::PathExt; +use pretty_assertions::assert_eq; +use std::collections::HashMap; + +const CUSTOM_THREAD_SECTION_ID: &str = "01984de2-8f74-7c91-a3b2-5c5e937cf317"; +const OTHER_THREAD_SECTION_ID: &str = "01984de2-8f74-7c91-a3b2-5c5e937cf319"; + +#[tokio::test] +async fn thread_section_ordering_batches_persisted_positions_and_entry_times() -> Result<()> { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init( + crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), + "test-provider".to_string(), + ) + .await?; + let first = ThreadId::new(); + let second = ThreadId::new(); + let unsectioned = ThreadId::new(); + let missing = ThreadId::new(); + + for thread_id in [first, second, unsectioned] { + runtime + .upsert_thread(&test_thread_metadata( + &codex_home, + thread_id, + codex_home.clone(), + )) + .await?; + } + + runtime + .move_thread_to_section( + first, + Some(crate::PINNED_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ) + .await?; + runtime + .move_thread_to_section( + second, + Some(crate::PINNED_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ) + .await?; + + let first_entered_at = runtime + .get_thread(first) + .await? + .expect("first pinned thread should exist") + .section_entered_at; + let second_entered_at = runtime + .get_thread(second) + .await? + .expect("second pinned thread should exist") + .section_entered_at; + + assert_eq!( + runtime + .get_thread_section_ordering(&[first, second, unsectioned, missing, first]) + .await?, + HashMap::from([ + (first, (Some(1_000_000), first_entered_at)), + (second, (Some(2_000_000), second_entered_at)), + (unsectioned, (None, None)), + ]) + ); + assert_eq!( + runtime.get_thread_section_ordering(&[]).await?, + HashMap::new() + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_sections_paginate_and_require_registered_identities() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init( + crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), + "test-provider".to_string(), + ) + .await + .expect("state db should initialize"); + let before_pinned = crate::ThreadSection { + id: "01984de2-8f74-7c91-a3b2-5c5e937cf317".to_string(), + name: "Before pinned".to_string(), + }; + let pinned = crate::ThreadSection { + id: crate::PINNED_THREAD_SECTION_ID.to_string(), + name: crate::PINNED_THREAD_SECTION_NAME.to_string(), + }; + let after_pinned = crate::ThreadSection { + id: "01984de2-8f74-7c91-a3b2-5c5e937cf319".to_string(), + name: "After pinned".to_string(), + }; + + for section in [&before_pinned, &after_pinned] { + sqlx::query("INSERT INTO thread_sections (id, name) VALUES (?, ?)") + .bind(§ion.id) + .bind(§ion.name) + .execute(runtime.pool.as_ref()) + .await + .expect("custom test sections should be explicitly registered"); + } + + assert_eq!( + runtime + .get_thread_section(&pinned.id) + .await + .expect("built-in section should load"), + Some(pinned.clone()) + ); + assert_eq!( + runtime + .get_thread_section("01984de2-8f74-7c91-a3b2-5c5e937cf320") + .await + .expect("missing section lookup should succeed"), + None + ); + + assert_eq!( + runtime + .list_thread_sections(/*cursor*/ None, /*limit*/ 1) + .await + .expect("first section page should load"), + crate::ThreadSectionsPage { + sections: vec![before_pinned.clone()], + next_cursor: Some(before_pinned.id.clone()), + } + ); + assert_eq!( + runtime + .list_thread_sections(Some(&before_pinned.id), /*limit*/ 1) + .await + .expect("pinned section page should load"), + crate::ThreadSectionsPage { + sections: vec![pinned.clone()], + next_cursor: Some(pinned.id.clone()), + } + ); + assert_eq!( + runtime + .list_thread_sections(Some(&pinned.id), /*limit*/ 1) + .await + .expect("final section page should load"), + crate::ThreadSectionsPage { + sections: vec![after_pinned], + next_cursor: None, + } + ); + + let thread_id = ThreadId::new(); + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.section = Some(before_pinned.clone()); + runtime + .upsert_thread(&metadata) + .await + .expect("registered section should be accepted"); + assert_eq!( + runtime + .get_thread(thread_id) + .await + .expect("sectioned thread should load") + .expect("sectioned thread should exist") + .section, + Some(before_pinned.clone()) + ); + assert!( + runtime + .move_thread_to_section( + thread_id, + /*section*/ Some("01984de2-8f74-7c91-a3b2-5c5e937cf320"), + /*before_thread_id*/ None, + ) + .await + .is_err(), + "thread sections must be explicitly registered before assignment" + ); + assert_eq!( + runtime + .get_thread(thread_id) + .await + .expect("thread should survive rejected section assignment") + .expect("thread should still exist") + .section, + Some(before_pinned) + ); +} + +#[tokio::test] +async fn thread_section_moves_round_trip_and_survive_rollout_reconciliation() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init( + crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), + "test-provider".to_string(), + ) + .await + .expect("state db should initialize"); + let thread_id = ThreadId::new(); + let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + runtime + .upsert_thread(&metadata) + .await + .expect("thread insert should succeed"); + assert_eq!( + runtime + .get_thread(thread_id) + .await + .unwrap() + .unwrap() + .section, + None + ); + + assert!( + runtime + .move_thread_to_section( + thread_id, + /*section*/ Some(crate::PINNED_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ) + .await + .unwrap() + ); + let pinned = runtime.get_thread(thread_id).await.unwrap().unwrap(); + assert_eq!( + (pinned.section.as_ref(), pinned.section_position), + ( + Some(&crate::ThreadSection { + id: crate::PINNED_THREAD_SECTION_ID.to_string(), + name: crate::PINNED_THREAD_SECTION_NAME.to_string(), + }), + Some(1_000_000), + ) + ); + assert!(pinned.section_entered_at.is_some()); + + assert!( + runtime + .move_thread_to_section( + thread_id, + /*section*/ Some(crate::PINNED_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ) + .await + .unwrap() + ); + assert_eq!( + runtime.get_thread(thread_id).await.unwrap().unwrap(), + pinned + ); + + runtime + .upsert_thread(&metadata) + .await + .expect("stale rollout metadata should reconcile"); + let reconciled = runtime.get_thread(thread_id).await.unwrap().unwrap(); + assert_eq!( + ( + reconciled.section, + reconciled.section_position, + reconciled.section_entered_at, + ), + ( + pinned.section, + pinned.section_position, + pinned.section_entered_at + ) + ); + + assert!( + runtime + .move_thread_to_section( + thread_id, /*section*/ None, /*before_thread_id*/ None, + ) + .await + .unwrap() + ); + let unpinned = runtime.get_thread(thread_id).await.unwrap().unwrap(); + assert_eq!( + ( + unpinned.section, + unpinned.section_position, + unpinned.section_entered_at, + ), + (None, None, None) + ); + assert!( + !runtime + .move_thread_to_section( + ThreadId::new(), + /*section*/ Some(crate::PINNED_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ) + .await + .unwrap() + ); +} + +#[tokio::test] +async fn concurrent_section_moves_preserve_unique_positions() -> Result<()> { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init( + crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), + "test-provider".to_string(), + ) + .await?; + let [first, second, third, fourth, fifth] = [ + "00000000-0000-0000-0000-000000000071", + "00000000-0000-0000-0000-000000000072", + "00000000-0000-0000-0000-000000000073", + "00000000-0000-0000-0000-000000000074", + "00000000-0000-0000-0000-000000000075", + ] + .map(|thread_id| ThreadId::from_string(thread_id).expect("valid thread id")); + + for thread_id in [first, second, third, fourth, fifth] { + runtime + .upsert_thread(&test_thread_metadata( + &codex_home, + thread_id, + codex_home.clone(), + )) + .await?; + } + + let updated = tokio::try_join!( + runtime.move_thread_to_section( + first, + Some(crate::PINNED_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ), + runtime.move_thread_to_section( + second, + Some(crate::PINNED_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ), + runtime.move_thread_to_section( + third, + Some(crate::PINNED_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ), + runtime.move_thread_to_section( + fourth, + Some(crate::PINNED_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ), + )?; + assert_eq!(updated, (true, true, true, true)); + + let mut entered_at = HashMap::new(); + for thread_id in [first, second, third, fourth] { + let thread = runtime + .get_thread(thread_id) + .await? + .expect("concurrently sectioned thread should exist"); + entered_at.insert( + thread_id, + thread + .section_entered_at + .expect("section entry time should be recorded"), + ); + } + let initial_positions = sqlx::query_scalar::<_, i64>( + "SELECT section_position FROM threads WHERE thread_section_id = ? ORDER BY section_position, id", + ) + .bind(crate::PINNED_THREAD_SECTION_ID) + .fetch_all(runtime.pool.as_ref()) + .await?; + assert_eq!( + initial_positions, + vec![1_000_000, 2_000_000, 3_000_000, 4_000_000] + ); + + let moved = tokio::try_join!( + runtime.move_thread_to_section(fourth, Some(crate::PINNED_THREAD_SECTION_ID), Some(first)), + runtime.move_thread_to_section(third, Some(crate::PINNED_THREAD_SECTION_ID), Some(first)), + )?; + assert_eq!(moved, (true, true)); + + let updated_and_moved = tokio::try_join!( + runtime.move_thread_to_section( + fifth, + Some(crate::PINNED_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ), + runtime.move_thread_to_section(first, Some(crate::PINNED_THREAD_SECTION_ID), Some(second)), + )?; + assert_eq!(updated_and_moved, (true, true)); + + let ordered_threads = sqlx::query_as::<_, (String, i64)>( + "SELECT id, section_position FROM threads WHERE thread_section_id = ? ORDER BY section_position, id", + ) + .bind(crate::PINNED_THREAD_SECTION_ID) + .fetch_all(runtime.pool.as_ref()) + .await?; + assert_eq!(ordered_threads.len(), 5); + assert!( + ordered_threads + .windows(2) + .all(|threads| threads[0].1 < threads[1].1), + "concurrent section mutations must preserve unique ordered positions: {ordered_threads:?}" + ); + assert_eq!( + ordered_threads.last().map(|(thread_id, _)| thread_id), + Some(&fifth.to_string()) + ); + + for (thread_id, original_entered_at) in entered_at { + assert_eq!( + runtime + .get_thread(thread_id) + .await? + .expect("moved thread should remain sectioned") + .section_entered_at, + Some(original_entered_at) + ); + } + + Ok(()) +} + +#[tokio::test] +async fn section_moves_preserve_entry_order_and_renumber_exhausted_ranks() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init( + crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), + "test-provider".to_string(), + ) + .await + .expect("state db should initialize"); + for (section_id, section_name) in [ + (CUSTOM_THREAD_SECTION_ID, "Custom section"), + (OTHER_THREAD_SECTION_ID, "Other section"), + ] { + sqlx::query("INSERT INTO thread_sections (id, name) VALUES (?, ?)") + .bind(section_id) + .bind(section_name) + .execute(runtime.pool.as_ref()) + .await + .expect("custom test sections should be explicitly registered"); + } + let first = ThreadId::from_string("00000000-0000-0000-0000-000000000051").unwrap(); + let second = ThreadId::from_string("00000000-0000-0000-0000-000000000052").unwrap(); + let third = ThreadId::from_string("00000000-0000-0000-0000-000000000053").unwrap(); + + for thread_id in [first, second, third] { + runtime + .upsert_thread(&test_thread_metadata( + &codex_home, + thread_id, + codex_home.clone(), + )) + .await + .unwrap(); + assert!( + runtime + .move_thread_to_section( + thread_id, + /*section*/ Some(CUSTOM_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ) + .await + .unwrap() + ); + } + + let mut initial = Vec::new(); + for thread_id in [first, second, third] { + initial.push(runtime.get_thread(thread_id).await.unwrap().unwrap()); + } + assert_eq!( + initial + .iter() + .map(|thread| thread.section_position) + .collect::>(), + vec![Some(1_000_000), Some(2_000_000), Some(3_000_000)] + ); + assert!( + initial + .iter() + .all(|thread| thread.section_entered_at.is_some()) + ); + let original_entered_at = initial[2].section_entered_at; + + runtime + .touch_thread_recency_at( + first, + DateTime::::from_timestamp(1_800_000_000, 0).unwrap(), + ) + .await + .unwrap(); + runtime + .move_thread_to_section(third, Some(CUSTOM_THREAD_SECTION_ID), Some(second)) + .await + .unwrap(); + let moved = runtime.get_thread(third).await.unwrap().unwrap(); + assert_eq!(moved.section_position, Some(1_500_000)); + assert_eq!(moved.section_entered_at, original_entered_at); + + runtime + .move_thread_to_section( + third, + Some(CUSTOM_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ) + .await + .unwrap(); + assert_eq!( + runtime + .get_thread(third) + .await + .unwrap() + .unwrap() + .section_position, + Some(3_000_000) + ); + + for (thread_id, position) in [(first, 1_i64), (second, 2), (third, 3)] { + sqlx::query("UPDATE threads SET section_position = ? WHERE id = ?") + .bind(position) + .bind(thread_id.to_string()) + .execute(runtime.pool.as_ref()) + .await + .unwrap(); + } + runtime + .move_thread_to_section(third, Some(CUSTOM_THREAD_SECTION_ID), Some(second)) + .await + .unwrap(); + let reordered = sqlx::query_scalar::<_, String>( + "SELECT id FROM threads WHERE thread_section_id = ? ORDER BY section_position, id", + ) + .bind(CUSTOM_THREAD_SECTION_ID) + .fetch_all(runtime.pool.as_ref()) + .await + .unwrap(); + assert_eq!( + reordered, + vec![first.to_string(), third.to_string(), second.to_string()] + ); + assert_eq!( + runtime + .get_thread(third) + .await + .unwrap() + .unwrap() + .section_position, + Some(1_500_000) + ); + + let unknown_section = "01984de2-8f74-7c91-a3b2-5c5e937cf320"; + let error = runtime + .move_thread_to_section(third, Some(unknown_section), /*before_thread_id*/ None) + .await + .expect_err("unregistered destination sections should be rejected"); + assert_eq!( + error.to_string(), + format!("section {unknown_section} does not exist") + ); + + runtime + .move_thread_to_section( + second, + /*section*/ Some(OTHER_THREAD_SECTION_ID), + /*before_thread_id*/ None, + ) + .await + .unwrap(); + sqlx::query("UPDATE threads SET section_entered_at_ms = ? WHERE id = ?") + .bind(1_i64) + .bind(third.to_string()) + .execute(runtime.pool.as_ref()) + .await + .unwrap(); + runtime + .move_thread_to_section(third, Some(OTHER_THREAD_SECTION_ID), Some(second)) + .await + .unwrap(); + let moved_across_sections = runtime.get_thread(third).await.unwrap().unwrap(); + assert_eq!( + moved_across_sections.section, + Some(crate::ThreadSection { + id: OTHER_THREAD_SECTION_ID.to_string(), + name: "Other section".to_string(), + }) + ); + assert_eq!(moved_across_sections.section_position, Some(500_000)); + assert!( + moved_across_sections + .section_entered_at + .is_some_and(|entered_at| entered_at.timestamp_millis() > 1) + ); + let error = runtime + .move_thread_to_section(third, /*section*/ None, Some(second)) + .await + .expect_err("clearing a section cannot accept a before-thread anchor"); + assert_eq!( + error.to_string(), + "before thread cannot be specified without a section" + ); + + runtime + .move_thread_to_section(third, /*section*/ None, /*before_thread_id*/ None) + .await + .unwrap(); + let cleared = runtime.get_thread(third).await.unwrap().unwrap(); + assert_eq!( + ( + cleared.section, + cleared.section_position, + cleared.section_entered_at + ), + (None, None, None) + ); +} diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 8160eb20211f..8ae291fe9c8f 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -39,6 +39,8 @@ SELECT FROM thread_sections WHERE thread_sections.id = threads.thread_section_id ) AS section_name, + threads.section_position, + threads.section_entered_at_ms, threads.git_sha, threads.git_branch, threads.git_origin_url @@ -53,58 +55,6 @@ WHERE threads.id = ? .transpose() } - /// Read an independently persisted thread section by its opaque identifier. - pub async fn get_thread_section( - &self, - id: &str, - ) -> anyhow::Result> { - let row = sqlx::query_as::<_, (String, String)>( - "SELECT id, name FROM thread_sections WHERE id = ?", - ) - .bind(id) - .fetch_optional(self.pool.as_ref()) - .await?; - Ok(row.map(|(id, name)| crate::ThreadSection { id, name })) - } - - /// List independently persisted sections in stable, cursor-paginated identifier order. - pub async fn list_thread_sections( - &self, - cursor: Option<&str>, - limit: usize, - ) -> anyhow::Result { - let page_size = limit.max(1); - let fetch_limit = i64::try_from(page_size.saturating_add(1))?; - let rows = sqlx::query_as::<_, (String, String)>( - r#" -SELECT id, name -FROM thread_sections -WHERE (? IS NULL OR id > ?) -ORDER BY id -LIMIT ? - "#, - ) - .bind(cursor) - .bind(cursor) - .bind(fetch_limit) - .fetch_all(self.pool.as_ref()) - .await?; - let mut sections = rows - .into_iter() - .map(|(id, name)| crate::ThreadSection { id, name }) - .collect::>(); - let next_cursor = if sections.len() > page_size { - sections.pop(); - sections.last().map(|section| section.id.clone()) - } else { - None - }; - Ok(crate::ThreadSectionsPage { - sections, - next_cursor, - }) - } - pub async fn get_thread_memory_mode(&self, id: ThreadId) -> anyhow::Result> { let row = sqlx::query("SELECT memory_mode FROM threads WHERE id = ?") .bind(id.to_string()) @@ -561,14 +511,20 @@ ON CONFLICT(child_thread_id) DO NOTHING sort_direction: SortDirection::Desc, search_term: None, }, - sort_key == crate::SortKey::RecencyAt, + matches!( + sort_key, + crate::SortKey::RecencyAt | crate::SortKey::SectionPosition + ), ); push_thread_order_and_limit( &mut builder, sort_key, SortDirection::Desc, OrderByIndex::Enabled, - sort_key == crate::SortKey::RecencyAt, + matches!( + sort_key, + crate::SortKey::RecencyAt | crate::SortKey::SectionPosition + ), limit, ); @@ -626,11 +582,13 @@ INSERT INTO threads ( archived, archived_at, thread_section_id, + section_position, + section_entered_at_ms, git_sha, git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -673,6 +631,8 @@ ON CONFLICT(id) DO NOTHING .bind(metadata.archived_at.is_some()) .bind(metadata.archived_at.map(datetime_to_epoch_seconds)) .bind(metadata.section.as_ref().map(|section| section.id.as_str())) + .bind(metadata.section_position) + .bind(metadata.section_entered_at.map(datetime_to_epoch_millis)) .bind(metadata.git_sha.as_deref()) .bind(metadata.git_branch.as_deref()) .bind(metadata.git_origin_url.as_deref()) @@ -723,20 +683,6 @@ ON CONFLICT(id) DO NOTHING Ok(result.rows_affected() > 0) } - /// Update the SQLite-owned section without changing other thread metadata. - pub async fn update_thread_section( - &self, - thread_id: ThreadId, - section: Option<&str>, - ) -> anyhow::Result { - let result = sqlx::query("UPDATE threads SET thread_section_id = ? WHERE id = ?") - .bind(section) - .bind(thread_id.to_string()) - .execute(self.pool.as_ref()) - .await?; - Ok(result.rows_affected() > 0) - } - pub async fn touch_thread_updated_at( &self, thread_id: ThreadId, @@ -911,11 +857,13 @@ INSERT INTO threads ( archived, archived_at, thread_section_id, + section_position, + section_entered_at_ms, git_sha, git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, @@ -987,6 +935,8 @@ ON CONFLICT(id) DO UPDATE SET .bind(metadata.archived_at.is_some()) .bind(metadata.archived_at.map(datetime_to_epoch_seconds)) .bind(metadata.section.as_ref().map(|section| section.id.as_str())) + .bind(metadata.section_position) + .bind(metadata.section_entered_at.map(datetime_to_epoch_millis)) .bind(metadata.git_sha.as_deref()) .bind(metadata.git_branch.as_deref()) .bind(metadata.git_origin_url.as_deref()) @@ -1206,8 +1156,11 @@ WITH RECURSIVE subtree(child_thread_id, parent_thread_id) AS ( ), None => builder.push(" FROM threads"), }; - let include_thread_id_tiebreaker = - relation_filter.is_some() || filters.sort_key == SortKey::RecencyAt; + let include_thread_id_tiebreaker = relation_filter.is_some() + || matches!( + filters.sort_key, + SortKey::RecencyAt | SortKey::SectionPosition + ); push_thread_filters_with_preview( builder, filters, @@ -1279,6 +1232,8 @@ SELECT FROM thread_sections WHERE thread_sections.id = threads.thread_section_id ) AS section_name, + threads.section_position, + threads.section_entered_at_ms, threads.git_sha, threads.git_branch, threads.git_origin_url @@ -1414,6 +1369,7 @@ fn push_thread_filters_with_preview<'a>( SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", SortKey::RecencyAt => "threads.recency_at_ms", + SortKey::SectionPosition => "threads.section_position", }; let operator = match sort_direction { SortDirection::Asc => ">", @@ -1462,6 +1418,7 @@ pub(super) fn push_thread_order_and_limit( SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", SortKey::RecencyAt => "threads.recency_at_ms", + SortKey::SectionPosition => "threads.section_position", }; let order_direction = match sort_direction { SortDirection::Asc => "ASC", @@ -1511,6 +1468,7 @@ mod tests { use pretty_assertions::assert_eq; use std::path::PathBuf; + const CUSTOM_THREAD_SECTION_ID: &str = "01984de2-8f74-7c91-a3b2-5c5e937cf317"; #[tokio::test] async fn upsert_thread_keeps_creation_memory_mode_for_existing_rows() { let codex_home = unique_temp_dir(); @@ -1579,210 +1537,6 @@ mod tests { assert_eq!(metadata.history_mode, ThreadHistoryMode::Paginated); } - #[tokio::test] - async fn thread_sections_paginate_and_require_registered_identities() { - let codex_home = unique_temp_dir(); - let runtime = StateRuntime::init( - crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), - "test-provider".to_string(), - ) - .await - .expect("state db should initialize"); - let before_pinned = crate::ThreadSection { - id: "01984de2-8f74-7c91-a3b2-5c5e937cf317".to_string(), - name: "Before pinned".to_string(), - }; - let pinned = crate::ThreadSection { - id: crate::PINNED_THREAD_SECTION_ID.to_string(), - name: crate::PINNED_THREAD_SECTION_NAME.to_string(), - }; - let after_pinned = crate::ThreadSection { - id: "01984de2-8f74-7c91-a3b2-5c5e937cf319".to_string(), - name: "After pinned".to_string(), - }; - - for section in [&before_pinned, &after_pinned] { - sqlx::query("INSERT INTO thread_sections (id, name) VALUES (?, ?)") - .bind(§ion.id) - .bind(§ion.name) - .execute(runtime.pool.as_ref()) - .await - .expect("custom test sections should be explicitly registered"); - } - - assert_eq!( - runtime - .get_thread_section(&pinned.id) - .await - .expect("built-in section should load"), - Some(pinned.clone()) - ); - assert_eq!( - runtime - .get_thread_section("01984de2-8f74-7c91-a3b2-5c5e937cf320") - .await - .expect("missing section lookup should succeed"), - None - ); - - assert_eq!( - runtime - .list_thread_sections(/*cursor*/ None, /*limit*/ 1) - .await - .expect("first section page should load"), - crate::ThreadSectionsPage { - sections: vec![before_pinned.clone()], - next_cursor: Some(before_pinned.id.clone()), - } - ); - assert_eq!( - runtime - .list_thread_sections(Some(&before_pinned.id), /*limit*/ 1) - .await - .expect("pinned section page should load"), - crate::ThreadSectionsPage { - sections: vec![pinned.clone()], - next_cursor: Some(pinned.id.clone()), - } - ); - assert_eq!( - runtime - .list_thread_sections(Some(&pinned.id), /*limit*/ 1) - .await - .expect("final section page should load"), - crate::ThreadSectionsPage { - sections: vec![after_pinned], - next_cursor: None, - } - ); - - let thread_id = ThreadId::new(); - let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); - metadata.section = Some(before_pinned.clone()); - runtime - .upsert_thread(&metadata) - .await - .expect("registered section should be accepted"); - assert_eq!( - runtime - .get_thread(thread_id) - .await - .expect("sectioned thread should load") - .expect("sectioned thread should exist") - .section, - Some(before_pinned.clone()) - ); - assert!( - runtime - .update_thread_section( - thread_id, - /*section*/ Some("01984de2-8f74-7c91-a3b2-5c5e937cf320"), - ) - .await - .is_err(), - "thread sections must be explicitly registered before assignment" - ); - assert_eq!( - runtime - .get_thread(thread_id) - .await - .expect("thread should survive rejected section assignment") - .expect("thread should still exist") - .section, - Some(before_pinned) - ); - } - - #[tokio::test] - async fn thread_section_updates_round_trip_and_survive_rollout_reconciliation() { - let codex_home = unique_temp_dir(); - let runtime = StateRuntime::init( - crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), - "test-provider".to_string(), - ) - .await - .expect("state db should initialize"); - let thread_id = ThreadId::new(); - let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); - runtime - .upsert_thread(&metadata) - .await - .expect("thread insert should succeed"); - assert_eq!( - runtime - .get_thread(thread_id) - .await - .unwrap() - .unwrap() - .section, - None - ); - - assert!( - runtime - .update_thread_section( - thread_id, - /*section*/ Some(crate::PINNED_THREAD_SECTION_ID), - ) - .await - .unwrap() - ); - assert_eq!( - runtime - .get_thread(thread_id) - .await - .unwrap() - .unwrap() - .section, - Some(crate::ThreadSection { - id: crate::PINNED_THREAD_SECTION_ID.to_string(), - name: crate::PINNED_THREAD_SECTION_NAME.to_string(), - }) - ); - - runtime - .upsert_thread(&metadata) - .await - .expect("stale rollout metadata should reconcile"); - assert_eq!( - runtime - .get_thread(thread_id) - .await - .unwrap() - .unwrap() - .section, - Some(crate::ThreadSection { - id: crate::PINNED_THREAD_SECTION_ID.to_string(), - name: crate::PINNED_THREAD_SECTION_NAME.to_string(), - }) - ); - - assert!( - runtime - .update_thread_section(thread_id, /*section*/ None) - .await - .unwrap() - ); - assert_eq!( - runtime - .get_thread(thread_id) - .await - .unwrap() - .unwrap() - .section, - None - ); - assert!( - !runtime - .update_thread_section( - ThreadId::new(), - /*section*/ Some(crate::PINNED_THREAD_SECTION_ID), - ) - .await - .unwrap() - ); - } - #[tokio::test] async fn list_threads_filters_sections_before_recency_pagination_and_uses_index() { let codex_home = unique_temp_dir(); @@ -1923,6 +1677,104 @@ mod tests { ); } + #[tokio::test] + async fn section_position_listing_uses_stable_indexed_keyset_pagination() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init( + crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), + "test-provider".to_string(), + ) + .await + .expect("state db should initialize"); + sqlx::query("INSERT INTO thread_sections (id, name) VALUES (?, ?)") + .bind(CUSTOM_THREAD_SECTION_ID) + .bind("Custom section") + .execute(runtime.pool.as_ref()) + .await + .expect("custom test section should be explicitly registered"); + let first = ThreadId::from_string("00000000-0000-0000-0000-000000000061").unwrap(); + let tied = ThreadId::from_string("00000000-0000-0000-0000-000000000062").unwrap(); + let last = ThreadId::from_string("00000000-0000-0000-0000-000000000063").unwrap(); + + for (thread_id, position) in [(first, 1_000_000), (tied, 1_000_000), (last, 2_000_000)] { + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.section = Some(crate::ThreadSection { + id: CUSTOM_THREAD_SECTION_ID.to_string(), + name: "Custom section".to_string(), + }); + metadata.section_position = Some(position); + metadata.section_entered_at = Some(metadata.updated_at); + runtime.upsert_thread(&metadata).await.unwrap(); + } + + let filters = |anchor| ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + section: Some(Some(CUSTOM_THREAD_SECTION_ID)), + anchor, + sort_key: SortKey::SectionPosition, + sort_direction: SortDirection::Asc, + search_term: None, + }; + let first_page = runtime + .list_threads(/*page_size*/ 1, filters(None)) + .await + .unwrap(); + let second_page = runtime + .list_threads( + /*page_size*/ 1, + filters(first_page.next_anchor.as_ref()), + ) + .await + .unwrap(); + let third_page = runtime + .list_threads( + /*page_size*/ 1, + filters(second_page.next_anchor.as_ref()), + ) + .await + .unwrap(); + assert_eq!( + [ + first_page.items[0].id, + second_page.items[0].id, + third_page.items[0].id + ], + [first, tied, last] + ); + assert_eq!(third_page.next_anchor, None); + + let mut builder = QueryBuilder::::new("EXPLAIN QUERY PLAN "); + push_list_threads_query( + &mut builder, + filters(/*anchor*/ None), + /*relation_filter*/ None, + /*limit*/ 2, + ); + let plan_details = builder + .build() + .fetch_all(runtime.pool.as_ref()) + .await + .unwrap() + .into_iter() + .map(|row| row.get::("detail")) + .collect::>(); + assert!( + plan_details + .iter() + .any(|detail| detail.contains("idx_threads_section_position")), + "section-position listing did not use its selective index: {plan_details:?}" + ); + assert!( + !plan_details + .iter() + .any(|detail| detail.contains("TEMP B-TREE")), + "section-position listing unexpectedly sorted outside its index: {plan_details:?}" + ); + } + #[tokio::test] async fn delete_thread_cleans_associated_state() -> Result<()> { let codex_home = unique_temp_dir(); diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index e3a7b10a1fd6..bca9e07df9a7 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -6,6 +6,7 @@ use std::sync::Mutex; use std::sync::MutexGuard; use std::sync::OnceLock; +use chrono::DateTime; use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; @@ -24,6 +25,7 @@ use crate::CreateThreadParams; use crate::DeleteThreadParams; use crate::ListThreadsParams; use crate::LoadThreadHistoryParams; +use crate::MoveThreadToSectionParams; use crate::ReadThreadByRolloutPathParams; use crate::ReadThreadParams; use crate::ResumeThreadParams; @@ -33,6 +35,7 @@ use crate::StoredThreadHistory; use crate::ThreadMetadataPatch; use crate::ThreadPage; use crate::ThreadRelationFilter; +use crate::ThreadSortKey; use crate::ThreadStore; use crate::ThreadStoreError; use crate::ThreadStoreFuture; @@ -149,13 +152,10 @@ mod tests { } store - .update_thread_metadata(UpdateThreadMetadataParams { + .move_thread_to_section(MoveThreadToSectionParams { thread_id: grandchild_thread_id, - patch: ThreadMetadataPatch { - section: Some(Some(codex_state::PINNED_THREAD_SECTION_ID.to_string())), - ..Default::default() - }, - include_archived: false, + section: Some(codex_state::PINNED_THREAD_SECTION_ID.to_string()), + before_thread_id: None, }) .await .expect("pin grandchild thread"); @@ -470,6 +470,9 @@ struct InMemoryThreadStoreState { created_threads: HashMap, histories: HashMap>, metadata_updates: HashMap, + sections: HashMap, + section_positions: HashMap, + section_entered_at: HashMap>, names: HashMap>, rollout_paths: HashMap, } @@ -666,6 +669,11 @@ impl InMemoryThreadStore { ) -> ThreadStoreResult { let mut state = self.state.lock().await; state.calls.update_thread_metadata += 1; + if !state.created_threads.contains_key(¶ms.thread_id) { + return Err(ThreadStoreError::ThreadNotFound { + thread_id: params.thread_id, + }); + } if let Some(name) = params.patch.name.clone() { state.names.insert(params.thread_id, name); } @@ -677,6 +685,97 @@ impl InMemoryThreadStore { stored_thread_from_state(&state, params.thread_id, /*include_history*/ false) } + async fn move_thread_to_section( + &self, + params: MoveThreadToSectionParams, + ) -> ThreadStoreResult<()> { + if params + .section + .as_deref() + .is_some_and(|section| section.trim().is_empty()) + { + return Err(ThreadStoreError::InvalidRequest { + message: "section must not be empty".to_owned(), + }); + } + if params.section.is_none() && params.before_thread_id.is_some() { + return Err(ThreadStoreError::InvalidRequest { + message: "before thread cannot be specified without a section".to_owned(), + }); + } + + let mut state = self.state.lock().await; + if !state.created_threads.contains_key(¶ms.thread_id) { + return Err(ThreadStoreError::ThreadNotFound { + thread_id: params.thread_id, + }); + } + let previous_section = state.sections.get(¶ms.thread_id).cloned(); + let Some(section) = params.section.as_deref() else { + state.sections.remove(¶ms.thread_id); + state.section_positions.remove(¶ms.thread_id); + state.section_entered_at.remove(¶ms.thread_id); + return Ok(()); + }; + if let Some(before_thread_id) = params.before_thread_id { + if before_thread_id == params.thread_id { + return Err(ThreadStoreError::InvalidRequest { + message: format!("thread {} cannot be moved before itself", params.thread_id), + }); + } + let before_section = state.sections.get(&before_thread_id).map(String::as_str); + if before_section != Some(section) { + return Err(ThreadStoreError::InvalidRequest { + message: format!( + "before thread {before_thread_id} is not in section {section}" + ), + }); + } + } + + let mut ordered_thread_ids = state + .sections + .iter() + .filter(|(thread_id, current_section)| { + **thread_id != params.thread_id && current_section.as_str() == section + }) + .map(|(thread_id, _)| *thread_id) + .collect::>(); + ordered_thread_ids.sort_by_key(|thread_id| { + ( + state + .section_positions + .get(thread_id) + .copied() + .unwrap_or(i64::MAX), + thread_id.to_string(), + ) + }); + let insert_at = params + .before_thread_id + .and_then(|before_thread_id| { + ordered_thread_ids + .iter() + .position(|thread_id| *thread_id == before_thread_id) + }) + .unwrap_or(ordered_thread_ids.len()); + ordered_thread_ids.insert(insert_at, params.thread_id); + for (index, thread_id) in ordered_thread_ids.into_iter().enumerate() { + let position = i64::try_from(index) + .unwrap_or(i64::MAX) + .saturating_add(1) + .saturating_mul(1_000_000); + state.section_positions.insert(thread_id, position); + } + if previous_section.as_deref() != Some(section) { + state + .section_entered_at + .insert(params.thread_id, Utc::now()); + state.sections.insert(params.thread_id, section.to_owned()); + } + Ok(()) + } + async fn delete_thread(&self, params: DeleteThreadParams) -> ThreadStoreResult<()> { let mut state = self.state.lock().await; state.calls.delete_thread += 1; @@ -684,6 +783,9 @@ impl InMemoryThreadStore { state.created_threads.remove(¶ms.thread_id); state.names.remove(¶ms.thread_id); state.metadata_updates.remove(¶ms.thread_id); + state.sections.remove(¶ms.thread_id); + state.section_positions.remove(¶ms.thread_id); + state.section_entered_at.remove(¶ms.thread_id); state .rollout_paths .retain(|_, thread_id| *thread_id != params.thread_id); @@ -805,6 +907,17 @@ impl ThreadStore for InMemoryThreadStore { thread.section.as_ref().map(|section| section.id.as_str()) == section.as_deref() }); } + if params.sort_key == ThreadSortKey::SectionPosition { + page.items.sort_by_key(|thread| { + ( + thread.section_position.unwrap_or(i64::MAX), + thread.thread_id.to_string(), + ) + }); + if params.sort_direction == crate::SortDirection::Desc { + page.items.reverse(); + } + } Ok(page) }) } @@ -816,6 +929,13 @@ impl ThreadStore for InMemoryThreadStore { Box::pin(InMemoryThreadStore::update_thread_metadata(self, params)) } + fn move_thread_to_section( + &self, + params: MoveThreadToSectionParams, + ) -> ThreadStoreFuture<'_, ()> { + Box::pin(InMemoryThreadStore::move_thread_to_section(self, params)) + } + fn archive_thread(&self, _params: ArchiveThreadParams) -> ThreadStoreFuture<'_, ()> { Box::pin(async move { self.state.lock().await.calls.archive_thread += 1; @@ -888,8 +1008,10 @@ fn stored_thread_from_state( .and_then(|metadata| metadata.advance_recency_at.or(metadata.updated_at)) .unwrap_or_else(Utc::now), archived_at: None, - section: metadata - .and_then(|metadata| metadata.section.clone().flatten()) + section: state + .sections + .get(&thread_id) + .cloned() .map(|id| codex_state::ThreadSection { name: if id == codex_state::PINNED_THREAD_SECTION_ID { codex_state::PINNED_THREAD_SECTION_NAME.to_string() @@ -898,6 +1020,8 @@ fn stored_thread_from_state( }, id, }), + section_position: state.section_positions.get(&thread_id).copied(), + section_entered_at: state.section_entered_at.get(&thread_id).copied(), cwd: metadata .and_then(|metadata| metadata.cwd.clone()) .unwrap_or_default(), diff --git a/codex-rs/thread-store/src/lib.rs b/codex-rs/thread-store/src/lib.rs index 8c248af417b6..444e3eea80e3 100644 --- a/codex-rs/thread-store/src/lib.rs +++ b/codex-rs/thread-store/src/lib.rs @@ -38,6 +38,7 @@ pub use types::ListItemsParams; pub use types::ListThreadsParams; pub use types::ListTurnsParams; pub use types::LoadThreadHistoryParams; +pub use types::MoveThreadToSectionParams; pub use types::PrepareForkParams; pub use types::PreparedFork; pub use types::ReadThreadByRolloutPathParams; diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index bafd81dec503..a583ea8864f8 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -145,6 +145,8 @@ pub(super) fn stored_thread_from_rollout_item( recency_at, archived_at, section: item.section, + section_position: None, + section_entered_at: None, cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), source, @@ -192,6 +194,20 @@ pub(super) fn sqlite_thread_name(metadata: &ThreadMetadata) -> Option { .map(str::to_string) } +pub(super) async fn resolve_thread_section_metadata( + state_db: &codex_state::StateRuntime, + thread_ids: &[ThreadId], +) -> HashMap, Option>)> { + if thread_ids.is_empty() { + return HashMap::new(); + } + + state_db + .get_thread_section_ordering(thread_ids) + .await + .unwrap_or_default() +} + pub(super) async fn resolve_thread_names( store: &LocalThreadStore, thread_history_modes: &HashMap, diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index 4867bce98b16..c51219bcdc46 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -1,13 +1,18 @@ use std::collections::HashMap; +use chrono::DateTime; +use chrono::Utc; use codex_rollout::RolloutConfig; use codex_rollout::RolloutRecorder; use codex_rollout::parse_cursor; +use codex_state::ThreadFilterOptions; use super::LocalThreadStore; use super::helpers::resolve_thread_names; +use super::helpers::resolve_thread_section_metadata; use super::helpers::set_thread_name; use super::helpers::stored_thread_from_rollout_item; +use super::read_thread::stored_thread_from_state_metadata; use crate::ListThreadsParams; use crate::SortDirection; use crate::ThreadPage; @@ -20,6 +25,9 @@ pub(super) async fn list_threads( store: &LocalThreadStore, params: ListThreadsParams, ) -> ThreadStoreResult { + if params.sort_key == ThreadSortKey::SectionPosition { + return list_section_threads(store, params).await; + } let cursor = params .cursor .as_deref() @@ -33,6 +41,7 @@ pub(super) async fn list_threads( ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, + ThreadSortKey::SectionPosition => unreachable!("section order uses the state database"), }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, @@ -47,7 +56,7 @@ pub(super) async fn list_threads( generate_memories: false, }; let page = list_rollout_threads( - state_db, + state_db.clone(), &rollout_config, store.config.default_model_provider_id.as_str(), ¶ms, @@ -84,10 +93,149 @@ pub(super) async fn list_threads( set_thread_name(thread, name); } } + if let Some(state_db) = state_db { + let sectioned_thread_ids = items + .iter() + .filter(|thread| thread.section.is_some()) + .map(|thread| thread.thread_id) + .collect::>(); + let section_metadata = + resolve_thread_section_metadata(state_db.as_ref(), §ioned_thread_ids).await; + for thread in items.iter_mut().filter(|thread| thread.section.is_some()) { + if let Some((section_position, section_entered_at)) = + section_metadata.get(&thread.thread_id) + { + thread.section_position = *section_position; + thread.section_entered_at = *section_entered_at; + } + } + } Ok(ThreadPage { items, next_cursor }) } +async fn list_section_threads( + store: &LocalThreadStore, + params: ListThreadsParams, +) -> ThreadStoreResult { + let section = params + .section + .as_ref() + .and_then(Option::as_deref) + .ok_or_else(|| ThreadStoreError::InvalidRequest { + message: "section-position sorting requires a section filter".to_owned(), + })?; + let state_db = store + .state_db() + .await + .ok_or_else(|| ThreadStoreError::Internal { + message: "state DB unavailable for section-ordered thread listing".to_owned(), + })?; + + let anchor = params + .cursor + .as_deref() + .map(|cursor| -> ThreadStoreResult { + let (position, thread_id) = + cursor + .split_once('|') + .ok_or_else(|| ThreadStoreError::InvalidRequest { + message: format!("invalid cursor: {cursor}"), + })?; + let timestamp = position + .parse::() + .ok() + .and_then(DateTime::::from_timestamp_millis) + .ok_or_else(|| ThreadStoreError::InvalidRequest { + message: format!("invalid cursor: {cursor}"), + })?; + let thread_id = codex_protocol::ThreadId::from_string(thread_id).map_err(|_| { + ThreadStoreError::InvalidRequest { + message: format!("invalid cursor: {cursor}"), + } + })?; + Ok(codex_state::Anchor { + ts: timestamp, + id: Some(thread_id), + }) + }) + .transpose()?; + let allowed_sources = params + .allowed_sources + .iter() + .map(|source| match serde_json::to_value(source) { + Ok(serde_json::Value::String(source)) => source, + Ok(source) => source.to_string(), + Err(_) => String::new(), + }) + .collect::>(); + let normalized_cwd_filters = params.cwd_filters.as_ref().map(|filters| { + filters + .iter() + .map(|cwd| codex_rollout::state_db::normalize_cwd_for_state_db(cwd)) + .collect::>() + }); + let filters = ThreadFilterOptions { + archived_only: params.archived, + allowed_sources: allowed_sources.as_slice(), + model_providers: params.model_providers.as_deref(), + cwd_filters: normalized_cwd_filters.as_deref(), + section: Some(Some(section)), + anchor: anchor.as_ref(), + sort_key: codex_state::SortKey::SectionPosition, + sort_direction: match params.sort_direction { + SortDirection::Asc => codex_state::SortDirection::Asc, + SortDirection::Desc => codex_state::SortDirection::Desc, + }, + search_term: params.search_term.as_deref(), + }; + let page = match params.relation_filter { + Some(ThreadRelationFilter::DirectChildrenOf(thread_id)) => { + state_db + .list_threads_by_relation( + params.page_size, + codex_state::ThreadRelationFilter::DirectChildrenOf(thread_id), + filters, + ) + .await + } + Some(ThreadRelationFilter::DescendantsOf(thread_id)) => { + state_db + .list_threads_by_relation( + params.page_size, + codex_state::ThreadRelationFilter::DescendantsOf(thread_id), + filters, + ) + .await + } + None => state_db.list_threads(params.page_size, filters).await, + } + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to list section-ordered threads: {err}"), + })?; + + let codex_state::ThreadsPage { + items: metadata_items, + parent_thread_ids, + next_anchor, + .. + } = page; + let items = metadata_items + .into_iter() + .map(|metadata| { + let parent_thread_id = parent_thread_ids.get(&metadata.id).copied(); + stored_thread_from_state_metadata(store, metadata, parent_thread_id) + }) + .collect(); + let next_cursor = next_anchor.and_then(|anchor| { + anchor.id.map(|thread_id| { + let position = anchor.ts.timestamp_millis(); + format!("{position}|{thread_id}") + }) + }); + Ok(ThreadPage { items, next_cursor }) +} + pub(super) async fn list_rollout_threads( state_db: Option, config: &RolloutConfig, @@ -202,6 +350,7 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadHistoryMode; + use codex_state::PINNED_THREAD_SECTION_ID; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use std::fs; @@ -209,6 +358,7 @@ mod tests { use uuid::Uuid; use super::*; + use crate::MoveThreadToSectionParams; use crate::ThreadStore; use crate::local::LocalThreadStore; use crate::local::test_support::test_config; @@ -505,6 +655,99 @@ mod tests { assert_eq!(page.items[0].source, SessionSource::Cli); } + #[tokio::test] + async fn section_listing_uses_sqlite_metadata_without_reading_rollouts() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let state = codex_state::StateRuntime::init( + config.sqlite.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("initialize state"); + let store = LocalThreadStore::new(config, Some(state.clone())); + let mut thread_ids = Vec::new(); + let mut first_rollout_path = None; + + for index in 0..3 { + let uuid = Uuid::from_u128(975 + index); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let timestamp = format!("2025-01-03T16-{index:02}-00"); + let rollout_path = + write_session_file(home.path(), ×tamp, uuid).expect("write rollout"); + codex_rollout::state_db::reconcile_rollout( + Some(state.as_ref()), + rollout_path.as_path(), + "test-provider", + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + store + .move_thread_to_section(MoveThreadToSectionParams { + thread_id, + section: Some(PINNED_THREAD_SECTION_ID.to_owned()), + before_thread_id: None, + }) + .await + .expect("append section member"); + thread_ids.push(thread_id); + if index == 0 { + first_rollout_path = Some(rollout_path); + } + } + fs::remove_file(first_rollout_path.expect("first rollout path")) + .expect("remove rollout without invalidating SQLite metadata"); + + let params = ListThreadsParams { + page_size: 2, + cursor: None, + sort_key: ThreadSortKey::SectionPosition, + sort_direction: SortDirection::Asc, + allowed_sources: Vec::new(), + model_providers: None, + cwd_filters: None, + section: Some(Some(PINNED_THREAD_SECTION_ID.to_owned())), + archived: false, + search_term: None, + relation_filter: None, + use_state_db_only: true, + }; + let page = store + .list_threads(params.clone()) + .await + .expect("section listing should use SQLite metadata"); + + assert_eq!( + page.items + .iter() + .map(|thread| thread.thread_id) + .collect::>(), + vec![thread_ids[0], thread_ids[1]] + ); + assert_eq!(page.next_cursor, Some(format!("2000000|{}", thread_ids[1]))); + + let next_page = store + .list_threads(ListThreadsParams { + cursor: page.next_cursor, + ..params + }) + .await + .expect("section cursor should continue listing from SQLite metadata"); + + assert_eq!( + next_page + .items + .iter() + .map(|thread| thread.thread_id) + .collect::>(), + vec![thread_ids[2]] + ); + assert_eq!(next_page.next_cursor, None); + } + #[tokio::test] async fn list_threads_rejects_invalid_cursor() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 9f3968fa5ffa..a25cb74c78d6 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -5,6 +5,7 @@ mod helpers; mod list_threads; mod live_writer; mod model_context; +mod move_thread_to_section; mod paginated_fork; mod read_thread; // This lands before the reader PRs that consume the shared lineage resolver. @@ -47,6 +48,7 @@ use crate::ListItemsParams; use crate::ListThreadsParams; use crate::ListTurnsParams; use crate::LoadThreadHistoryParams; +use crate::MoveThreadToSectionParams; use crate::PrepareForkParams; use crate::PreparedFork; use crate::ReadThreadByRolloutPathParams; @@ -489,6 +491,13 @@ impl ThreadStore for LocalThreadStore { Box::pin(async move { update_thread_metadata::update_thread_metadata(self, params).await }) } + fn move_thread_to_section( + &self, + params: MoveThreadToSectionParams, + ) -> ThreadStoreFuture<'_, ()> { + Box::pin(async move { move_thread_to_section::move_thread_to_section(self, params).await }) + } + fn archive_thread(&self, params: ArchiveThreadParams) -> ThreadStoreFuture<'_, ()> { Box::pin(async move { archive_thread::archive_threads( diff --git a/codex-rs/thread-store/src/local/move_thread_to_section.rs b/codex-rs/thread-store/src/local/move_thread_to_section.rs new file mode 100644 index 000000000000..ef6d641d536b --- /dev/null +++ b/codex-rs/thread-store/src/local/move_thread_to_section.rs @@ -0,0 +1,58 @@ +use super::LocalThreadStore; +use crate::MoveThreadToSectionParams; +use crate::ThreadStoreError; +use crate::ThreadStoreResult; + +pub(super) async fn move_thread_to_section( + store: &LocalThreadStore, + params: MoveThreadToSectionParams, +) -> ThreadStoreResult<()> { + if params + .section + .as_deref() + .is_some_and(|section| section.trim().is_empty()) + { + return Err(ThreadStoreError::InvalidRequest { + message: "section must not be empty".to_owned(), + }); + } + if params.section.is_none() && params.before_thread_id.is_some() { + return Err(ThreadStoreError::InvalidRequest { + message: "before thread cannot be specified without a section".to_owned(), + }); + } + + let Some(state_db) = store.state_db().await else { + return Err(ThreadStoreError::Unsupported { + operation: "thread/section/move", + }); + }; + + let updated = state_db + .move_thread_to_section( + params.thread_id, + params.section.as_deref(), + params.before_thread_id, + ) + .await + .map_err(|err| { + let message = err.to_string(); + if message.starts_with("before thread ") + || message.starts_with("thread ") + || message.starts_with("section ") + { + ThreadStoreError::InvalidRequest { message } + } else { + ThreadStoreError::Internal { + message: format!("failed to move thread {}: {message}", params.thread_id), + } + } + })?; + if !updated { + return Err(ThreadStoreError::ThreadNotFound { + thread_id: params.thread_id, + }); + } + + Ok(()) +} diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index a1bc838aaec8..b291cc3fb697 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -63,6 +63,8 @@ pub(super) async fn read_thread( { rollout_thread.recency_at = thread.recency_at; rollout_thread.section = thread.section; + rollout_thread.section_position = thread.section_position; + rollout_thread.section_entered_at = thread.section_entered_at; if thread.name.is_some() { rollout_thread.name = thread.name; } @@ -133,6 +135,8 @@ pub(super) async fn read_thread_by_rollout_path( } else { thread.recency_at = metadata.recency_at; thread.section = metadata.section; + thread.section_position = metadata.section_position; + thread.section_entered_at = metadata.section_entered_at; let (fallback_sha, fallback_branch, fallback_origin_url) = match thread.git_info.take() { Some(info) => ( @@ -326,12 +330,22 @@ async fn read_sqlite_metadata( runtime.get_thread(thread_id).await.ok().flatten() } -async fn stored_thread_from_sqlite_metadata( +pub(super) async fn stored_thread_from_sqlite_metadata( store: &LocalThreadStore, metadata: ThreadMetadata, ) -> ThreadStoreResult { let session_meta = match read_required_session_meta_line(metadata.rollout_path.as_path()).await { + Ok(meta_line) if meta_line.meta.id != metadata.id => { + return Err(ThreadStoreError::Internal { + message: format!( + "session metadata {} belongs to thread {}, expected {}", + metadata.rollout_path.display(), + meta_line.meta.id, + metadata.id + ), + }); + } Ok(meta_line) => Some(meta_line.meta), Err(_) if codex_rollout::existing_rollout_path(metadata.rollout_path.as_path()) @@ -349,7 +363,6 @@ async fn stored_thread_from_sqlite_metadata( }); } }; - let rollout_path = codex_rollout::plain_rollout_path(metadata.rollout_path.as_path()); let forked_from_id = session_meta.as_ref().and_then(|meta| meta.forked_from_id); let parent_thread_id = session_meta.as_ref().and_then(|meta| meta.parent_thread_id); let history_mode = session_meta @@ -357,6 +370,23 @@ async fn stored_thread_from_sqlite_metadata( .map(|meta| meta.history_mode) .unwrap_or(metadata.history_mode); let name = thread_name_from_metadata(store, &metadata, history_mode).await; + let mut thread = stored_thread_from_state_metadata(store, metadata, parent_thread_id); + thread.forked_from_id = forked_from_id; + thread.history_mode = history_mode; + thread.name = name; + Ok(thread) +} + +pub(super) fn stored_thread_from_state_metadata( + store: &LocalThreadStore, + metadata: ThreadMetadata, + parent_thread_id: Option, +) -> StoredThread { + let name = match metadata.history_mode { + ThreadHistoryMode::Paginated => sqlite_thread_name(&metadata), + ThreadHistoryMode::Legacy => distinct_thread_metadata_title(&metadata), + }; + let rollout_path = codex_rollout::plain_rollout_path(metadata.rollout_path.as_path()); let preview = metadata .preview .clone() @@ -364,11 +394,11 @@ async fn stored_thread_from_sqlite_metadata( .unwrap_or_default(); let permission_profile = permission_profile_from_metadata_value(&metadata.sandbox_policy, metadata.cwd.as_path()); - Ok(StoredThread { + StoredThread { thread_id: metadata.id, extra_config: None, rollout_path: Some(rollout_path), - forked_from_id, + forked_from_id: None, parent_thread_id, preview, name, @@ -384,10 +414,12 @@ async fn stored_thread_from_sqlite_metadata( recency_at: metadata.recency_at, archived_at: metadata.archived_at, section: metadata.section, + section_position: metadata.section_position, + section_entered_at: metadata.section_entered_at, cwd: metadata.cwd, cli_version: metadata.cli_version, source: parse_session_source(&metadata.source), - history_mode, + history_mode: metadata.history_mode, thread_source: metadata.thread_source, agent_nickname: metadata.agent_nickname, agent_role: metadata.agent_role, @@ -402,7 +434,7 @@ async fn stored_thread_from_sqlite_metadata( token_usage: None, first_user_message: metadata.first_user_message, history: None, - }) + } } async fn thread_name_from_metadata( @@ -480,6 +512,8 @@ fn stored_thread_from_meta_line( recency_at: updated_at, archived_at: archived.then_some(updated_at), section: None, + section_position: None, + section_entered_at: None, cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, source: meta_line.meta.source, @@ -635,6 +669,12 @@ mod tests { builder.recency_at = Some(recency_at); let mut metadata = builder.build(config.default_model_provider_id.as_str()); metadata.title = "Stale SQLite name".to_string(); + metadata.section = Some(codex_state::ThreadSection { + id: codex_state::PINNED_THREAD_SECTION_ID.to_string(), + name: codex_state::PINNED_THREAD_SECTION_NAME.to_string(), + }); + metadata.section_position = Some(2_000_000); + metadata.section_entered_at = Some(recency_at); runtime .upsert_thread(&metadata) .await @@ -655,6 +695,12 @@ mod tests { let git_info = thread.git_info.expect("git info should be present"); assert_eq!(thread.name.as_deref(), Some("Latest index name")); assert_eq!(thread.recency_at, recency_at); + assert_eq!( + thread.section.as_ref().map(|section| section.id.as_str()), + Some(codex_state::PINNED_THREAD_SECTION_ID) + ); + assert_eq!(thread.section_position, Some(2_000_000)); + assert_eq!(thread.section_entered_at, Some(recency_at)); assert_eq!(git_info.branch.as_deref(), Some("sqlite-branch")); assert_eq!( git_info.commit_hash.as_ref().map(|sha| sha.0.as_str()), @@ -827,7 +873,7 @@ mod tests { text: "Rollout user message".to_string(), text_elements: Vec::new(), }])), - started_at_ms: None, + started_at_ms: Some(0), completed_at_ms: 0, })), ) diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index 3d3b214aac40..35b9aee9daa4 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -8,6 +8,7 @@ use codex_rollout::search_rollout_matches; use super::LocalThreadStore; use super::helpers::resolve_thread_names; +use super::helpers::resolve_thread_section_metadata; use super::helpers::set_thread_name; use super::helpers::stored_thread_from_rollout_item; use super::list_threads::list_rollout_threads; @@ -52,6 +53,11 @@ pub(super) async fn search_threads( ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, + ThreadSortKey::SectionPosition => { + return Err(ThreadStoreError::InvalidRequest { + message: "section-position sorting requires a section filter".to_owned(), + }); + } }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, @@ -166,6 +172,23 @@ pub(super) async fn search_threads( }) }) .collect::>(); + if let Some(state_db) = state_db { + let sectioned_thread_ids = items + .iter() + .filter(|item| item.thread.section.is_some()) + .map(|item| item.thread.thread_id) + .collect::>(); + let mut section_metadata = + resolve_thread_section_metadata(state_db.as_ref(), §ioned_thread_ids).await; + for item in &mut items { + if let Some((section_position, section_entered_at)) = + section_metadata.remove(&item.thread.thread_id) + { + item.thread.section_position = section_position; + item.thread.section_entered_at = section_entered_at; + } + } + } set_thread_search_result_names(store, &mut items).await; Ok(ThreadSearchPage { items, next_cursor }) @@ -188,10 +211,12 @@ fn cursor_from_thread_search_item( .as_deref() .or(item.item.updated_at.as_deref()) .or(item.item.created_at.as_deref())?, + ThreadSortKey::SectionPosition => return None, }; match sort_key { ThreadSortKey::RecencyAt => parse_cursor(&format!("{timestamp}|{}", item.item.thread_id?)), ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => parse_cursor(timestamp), + ThreadSortKey::SectionPosition => None, } } diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 9471a5c95a02..44952e3c7f23 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -264,7 +264,7 @@ async fn apply_metadata_update( let sqlite_write_result: ThreadStoreResult<()> = if let Some(state_db) = state_db.as_ref() { let patch = patch.clone(); async { - let mut existing = + let existing = state_db .get_thread(thread_id) .await @@ -277,30 +277,6 @@ async fn apply_metadata_update( rollout_path_archived = resolved.archived; rollout_path = Some(resolved.path); } - if existing.is_none() - && patch.section.is_some() - && let Some(path) = rollout_path.as_deref() - && let Some(existing_rollout_path) = - codex_rollout::existing_rollout_path(path).await - { - codex_rollout::state_db::reconcile_rollout( - Some(state_db.as_ref()), - existing_rollout_path.as_path(), - store.config.default_model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ Some(rollout_path_archived), - /*new_thread_memory_mode*/ None, - ) - .await; - existing = state_db.get_thread(thread_id).await.map_err(|err| { - ThreadStoreError::Internal { - message: format!( - "failed to read reconciled thread metadata for {thread_id}: {err}" - ), - } - })?; - } let mut metadata = match existing.clone() { Some(metadata) => metadata, None => { @@ -389,24 +365,6 @@ async fn apply_metadata_update( if let Some(first_user_message) = patch.first_user_message { metadata.first_user_message = Some(first_user_message); } - if let Some(section) = patch.section.clone() { - metadata.section = match section { - Some(section_id) => Some( - state_db - .get_thread_section(§ion_id) - .await - .map_err(|err| ThreadStoreError::Internal { - message: format!( - "failed to read section {section_id} for thread {thread_id}: {err}" - ), - })? - .ok_or_else(|| ThreadStoreError::InvalidRequest { - message: format!("thread section not found: {section_id}"), - })?, - ), - None => None, - }; - } if let Some(git_info) = patch.git_info { let existing_git_info = git_info_from_parts( metadata.git_sha.clone(), @@ -424,21 +382,6 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to update thread metadata for {thread_id}: {err}"), })?; - if let Some(section) = patch.section { - let updated = state_db - .update_thread_section(thread_id, section.as_deref()) - .await - .map_err(|err| ThreadStoreError::Internal { - message: format!("failed to update section for thread {thread_id}: {err}"), - })?; - if !updated { - return Err(ThreadStoreError::Internal { - message: format!( - "thread metadata unavailable before section update: {thread_id}" - ), - }); - } - } if let Some(name) = patch.name.as_ref() { let history_mode = history_mode.ok_or_else(|| ThreadStoreError::Internal { message: format!( @@ -603,9 +546,8 @@ fn sqlite_write_failure_should_block(patch: &ThreadMetadataPatch) -> bool { // transcript-derived metadata, thread names, and memory-mode indexing were log-only. Keep that // failure isolation so a corrupted optional state DB does not make JSONL transcript durability // look broken. Explicit git-only updates still require SQLite because partial git patches need - // the existing SQLite value to preserve unspecified fields. User-selected section state is - // SQLite-only, so losing its write must also fail the explicit metadata update. - patch.section.is_some() || (patch.git_info.is_some() && !has_observed_metadata_facts(patch)) + // the existing SQLite value to preserve unspecified fields. + patch.git_info.is_some() && !has_observed_metadata_facts(patch) } fn sqlite_write_error_is_best_effort(err: &ThreadStoreError) -> bool { @@ -865,6 +807,7 @@ mod tests { use super::*; use crate::GitInfoPatch; use crate::ListThreadsParams; + use crate::MoveThreadToSectionParams; use crate::ResumeThreadParams; use crate::SortDirection; use crate::ThreadMetadataPatch; @@ -905,7 +848,7 @@ mod tests { } #[tokio::test] - async fn section_only_metadata_updates_persist_in_sqlite_without_changing_the_rollout() { + async fn section_moves_persist_in_sqlite_without_changing_the_rollout() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); let uuid = Uuid::from_u128(320); @@ -921,17 +864,33 @@ mod tests { .expect("state db should initialize"); let store = LocalThreadStore::new(config, Some(runtime.clone())); + codex_rollout::state_db::reconcile_rollout( + Some(runtime.as_ref()), + rollout_path.as_path(), + "test-provider", + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + store + .move_thread_to_section(MoveThreadToSectionParams { + thread_id, + section: Some(codex_state::PINNED_THREAD_SECTION_ID.to_string()), + before_thread_id: None, + }) + .await + .expect("pin thread"); + let pinned = store - .update_thread_metadata(UpdateThreadMetadataParams { + .read_thread(ReadThreadParams { thread_id, - patch: ThreadMetadataPatch { - section: Some(Some(codex_state::PINNED_THREAD_SECTION_ID.to_string())), - ..Default::default() - }, include_archived: false, + include_history: false, }) .await - .expect("pin thread"); + .expect("read pinned thread"); assert_eq!( pinned.section, @@ -999,17 +958,23 @@ mod tests { original_rollout ); + store + .move_thread_to_section(MoveThreadToSectionParams { + thread_id, + section: None, + before_thread_id: None, + }) + .await + .expect("clear thread section"); + let unpinned = store - .update_thread_metadata(UpdateThreadMetadataParams { + .read_thread(ReadThreadParams { thread_id, - patch: ThreadMetadataPatch { - section: Some(None), - ..Default::default() - }, include_archived: false, + include_history: false, }) .await - .expect("clear thread section"); + .expect("read unpinned thread"); assert_eq!(unpinned.section, None); assert_eq!( @@ -1829,14 +1794,6 @@ mod tests { })); } - #[test] - fn sqlite_failures_block_for_explicit_section_updates() { - assert!(sqlite_write_failure_should_block(&ThreadMetadataPatch { - section: Some(None), - ..Default::default() - })); - } - #[tokio::test] async fn metadata_patch_applies_title_over_existing_name() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/store.rs b/codex-rs/thread-store/src/store.rs index f616f265ff86..b35365fcb712 100644 --- a/codex-rs/thread-store/src/store.rs +++ b/codex-rs/thread-store/src/store.rs @@ -15,6 +15,7 @@ use crate::ListItemsParams; use crate::ListThreadsParams; use crate::ListTurnsParams; use crate::LoadThreadHistoryParams; +use crate::MoveThreadToSectionParams; use crate::PrepareForkParams; use crate::PreparedFork; use crate::ReadThreadByRolloutPathParams; @@ -178,6 +179,18 @@ pub trait ThreadStore: Any + Send + Sync { params: UpdateThreadMetadataParams, ) -> ThreadStoreFuture<'_, StoredThread>; + /// Moves a thread to, within, or out of a server-ordered section. + fn move_thread_to_section( + &self, + _params: MoveThreadToSectionParams, + ) -> ThreadStoreFuture<'_, ()> { + Box::pin(async { + Err(ThreadStoreError::Unsupported { + operation: "thread/section/move", + }) + }) + } + /// Archives a thread. fn archive_thread(&self, params: ArchiveThreadParams) -> ThreadStoreFuture<'_, ()>; diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index caef440ffddc..d6a54a114662 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -260,6 +260,8 @@ pub enum ThreadSortKey { UpdatedAt, /// Sort by the thread's product recency timestamp. RecencyAt, + /// Sort by the thread's persisted position within its section. + SectionPosition, } /// The direction to use when listing stored threads. @@ -567,6 +569,12 @@ pub struct StoredThread { pub archived_at: Option>, /// The user-selected section for this thread, if any. pub section: Option, + /// The server-owned ordering position within the thread's section. + #[serde(default)] + pub section_position: Option, + /// The time when the thread most recently entered its current section. + #[serde(default)] + pub section_entered_at: Option>, /// Working directory captured for the thread. pub cwd: PathBuf, /// CLI version captured for the thread. @@ -723,13 +731,6 @@ pub struct ThreadMetadataPatch { pub token_usage: Option, /// First user message observed for this thread. pub first_user_message: Option, - /// Replacement user-selected section, clear request, or no-op. - #[serde( - default, - skip_serializing_if = "Option::is_none", - with = "optional_option" - )] - pub section: ClearableField, /// Git metadata patch. pub git_info: Option, /// Thread memory behavior. @@ -806,9 +807,6 @@ impl ThreadMetadataPatch { if next.first_user_message.is_some() { self.first_user_message = next.first_user_message; } - if next.section.is_some() { - self.section = next.section; - } if let Some(git_info) = next.git_info { self.git_info .get_or_insert_with(GitInfoPatch::default) @@ -841,7 +839,6 @@ impl ThreadMetadataPatch { && self.permission_profile.is_none() && self.token_usage.is_none() && self.first_user_message.is_none() - && self.section.is_none() && self.git_info.is_none() && self.memory_mode.is_none() } @@ -858,6 +855,17 @@ pub struct UpdateThreadMetadataParams { pub include_archived: bool, } +/// Parameters for moving a thread to, within, or out of a server-ordered section. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MoveThreadToSectionParams { + /// Thread to move. + pub thread_id: ThreadId, + /// Destination section, or `None` to remove the thread from its section. + pub section: Option, + /// Existing section member to insert before, or `None` to append. + pub before_thread_id: Option, +} + /// Parameters for archiving or unarchiving a thread. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ArchiveThreadParams { @@ -983,7 +991,6 @@ mod tests { let mut current = ThreadMetadataPatch { name: Some(Some("old name".to_string())), preview: Some("old preview".to_string()), - section: Some(Some("pinned".to_string())), git_info: Some(GitInfoPatch { sha: Some(Some("abc123".to_string())), branch: Some(Some("main".to_string())), @@ -996,7 +1003,6 @@ mod tests { name: Some(None), preview: None, title: Some("new title".to_string()), - section: Some(None), git_info: Some(GitInfoPatch { sha: None, branch: Some(Some("feature".to_string())), @@ -1008,7 +1014,6 @@ mod tests { assert_eq!(current.name, Some(None)); assert_eq!(current.preview.as_deref(), Some("old preview")); assert_eq!(current.title.as_deref(), Some("new title")); - assert_eq!(current.section, Some(None)); assert_eq!( current.git_info, Some(GitInfoPatch { diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index b0b16cbc0b96..92072c61d238 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -143,6 +143,7 @@ mod tests { preview: String::new(), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 0, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index fb20b6a4fb65..96fc3119732b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3462,6 +3462,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re preview: "agent thread".to_string(), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "agent-provider".to_string(), created_at: 1, @@ -3559,6 +3560,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ preview: "agent thread".to_string(), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "agent-provider".to_string(), created_at: 1, @@ -3623,6 +3625,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { preview: "read thread".to_string(), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "read-provider".to_string(), created_at: 1, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 5b0d2e1ea3d0..c90c8a7f3eef 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -418,6 +418,7 @@ mod tests { preview: "read thread".to_string(), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "read-provider".to_string(), created_at: 1, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 354a3f66462f..75c5c163927c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -823,7 +823,6 @@ impl AppServerSession { request_id, params: ThreadMetadataUpdateParams { thread_id: thread_id.to_string(), - section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some(branch)), @@ -2680,6 +2679,7 @@ mod tests { preview: "hello".to_string(), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index f993fef7954b..1e06b1cd8891 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -615,7 +615,9 @@ fn spawn_app_server_page_loader( fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { match sort_key { ThreadSortKey::CreatedAt => "Created", - ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => "Updated", + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt | ThreadSortKey::SectionPosition => { + "Updated" + } } } @@ -1644,7 +1646,9 @@ impl PickerState { fn toggle_sort_key(&mut self) { self.sort_key = match self.sort_key { ThreadSortKey::CreatedAt => ThreadSortKey::UpdatedAt, - ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => ThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt + | ThreadSortKey::RecencyAt + | ThreadSortKey::SectionPosition => ThreadSortKey::CreatedAt, }; self.start_initial_load(); } @@ -2645,7 +2649,9 @@ fn render_dense_session_lines( let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); let date = match state.sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt | ThreadSortKey::SectionPosition => { + updated + } }; let mut lines = vec![dense_summary_line(DenseSummaryInput { marker, @@ -2774,7 +2780,9 @@ fn render_footer_lines( ) -> Vec> { let date = match sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt | ThreadSortKey::SectionPosition => { + updated + } }; let mut parts = vec![FooterPart::Date(date.to_string())]; if show_cwd { @@ -5759,6 +5767,7 @@ session_picker_view = "dense" preview: String::from("remote thread"), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, @@ -5799,6 +5808,7 @@ session_picker_view = "dense" preview: String::from("preview"), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, @@ -5877,6 +5887,7 @@ session_picker_view = "dense" preview: String::from("preview"), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, @@ -5948,6 +5959,7 @@ session_picker_view = "dense" preview: String::from("preview"), ephemeral: false, section: None, + section_entered_at: None, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1,