diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 095b984b38..22d97bd8a9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -204,7 +204,7 @@ fn sample_thread_with_metadata( parent_thread_id, preview: "first prompt".to_string(), ephemeral, - is_pinned: false, + section: 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 202a6a0b57..40e18b2857 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -479,7 +479,7 @@ fn sample_thread(thread_id: &str) -> Thread { parent_thread_id: None, preview: "first prompt".to_string(), ephemeral: false, - is_pinned: false, + section: 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 8588a34069..72d8298265 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4154,13 +4154,6 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, - "isPinned": { - "description": "Optional pinned filter; when set, only threads matching this value are returned.", - "type": [ - "boolean", - "null" - ] - }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -4187,6 +4180,13 @@ "null" ] }, + "sectionId": { + "description": "Omit to include every section, set to `null` for unsectioned threads, or provide a section ID to return only threads in that section.", + "type": [ + "string", + "null" + ] + }, "sortDirection": { "anyOf": [ { @@ -4293,10 +4293,10 @@ ], "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." }, - "isPinned": { - "description": "Patch whether this thread is pinned. Omit to leave the stored value unchanged.", + "sectionId": { + "description": "Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID.", "type": [ - "boolean", + "string", "null" ] }, @@ -4572,6 +4572,28 @@ ], "type": "object" }, + "ThreadSectionListParams": { + "description": "Parameters for listing independently persisted thread sections.", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Maximum number of sections to return.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "ThreadSetNameParams": { "properties": { "name": { @@ -5634,6 +5656,30 @@ "title": "Thread/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "threadSection/list" + ], + "title": "ThreadSection/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSectionListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/listRequest", + "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 636a2433c2..f92cce4896 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3758,11 +3758,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -3800,6 +3795,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -4969,6 +4976,24 @@ ], "type": "object" }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ThreadSettings": { "properties": { "activePermissionProfile": { 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 f89d3eac44..99f80ada8b 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 @@ -636,6 +636,30 @@ "title": "Thread/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "threadSection/list" + ], + "title": "ThreadSection/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadSectionListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -18089,11 +18113,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -18131,6 +18150,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -19554,13 +19585,6 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, - "isPinned": { - "description": "Optional pinned filter; when set, only threads matching this value are returned.", - "type": [ - "boolean", - "null" - ] - }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -19587,6 +19611,13 @@ "null" ] }, + "sectionId": { + "description": "Omit to include every section, set to `null` for unsectioned threads, or provide a section ID to return only threads in that section.", + "type": [ + "string", + "null" + ] + }, "sortDirection": { "anyOf": [ { @@ -19751,10 +19782,10 @@ ], "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." }, - "isPinned": { - "description": "Patch whether this thread is pinned. Omit to leave the stored value unchanged.", + "sectionId": { + "description": "Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID.", "type": [ - "boolean", + "string", "null" ] }, @@ -20346,6 +20377,72 @@ ], "type": "object" }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "ThreadSectionListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for listing independently persisted thread sections.", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Maximum number of sections to return.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ThreadSectionListParams", + "type": "object" + }, + "ThreadSectionListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "One page of independently persisted thread sections.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/ThreadSection" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor for the next page, or `null` when no sections remain.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadSectionListResponse", + "type": "object" + }, "ThreadSetNameParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f53fe8228b..0c6850f595 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 @@ -1786,6 +1786,30 @@ "title": "Thread/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "threadSection/list" + ], + "title": "ThreadSection/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSectionListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -15853,11 +15877,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -15895,6 +15914,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -17318,13 +17349,6 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, - "isPinned": { - "description": "Optional pinned filter; when set, only threads matching this value are returned.", - "type": [ - "boolean", - "null" - ] - }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -17351,6 +17375,13 @@ "null" ] }, + "sectionId": { + "description": "Omit to include every section, set to `null` for unsectioned threads, or provide a section ID to return only threads in that section.", + "type": [ + "string", + "null" + ] + }, "sortDirection": { "anyOf": [ { @@ -17515,10 +17546,10 @@ ], "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." }, - "isPinned": { - "description": "Patch whether this thread is pinned. Omit to leave the stored value unchanged.", + "sectionId": { + "description": "Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID.", "type": [ - "boolean", + "string", "null" ] }, @@ -18110,6 +18141,72 @@ ], "type": "object" }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "ThreadSectionListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for listing independently persisted thread sections.", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Maximum number of sections to return.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ThreadSectionListParams", + "type": "object" + }, + "ThreadSectionListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "One page of independently persisted thread sections.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/ThreadSection" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor for the next page, or `null` when no sections remain.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadSectionListResponse", + "type": "object" + }, "ThreadSetNameParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 b531012b0c..ead666369d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1110,11 +1110,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -1152,6 +1147,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -2000,6 +2007,24 @@ } ] }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ThreadSource": { "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 342b3c1a4c..5f6b620115 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -71,13 +71,6 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, - "isPinned": { - "description": "Optional pinned filter; when set, only threads matching this value are returned.", - "type": [ - "boolean", - "null" - ] - }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -104,6 +97,13 @@ "null" ] }, + "sectionId": { + "description": "Omit to include every section, set to `null` for unsectioned threads, or provide a section ID to return only threads in that section.", + "type": [ + "string", + "null" + ] + }, "sortDirection": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index fa7cc266d1..b2c44e214a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -901,11 +901,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -943,6 +938,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1791,6 +1798,24 @@ } ] }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ThreadSource": { "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 edba5e6c60..7866be89b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json @@ -40,10 +40,10 @@ ], "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." }, - "isPinned": { - "description": "Patch whether this thread is pinned. Omit to leave the stored value unchanged.", + "sectionId": { + "description": "Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID.", "type": [ - "boolean", + "string", "null" ] }, 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 4f7fcccf8c..8e3de74501 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -901,11 +901,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -943,6 +938,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1791,6 +1798,24 @@ } ] }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ThreadSource": { "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 312b0b44af..73449d46e7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -901,11 +901,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -943,6 +938,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1791,6 +1798,24 @@ } ] }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ThreadSource": { "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 ad6428f7a0..d3ffee1def 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1110,11 +1110,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -1152,6 +1147,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -2000,6 +2007,24 @@ } ] }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ThreadSource": { "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 284029b0f9..63982aa302 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -901,11 +901,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -943,6 +938,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1791,6 +1798,24 @@ } ] }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ThreadSource": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionListParams.json new file mode 100644 index 0000000000..790aeacd45 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionListParams.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for listing independently persisted thread sections.", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Maximum number of sections to return.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ThreadSectionListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionListResponse.json new file mode 100644 index 0000000000..8e684aa854 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionListResponse.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + } + }, + "description": "One page of independently persisted thread sections.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/ThreadSection" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor for the next page, or `null` when no sections remain.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadSectionListResponse", + "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 6906523289..14569d764f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1110,11 +1110,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -1152,6 +1147,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -2000,6 +2007,24 @@ } ] }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ThreadSource": { "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 d14cff1108..850d62c101 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -901,11 +901,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -943,6 +938,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1791,6 +1798,24 @@ } ] }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ThreadSource": { "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 03e1fb6cdb..46d0ee41ca 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -901,11 +901,6 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, - "isPinned": { - "default": false, - "description": "Whether the thread has been pinned by the user.", - "type": "boolean" - }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -943,6 +938,18 @@ "null" ] }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1791,6 +1798,24 @@ } ] }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ThreadSource": { "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 b67f518ac4..cb98418906 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -78,6 +78,7 @@ import type { ThreadMetadataUpdateParams } from "./v2/ThreadMetadataUpdateParams 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 { ThreadSetNameParams } from "./v2/ThreadSetNameParams"; import type { ThreadShellCommandParams } from "./v2/ThreadShellCommandParams"; import type { ThreadStartParams } from "./v2/ThreadStartParams"; @@ -91,4 +92,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": "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/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 1944cbd67b..61fda97efb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -4,6 +4,7 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { GitInfo } from "./GitInfo"; import type { SessionSource } from "./SessionSource"; +import type { ThreadSection } from "./ThreadSection"; import type { ThreadSource } from "./ThreadSource"; import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; @@ -27,9 +28,9 @@ preview: string, /** * Whether the thread is ephemeral and should not be materialized on disk. */ ephemeral: boolean, /** - * Whether the thread has been pinned by the user. + * The independently persisted section selected for this thread, if any. */ -isPinned: boolean, /** +section: ThreadSection | null, /** * Model provider used for this thread (for example, 'openai'). */ modelProvider: string, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts index 7d611ddc96..3bff76e2c7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -30,9 +30,10 @@ sourceKinds?: Array | null, /** * If false or null, only non-archived threads are returned. */ archived?: boolean | null, /** - * Optional pinned filter; when set, only threads matching this value are returned. + * Omit to include every section, set to `null` for unsectioned threads, + * or provide a section ID to return only threads in that section. */ -isPinned?: boolean | null, /** +sectionId?: string | null, /** * Optional cwd filter or filters; when set, only threads whose session cwd * exactly matches one of these paths are returned. */ 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 4e670e8471..829c46ba23 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts @@ -11,6 +11,6 @@ export type ThreadMetadataUpdateParams = { threadId: string, */ gitInfo?: ThreadMetadataGitInfoUpdateParams | null, /** - * Patch whether this thread is pinned. Omit to leave the stored value unchanged. + * Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID. */ -isPinned?: boolean | null, }; +sectionId?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSection.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSection.ts new file mode 100644 index 0000000000..ee5bc7e266 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSection.ts @@ -0,0 +1,16 @@ +// 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. + +/** + * An independently persisted, user-visible thread section. + */ +export type ThreadSection = { +/** + * Opaque UUIDv7 identity that remains stable when the section is renamed. + */ +id: string, +/** + * The current user-visible section name. + */ +name: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionListParams.ts new file mode 100644 index 0000000000..57a28c79ea --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionListParams.ts @@ -0,0 +1,16 @@ +// 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 listing independently persisted thread sections. + */ +export type ThreadSectionListParams = { +/** + * Opaque pagination cursor returned by a previous call. + */ +cursor?: string | null, +/** + * Maximum number of sections to return. + */ +limit?: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionListResponse.ts new file mode 100644 index 0000000000..08bebabf06 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionListResponse.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadSection } from "./ThreadSection"; + +/** + * One page of independently persisted thread sections. + */ +export type ThreadSectionListResponse = { data: Array, +/** + * Opaque cursor for the next page, or `null` when no sections remain. + */ +nextCursor: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 9cdebd57b3..edbda8a022 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -467,6 +467,9 @@ export type { ThreadResumeResponse } from "./ThreadResumeResponse"; export type { ThreadRollbackParams } from "./ThreadRollbackParams"; export type { ThreadRollbackResponse } from "./ThreadRollbackResponse"; export type { ThreadSearchResult } from "./ThreadSearchResult"; +export type { ThreadSection } from "./ThreadSection"; +export type { ThreadSectionListParams } from "./ThreadSectionListParams"; +export type { ThreadSectionListResponse } from "./ThreadSectionListResponse"; 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 1f624e529c..63471e6599 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -633,6 +633,11 @@ client_request_definitions! { serialization: None, response: v2::ThreadListResponse, }, + ThreadSectionList => "threadSection/list" { + params: v2::ThreadSectionListParams, + serialization: None, + response: v2::ThreadSectionListResponse, + }, #[experimental("thread/search")] ThreadSearch => "thread/search" { params: v2::ThreadSearchParams, @@ -2730,7 +2735,7 @@ mod tests { parent_thread_id: None, preview: "first prompt".to_string(), ephemeral: true, - is_pinned: false, + section: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, @@ -2783,7 +2788,7 @@ mod tests { "parentThreadId": null, "preview": "first prompt", "ephemeral": true, - "isPinned": false, + "section": null, "historyMode": "legacy", "modelProvider": "openai", "createdAt": 1, 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 300ac4c3eb..3c8e13369d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -185,7 +185,10 @@ fn thread_resume_response_round_trips_initial_turns_page() { parent_thread_id: None, preview: String::new(), ephemeral: false, - is_pinned: true, + section: Some(ThreadSection { + id: "01984de2-8f74-7c91-a3b2-5c5e937cf318".to_string(), + name: "Pinned".to_string(), + }), history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, @@ -226,16 +229,22 @@ fn thread_resume_response_round_trips_initial_turns_page() { }; let value = serde_json::to_value(&response).expect("serialize thread resume response"); - assert_eq!(value["thread"]["isPinned"], json!(true)); + assert_eq!( + value["thread"]["section"], + json!({ + "id": "01984de2-8f74-7c91-a3b2-5c5e937cf318", + "name": "Pinned", + }) + ); let mut legacy_thread = value["thread"].clone(); legacy_thread .as_object_mut() .expect("serialized thread should be an object") - .remove("isPinned"); + .remove("section"); let legacy_thread = serde_json::from_value::(legacy_thread).expect("deserialize legacy thread"); - assert!(!legacy_thread.is_pinned); + assert_eq!(legacy_thread.section, None); assert_eq!( value.get("initialTurnsPage"), @@ -369,39 +378,122 @@ fn thread_list_params_accepts_state_db_only_flag() { } #[test] -fn thread_list_params_accepts_pinned_filter() { - for is_pinned in [true, false] { +fn thread_list_params_accepts_section_id_filter() { + for section_id in [ + "01984de2-8f74-7c91-a3b2-5c5e937cf318", + "01984de2-8f74-7c91-a3b2-5c5e937cf319", + ] { let params = serde_json::from_value::(json!({ - "isPinned": is_pinned, + "sectionId": section_id, })) - .expect("pinned filter should deserialize"); + .expect("section ID filter should deserialize"); - assert_eq!(params.is_pinned, Some(is_pinned)); + assert_eq!( + params.section_id.as_ref().map(|section| section.as_deref()), + Some(Some(section_id)) + ); + assert_eq!( + serde_json::to_value(¶ms) + .expect("section ID filter should serialize") + .get("sectionId"), + Some(&json!(section_id)) + ); } + let params = serde_json::from_value::(json!({ + "sectionId": null, + })) + .expect("unsectioned thread filter should deserialize"); + assert_eq!(params.section_id, Some(None)); + assert_eq!( + serde_json::to_value(¶ms) + .expect("unsectioned thread filter should serialize") + .get("sectionId"), + Some(&json!(null)) + ); + let params = serde_json::from_value::(json!({})) - .expect("omitted pinned filter should deserialize"); - assert_eq!(params.is_pinned, None); + .expect("omitted section ID filter should deserialize"); + assert_eq!(params.section_id, None); + assert!( + serde_json::to_value(¶ms) + .expect("omitted section ID filter should serialize") + .get("sectionId") + .is_none() + ); } #[test] -fn thread_metadata_update_params_accepts_pinned_patch() { - for is_pinned in [true, false] { +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", - "isPinned": is_pinned, + "sectionId": section_id, })) - .expect("pinned metadata patch should deserialize"); + .expect("section ID metadata patch should deserialize"); - assert_eq!(params.is_pinned, Some(is_pinned)); + 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("omitted pinned metadata patch should deserialize"); - assert_eq!(params.is_pinned, None); + .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!({ + "cursor": "section-cursor", + "limit": 25, + })) + .expect("section list parameters should deserialize"); + assert_eq!( + params, + ThreadSectionListParams { + cursor: Some("section-cursor".to_string()), + limit: Some(25), + } + ); + + let response = ThreadSectionListResponse { + data: vec![ThreadSection { + id: "01984de2-8f74-7c91-a3b2-5c5e937cf318".to_string(), + name: "Pinned".to_string(), + }], + next_cursor: None, + }; + let value = serde_json::to_value(&response).expect("section list should serialize"); + assert_eq!( + value, + json!({ + "data": [{ + "id": "01984de2-8f74-7c91-a3b2-5c5e937cf318", + "name": "Pinned", + }], + "nextCursor": null, + }) + ); + assert_eq!( + serde_json::from_value::(value) + .expect("section list should deserialize"), + response + ); } #[test] 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 966f45aa49..75b61357c8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -6,6 +6,7 @@ use super::SandboxPolicy; use super::Thread; use super::ThreadHistoryMode; use super::ThreadItem; +use super::ThreadSection; use super::ThreadSource; use super::Turn; use super::TurnEnvironmentParams; @@ -865,9 +866,15 @@ pub struct ThreadMetadataUpdateParams { /// provide a string to replace the stored value. #[ts(optional = nullable)] pub git_info: Option, - /// Patch whether this thread is pinned. Omit to leave the stored value unchanged. - #[ts(optional = nullable)] - pub is_pinned: 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)] @@ -1093,6 +1100,29 @@ pub struct ThreadRollbackResponse { pub thread: Thread, } +/// Parameters for listing independently persisted thread sections. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSectionListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Maximum number of sections to return. + #[ts(optional = nullable)] + pub limit: Option, +} + +/// One page of independently persisted thread sections. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSectionListResponse { + pub data: Vec, + /// Opaque cursor for the next page, or `null` when no sections remain. + pub next_cursor: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1121,9 +1151,16 @@ pub struct ThreadListParams { /// If false or null, only non-archived threads are returned. #[ts(optional = nullable)] pub archived: Option, - /// Optional pinned filter; when set, only threads matching this value are returned. - #[ts(optional = nullable)] - pub is_pinned: Option, + /// Omit to include every section, set to `null` for unsectioned threads, + /// or provide a section ID to return only threads in that section. + #[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>, /// Optional cwd filter or filters; when set, only threads whose session cwd /// exactly matches one of these paths are returned. #[ts(optional = nullable, type = "string | Array | null")] 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 4e681271d1..064fd82435 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 @@ -164,6 +164,17 @@ pub struct GitInfo { pub origin_url: Option, } +/// An independently persisted, user-visible thread section. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSection { + /// Opaque UUIDv7 identity that remains stable when the section is renamed. + pub id: String, + /// The current user-visible section name. + pub name: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -183,9 +194,9 @@ pub struct Thread { pub preview: String, /// Whether the thread is ephemeral and should not be materialized on disk. pub ephemeral: bool, - /// Whether the thread has been pinned by the user. + /// The independently persisted section selected for this thread, if any. #[serde(default)] - pub is_pinned: bool, + pub section: Option, /// Persisted thread history contract selected when this thread was created. #[experimental("thread.historyMode")] #[serde(default)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 2fd6f365a6..0f04339226 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1308,7 +1308,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3 model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, parent_thread_id: None, ancestor_thread_id: None, cwd: None, diff --git a/codex-rs/app-server-transport/src/outgoing_message.rs b/codex-rs/app-server-transport/src/outgoing_message.rs index f080d1fb13..4aece351d6 100644 --- a/codex-rs/app-server-transport/src/outgoing_message.rs +++ b/codex-rs/app-server-transport/src/outgoing_message.rs @@ -21,6 +21,7 @@ impl fmt::Display for ConnectionId { /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] +#[allow(clippy::large_enum_variant)] pub enum OutgoingMessage { Request(ServerRequest), /// AppServerNotification is specific to the case where this is run as an diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 81406c1aaf..cfd15f90a1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -143,13 +143,14 @@ 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`, `isPinned`, `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. 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 `isPinned`, and returns the refreshed `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/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. @@ -392,7 +393,7 @@ Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thr - `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). -- `isPinned` — when provided, return only threads whose persisted pin state matches the requested value; omit it to include both pinned and unpinned threads. +- `sectionId` — provide an ID from `threadSection/list` to return threads from that section; pass `null` to return only threads without a section; or omit it to include threads from every section and threads without a section. - `cwd` — restrict results to threads whose session cwd exactly matches this path, or one of these paths when an array is provided. Relative paths are resolved against the app-server process cwd before matching. - `useStateDbOnly` — when `true`, return from the state DB without scanning JSONL rollouts to repair metadata. Omit or pass `false` to preserve the default scan-and-repair behavior. - `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive). diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 1f10da7ec4..58f4d8d401 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2217,7 +2217,7 @@ mod tests { updated_at: created_at, recency_at: created_at, archived_at: None, - is_pinned: false, + section: 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 4ca6ddea9d..854ef5b6b9 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::ThreadSectionList { params, .. } => { + self.thread_processor.thread_section_list(params).await + } ClientRequest::ThreadSettingsUpdate { params, .. } => { self.turn_processor .thread_settings_update(&request_id, params) 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 85dfb9ecfe..88595243fe 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -3,6 +3,9 @@ use super::turn_processor::can_accept_direct_input; use super::*; use crate::error_code::method_not_found; 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_extension_api::ExtensionDataInit; use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErrorDetails; @@ -20,7 +23,7 @@ struct ThreadListFilters { model_providers: Option>, source_kinds: Option>, archived: bool, - is_pinned: Option, + section_id: Option>, cwd_filters: Option>, search_term: Option, use_state_db_only: bool, @@ -695,6 +698,39 @@ impl ThreadRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn thread_section_list( + &self, + params: ThreadSectionListParams, + ) -> Result, JSONRPCErrorError> { + let limit = params + .limit + .map(|value| value as usize) + .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) + .clamp(1, THREAD_LIST_MAX_LIMIT); + let state_db = self.state_db.clone().ok_or_else(|| { + method_not_found("threadSection/list is unavailable without sqlite state") + })?; + let page = state_db + .list_thread_sections(params.cursor.as_deref(), limit) + .await + .map_err(|err| internal_error(format!("failed to list thread sections: {err}")))?; + + Ok(Some( + ThreadSectionListResponse { + data: page + .sections + .into_iter() + .map(|section| ThreadSection { + id: section.id, + name: section.name, + }) + .collect(), + next_cursor: page.next_cursor, + } + .into(), + )) + } + pub(crate) async fn thread_search( &self, params: ThreadSearchParams, @@ -1638,13 +1674,13 @@ impl ThreadRequestProcessor { let ThreadMetadataUpdateParams { thread_id, git_info, - is_pinned, + 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() && is_pinned.is_none() { + if git_info.is_none() && section_id.is_none() { return Err(invalid_request( "thread metadata update must include at least one field", )); @@ -1676,14 +1712,13 @@ impl ThreadRequestProcessor { ) .transpose()?; - let patch = StoreThreadMetadataPatch { - git_info, - is_pinned, - ..Default::default() - }; - let updated_thread = { 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 .update_thread_metadata(thread_uuid, patch, /*include_archived*/ true) .await @@ -1977,7 +2012,7 @@ impl ThreadRequestProcessor { model_providers, source_kinds, archived, - is_pinned, + section_id, cwd, use_state_db_only, search_term, @@ -2022,7 +2057,7 @@ impl ThreadRequestProcessor { model_providers, source_kinds, archived: archived.unwrap_or(false), - is_pinned, + section_id, cwd_filters, search_term, use_state_db_only, @@ -4448,7 +4483,7 @@ impl ThreadRequestProcessor { model_providers, source_kinds, archived, - is_pinned, + section_id, cwd_filters, search_term, use_state_db_only, @@ -4496,7 +4531,7 @@ impl ThreadRequestProcessor { model_providers: model_provider_filter.clone(), cwd_filters: cwd_filters.clone(), archived, - is_pinned, + section: section_id.clone(), search_term: search_term.clone(), use_state_db_only, relation_filter, @@ -5081,7 +5116,10 @@ pub(crate) fn thread_from_stored_thread( parent_thread_id: thread.parent_thread_id.map(|id| id.to_string()), preview: thread.preview, ephemeral: false, - is_pinned: thread.is_pinned, + section: thread.section.map(|section| ThreadSection { + id: section.id, + name: section.name, + }), history_mode: thread.history_mode.into(), model_provider: if thread.model_provider.is_empty() { fallback_provider.to_string() @@ -5293,7 +5331,7 @@ fn build_thread_from_snapshot( parent_thread_id: config_snapshot.parent_thread_id.map(|id| id.to_string()), preview: String::new(), ephemeral: config_snapshot.ephemeral, - is_pinned: false, + section: 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 461e1793bb..26760c6546 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 @@ -480,7 +480,7 @@ mod thread_processor_behavior_tests { updated_at: updated_at.with_timezone(&Utc), recency_at: updated_at.with_timezone(&Utc), archived_at: None, - is_pinned: false, + section: 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 fa03d34832..e580616e66 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 @@ -194,7 +194,7 @@ mod tests { parent_thread_id: None, preview: "preview".to_string(), ephemeral: false, - is_pinned: false, + section: 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 8376866ace..b72b08f210 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -307,7 +307,7 @@ pub(crate) fn summary_to_thread( parent_thread_id: None, preview, ephemeral: false, - is_pinned: false, + section: 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/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 76ac3f7107..70d923ec8c 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -414,7 +414,7 @@ source = {:?} model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1665,7 +1665,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: true, search_term: None, @@ -1852,7 +1852,7 @@ required = true model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1936,7 +1936,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -2017,7 +2017,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions() model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -2144,7 +2144,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -2259,7 +2259,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index df3f3df726..030131a3db 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadSectionListParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -66,6 +67,41 @@ use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +#[tokio::test] +async fn thread_section_list_without_sqlite_returns_method_not_found() -> Result<()> { + let codex_home = TempDir::new()?; + let store_id = Uuid::new_v4().to_string(); + create_config_toml_with_thread_store(codex_home.path(), "http://127.0.0.1:1", &store_id)?; + let _in_memory_store = InMemoryThreadStoreId { store_id }; + let client = start_in_process_server(codex_home.path()).await?; + + let error = client + .request(ClientRequest::ThreadSectionList { + request_id: RequestId::Integer(1), + params: ThreadSectionListParams::default(), + }) + .await? + .expect_err("section discovery requires sqlite state"); + + assert_eq!(error.code, -32601); + assert_eq!( + error.message, + "threadSection/list is unavailable without sqlite state" + ); + + client.shutdown().await?; + assert!( + !codex_home.path().join("state_5.sqlite").exists(), + "section discovery must not create local SQLite state" + ); + assert!( + !codex_home.path().join("sessions").exists(), + "section discovery must not create local thread persistence" + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_rejects_paginated_history_without_list_support() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -177,7 +213,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist model_providers: Some(Vec::new()), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 40fb4249f8..ebcc6a50f6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -96,7 +96,7 @@ async fn list_threads(mcp: &mut TestAppServer) -> Result { model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 61d9f1c1fc..ab54970e6e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -96,7 +96,7 @@ async fn list_threads_with_sort( model_providers: providers, source_kinds, archived, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -134,7 +134,7 @@ async fn list_threads_for_relation( model_providers, source_kinds, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -538,7 +538,7 @@ async fn thread_list_respects_cwd_filters() -> Result<()> { model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: Some(ThreadListCwdFilter::Many(vec![ first_target_cwd.to_string_lossy().into_owned(), second_target_cwd.to_string_lossy().into_owned(), @@ -650,7 +650,7 @@ sqlite = true model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: Some("needle".to_string()), @@ -853,7 +853,7 @@ sqlite = true model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -888,7 +888,7 @@ sqlite = true model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: Some(ThreadListCwdFilter::One( stale_cwd.to_string_lossy().into_owned(), )), @@ -916,7 +916,7 @@ sqlite = true model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: Some(ThreadListCwdFilter::One( stale_cwd.to_string_lossy().into_owned(), )), @@ -1100,7 +1100,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1125,7 +1125,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { model_providers: None, source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1917,7 +1917,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1957,7 +1957,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -2193,7 +2193,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> { model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index 2a51fb7f7f..8fbe8f321a 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 @@ -18,6 +18,9 @@ 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::ThreadSection; +use codex_app_server_protocol::ThreadSectionListParams; +use codex_app_server_protocol::ThreadSectionListResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -28,6 +31,8 @@ use codex_git_utils::GitSha; use codex_protocol::ThreadId; use codex_protocol::protocol::GitInfo as RolloutGitInfo; use codex_rollout::state_db::reconcile_rollout; +use codex_state::PINNED_THREAD_SECTION_ID; +use codex_state::PINNED_THREAD_SECTION_NAME; use codex_state::StateRuntime; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; @@ -92,12 +97,49 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let pinned_section = ThreadSection { + id: PINNED_THREAD_SECTION_ID.to_string(), + name: PINNED_THREAD_SECTION_NAME.to_string(), + }; + let section_list_id = mcp + .send_raw_request( + "threadSection/list", + Some(serde_json::to_value(ThreadSectionListParams { + cursor: None, + limit: Some(1), + })?), + ) + .await?; + let empty_membership_sections: ThreadSectionListResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(section_list_id)).await??; + assert_eq!(empty_membership_sections.data, vec![pinned_section.clone()]); + assert_eq!(empty_membership_sections.next_cursor, None); + + let unknown_section_id = "01984de2-8f74-7c91-a3b2-5c5e937cf319"; + let unknown_section_request_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: initially_unpinned.clone(), + git_info: None, + section_id: Some(Some(unknown_section_id.to_string())), + }) + .await?; + let unknown_section_error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(unknown_section_request_id)), + ) + .await??; + 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}") + ); + for thread_id in [older_pinned, newer_pinned] { let request_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), git_info: None, - is_pinned: Some(true), + section_id: Some(Some(PINNED_THREAD_SECTION_ID.to_string())), }) .await?; let response = timeout( @@ -105,15 +147,15 @@ 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_is_pinned = response + let wire_section = response .result .get("thread") - .and_then(|thread| thread.get("isPinned")) - .and_then(Value::as_bool); + .and_then(|thread| thread.get("section")) + .cloned(); let ThreadMetadataUpdateResponse { thread } = to_response(response)?; assert_eq!(thread.id, *thread_id); - assert!(thread.is_pinned); - assert_eq!(wire_is_pinned, Some(true)); + assert_eq!(thread.section, Some(pinned_section.clone())); + assert_eq!(wire_section, Some(serde_json::to_value(&pinned_section)?)); } let list_params = ThreadListParams { @@ -124,7 +166,7 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination model_providers: None, source_kinds: None, archived: None, - is_pinned: Some(true), + section_id: Some(Some(PINNED_THREAD_SECTION_ID.to_string())), cwd: None, use_state_db_only: false, search_term: None, @@ -140,7 +182,7 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination let first_page: ThreadListResponse = to_response(response)?; assert_eq!(first_page.data.len(), 1); assert_eq!(first_page.data[0].id, *newer_pinned); - assert!(first_page.data[0].is_pinned); + assert_eq!(first_page.data[0].section, Some(pinned_section.clone())); let request_id = mcp .send_thread_list_request(ThreadListParams { @@ -156,13 +198,13 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination let second_page: ThreadListResponse = to_response(response)?; assert_eq!(second_page.data.len(), 1); assert_eq!(second_page.data[0].id, *older_pinned); - assert!(second_page.data[0].is_pinned); + assert_eq!(second_page.data[0].section, Some(pinned_section.clone())); let request_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: newer_pinned.clone(), git_info: None, - is_pinned: Some(false), + section_id: Some(None), }) .await?; let response = timeout( @@ -171,12 +213,12 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination ) .await??; let ThreadMetadataUpdateResponse { thread } = to_response(response)?; - assert!(!thread.is_pinned); + assert_eq!(thread.section, None); let request_id = mcp .send_thread_list_request(ThreadListParams { limit: Some(10), - is_pinned: Some(false), + section_id: Some(None), ..list_params }) .await?; @@ -185,16 +227,35 @@ async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - let unpinned_page: ThreadListResponse = to_response(response)?; + let unsectioned_page: ThreadListResponse = to_response(response)?; assert_eq!( - unpinned_page + unsectioned_page .data .iter() .map(|thread| thread.id.as_str()) .collect::>(), [newer_pinned.as_str(), initially_unpinned.as_str()] ); - assert!(unpinned_page.data.iter().all(|thread| !thread.is_pinned)); + assert!( + unsectioned_page + .data + .iter() + .all(|thread| thread.section.is_none()) + ); + + let section_list_id = mcp + .send_raw_request( + "threadSection/list", + Some(serde_json::to_value(ThreadSectionListParams { + cursor: None, + limit: Some(1), + })?), + ) + .await?; + let sections_after_clear: ThreadSectionListResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(section_list_id)).await??; + assert_eq!(sections_after_clear.data, vec![pinned_section]); + assert_eq!(sections_after_clear.next_cursor, None); Ok(()) } @@ -227,7 +288,7 @@ 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(), - is_pinned: None, + section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/sidebar-pr".to_string())), @@ -326,7 +387,7 @@ 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, - is_pinned: None, + section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: None, @@ -377,7 +438,7 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread.id.clone(), - is_pinned: None, + section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/ephemeral".to_string())), @@ -400,6 +461,28 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { ) ); + let clear_section_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread.id.clone(), + section_id: Some(None), + git_info: None, + }) + .await?; + let clear_section_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(clear_section_id)), + ) + .await??; + + assert_eq!(clear_section_err.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + clear_section_err.error.message, + format!( + "ephemeral thread does not support metadata updates: {}", + thread.id + ) + ); + Ok(()) } @@ -430,7 +513,7 @@ 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(), - is_pinned: None, + section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/stored-thread".to_string())), @@ -515,7 +598,7 @@ 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(), - is_pinned: None, + section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/loaded-thread".to_string())), @@ -583,7 +666,7 @@ 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(), - is_pinned: None, + section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/archived-thread".to_string())), @@ -644,7 +727,7 @@ 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(), - is_pinned: None, + section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: Some(None), branch: Some(None), diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 00c1ded53b..8ad3dc0214 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -254,7 +254,7 @@ async fn paginated_stored_thread_routes_projected_turns_and_rejects_legacy_histo model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -955,7 +955,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> model_providers: Some(Vec::new()), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1334,7 +1334,7 @@ async fn paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: true, search_term: 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 9cc5318341..2d680d1ec6 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,7 @@ 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(), - is_pinned: None, + section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/pr-branch".to_string())), @@ -2723,7 +2723,6 @@ async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Res .and_then(|git| git.branch.as_deref()), Some("feature/pr-branch") ); - Ok(()) } @@ -3021,7 +3020,6 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { .. } = timeout(DEFAULT_READ_TIMEOUT, secondary.read_response(resume_id)).await??; assert_ne!(resumed_thread.status, ThreadStatus::NotLoaded); - timeout( DEFAULT_READ_TIMEOUT, primary.read_stream_until_notification_message("turn/completed"), 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 77461e940b..8f87f34df8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -15,6 +15,7 @@ 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::ThreadSection; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; @@ -36,6 +37,8 @@ use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode; +use codex_state::PINNED_THREAD_SECTION_ID; +use codex_state::PINNED_THREAD_SECTION_NAME; use codex_thread_store::CreateThreadParams; use codex_thread_store::InMemoryThreadStore; use codex_thread_store::ThreadMetadataPatch; @@ -97,17 +100,21 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result ) .await??; + let pinned_section = ThreadSection { + id: PINNED_THREAD_SECTION_ID.to_string(), + name: PINNED_THREAD_SECTION_NAME.to_string(), + }; let pin_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread.id.clone(), git_info: None, - is_pinned: Some(true), + section_id: Some(Some(PINNED_THREAD_SECTION_ID.to_string())), }) .await?; let ThreadMetadataUpdateResponse { thread: pinned_thread, } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(pin_id)).await??; - assert!(pinned_thread.is_pinned); + assert_eq!(pinned_thread.section, Some(pinned_section.clone())); let found_rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) @@ -166,7 +173,7 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result ) .await??; assert_eq!(unarchived_notification.thread_id, thread.id); - assert!(unarchived_thread.is_pinned); + assert_eq!(unarchived_thread.section, Some(pinned_section.clone())); assert!( unarchived_thread.updated_at > old_timestamp, "expected updated_at to be bumped on unarchive" @@ -179,7 +186,10 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result .and_then(Value::as_object) .expect("thread/unarchive result.thread must be an object"); assert_eq!(unarchived_thread.name, None); - assert_eq!(thread_json.get("isPinned"), Some(&Value::Bool(true))); + assert_eq!( + thread_json.get("section"), + Some(&serde_json::to_value(&pinned_section)?) + ); assert_eq!( thread_json.get("name"), Some(&Value::Null), diff --git a/codex-rs/core/src/realtime_context.rs b/codex-rs/core/src/realtime_context.rs index 2281d75da8..374639d47a 100644 --- a/codex-rs/core/src/realtime_context.rs +++ b/codex-rs/core/src/realtime_context.rs @@ -141,7 +141,7 @@ async fn load_recent_threads(sess: &Session) -> Vec { cwd_filters: None, relation_filter: None, archived: false, - is_pinned: None, + section: None, search_term: None, use_state_db_only: false, }) diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index e2dfbf06bf..b86f90145e 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -52,7 +52,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre .single() .expect("valid timestamp"), archived_at: None, - is_pinned: false, + section: None, cwd: PathBuf::from(cwd), cli_version: "test".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5073c31904..6c031b5418 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1485,7 +1485,7 @@ async fn resolve_resume_thread_id( model_providers: model_providers.clone(), source_kinds: Some(all_thread_source_kinds()), archived: Some(false), - is_pinned: None, + section_id: None, parent_thread_id: None, ancestor_thread_id: None, cwd: None, @@ -1553,7 +1553,7 @@ async fn resolve_resume_thread_id( model_providers: model_providers.clone(), source_kinds: Some(all_thread_source_kinds()), archived: Some(false), - is_pinned: None, + section_id: None, parent_thread_id: None, ancestor_thread_id: None, cwd: None, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 624128fe92..b6bc6a6197 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -335,7 +335,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { parent_thread_id: None, preview: String::new(), ephemeral: false, - is_pinned: false, + section: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 0, @@ -803,7 +803,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { parent_thread_id: None, preview: String::new(), ephemeral: false, - is_pinned: false, + section: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 0, diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index dc5f9878a7..376e54a24f 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -55,8 +55,8 @@ pub struct ThreadItem { pub first_user_message: Option, /// Best available user-facing preview for discovery and list display. pub preview: Option, - /// Whether the thread is pinned in SQLite-owned metadata. - pub is_pinned: bool, + /// The user-selected section in SQLite-owned metadata. + pub section: Option, /// Working directory from session metadata. pub cwd: Option, /// Git branch from session metadata. @@ -832,7 +832,7 @@ async fn build_thread_item( thread_id, first_user_message, preview, - is_pinned: false, + section: None, cwd, git_branch, git_sha, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 43be695398..8c1dd81c37 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -472,7 +472,7 @@ impl RolloutRecorder { cwd_filters, /*relation_filter*/ None, archived, - /*is_pinned*/ None, + /*section*/ None, search_term, ) .await @@ -582,7 +582,7 @@ impl RolloutRecorder { cwd_filters, /*relation_filter*/ None, archived, - /*is_pinned*/ None, + /*section*/ None, search_term, ) .await; @@ -612,7 +612,7 @@ impl RolloutRecorder { cwd_filters, /*relation_filter*/ None, archived, - /*is_pinned*/ None, + /*section*/ None, search_term, ) .await @@ -653,7 +653,7 @@ impl RolloutRecorder { cwd_filters, /*relation_filter*/ None, archived, - /*is_pinned*/ None, + /*section*/ None, search_term, ) .await @@ -733,7 +733,7 @@ impl RolloutRecorder { cwd_filter.as_ref().map(std::slice::from_ref), /*relation_filter*/ None, /*archived*/ false, - /*is_pinned*/ None, + /*section*/ None, /*search_term*/ None, ) .await @@ -1202,7 +1202,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt thread_id: _state_thread_id, first_user_message, preview, - is_pinned, + section, cwd, git_branch, git_sha, @@ -1225,7 +1225,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt if item.preview.is_none() { item.preview = preview; } - item.is_pinned = is_pinned; + item.section = section; if item.cwd.is_none() { item.cwd = cwd; } @@ -1961,7 +1961,7 @@ fn thread_item_from_state_metadata( thread_id: Some(item.id), first_user_message: item.first_user_message, preview: item.preview, - is_pinned: item.is_pinned, + section: item.section, cwd: Some(item.cwd), git_branch: item.git_branch, git_sha: item.git_sha, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 6d30ace613..f3394be5bf 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1437,7 +1437,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi thread_id: Some(filesystem_thread_id), first_user_message: Some("filesystem message".to_string()), preview: Some("filesystem preview".to_string()), - is_pinned: false, + section: None, cwd: None, git_branch: Some("filesystem-branch".to_string()), git_sha: Some("filesystem-sha".to_string()), @@ -1458,7 +1458,10 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi thread_id: Some(state_thread_id), first_user_message: Some("state message".to_string()), preview: Some("state preview".to_string()), - is_pinned: true, + section: Some(codex_state::ThreadSection { + id: codex_state::PINNED_THREAD_SECTION_ID.to_string(), + name: codex_state::PINNED_THREAD_SECTION_NAME.to_string(), + }), cwd: Some(PathBuf::from("/tmp/state-cwd")), git_branch: Some("state-branch".to_string()), git_sha: Some("state-sha".to_string()), @@ -1479,7 +1482,13 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi assert_eq!(item.path, filesystem_path); assert_eq!(item.thread_id, Some(filesystem_thread_id)); - assert!(item.is_pinned); + assert_eq!( + item.section, + Some(codex_state::ThreadSection { + id: codex_state::PINNED_THREAD_SECTION_ID.to_string(), + name: codex_state::PINNED_THREAD_SECTION_NAME.to_string(), + }) + ); assert_eq!( item.first_user_message.as_deref(), Some("filesystem message") diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index 7d4fd9be6f..b0e8bb6427 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -361,7 +361,7 @@ pub async fn list_threads_db( cwd_filters: Option<&[PathBuf]>, relation_filter: Option, archived: bool, - is_pinned: Option, + section: Option>, search_term: Option<&str>, ) -> Option { let ctx = context?; @@ -403,7 +403,7 @@ pub async fn list_threads_db( if let Some(relation_filter) = relation_filter { let filters = codex_state::ThreadFilterOptions { archived_only: archived, - is_pinned, + section, allowed_sources: allowed_sources.as_slice(), model_providers: model_providers.as_deref(), cwd_filters: normalized_cwd_filters.as_deref(), @@ -430,7 +430,7 @@ pub async fn list_threads_db( while items.len() < page_size { let filters = codex_state::ThreadFilterOptions { archived_only: archived, - is_pinned, + section, allowed_sources: allowed_sources.as_slice(), model_providers: model_providers.as_deref(), cwd_filters: normalized_cwd_filters.as_deref(), diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index d77b68941f..36216ffa50 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -83,7 +83,7 @@ async fn list_threads_db_rejects_mismatched_sqlite_config_without_cleanup() -> a /*cwd_filters*/ None, /*relation_filter*/ None, /*archived*/ false, - /*is_pinned*/ None, + /*section*/ None, /*search_term*/ None, ) .await; diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index e962da0ecc..5a7d0c1b9a 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -650,7 +650,7 @@ async fn test_list_conversations_latest_first() { thread_id: Some(thread_id_from_uuid(u3)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -671,7 +671,7 @@ async fn test_list_conversations_latest_first() { thread_id: Some(thread_id_from_uuid(u2)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -692,7 +692,7 @@ async fn test_list_conversations_latest_first() { thread_id: Some(thread_id_from_uuid(u1)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -806,7 +806,7 @@ async fn test_pagination_cursor() { thread_id: Some(thread_id_from_uuid(u5)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -827,7 +827,7 @@ async fn test_pagination_cursor() { thread_id: Some(thread_id_from_uuid(u4)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -884,7 +884,7 @@ async fn test_pagination_cursor() { thread_id: Some(thread_id_from_uuid(u3)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -905,7 +905,7 @@ async fn test_pagination_cursor() { thread_id: Some(thread_id_from_uuid(u2)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -954,7 +954,7 @@ async fn test_pagination_cursor() { thread_id: Some(thread_id_from_uuid(u1)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -1128,7 +1128,7 @@ async fn test_get_thread_contents() { thread_id: Some(thread_id_from_uuid(uuid)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -1496,7 +1496,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { thread_id: Some(thread_id_from_uuid(u3)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -1517,7 +1517,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { thread_id: Some(thread_id_from_uuid(u2)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), - is_pinned: false, + section: None, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, diff --git a/codex-rs/state/migrations/0045_threads_section.sql b/codex-rs/state/migrations/0045_threads_section.sql new file mode 100644 index 0000000000..84fb1c8f51 --- /dev/null +++ b/codex-rs/state/migrations/0045_threads_section.sql @@ -0,0 +1,14 @@ +CREATE TABLE thread_sections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL +); + +INSERT INTO thread_sections (id, name) +VALUES ('01984de2-8f74-7c91-a3b2-5c5e937cf318', 'Pinned'); + +ALTER TABLE threads ADD COLUMN thread_section_id TEXT + REFERENCES thread_sections(id) ON DELETE SET NULL; + +CREATE INDEX idx_threads_section_recency_at_ms + ON threads(archived, thread_section_id, recency_at_ms DESC, id DESC) + 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 7be5363760..2d1a91fb1d 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -695,7 +695,7 @@ mod tests { tokens_used: 1, first_user_message: None, archived_at: None, - is_pinned: false, + section: None, git_sha: None, git_branch: None, git_origin_url: None, diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 0c79b7492d..5e5233cb84 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -51,6 +51,8 @@ pub use model::ThreadGoalStatus; pub use model::ThreadMetadata; pub use model::ThreadMetadataBuilder; pub use model::ThreadRelationFilter; +pub use model::ThreadSection; +pub use model::ThreadSectionsPage; pub use model::ThreadsPage; pub use runtime::ExternalAgentConfigImportDetailsRecord; pub use runtime::ExternalAgentConfigImportFailureRecord; @@ -78,6 +80,12 @@ pub use telemetry::install_process_db_telemetry; pub use telemetry::record_backfill_gate; pub use telemetry::record_fallback; +/// Stable UUIDv7 identifying the built-in pinned thread section. +pub const PINNED_THREAD_SECTION_ID: &str = "01984de2-8f74-7c91-a3b2-5c5e937cf318"; + +/// User-facing name of the built-in pinned thread section. +pub const PINNED_THREAD_SECTION_NAME: &str = "Pinned"; + /// Environment variable for overriding the SQLite state database home directory. pub const SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME"; diff --git a/codex-rs/state/src/migrations_tests.rs b/codex-rs/state/src/migrations_tests.rs index d5d836fdcd..a01b2685b4 100644 --- a/codex-rs/state/src/migrations_tests.rs +++ b/codex-rs/state/src/migrations_tests.rs @@ -1,4 +1,5 @@ use codex_utils_absolute_path::test_support::PathExt; +use pretty_assertions::assert_eq; use sqlx::Connection; use sqlx::Row; use sqlx::migrate::Migration; @@ -8,6 +9,10 @@ use std::borrow::Cow; use super::STATE_MIGRATOR; use super::THREAD_HISTORY_MIGRATOR; use super::repair_legacy_recency_migration_version; +use crate::PINNED_THREAD_SECTION_ID; +use crate::PINNED_THREAD_SECTION_NAME; + +const CUSTOM_THREAD_SECTION_ID: &str = "01984de2-8f74-7c91-a3b2-5c5e937cf317"; fn migrator_through(version: i64) -> Migrator { Migrator { @@ -28,7 +33,7 @@ fn migrator_through(version: i64) -> Migrator { } #[tokio::test] -async fn pinned_threads_migration_defaults_existing_and_legacy_rows_to_unpinned() { +async fn thread_section_migration_preserves_legacy_pin_compatibility() { let sqlite_home = crate::runtime::test_support::unique_temp_dir(); tokio::fs::create_dir_all(&sqlite_home) .await @@ -42,20 +47,25 @@ async fn pinned_threads_migration_defaults_existing_and_legacy_rows_to_unpinned( .open_read_write_pool(&state_path) .await .expect("sqlite database should open"); - migrator_through(/*version*/ 42) + migrator_through(/*version*/ 44) .run(&pool) .await - .expect("pre-pin migrations should apply"); + .expect("released thread migrations should apply"); for thread_id in [ "00000000-0000-0000-0000-000000000043", "00000000-0000-0000-0000-000000000044", ] { if thread_id.ends_with("44") { + sqlx::query("UPDATE threads SET is_pinned = 1 WHERE id = ?") + .bind("00000000-0000-0000-0000-000000000043") + .execute(&pool) + .await + .expect("legacy pin should remain writable before section migration"); STATE_MIGRATOR .run(&pool) .await - .expect("pin migration should apply"); + .expect("section migration should apply"); } sqlx::query( r#" @@ -92,11 +102,86 @@ INSERT INTO threads ( .expect("legacy thread insert should succeed"); } - let pinned_values = sqlx::query_scalar::<_, bool>("SELECT is_pinned FROM threads ORDER BY id") - .fetch_all(&pool) + let registered_sections = + sqlx::query_as::<_, (String, String)>("SELECT id, name FROM thread_sections ORDER BY id") + .fetch_all(&pool) + .await + .expect("independent thread sections should load"); + assert_eq!( + registered_sections, + vec![( + PINNED_THREAD_SECTION_ID.to_string(), + PINNED_THREAD_SECTION_NAME.to_string(), + )] + ); + + let threads = sqlx::query_as::<_, (i64, Option)>( + "SELECT is_pinned, thread_section_id FROM threads ORDER BY id", + ) + .fetch_all(&pool) + .await + .expect("legacy and section-aware thread metadata should load"); + assert_eq!(threads, vec![(1, None), (0, None)]); + + sqlx::query("INSERT INTO thread_sections (id, name) VALUES (?, ?)") + .bind(CUSTOM_THREAD_SECTION_ID) + .bind("Custom section") + .execute(&pool) .await - .expect("pin states should load"); - assert_eq!(pinned_values, vec![false, false]); + .expect("custom sections should have independent persisted identities"); + + let thread_id = "00000000-0000-0000-0000-000000000043"; + sqlx::query("UPDATE threads SET thread_section_id = ? WHERE id = ?") + .bind(CUSTOM_THREAD_SECTION_ID) + .bind(thread_id) + .execute(&pool) + .await + .expect("threads should reference independently persisted sections"); + sqlx::query("UPDATE threads SET is_pinned = 0 WHERE id = ?") + .bind(thread_id) + .execute(&pool) + .await + .expect("released binaries should still update the legacy pin column"); + let thread = sqlx::query_as::<_, (i64, Option)>( + "SELECT is_pinned, thread_section_id FROM threads WHERE id = ?", + ) + .bind(thread_id) + .fetch_one(&pool) + .await + .expect("legacy pin updates should not overwrite the authoritative section"); + assert_eq!(thread, (0, Some(CUSTOM_THREAD_SECTION_ID.to_string()))); + + sqlx::query("UPDATE threads SET thread_section_id = NULL WHERE id = ?") + .bind(thread_id) + .execute(&pool) + .await + .expect("threads should be removable from sections"); + + let registered_sections = + sqlx::query_as::<_, (String, String)>("SELECT id, name FROM thread_sections ORDER BY id") + .fetch_all(&pool) + .await + .expect("empty sections should remain independently discoverable"); + assert_eq!( + registered_sections, + vec![ + ( + CUSTOM_THREAD_SECTION_ID.to_string(), + "Custom section".to_string(), + ), + ( + PINNED_THREAD_SECTION_ID.to_string(), + PINNED_THREAD_SECTION_NAME.to_string(), + ), + ] + ); + + let mut released_pin_migrator = migrator_through(/*version*/ 44); + released_pin_migrator.ignore_missing = true; + released_pin_migrator + .run(&pool) + .await + .expect("released pin-capable binaries should tolerate newer migrations"); pool.close().await; } diff --git a/codex-rs/state/src/model/mod.rs b/codex-rs/state/src/model/mod.rs index f99aa7cf35..d5b041214c 100644 --- a/codex-rs/state/src/model/mod.rs +++ b/codex-rs/state/src/model/mod.rs @@ -26,6 +26,8 @@ pub use thread_metadata::SortKey; pub use thread_metadata::ThreadMetadata; pub use thread_metadata::ThreadMetadataBuilder; pub use thread_metadata::ThreadRelationFilter; +pub use thread_metadata::ThreadSection; +pub use thread_metadata::ThreadSectionsPage; pub use thread_metadata::ThreadsPage; pub(crate) use thread_goal::ThreadGoalRow; diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index e1ae5c9188..22e4d3cea1 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -8,6 +8,8 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadSource; +use serde::Deserialize; +use serde::Serialize; use sqlx::Row; use sqlx::sqlite::SqliteRow; use std::collections::HashMap; @@ -49,6 +51,24 @@ pub struct Anchor { pub id: Option, } +/// An independently persisted thread section and its user-facing name. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThreadSection { + /// Opaque UUIDv7 identifying the section independently of its name. + pub id: String, + /// User-facing section name. + pub name: String, +} + +/// A cursor-paginated page of independently persisted thread sections. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThreadSectionsPage { + /// Sections in ascending identifier order. + pub sections: Vec, + /// Identifier after which the next page starts, if any. + pub next_cursor: Option, +} + /// A single page of thread metadata results. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ThreadsPage { @@ -124,8 +144,8 @@ pub struct ThreadMetadata { pub first_user_message: Option, /// The archive timestamp, if the thread is archived. pub archived_at: Option>, - /// Whether the thread was explicitly pinned by the user. - pub is_pinned: bool, + /// The user-selected section for this thread, if any. + pub section: Option, /// The git commit SHA, if known. pub git_sha: Option, /// The git branch name, if known. @@ -256,7 +276,7 @@ impl ThreadMetadataBuilder { tokens_used: 0, first_user_message: None, archived_at: self.archived_at.map(canonicalize_datetime), - is_pinned: false, + section: None, git_sha: self.git_sha.clone(), git_branch: self.git_branch.clone(), git_origin_url: self.git_origin_url.clone(), @@ -371,8 +391,8 @@ impl ThreadMetadata { if self.archived_at != other.archived_at { diffs.push("archived_at"); } - if self.is_pinned != other.is_pinned { - diffs.push("is_pinned"); + if self.section != other.section { + diffs.push("section"); } if self.git_sha != other.git_sha { diffs.push("git_sha"); @@ -417,7 +437,8 @@ pub(crate) struct ThreadRow { tokens_used: i64, first_user_message: String, archived_at: Option, - is_pinned: bool, + section: Option, + section_name: Option, git_sha: Option, git_branch: Option, git_origin_url: Option, @@ -450,7 +471,8 @@ impl ThreadRow { tokens_used: row.try_get("tokens_used")?, first_user_message: row.try_get("first_user_message")?, archived_at: row.try_get("archived_at")?, - is_pinned: row.try_get("is_pinned")?, + section: row.try_get("section")?, + section_name: row.try_get("section_name")?, git_sha: row.try_get("git_sha")?, git_branch: row.try_get("git_branch")?, git_origin_url: row.try_get("git_origin_url")?, @@ -487,7 +509,8 @@ impl TryFrom for ThreadMetadata { tokens_used, first_user_message, archived_at, - is_pinned, + section, + section_name, git_sha, git_branch, git_origin_url, @@ -497,6 +520,20 @@ impl TryFrom for ThreadMetadata { .transpose() .map_err(anyhow::Error::msg)?; let history_mode = history_mode.parse().map_err(anyhow::Error::msg)?; + let section = match (section, section_name) { + (Some(id), Some(name)) => Some(ThreadSection { id, name }), + (None, None) => None, + (Some(id), None) => { + return Err(anyhow::anyhow!( + "thread references an unknown section: {id}" + )); + } + (None, Some(name)) => { + return Err(anyhow::anyhow!( + "thread has a section name without a section id: {name}" + )); + } + }; Ok(Self { id: ThreadId::try_from(id)?, rollout_path: PathBuf::from(rollout_path), @@ -523,7 +560,7 @@ impl TryFrom for ThreadMetadata { tokens_used, first_user_message: (!first_user_message.is_empty()).then_some(first_user_message), archived_at: archived_at.map(epoch_seconds_to_datetime).transpose()?, - is_pinned, + section, git_sha, git_branch, git_origin_url, @@ -622,7 +659,8 @@ mod tests { tokens_used: 1, first_user_message: String::new(), archived_at: None, - is_pinned: false, + section: None, + section_name: None, git_sha: None, git_branch: None, git_origin_url: None, @@ -656,7 +694,7 @@ mod tests { tokens_used: 1, first_user_message: None, archived_at: None, - is_pinned: false, + section: None, git_sha: None, git_branch: None, git_origin_url: None, diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index c620dcd617..a1d16dd28e 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -195,7 +195,12 @@ SELECT threads.tokens_used, threads.first_user_message, threads.archived_at, - threads.is_pinned, + threads.thread_section_id AS section, + ( + SELECT thread_sections.name + FROM thread_sections + WHERE thread_sections.id = threads.thread_section_id + ) AS section_name, threads.git_sha, threads.git_branch, threads.git_origin_url @@ -209,7 +214,7 @@ FROM threads allowed_sources, model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, anchor: None, sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -571,7 +576,12 @@ SELECT threads.tokens_used, threads.first_user_message, threads.archived_at, - threads.is_pinned, + threads.thread_section_id AS section, + ( + SELECT thread_sections.name + FROM thread_sections + WHERE thread_sections.id = threads.thread_section_id + ) AS section_name, 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 e18c68f825..409cba75aa 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -69,7 +69,7 @@ pub(super) fn test_thread_metadata( tokens_used: 0, first_user_message: Some("hello".to_string()), archived_at: None, - is_pinned: false, + section: None, git_sha: None, git_branch: None, git_origin_url: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 69bcd9bcd1..8160eb2021 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -33,7 +33,12 @@ SELECT threads.tokens_used, threads.first_user_message, threads.archived_at, - threads.is_pinned, + threads.thread_section_id AS section, + ( + SELECT thread_sections.name + FROM thread_sections + WHERE thread_sections.id = threads.thread_section_id + ) AS section_name, threads.git_sha, threads.git_branch, threads.git_origin_url @@ -48,6 +53,58 @@ 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()) @@ -373,7 +430,7 @@ ON CONFLICT(child_thread_id) DO NOTHING allowed_sources, model_providers, cwd_filters: None, - is_pinned: None, + section: None, anchor: None, sort_key: crate::SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -498,7 +555,7 @@ ON CONFLICT(child_thread_id) DO NOTHING allowed_sources, model_providers, cwd_filters: None, - is_pinned: None, + section: None, anchor, sort_key, sort_direction: SortDirection::Desc, @@ -568,7 +625,7 @@ INSERT INTO threads ( first_user_message, archived, archived_at, - is_pinned, + thread_section_id, git_sha, git_branch, git_origin_url, @@ -615,7 +672,7 @@ ON CONFLICT(id) DO NOTHING .bind(metadata.first_user_message.as_deref().unwrap_or_default()) .bind(metadata.archived_at.is_some()) .bind(metadata.archived_at.map(datetime_to_epoch_seconds)) - .bind(metadata.is_pinned) + .bind(metadata.section.as_ref().map(|section| section.id.as_str())) .bind(metadata.git_sha.as_deref()) .bind(metadata.git_branch.as_deref()) .bind(metadata.git_origin_url.as_deref()) @@ -666,14 +723,14 @@ ON CONFLICT(id) DO NOTHING Ok(result.rows_affected() > 0) } - /// Update the SQLite-owned pinned state without changing other thread metadata. - pub async fn update_thread_pin( + /// Update the SQLite-owned section without changing other thread metadata. + pub async fn update_thread_section( &self, thread_id: ThreadId, - is_pinned: bool, + section: Option<&str>, ) -> anyhow::Result { - let result = sqlx::query("UPDATE threads SET is_pinned = ? WHERE id = ?") - .bind(is_pinned) + 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?; @@ -853,7 +910,7 @@ INSERT INTO threads ( first_user_message, archived, archived_at, - is_pinned, + thread_section_id, git_sha, git_branch, git_origin_url, @@ -929,7 +986,7 @@ ON CONFLICT(id) DO UPDATE SET .bind(metadata.first_user_message.as_deref().unwrap_or_default()) .bind(metadata.archived_at.is_some()) .bind(metadata.archived_at.map(datetime_to_epoch_seconds)) - .bind(metadata.is_pinned) + .bind(metadata.section.as_ref().map(|section| section.id.as_str())) .bind(metadata.git_sha.as_deref()) .bind(metadata.git_branch.as_deref()) .bind(metadata.git_origin_url.as_deref()) @@ -1216,7 +1273,12 @@ SELECT threads.tokens_used, threads.first_user_message, threads.archived_at, - threads.is_pinned, + threads.thread_section_id AS section, + ( + SELECT thread_sections.name + FROM thread_sections + WHERE thread_sections.id = threads.thread_section_id + ) AS section_name, threads.git_sha, threads.git_branch, threads.git_origin_url @@ -1249,7 +1311,7 @@ pub struct ThreadFilterOptions<'a> { pub allowed_sources: &'a [String], pub model_providers: Option<&'a [String]>, pub cwd_filters: Option<&'a [PathBuf]>, - pub is_pinned: Option, + pub section: Option>, pub anchor: Option<&'a crate::Anchor>, pub sort_key: SortKey, pub sort_direction: SortDirection, @@ -1280,7 +1342,7 @@ fn push_thread_filters_with_preview<'a>( allowed_sources, model_providers, cwd_filters, - is_pinned, + section, anchor, sort_key, sort_direction, @@ -1295,9 +1357,15 @@ fn push_thread_filters_with_preview<'a>( if !include_empty_preview { builder.push(" AND threads.preview <> ''"); } - if let Some(is_pinned) = is_pinned { - builder.push(" AND threads.is_pinned = "); - builder.push_bind(is_pinned); + match section { + Some(Some(section)) => { + builder.push(" AND threads.thread_section_id = "); + builder.push_bind(section); + } + Some(None) => { + builder.push(" AND threads.thread_section_id IS NULL"); + } + None => {} } if !allowed_sources.is_empty() { builder.push(" AND threads.source IN ("); @@ -1512,7 +1580,121 @@ mod tests { } #[tokio::test] - async fn thread_pin_updates_round_trip_and_survive_rollout_reconciliation() { + 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()), @@ -1526,67 +1708,83 @@ mod tests { .upsert_thread(&metadata) .await .expect("thread insert should succeed"); - assert!( - !runtime + assert_eq!( + runtime .get_thread(thread_id) .await .unwrap() .unwrap() - .is_pinned + .section, + None ); assert!( runtime - .update_thread_pin(thread_id, /*is_pinned*/ true) + .update_thread_section( + thread_id, + /*section*/ Some(crate::PINNED_THREAD_SECTION_ID), + ) .await .unwrap() ); - assert!( + assert_eq!( runtime .get_thread(thread_id) .await .unwrap() .unwrap() - .is_pinned + .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!( + assert_eq!( runtime .get_thread(thread_id) .await .unwrap() .unwrap() - .is_pinned + .section, + Some(crate::ThreadSection { + id: crate::PINNED_THREAD_SECTION_ID.to_string(), + name: crate::PINNED_THREAD_SECTION_NAME.to_string(), + }) ); assert!( runtime - .update_thread_pin(thread_id, /*is_pinned*/ false) + .update_thread_section(thread_id, /*section*/ None) .await .unwrap() ); - assert!( - !runtime + assert_eq!( + runtime .get_thread(thread_id) .await .unwrap() .unwrap() - .is_pinned + .section, + None ); assert!( !runtime - .update_thread_pin(ThreadId::new(), /*is_pinned*/ true) + .update_thread_section( + ThreadId::new(), + /*section*/ Some(crate::PINNED_THREAD_SECTION_ID), + ) .await .unwrap() ); } #[tokio::test] - async fn list_threads_filters_pins_before_recency_pagination_and_uses_index() { + async fn list_threads_filters_sections_before_recency_pagination_and_uses_index() { let codex_home = unique_temp_dir(); let runtime = StateRuntime::init( crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), @@ -1601,40 +1799,63 @@ mod tests { let oldest_unpinned = ThreadId::from_string("00000000-0000-0000-0000-000000000044").unwrap(); - for (thread_id, recency_at, is_pinned) in [ - (oldest_pinned, 1_700_000_001, true), - (newest_unpinned, 1_700_000_003, false), - (newest_pinned, 1_700_000_002, true), - (oldest_unpinned, 1_700_000_000, false), + for (thread_id, recency_at, section) in [ + ( + oldest_pinned, + 1_700_000_001, + Some(crate::PINNED_THREAD_SECTION_ID), + ), + (newest_unpinned, 1_700_000_003, None), + ( + newest_pinned, + 1_700_000_002, + Some(crate::PINNED_THREAD_SECTION_ID), + ), + (oldest_unpinned, 1_700_000_000, None), ] { let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); metadata.recency_at = DateTime::::from_timestamp(recency_at, 0).unwrap(); - metadata.is_pinned = is_pinned; + metadata.section = section.map(|id| crate::ThreadSection { + id: id.to_string(), + name: crate::PINNED_THREAD_SECTION_NAME.to_string(), + }); runtime.upsert_thread(&metadata).await.unwrap(); } - let filters = |anchor, is_pinned| ThreadFilterOptions { + let filters = |anchor, section| ThreadFilterOptions { archived_only: false, allowed_sources: &[], model_providers: None, cwd_filters: None, - is_pinned: Some(is_pinned), + section, anchor, sort_key: SortKey::RecencyAt, sort_direction: SortDirection::Desc, search_term: None, }; let first_page = runtime - .list_threads(/*page_size*/ 1, filters(None, true)) + .list_threads( + /*page_size*/ 1, + filters(None, Some(Some(crate::PINNED_THREAD_SECTION_ID))), + ) .await .unwrap(); assert_eq!(first_page.items.len(), 1); assert_eq!(first_page.items[0].id, newest_pinned); - assert!(first_page.items[0].is_pinned); + assert_eq!( + first_page.items[0].section, + Some(crate::ThreadSection { + id: crate::PINNED_THREAD_SECTION_ID.to_string(), + name: crate::PINNED_THREAD_SECTION_NAME.to_string(), + }) + ); let second_page = runtime .list_threads( /*page_size*/ 1, - filters(first_page.next_anchor.as_ref(), true), + filters( + first_page.next_anchor.as_ref(), + Some(Some(crate::PINNED_THREAD_SECTION_ID)), + ), ) .await .unwrap(); @@ -1642,12 +1863,12 @@ mod tests { assert_eq!(second_page.items[0].id, oldest_pinned); assert_eq!(second_page.next_anchor, None); - let unpinned_page = runtime - .list_threads(/*page_size*/ 10, filters(None, false)) + let unsectioned_page = runtime + .list_threads(/*page_size*/ 10, filters(None, Some(None))) .await .unwrap(); assert_eq!( - unpinned_page + unsectioned_page .items .iter() .map(|thread| thread.id) @@ -1655,10 +1876,28 @@ mod tests { vec![newest_unpinned, oldest_unpinned] ); + let all_sections_page = runtime + .list_threads(/*page_size*/ 10, filters(None, None)) + .await + .unwrap(); + assert_eq!( + all_sections_page + .items + .iter() + .map(|thread| thread.id) + .collect::>(), + vec![ + newest_unpinned, + newest_pinned, + oldest_pinned, + oldest_unpinned, + ] + ); + let mut builder = QueryBuilder::::new("EXPLAIN QUERY PLAN "); push_list_threads_query( &mut builder, - filters(None, true), + filters(None, Some(Some(crate::PINNED_THREAD_SECTION_ID))), /*relation_filter*/ None, /*limit*/ 2, ); @@ -1673,14 +1912,14 @@ mod tests { assert!( plan_details .iter() - .any(|detail| detail.contains("idx_threads_pinned_recency_at_ms")), - "pinned listing did not use its selective recency index: {plan_details:?}" + .any(|detail| detail.contains("idx_threads_section_recency_at_ms")), + "section listing did not use its selective recency index: {plan_details:?}" ); assert!( !plan_details .iter() .any(|detail| detail.contains("TEMP B-TREE")), - "pinned listing unexpectedly sorted outside its index: {plan_details:?}" + "section listing unexpectedly sorted outside its index: {plan_details:?}" ); } @@ -1868,7 +2107,7 @@ mod tests { allowed_sources: &[], model_providers: Some(&model_providers), cwd_filters: None, - is_pinned: None, + section: None, anchor: Some(&anchor), sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Asc, @@ -1897,7 +2136,7 @@ mod tests { allowed_sources: &[], model_providers: Some(&model_providers), cwd_filters: None, - is_pinned: None, + section: None, anchor: page.next_anchor.as_ref(), sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Asc, @@ -1954,7 +2193,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: Some(cwd_filters.as_slice()), - is_pinned: None, + section: None, anchor: None, sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -1987,7 +2226,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: Some(cwd_filters.as_slice()), - is_pinned: None, + section: None, anchor: first_page.next_anchor.as_ref(), sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -2013,7 +2252,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: Some(&[]), - is_pinned: None, + section: None, anchor: None, sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -2081,7 +2320,7 @@ mod tests { allowed_sources: &[], model_providers: Some(&model_providers), cwd_filters, - is_pinned: None, + section: None, anchor, sort_key, sort_direction: SortDirection::Desc, @@ -2182,7 +2421,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, anchor: None, sort_key: SortKey::CreatedAt, sort_direction: SortDirection::Desc, @@ -2211,7 +2450,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, anchor, sort_key: SortKey::CreatedAt, sort_direction: SortDirection::Desc, @@ -2895,7 +3134,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, anchor: None, sort_key: SortKey::RecencyAt, sort_direction: SortDirection::Desc, @@ -2928,7 +3167,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, anchor: first_page.next_anchor.as_ref(), sort_key: SortKey::RecencyAt, sort_direction: SortDirection::Desc, @@ -2961,7 +3200,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, anchor: second_page.next_anchor.as_ref(), sort_key: SortKey::RecencyAt, sort_direction: SortDirection::Desc, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 5ae40a5b31..e3a7b10a1f 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -152,7 +152,7 @@ mod tests { .update_thread_metadata(UpdateThreadMetadataParams { thread_id: grandchild_thread_id, patch: ThreadMetadataPatch { - is_pinned: Some(true), + section: Some(Some(codex_state::PINNED_THREAD_SECTION_ID.to_string())), ..Default::default() }, include_archived: false, @@ -170,7 +170,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, archived: false, search_term: None, relation_filter: Some(ThreadRelationFilter::DirectChildrenOf(parent_thread_id)), @@ -198,7 +198,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, archived: false, search_term: None, relation_filter: Some(ThreadRelationFilter::DescendantsOf(parent_thread_id)), @@ -226,7 +226,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: Some(true), + section: Some(Some(codex_state::PINNED_THREAD_SECTION_ID.to_string())), archived: false, search_term: None, relation_filter: Some(ThreadRelationFilter::DescendantsOf(parent_thread_id)), @@ -243,6 +243,34 @@ mod tests { .collect::>(), vec![grandchild_thread_id] ); + + let page = ThreadStore::list_threads( + &store, + ListThreadsParams { + page_size: 10, + cursor: None, + sort_key: ThreadSortKey::CreatedAt, + sort_direction: SortDirection::Desc, + allowed_sources: Vec::new(), + model_providers: None, + cwd_filters: None, + section: Some(None), + archived: false, + search_term: None, + relation_filter: Some(ThreadRelationFilter::DescendantsOf(parent_thread_id)), + use_state_db_only: false, + }, + ) + .await + .expect("list unsectioned descendant threads"); + + assert_eq!( + page.items + .into_iter() + .map(|item| item.thread_id) + .collect::>(), + vec![child_thread_id] + ); } #[tokio::test] @@ -772,8 +800,10 @@ impl ThreadStore for InMemoryThreadStore { } None => {} } - if let Some(is_pinned) = params.is_pinned { - page.items.retain(|thread| thread.is_pinned == is_pinned); + if let Some(section) = params.section.as_ref() { + page.items.retain(|thread| { + thread.section.as_ref().map(|section| section.id.as_str()) == section.as_deref() + }); } Ok(page) }) @@ -858,9 +888,16 @@ fn stored_thread_from_state( .and_then(|metadata| metadata.advance_recency_at.or(metadata.updated_at)) .unwrap_or_else(Utc::now), archived_at: None, - is_pinned: metadata - .and_then(|metadata| metadata.is_pinned) - .unwrap_or(false), + section: metadata + .and_then(|metadata| metadata.section.clone().flatten()) + .map(|id| codex_state::ThreadSection { + name: if id == codex_state::PINNED_THREAD_SECTION_ID { + codex_state::PINNED_THREAD_SECTION_NAME.to_string() + } else { + id.clone() + }, + id, + }), cwd: metadata .and_then(|metadata| metadata.cwd.clone()) .unwrap_or_default(), diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index 70fa746b13..656bc6766d 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -241,7 +241,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, archived: true, search_term: None, relation_filter: None, diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index b072793130..bafd81dec5 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -144,7 +144,7 @@ pub(super) fn stored_thread_from_rollout_item( updated_at, recency_at, archived_at, - is_pinned: item.is_pinned, + section: item.section, cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), source, diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index 0e0044a400..4867bce98b 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -97,7 +97,7 @@ pub(super) async fn list_rollout_threads( sort_key: codex_rollout::ThreadSortKey, sort_direction: codex_rollout::SortDirection, ) -> ThreadStoreResult { - if params.relation_filter.is_some() || params.is_pinned.is_some() { + if params.relation_filter.is_some() || params.section.is_some() { let relation_filter = params .relation_filter .map(|relation_filter| match relation_filter { @@ -120,7 +120,7 @@ pub(super) async fn list_rollout_threads( params.cwd_filters.as_deref(), relation_filter, params.archived, - params.is_pinned, + params.section.as_ref().map(Option::as_deref), params.search_term.as_deref(), ) .await @@ -240,7 +240,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, archived: false, search_term: None, relation_filter: None, @@ -301,7 +301,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, archived: false, search_term: Some("needle".to_string()), relation_filter: None, @@ -374,7 +374,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, archived: false, search_term: Some("canonical".to_string()), relation_filter: None, @@ -411,7 +411,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, archived: false, search_term: None, relation_filter: None, @@ -428,7 +428,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, archived: true, search_term: None, relation_filter: None, @@ -481,7 +481,7 @@ mod tests { allowed_sources: vec![SessionSource::Cli], model_providers: Some(vec!["test-provider".to_string()]), cwd_filters: None, - is_pinned: None, + section: None, archived: false, search_term: None, relation_filter: None, @@ -519,7 +519,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, archived: false, search_term: None, relation_filter: None, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 686d4d750d..a1bc838aae 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -62,7 +62,7 @@ pub(super) async fn read_thread( && !rollout_thread.preview.is_empty() { rollout_thread.recency_at = thread.recency_at; - rollout_thread.is_pinned = thread.is_pinned; + rollout_thread.section = thread.section; if thread.name.is_some() { rollout_thread.name = thread.name; } @@ -132,7 +132,7 @@ pub(super) async fn read_thread_by_rollout_path( thread = stored_thread_from_sqlite_metadata(store, metadata).await?; } else { thread.recency_at = metadata.recency_at; - thread.is_pinned = metadata.is_pinned; + thread.section = metadata.section; let (fallback_sha, fallback_branch, fallback_origin_url) = match thread.git_info.take() { Some(info) => ( @@ -383,7 +383,7 @@ async fn stored_thread_from_sqlite_metadata( updated_at: metadata.updated_at, recency_at: metadata.recency_at, archived_at: metadata.archived_at, - is_pinned: metadata.is_pinned, + section: metadata.section, cwd: metadata.cwd, cli_version: metadata.cli_version, source: parse_session_source(&metadata.source), @@ -479,7 +479,7 @@ fn stored_thread_from_meta_line( updated_at, recency_at: updated_at, archived_at: archived.then_some(updated_at), - is_pinned: false, + section: None, cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, source: meta_line.meta.source, diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index 5963d3dcab..3d3b214aac 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -93,7 +93,7 @@ pub(super) async fn search_threads( allowed_sources: params.allowed_sources.clone(), model_providers: None, cwd_filters: None, - is_pinned: None, + section: None, archived: params.archived, search_term: None, relation_filter: None, diff --git a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs index 9ff102c3c3..5fa69246d1 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs @@ -125,7 +125,7 @@ async fn split_homes_support_backfill_listing_and_paginated_history() { archived: false, search_term: None, relation_filter: None, - is_pinned: None, + section: None, use_state_db_only: true, }) .await diff --git a/codex-rs/thread-store/src/local/unarchive_thread.rs b/codex-rs/thread-store/src/local/unarchive_thread.rs index d39abf3687..9a55959fad 100644 --- a/codex-rs/thread-store/src/local/unarchive_thread.rs +++ b/codex-rs/thread-store/src/local/unarchive_thread.rs @@ -165,7 +165,10 @@ mod tests { builder.cli_version = Some("test_version".to_string()); let mut metadata = builder.build(config.default_model_provider_id.as_str()); metadata.archived_at = Some(metadata.updated_at); - metadata.is_pinned = true; + metadata.section = Some(codex_state::ThreadSection { + id: codex_state::PINNED_THREAD_SECTION_ID.to_string(), + name: codex_state::PINNED_THREAD_SECTION_NAME.to_string(), + }); runtime .upsert_thread(&metadata) .await @@ -175,7 +178,7 @@ mod tests { .unarchive_thread(ArchiveThreadParams { thread_id }) .await .expect("unarchive thread"); - assert!(unarchived.is_pinned); + assert_eq!(unarchived.section, metadata.section); let restored_path = home .path() @@ -189,6 +192,6 @@ mod tests { assert_eq!(updated.rollout_path, restored_path); assert_eq!(updated.archived_at, None); assert_eq!(updated.recency_at, metadata.recency_at); - assert!(updated.is_pinned); + assert_eq!(updated.section, metadata.section); } } 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 50df1e76ff..9471a5c95a 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -278,7 +278,7 @@ async fn apply_metadata_update( rollout_path = Some(resolved.path); } if existing.is_none() - && patch.is_pinned.is_some() + && patch.section.is_some() && let Some(path) = rollout_path.as_deref() && let Some(existing_rollout_path) = codex_rollout::existing_rollout_path(path).await @@ -389,8 +389,23 @@ 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(is_pinned) = patch.is_pinned { - metadata.is_pinned = is_pinned; + 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( @@ -409,19 +424,17 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to update thread metadata for {thread_id}: {err}"), })?; - if let Some(is_pinned) = patch.is_pinned { + if let Some(section) = patch.section { let updated = state_db - .update_thread_pin(thread_id, is_pinned) + .update_thread_section(thread_id, section.as_deref()) .await .map_err(|err| ThreadStoreError::Internal { - message: format!( - "failed to update pin state for thread {thread_id}: {err}" - ), + message: format!("failed to update section for thread {thread_id}: {err}"), })?; if !updated { return Err(ThreadStoreError::Internal { message: format!( - "thread metadata unavailable before pin update: {thread_id}" + "thread metadata unavailable before section update: {thread_id}" ), }); } @@ -590,9 +603,9 @@ 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 pin state is + // 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.is_pinned.is_some() || (patch.git_info.is_some() && !has_observed_metadata_facts(patch)) + patch.section.is_some() || (patch.git_info.is_some() && !has_observed_metadata_facts(patch)) } fn sqlite_write_error_is_best_effort(err: &ThreadStoreError) -> bool { @@ -892,7 +905,7 @@ mod tests { } #[tokio::test] - async fn pin_only_metadata_updates_persist_in_sqlite_without_changing_the_rollout() { + async fn section_only_metadata_updates_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); @@ -912,7 +925,7 @@ mod tests { .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { - is_pinned: Some(true), + section: Some(Some(codex_state::PINNED_THREAD_SECTION_ID.to_string())), ..Default::default() }, include_archived: false, @@ -920,13 +933,25 @@ mod tests { .await .expect("pin thread"); - assert!(pinned.is_pinned); + assert_eq!( + pinned.section, + Some(codex_state::ThreadSection { + id: codex_state::PINNED_THREAD_SECTION_ID.to_string(), + name: codex_state::PINNED_THREAD_SECTION_NAME.to_string(), + }) + ); let pinned_metadata = runtime .get_thread(thread_id) .await .expect("read pinned metadata") .expect("pinned metadata"); - assert!(pinned_metadata.is_pinned); + assert_eq!( + pinned_metadata.section, + Some(codex_state::ThreadSection { + id: codex_state::PINNED_THREAD_SECTION_ID.to_string(), + name: codex_state::PINNED_THREAD_SECTION_NAME.to_string(), + }) + ); assert_eq!(pinned_metadata.preview.as_deref(), Some("Hello from user")); assert_eq!(pinned_metadata.source, "cli"); let pinned_page = store @@ -938,7 +963,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, - is_pinned: Some(true), + section: Some(Some(codex_state::PINNED_THREAD_SECTION_ID.to_string())), archived: false, search_term: None, relation_filter: None, @@ -962,7 +987,13 @@ mod tests { ) .await .expect("read pinned thread by rollout path"); - assert!(read_by_path.is_pinned); + assert_eq!( + read_by_path.section, + Some(codex_state::ThreadSection { + id: codex_state::PINNED_THREAD_SECTION_ID.to_string(), + name: codex_state::PINNED_THREAD_SECTION_NAME.to_string(), + }) + ); assert_eq!( std::fs::read_to_string(&rollout_path).expect("read rollout"), original_rollout @@ -972,22 +1003,23 @@ mod tests { .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { - is_pinned: Some(false), + section: Some(None), ..Default::default() }, include_archived: false, }) .await - .expect("unpin thread"); + .expect("clear thread section"); - assert!(!unpinned.is_pinned); - assert!( - !runtime + assert_eq!(unpinned.section, None); + assert_eq!( + runtime .get_thread(thread_id) .await - .expect("read unpinned metadata") - .expect("unpinned metadata") - .is_pinned + .expect("read cleared metadata") + .expect("cleared metadata") + .section, + None ); assert_eq!( std::fs::read_to_string(&rollout_path).expect("read rollout"), @@ -1798,9 +1830,9 @@ mod tests { } #[test] - fn sqlite_failures_block_for_explicit_pin_updates() { + fn sqlite_failures_block_for_explicit_section_updates() { assert!(sqlite_write_failure_should_block(&ThreadMetadataPatch { - is_pinned: Some(false), + section: Some(None), ..Default::default() })); } @@ -2078,7 +2110,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: Some(Vec::new()), cwd_filters: Some(vec![workspace]), - is_pinned: None, + section: None, archived: false, search_term: None, relation_filter: None, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index f710d61466..caef440ffd 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -300,8 +300,9 @@ pub struct ListThreadsParams { /// Optional cwd filters. `None` means all working directories, while an empty vector matches no /// threads. pub cwd_filters: Option>, - /// Optional persisted pin-state filter. - pub is_pinned: Option, + /// Omit to include every section, set to `None` to match unsectioned + /// threads, or provide a section ID to match that section. + pub section: Option>, /// Whether archived threads should be listed instead of active threads. pub archived: bool, /// Optional substring/full-text search term for thread title/preview. @@ -564,8 +565,8 @@ pub struct StoredThread { pub recency_at: DateTime, /// Thread archive timestamp, if archived. pub archived_at: Option>, - /// Whether this thread has been pinned by the user. - pub is_pinned: bool, + /// The user-selected section for this thread, if any. + pub section: Option, /// Working directory captured for the thread. pub cwd: PathBuf, /// CLI version captured for the thread. @@ -722,8 +723,13 @@ pub struct ThreadMetadataPatch { pub token_usage: Option, /// First user message observed for this thread. pub first_user_message: Option, - /// Replacement user-selected thread pin state. - pub is_pinned: 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. @@ -800,8 +806,8 @@ impl ThreadMetadataPatch { if next.first_user_message.is_some() { self.first_user_message = next.first_user_message; } - if next.is_pinned.is_some() { - self.is_pinned = next.is_pinned; + if next.section.is_some() { + self.section = next.section; } if let Some(git_info) = next.git_info { self.git_info @@ -835,7 +841,7 @@ impl ThreadMetadataPatch { && self.permission_profile.is_none() && self.token_usage.is_none() && self.first_user_message.is_none() - && self.is_pinned.is_none() + && self.section.is_none() && self.git_info.is_none() && self.memory_mode.is_none() } @@ -977,7 +983,7 @@ mod tests { let mut current = ThreadMetadataPatch { name: Some(Some("old name".to_string())), preview: Some("old preview".to_string()), - is_pinned: Some(true), + section: Some(Some("pinned".to_string())), git_info: Some(GitInfoPatch { sha: Some(Some("abc123".to_string())), branch: Some(Some("main".to_string())), @@ -990,7 +996,7 @@ mod tests { name: Some(None), preview: None, title: Some("new title".to_string()), - is_pinned: Some(false), + section: Some(None), git_info: Some(GitInfoPatch { sha: None, branch: Some(Some("feature".to_string())), @@ -1002,7 +1008,7 @@ 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.is_pinned, Some(false)); + assert_eq!(current.section, Some(None)); assert_eq!( current.git_info, Some(GitInfoPatch { diff --git a/codex-rs/tui/src/app/agent_picker.rs b/codex-rs/tui/src/app/agent_picker.rs index 5113182659..5b7b3be55c 100644 --- a/codex-rs/tui/src/app/agent_picker.rs +++ b/codex-rs/tui/src/app/agent_picker.rs @@ -44,7 +44,7 @@ impl App { model_providers: Some(vec![]), source_kinds: Some(vec![ThreadSourceKind::SubAgentThreadSpawn]), archived: None, - is_pinned: None, + section_id: None, cwd: None, use_state_db_only: true, search_term: None, diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index bb95ba29d2..b0b16cbc0b 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -142,7 +142,7 @@ mod tests { parent_thread_id: None, preview: String::new(), ephemeral: false, - is_pinned: false, + section: 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 970300b891..1a96ce0fe3 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3459,7 +3459,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re parent_thread_id: None, preview: "agent thread".to_string(), ephemeral: false, - is_pinned: false, + section: None, history_mode: Default::default(), model_provider: "agent-provider".to_string(), created_at: 1, @@ -3556,7 +3556,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ parent_thread_id: None, preview: "agent thread".to_string(), ephemeral: false, - is_pinned: false, + section: None, history_mode: Default::default(), model_provider: "agent-provider".to_string(), created_at: 1, @@ -3620,7 +3620,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { parent_thread_id: None, preview: "read thread".to_string(), ephemeral: false, - is_pinned: false, + section: 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 aeadb631eb..5b0d2e1ea3 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -417,7 +417,7 @@ mod tests { parent_thread_id: None, preview: "read thread".to_string(), ephemeral: false, - is_pinned: false, + section: 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 81b38697da..01121ce439 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -823,7 +823,7 @@ impl AppServerSession { request_id, params: ThreadMetadataUpdateParams { thread_id: thread_id.to_string(), - is_pinned: None, + section_id: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some(branch)), @@ -2679,7 +2679,7 @@ mod tests { parent_thread_id: None, preview: "hello".to_string(), ephemeral: false, - is_pinned: false, + section: None, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 71dcf0b96e..7af8bc8885 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -631,7 +631,7 @@ async fn lookup_session_target_by_name_with_app_server( model_providers: None, source_kinds: Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]), archived: Some(false), - is_pinned: None, + section_id: None, parent_thread_id: None, ancestor_thread_id: None, cwd: None, @@ -746,7 +746,7 @@ fn latest_session_lookup_params( }, source_kinds: Some(resume_source_kinds(include_non_interactive)), archived: Some(false), - is_pinned: None, + section_id: None, parent_thread_id: None, ancestor_thread_id: None, cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index b93223e242..0ccdeb590d 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1851,7 +1851,7 @@ fn thread_list_params( }, source_kinds: Some(crate::resume_source_kinds(include_non_interactive)), archived: Some(false), - is_pinned: None, + section_id: None, parent_thread_id: None, ancestor_thread_id: None, cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().into_owned())), @@ -5758,7 +5758,7 @@ session_picker_view = "dense" parent_thread_id: None, preview: String::from("remote thread"), ephemeral: false, - is_pinned: false, + section: None, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, @@ -5798,7 +5798,7 @@ session_picker_view = "dense" parent_thread_id: None, preview: String::from("preview"), ephemeral: false, - is_pinned: false, + section: None, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, @@ -5876,7 +5876,7 @@ session_picker_view = "dense" parent_thread_id: None, preview: String::from("preview"), ephemeral: false, - is_pinned: false, + section: None, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, @@ -5947,7 +5947,7 @@ session_picker_view = "dense" parent_thread_id: None, preview: String::from("preview"), ephemeral: false, - is_pinned: false, + section: None, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 6b94655bcf..1dea0ffe0f 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -180,7 +180,7 @@ async fn lookup_session_by_exact_name( /*include_non_interactive*/ false, )), archived: Some(archived), - is_pinned: None, + section_id: None, parent_thread_id: None, ancestor_thread_id: None, cwd: None,