diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index b2cbff661b..0fc2456e3f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4590,6 +4590,32 @@ ], "type": "string" }, + "ThreadSectionCreateParams": { + "description": "Parameters for creating an independently persisted thread section.", + "properties": { + "name": { + "description": "The user-visible name of the section.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "ThreadSectionDeleteParams": { + "description": "Parameters for deleting an independently persisted thread section.", + "properties": { + "sectionId": { + "description": "The stable, server-generated identity of the section to delete.", + "type": "string" + } + }, + "required": [ + "sectionId" + ], + "type": "object" + }, "ThreadSectionListParams": { "description": "Parameters for listing independently persisted thread sections.", "properties": { @@ -4640,6 +4666,24 @@ ], "type": "object" }, + "ThreadSectionUpdateParams": { + "description": "Parameters for updating an independently persisted thread section.", + "properties": { + "name": { + "description": "The updated user-visible name of the section.", + "type": "string" + }, + "sectionId": { + "description": "The stable, server-generated identity of the section to update.", + "type": "string" + } + }, + "required": [ + "name", + "sectionId" + ], + "type": "object" + }, "ThreadSetNameParams": { "properties": { "name": { @@ -5751,6 +5795,78 @@ "title": "ThreadSection/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "threadSection/create" + ], + "title": "ThreadSection/createRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSectionCreateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/createRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "threadSection/update" + ], + "title": "ThreadSection/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSectionUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "threadSection/delete" + ], + "title": "ThreadSection/deleteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSectionDeleteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/deleteRequest", + "type": "object" + }, { "properties": { "id": { 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 a1d3cd4c51..ab4baa76f8 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 @@ -684,6 +684,78 @@ "title": "ThreadSection/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "threadSection/create" + ], + "title": "ThreadSection/createRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadSectionCreateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/createRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "threadSection/update" + ], + "title": "ThreadSection/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadSectionUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "threadSection/delete" + ], + "title": "ThreadSection/deleteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadSectionDeleteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/deleteRequest", + "type": "object" + }, { "properties": { "id": { @@ -20593,6 +20665,56 @@ ], "type": "object" }, + "ThreadSectionCreateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for creating an independently persisted thread section.", + "properties": { + "name": { + "description": "The user-visible name of the section.", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ThreadSectionCreateParams", + "type": "object" + }, + "ThreadSectionCreateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "The independently persisted section created by the server.", + "properties": { + "section": { + "$ref": "#/definitions/v2/ThreadSection" + } + }, + "required": [ + "section" + ], + "title": "ThreadSectionCreateResponse", + "type": "object" + }, + "ThreadSectionDeleteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for deleting an independently persisted thread section.", + "properties": { + "sectionId": { + "description": "The stable, server-generated identity of the section to delete.", + "type": "string" + } + }, + "required": [ + "sectionId" + ], + "title": "ThreadSectionDeleteParams", + "type": "object" + }, + "ThreadSectionDeleteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful deletion does not return additional section data.", + "title": "ThreadSectionDeleteResponse", + "type": "object" + }, "ThreadSectionListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Parameters for listing independently persisted thread sections.", @@ -20676,6 +20798,40 @@ "title": "ThreadSectionMoveResponse", "type": "object" }, + "ThreadSectionUpdateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for updating an independently persisted thread section.", + "properties": { + "name": { + "description": "The updated user-visible name of the section.", + "type": "string" + }, + "sectionId": { + "description": "The stable, server-generated identity of the section to update.", + "type": "string" + } + }, + "required": [ + "name", + "sectionId" + ], + "title": "ThreadSectionUpdateParams", + "type": "object" + }, + "ThreadSectionUpdateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "The independently persisted section after its name is updated.", + "properties": { + "section": { + "$ref": "#/definitions/v2/ThreadSection" + } + }, + "required": [ + "section" + ], + "title": "ThreadSectionUpdateResponse", + "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 fa03272c9d..42efdb4414 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 @@ -1834,6 +1834,78 @@ "title": "ThreadSection/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "threadSection/create" + ], + "title": "ThreadSection/createRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSectionCreateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/createRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "threadSection/update" + ], + "title": "ThreadSection/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSectionUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "threadSection/delete" + ], + "title": "ThreadSection/deleteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSectionDeleteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ThreadSection/deleteRequest", + "type": "object" + }, { "properties": { "id": { @@ -18357,6 +18429,56 @@ ], "type": "object" }, + "ThreadSectionCreateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for creating an independently persisted thread section.", + "properties": { + "name": { + "description": "The user-visible name of the section.", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ThreadSectionCreateParams", + "type": "object" + }, + "ThreadSectionCreateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "The independently persisted section created by the server.", + "properties": { + "section": { + "$ref": "#/definitions/ThreadSection" + } + }, + "required": [ + "section" + ], + "title": "ThreadSectionCreateResponse", + "type": "object" + }, + "ThreadSectionDeleteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for deleting an independently persisted thread section.", + "properties": { + "sectionId": { + "description": "The stable, server-generated identity of the section to delete.", + "type": "string" + } + }, + "required": [ + "sectionId" + ], + "title": "ThreadSectionDeleteParams", + "type": "object" + }, + "ThreadSectionDeleteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful deletion does not return additional section data.", + "title": "ThreadSectionDeleteResponse", + "type": "object" + }, "ThreadSectionListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Parameters for listing independently persisted thread sections.", @@ -18440,6 +18562,40 @@ "title": "ThreadSectionMoveResponse", "type": "object" }, + "ThreadSectionUpdateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for updating an independently persisted thread section.", + "properties": { + "name": { + "description": "The updated user-visible name of the section.", + "type": "string" + }, + "sectionId": { + "description": "The stable, server-generated identity of the section to update.", + "type": "string" + } + }, + "required": [ + "name", + "sectionId" + ], + "title": "ThreadSectionUpdateParams", + "type": "object" + }, + "ThreadSectionUpdateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "The independently persisted section after its name is updated.", + "properties": { + "section": { + "$ref": "#/definitions/ThreadSection" + } + }, + "required": [ + "section" + ], + "title": "ThreadSectionUpdateResponse", + "type": "object" + }, "ThreadSetNameParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionCreateParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionCreateParams.json new file mode 100644 index 0000000000..02af3c2422 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionCreateParams.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for creating an independently persisted thread section.", + "properties": { + "name": { + "description": "The user-visible name of the section.", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ThreadSectionCreateParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionCreateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionCreateResponse.json new file mode 100644 index 0000000000..b5f52ab7a1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionCreateResponse.json @@ -0,0 +1,34 @@ +{ + "$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": "The independently persisted section created by the server.", + "properties": { + "section": { + "$ref": "#/definitions/ThreadSection" + } + }, + "required": [ + "section" + ], + "title": "ThreadSectionCreateResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionDeleteParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionDeleteParams.json new file mode 100644 index 0000000000..89c75bf40c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionDeleteParams.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for deleting an independently persisted thread section.", + "properties": { + "sectionId": { + "description": "The stable, server-generated identity of the section to delete.", + "type": "string" + } + }, + "required": [ + "sectionId" + ], + "title": "ThreadSectionDeleteParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionDeleteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionDeleteResponse.json new file mode 100644 index 0000000000..fc2187df9a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionDeleteResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful deletion does not return additional section data.", + "title": "ThreadSectionDeleteResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionUpdateParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionUpdateParams.json new file mode 100644 index 0000000000..5a9011937b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionUpdateParams.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parameters for updating an independently persisted thread section.", + "properties": { + "name": { + "description": "The updated user-visible name of the section.", + "type": "string" + }, + "sectionId": { + "description": "The stable, server-generated identity of the section to update.", + "type": "string" + } + }, + "required": [ + "name", + "sectionId" + ], + "title": "ThreadSectionUpdateParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionUpdateResponse.json new file mode 100644 index 0000000000..09bda7bce0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSectionUpdateResponse.json @@ -0,0 +1,34 @@ +{ + "$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": "The independently persisted section after its name is updated.", + "properties": { + "section": { + "$ref": "#/definitions/ThreadSection" + } + }, + "required": [ + "section" + ], + "title": "ThreadSectionUpdateResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 64a636cd83..ac2cb7bfff 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index eecbdeae82..057231b979 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index adf9118c1f..d746912e7e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -78,8 +78,11 @@ 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 { ThreadSectionCreateParams } from "./v2/ThreadSectionCreateParams"; +import type { ThreadSectionDeleteParams } from "./v2/ThreadSectionDeleteParams"; import type { ThreadSectionListParams } from "./v2/ThreadSectionListParams"; import type { ThreadSectionMoveParams } from "./v2/ThreadSectionMoveParams"; +import type { ThreadSectionUpdateParams } from "./v2/ThreadSectionUpdateParams"; import type { ThreadSetNameParams } from "./v2/ThreadSetNameParams"; import type { ThreadShellCommandParams } from "./v2/ThreadShellCommandParams"; import type { ThreadStartParams } from "./v2/ThreadStartParams"; @@ -93,4 +96,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/section/move", id: RequestId, params: ThreadSectionMoveParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "threadSection/list", id: RequestId, params: ThreadSectionListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/read", id: RequestId, params: AppsReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "app/installed", id: RequestId, params: AppsInstalledParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/recordHistory", id: RequestId, params: ExternalAgentConfigImportHistoryRecordParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/section/move", id: RequestId, params: ThreadSectionMoveParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "threadSection/list", id: RequestId, params: ThreadSectionListParams, } | { "method": "threadSection/create", id: RequestId, params: ThreadSectionCreateParams, } | { "method": "threadSection/update", id: RequestId, params: ThreadSectionUpdateParams, } | { "method": "threadSection/delete", id: RequestId, params: ThreadSectionDeleteParams, } | { "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/ThreadSectionCreateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionCreateParams.ts new file mode 100644 index 0000000000..f9b1cffc95 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionCreateParams.ts @@ -0,0 +1,12 @@ +// 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 creating an independently persisted thread section. + */ +export type ThreadSectionCreateParams = { +/** + * The user-visible name of the section. + */ +name: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionCreateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionCreateResponse.ts new file mode 100644 index 0000000000..ef51ca0338 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionCreateResponse.ts @@ -0,0 +1,9 @@ +// 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"; + +/** + * The independently persisted section created by the server. + */ +export type ThreadSectionCreateResponse = { section: ThreadSection, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionDeleteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionDeleteParams.ts new file mode 100644 index 0000000000..2cb21bcd74 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionDeleteParams.ts @@ -0,0 +1,12 @@ +// 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 deleting an independently persisted thread section. + */ +export type ThreadSectionDeleteParams = { +/** + * The stable, server-generated identity of the section to delete. + */ +sectionId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionDeleteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionDeleteResponse.ts new file mode 100644 index 0000000000..0ad58d0145 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionDeleteResponse.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Successful deletion does not return additional section data. + */ +export type ThreadSectionDeleteResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionUpdateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionUpdateParams.ts new file mode 100644 index 0000000000..4134f238f9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionUpdateParams.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 updating an independently persisted thread section. + */ +export type ThreadSectionUpdateParams = { +/** + * The stable, server-generated identity of the section to update. + */ +sectionId: string, +/** + * The updated user-visible name of the section. + */ +name: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionUpdateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionUpdateResponse.ts new file mode 100644 index 0000000000..54f4c1567c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSectionUpdateResponse.ts @@ -0,0 +1,9 @@ +// 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"; + +/** + * The independently persisted section after its name is updated. + */ +export type ThreadSectionUpdateResponse = { section: ThreadSection, }; 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 9dabc65141..8190bbdae1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -474,10 +474,16 @@ export type { ThreadRollbackResponse } from "./ThreadRollbackResponse"; export type { ThreadSearchResult } from "./ThreadSearchResult"; export type { ThreadSearchSortKey } from "./ThreadSearchSortKey"; export type { ThreadSection } from "./ThreadSection"; +export type { ThreadSectionCreateParams } from "./ThreadSectionCreateParams"; +export type { ThreadSectionCreateResponse } from "./ThreadSectionCreateResponse"; +export type { ThreadSectionDeleteParams } from "./ThreadSectionDeleteParams"; +export type { ThreadSectionDeleteResponse } from "./ThreadSectionDeleteResponse"; export type { ThreadSectionListParams } from "./ThreadSectionListParams"; export type { ThreadSectionListResponse } from "./ThreadSectionListResponse"; export type { ThreadSectionMoveParams } from "./ThreadSectionMoveParams"; export type { ThreadSectionMoveResponse } from "./ThreadSectionMoveResponse"; +export type { ThreadSectionUpdateParams } from "./ThreadSectionUpdateParams"; +export type { ThreadSectionUpdateResponse } from "./ThreadSectionUpdateResponse"; 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 37f81e738c..350b11754a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -633,9 +633,24 @@ client_request_definitions! { }, ThreadSectionList => "threadSection/list" { params: v2::ThreadSectionListParams, - serialization: None, + serialization: global_shared_read("thread-sections"), response: v2::ThreadSectionListResponse, }, + ThreadSectionCreate => "threadSection/create" { + params: v2::ThreadSectionCreateParams, + serialization: global("thread-sections"), + response: v2::ThreadSectionCreateResponse, + }, + ThreadSectionUpdate => "threadSection/update" { + params: v2::ThreadSectionUpdateParams, + serialization: global("thread-sections"), + response: v2::ThreadSectionUpdateResponse, + }, + ThreadSectionDelete => "threadSection/delete" { + params: v2::ThreadSectionDeleteParams, + serialization: global("thread-sections"), + response: v2::ThreadSectionDeleteResponse, + }, #[experimental("thread/search")] ThreadSearch => "thread/search" { params: v2::ThreadSearchParams, 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 569593ac3f..0208742789 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1139,6 +1139,57 @@ pub struct ThreadSectionListResponse { pub next_cursor: Option, } +/// Parameters for creating an independently persisted thread section. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSectionCreateParams { + /// The user-visible name of the section. + pub name: String, +} + +/// The independently persisted section created by the server. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSectionCreateResponse { + pub section: ThreadSection, +} + +/// Parameters for updating an independently persisted thread section. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSectionUpdateParams { + /// The stable, server-generated identity of the section to update. + pub section_id: String, + /// The updated user-visible name of the section. + pub name: String, +} + +/// The independently persisted section after its name is updated. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSectionUpdateResponse { + pub section: ThreadSection, +} + +/// Parameters for deleting an independently persisted thread section. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSectionDeleteParams { + /// The stable, server-generated identity of the section to delete. + pub section_id: String, +} + +/// Successful deletion does not return additional section data. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSectionDeleteResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 475e4bfba8..69e7f2f2dd 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -145,6 +145,9 @@ Example with notification opt-out: - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `sectionId`, `cwd`, and `searchTerm` filters. Set `sortKey` to `"section_position"` when listing a section in its persisted manual order. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `threadSection/list` — page through independently persisted thread sections and their display names, including sections that do not currently contain any threads. +- `threadSection/create` — create a durable custom section with a server-generated UUID and a nonempty display name; returns its `section`. +- `threadSection/update` — rename an existing custom section while retaining its stable UUID; returns the updated `section`. The built-in pinned section cannot be renamed. +- `threadSection/delete` — delete an existing custom section and atomically return its member threads to the unsectioned list; returns `{}`. The built-in pinned section cannot be deleted. - `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`. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 3f2447e9d4..3289011625 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1120,6 +1120,15 @@ impl MessageProcessor { ClientRequest::ThreadSectionList { params, .. } => { self.thread_processor.thread_section_list(params).await } + ClientRequest::ThreadSectionCreate { params, .. } => { + self.thread_processor.thread_section_create(params).await + } + ClientRequest::ThreadSectionUpdate { params, .. } => { + self.thread_processor.thread_section_update(params).await + } + ClientRequest::ThreadSectionDelete { params, .. } => { + self.thread_processor.thread_section_delete(params).await + } ClientRequest::ThreadSettingsUpdate { params, .. } => { self.turn_processor .thread_settings_update(&request_id, params) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 2cf28de75c..5d9bec815b 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -540,6 +540,7 @@ mod search; mod thread_enrichment; mod thread_fork_goal; mod thread_processor; +mod thread_sections; mod token_usage_replay; mod turn_processor; mod windows_sandbox_processor; 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 35548b3e1e..2b3b5fa21b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -5,8 +5,6 @@ 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_app_server_protocol::ThreadSectionMoveParams; use codex_app_server_protocol::ThreadSectionMoveResponse; use codex_extension_api::ExtensionDataInit; @@ -16,8 +14,8 @@ use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::protocol::ThreadHistoryMode; -const THREAD_LIST_DEFAULT_LIMIT: usize = 25; -const THREAD_LIST_MAX_LIMIT: usize = 100; +pub(super) const THREAD_LIST_DEFAULT_LIMIT: usize = 25; +pub(super) const THREAD_LIST_MAX_LIMIT: usize = 100; const CODEX_TUI_CLIENT_NAME: &str = "codex-tui"; const THREAD_ROLLBACK_DEPRECATION_SUMMARY: &str = "thread/rollback is deprecated and will be removed soon"; @@ -743,39 +741,6 @@ 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, diff --git a/codex-rs/app-server/src/request_processors/thread_sections.rs b/codex-rs/app-server/src/request_processors/thread_sections.rs new file mode 100644 index 0000000000..9dbed3a0ed --- /dev/null +++ b/codex-rs/app-server/src/request_processors/thread_sections.rs @@ -0,0 +1,195 @@ +use super::thread_processor::THREAD_LIST_DEFAULT_LIMIT; +use super::thread_processor::THREAD_LIST_MAX_LIMIT; +use super::thread_processor::ThreadRequestProcessor; +use crate::error_code::internal_error; +use crate::error_code::invalid_params; +use crate::error_code::method_not_found; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ThreadSection; +use codex_app_server_protocol::ThreadSectionCreateParams; +use codex_app_server_protocol::ThreadSectionCreateResponse; +use codex_app_server_protocol::ThreadSectionDeleteParams; +use codex_app_server_protocol::ThreadSectionDeleteResponse; +use codex_app_server_protocol::ThreadSectionListParams; +use codex_app_server_protocol::ThreadSectionListResponse; +use codex_app_server_protocol::ThreadSectionUpdateParams; +use codex_app_server_protocol::ThreadSectionUpdateResponse; +use codex_state::PINNED_THREAD_SECTION_ID; +use codex_thread_store::CreateThreadSectionParams as StoreCreateThreadSectionParams; +use codex_thread_store::DeleteThreadSectionParams as StoreDeleteThreadSectionParams; +use codex_thread_store::ListThreadSectionsParams as StoreListThreadSectionsParams; +use codex_thread_store::RenameThreadSectionParams as StoreRenameThreadSectionParams; +use codex_thread_store::ThreadStoreError; + +impl ThreadRequestProcessor { + pub(crate) async fn thread_section_list( + &self, + params: ThreadSectionListParams, + ) -> Result, JSONRPCErrorError> { + const OPERATION: &str = "threadSection/list"; + self.ensure_thread_sections_supported(OPERATION)?; + let limit = params + .limit + .map(|value| value as usize) + .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) + .clamp(1, THREAD_LIST_MAX_LIMIT); + let page = self + .thread_store + .list_thread_sections(StoreListThreadSectionsParams { + cursor: params.cursor, + limit, + }) + .await + .map_err(|err| thread_section_store_error(OPERATION, 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_section_create( + &self, + params: ThreadSectionCreateParams, + ) -> Result, JSONRPCErrorError> { + const OPERATION: &str = "threadSection/create"; + self.ensure_thread_sections_supported(OPERATION)?; + let name = params.name.trim(); + if name.is_empty() { + return Err(invalid_params("section name must not be empty")); + } + let section = self + .thread_store + .create_thread_section(StoreCreateThreadSectionParams { + name: name.to_string(), + }) + .await + .map_err(|err| thread_section_store_error(OPERATION, err))?; + + Ok(Some( + ThreadSectionCreateResponse { + section: ThreadSection { + id: section.id, + name: section.name, + }, + } + .into(), + )) + } + + pub(crate) async fn thread_section_update( + &self, + params: ThreadSectionUpdateParams, + ) -> Result, JSONRPCErrorError> { + const OPERATION: &str = "threadSection/update"; + self.ensure_thread_sections_supported(OPERATION)?; + let name = params.name.trim(); + if name.is_empty() { + return Err(invalid_params("section name must not be empty")); + } + if params.section_id.trim().is_empty() { + return Err(invalid_params("sectionId must not be empty")); + } + if params.section_id == PINNED_THREAD_SECTION_ID { + return Err(invalid_params( + "the built-in pinned section cannot be renamed", + )); + } + let section = self + .thread_store + .rename_thread_section(StoreRenameThreadSectionParams { + section_id: params.section_id.clone(), + name: name.to_string(), + }) + .await + .map_err(|err| thread_section_store_error(OPERATION, err))? + .ok_or_else(|| { + invalid_params(format!("thread section not found: {}", params.section_id)) + })?; + + Ok(Some( + ThreadSectionUpdateResponse { + section: ThreadSection { + id: section.id, + name: section.name, + }, + } + .into(), + )) + } + + pub(crate) async fn thread_section_delete( + &self, + params: ThreadSectionDeleteParams, + ) -> Result, JSONRPCErrorError> { + const OPERATION: &str = "threadSection/delete"; + self.ensure_thread_sections_supported(OPERATION)?; + if params.section_id.trim().is_empty() { + return Err(invalid_params("sectionId must not be empty")); + } + if params.section_id == PINNED_THREAD_SECTION_ID { + return Err(invalid_params( + "the built-in pinned section cannot be deleted", + )); + } + let deleted = self + .thread_store + .delete_thread_section(StoreDeleteThreadSectionParams { + section_id: params.section_id.clone(), + }) + .await + .map_err(|err| thread_section_store_error(OPERATION, err))?; + if !deleted { + return Err(invalid_params(format!( + "thread section not found: {}", + params.section_id + ))); + } + + Ok(Some(ThreadSectionDeleteResponse {}.into())) + } + + fn ensure_thread_sections_supported( + &self, + operation: &'static str, + ) -> Result<(), JSONRPCErrorError> { + if self.thread_store.supports_thread_sections() { + Ok(()) + } else { + Err(unsupported_thread_section_operation(operation)) + } + } +} + +fn unsupported_thread_section_operation(operation: &'static str) -> JSONRPCErrorError { + method_not_found(format!("{operation} is unavailable without sqlite state")) +} + +fn thread_section_store_error( + operation: &'static str, + error: ThreadStoreError, +) -> JSONRPCErrorError { + match error { + ThreadStoreError::Unsupported { .. } => unsupported_thread_section_operation(operation), + ThreadStoreError::InvalidRequest { message } => invalid_params(message), + error @ (ThreadStoreError::ThreadNotFound { .. } + | ThreadStoreError::Conflict { .. } + | ThreadStoreError::Internal { .. }) => { + let action = operation + .strip_prefix("threadSection/") + .unwrap_or(operation); + internal_error(format!("failed to {action} thread section: {error}")) + } + } +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 505393ea1c..be0f004877 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -87,6 +87,7 @@ mod thread_name_websocket; mod thread_read; mod thread_resume; mod thread_rollback; +mod thread_sections; mod thread_settings_update; mod thread_shell_command; mod thread_start; 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 decf45e98d..91d4820f25 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,7 +37,10 @@ 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::ThreadSectionCreateParams; +use codex_app_server_protocol::ThreadSectionDeleteParams; use codex_app_server_protocol::ThreadSectionListParams; +use codex_app_server_protocol::ThreadSectionUpdateParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -55,6 +58,7 @@ 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_thread_store::CreateThreadParams as StoreCreateThreadParams; use codex_thread_store::InMemoryThreadStore; use codex_thread_store::ThreadPersistenceMetadata; @@ -68,36 +72,92 @@ 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<()> { +async fn thread_section_operations_without_sqlite_return_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 { + let section_id = Uuid::now_v7().to_string(); + + for request in [ + ClientRequest::ThreadSectionList { request_id: RequestId::Integer(1), params: ThreadSectionListParams::default(), - }) - .await? - .expect_err("section discovery requires sqlite state"); + }, + ClientRequest::ThreadSectionCreate { + request_id: RequestId::Integer(2), + params: ThreadSectionCreateParams { + name: "Work".to_string(), + }, + }, + ClientRequest::ThreadSectionUpdate { + request_id: RequestId::Integer(3), + params: ThreadSectionUpdateParams { + section_id: section_id.clone(), + name: "Projects".to_string(), + }, + }, + ClientRequest::ThreadSectionDelete { + request_id: RequestId::Integer(4), + params: ThreadSectionDeleteParams { section_id }, + }, + ClientRequest::ThreadSectionCreate { + request_id: RequestId::Integer(5), + params: ThreadSectionCreateParams { + name: " ".to_string(), + }, + }, + ClientRequest::ThreadSectionUpdate { + request_id: RequestId::Integer(6), + params: ThreadSectionUpdateParams { + section_id: " ".to_string(), + name: "Work".to_string(), + }, + }, + ClientRequest::ThreadSectionUpdate { + request_id: RequestId::Integer(7), + params: ThreadSectionUpdateParams { + section_id: PINNED_THREAD_SECTION_ID.to_string(), + name: "Pinned again".to_string(), + }, + }, + ClientRequest::ThreadSectionDelete { + request_id: RequestId::Integer(8), + params: ThreadSectionDeleteParams { + section_id: " ".to_string(), + }, + }, + ClientRequest::ThreadSectionDelete { + request_id: RequestId::Integer(9), + params: ThreadSectionDeleteParams { + section_id: PINNED_THREAD_SECTION_ID.to_string(), + }, + }, + ClientRequest::ThreadSectionUpdate { + request_id: RequestId::Integer(10), + params: ThreadSectionUpdateParams { + section_id: PINNED_THREAD_SECTION_ID.to_string(), + name: " ".to_string(), + }, + }, + ] { + let method = request.method_name(); + let error = client + .request(request) + .await? + .expect_err("section management requires sqlite state"); - assert_eq!(error.code, -32601); - assert_eq!( - error.message, - "threadSection/list is unavailable without sqlite state" - ); + assert_eq!(error.code, -32601); + assert_eq!( + error.message, + format!("{method} 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" - ); + assert_no_local_persistence_artifacts(codex_home.path())?; Ok(()) } @@ -466,14 +526,13 @@ fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> { "non-local thread persistence should not create sqlite artifacts: {sqlite_artifacts:?}" ); let mut entries = codex_home_entries(codex_home)?; - // Bazel test runs may initialize shell snapshot storage under codex_home. - // That is not thread persistence; keep the assertion focused on rollout, - // session, sqlite, and other unexpected thread-store artifacts. + // Host startup may leave sandbox migration markers, and Bazel test runs may + // initialize shell snapshot storage. Neither is thread persistence. + entries.remove(".sandbox_migration"); entries.remove("shell_snapshots"); assert_eq!( entries, BTreeSet::from([ - ".sandbox_migration".to_string(), "config.toml".to_string(), "installation_id".to_string(), "skills".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_sections.rs b/codex-rs/app-server/tests/suite/v2/thread_sections.rs new file mode 100644 index 0000000000..5798238bed --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_sections.rs @@ -0,0 +1,360 @@ +use anyhow::Result; +use app_test_support::MockResponsesConfig; +use app_test_support::TestAppServer; +use app_test_support::create_fake_rollout; +use app_test_support::create_mock_responses_server_repeating_assistant; +use codex_app_server::INVALID_PARAMS_ERROR_CODE; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadSection; +use codex_app_server_protocol::ThreadSectionCreateParams; +use codex_app_server_protocol::ThreadSectionCreateResponse; +use codex_app_server_protocol::ThreadSectionDeleteParams; +use codex_app_server_protocol::ThreadSectionDeleteResponse; +use codex_app_server_protocol::ThreadSectionListParams; +use codex_app_server_protocol::ThreadSectionListResponse; +use codex_app_server_protocol::ThreadSectionMoveParams; +use codex_app_server_protocol::ThreadSectionMoveResponse; +use codex_app_server_protocol::ThreadSectionUpdateParams; +use codex_app_server_protocol::ThreadSectionUpdateResponse; +use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::ThreadUnarchiveResponse; +use codex_features::Feature; +use codex_state::PINNED_THREAD_SECTION_ID; +use codex_state::PINNED_THREAD_SECTION_NAME; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; +use uuid::Uuid; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +async fn section_request_error( + server: &mut TestAppServer, + method: &str, + params: Value, +) -> Result { + let request_id = server.send_raw_request(method, Some(params)).await?; + timeout( + DEFAULT_READ_TIMEOUT, + server.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await? +} + +#[tokio::test] +async fn custom_sections_remain_discoverable_across_ordered_updates_and_restart() -> Result<()> { + let responses = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&responses.uri()) + .enable_feature(Feature::Sqlite) + .write(codex_home.path())?; + let mut server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + + let created: ThreadSectionCreateResponse = server + .request(|request_id| ClientRequest::ThreadSectionCreate { + request_id, + params: ThreadSectionCreateParams { + name: " Work ".to_string(), + }, + }) + .await?; + assert_eq!(created.section.name, "Work"); + assert_eq!(Uuid::parse_str(&created.section.id)?.get_version_num(), 7); + + let retained: ThreadSectionCreateResponse = server + .request(|request_id| ClientRequest::ThreadSectionCreate { + request_id, + params: ThreadSectionCreateParams { + name: "Personal".to_string(), + }, + }) + .await?; + let discovered: ThreadSectionListResponse = server + .request(|request_id| ClientRequest::ThreadSectionList { + request_id, + params: ThreadSectionListParams { + cursor: None, + limit: Some(20), + }, + }) + .await?; + assert_eq!(discovered.data.len(), 3); + assert!(discovered.data.contains(&created.section)); + assert!(discovered.data.contains(&retained.section)); + + let first_rename = server + .send_raw_request( + "threadSection/update", + Some(json!({ "sectionId": created.section.id, "name": "Queued work" })), + ) + .await?; + let second_rename = server + .send_raw_request( + "threadSection/update", + Some(json!({ "sectionId": created.section.id, "name": " Projects " })), + ) + .await?; + let listed_after_renames = server + .send_raw_request("threadSection/list", Some(json!({ "limit": 20 }))) + .await?; + let _: ThreadSectionUpdateResponse = + timeout(DEFAULT_READ_TIMEOUT, server.read_response(first_rename)).await??; + let renamed: ThreadSectionUpdateResponse = + timeout(DEFAULT_READ_TIMEOUT, server.read_response(second_rename)).await??; + let observed: ThreadSectionListResponse = timeout( + DEFAULT_READ_TIMEOUT, + server.read_response(listed_after_renames), + ) + .await??; + assert_eq!( + renamed.section, + ThreadSection { + id: created.section.id, + name: "Projects".to_string(), + } + ); + assert!(observed.data.contains(&renamed.section)); + + drop(server); + let mut restarted = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let persisted: ThreadSectionListResponse = restarted + .request(|request_id| ClientRequest::ThreadSectionList { + request_id, + params: ThreadSectionListParams { + cursor: None, + limit: Some(20), + }, + }) + .await?; + assert_eq!( + persisted.data, + vec![ + ThreadSection { + id: PINNED_THREAD_SECTION_ID.to_string(), + name: PINNED_THREAD_SECTION_NAME.to_string(), + }, + renamed.section, + retained.section, + ] + ); + + Ok(()) +} + +#[tokio::test] +async fn deleting_custom_sections_unassigns_active_and_archived_members() -> Result<()> { + let responses = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&responses.uri()) + .enable_feature(Feature::Sqlite) + .write(codex_home.path())?; + let first_thread = create_fake_rollout( + codex_home.path(), + "2025-01-06T08-00-00", + "2025-01-06T08:00:00Z", + "First thread", + Some("mock_provider"), + /*git_info*/ None, + )?; + let archived_thread = create_fake_rollout( + codex_home.path(), + "2025-01-06T09-00-00", + "2025-01-06T09:00:00Z", + "Archived thread", + Some("mock_provider"), + /*git_info*/ None, + )?; + let mut server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let request_id = server + .send_raw_request("thread/list", Some(json!({ "limit": 20 }))) + .await?; + let _: ThreadListResponse = + timeout(DEFAULT_READ_TIMEOUT, server.read_response(request_id)).await??; + + let created: ThreadSectionCreateResponse = server + .request(|request_id| ClientRequest::ThreadSectionCreate { + request_id, + params: ThreadSectionCreateParams { + name: "Work".to_string(), + }, + }) + .await?; + for thread_id in [&first_thread, &archived_thread] { + let _: ThreadSectionMoveResponse = server + .request(|request_id| ClientRequest::ThreadSectionMove { + request_id, + params: ThreadSectionMoveParams { + thread_id: thread_id.clone(), + section_id: Some(created.section.id.clone()), + before_thread_id: None, + }, + }) + .await?; + } + let _: ThreadArchiveResponse = server + .request(|request_id| ClientRequest::ThreadArchive { + request_id, + params: ThreadArchiveParams { + thread_id: archived_thread.clone(), + }, + }) + .await?; + + let renamed: ThreadSectionUpdateResponse = server + .request(|request_id| ClientRequest::ThreadSectionUpdate { + request_id, + params: ThreadSectionUpdateParams { + section_id: created.section.id.clone(), + name: "Projects".to_string(), + }, + }) + .await?; + let renamed_member: ThreadReadResponse = server + .request(|request_id| ClientRequest::ThreadRead { + request_id, + params: ThreadReadParams { + thread_id: first_thread.clone(), + include_turns: false, + }, + }) + .await?; + assert_eq!(renamed_member.thread.section, Some(renamed.section)); + + let _: ThreadSectionDeleteResponse = server + .request(|request_id| ClientRequest::ThreadSectionDelete { + request_id, + params: ThreadSectionDeleteParams { + section_id: created.section.id, + }, + }) + .await?; + let unsectioned: ThreadReadResponse = server + .request(|request_id| ClientRequest::ThreadRead { + request_id, + params: ThreadReadParams { + thread_id: first_thread, + include_turns: false, + }, + }) + .await?; + assert_eq!( + ( + unsectioned.thread.section, + unsectioned.thread.section_entered_at + ), + (None, None) + ); + let restored: ThreadUnarchiveResponse = server + .request(|request_id| ClientRequest::ThreadUnarchive { + request_id, + params: ThreadUnarchiveParams { + thread_id: archived_thread.clone(), + }, + }) + .await?; + assert_eq!( + (restored.thread.section, restored.thread.section_entered_at), + (None, None) + ); + + drop(server); + let mut restarted = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let restored_after_restart: ThreadReadResponse = restarted + .request(|request_id| ClientRequest::ThreadRead { + request_id, + params: ThreadReadParams { + thread_id: archived_thread, + include_turns: false, + }, + }) + .await?; + assert_eq!( + ( + restored_after_restart.thread.section, + restored_after_restart.thread.section_entered_at + ), + (None, None) + ); + + Ok(()) +} + +#[tokio::test] +async fn custom_section_management_rejects_empty_names_missing_ids_and_pinned_mutations() +-> Result<()> { + let responses = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&responses.uri()) + .enable_feature(Feature::Sqlite) + .write(codex_home.path())?; + let mut server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let missing_id = Uuid::now_v7().to_string(); + + for (method, params, message) in [ + ( + "threadSection/create", + json!({ "name": " " }), + "section name must not be empty", + ), + ( + "threadSection/update", + json!({ "sectionId": " ", "name": "Work" }), + "sectionId must not be empty", + ), + ( + "threadSection/update", + json!({ "sectionId": PINNED_THREAD_SECTION_ID, "name": "Pinned again" }), + "the built-in pinned section cannot be renamed", + ), + ( + "threadSection/delete", + json!({ "sectionId": PINNED_THREAD_SECTION_ID }), + "the built-in pinned section cannot be deleted", + ), + ] { + let error = section_request_error(&mut server, method, params).await?; + assert_eq!(error.error.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!(error.error.message, message); + } + + for (method, params) in [ + ( + "threadSection/update", + json!({ "sectionId": missing_id, "name": "Work" }), + ), + ("threadSection/delete", json!({ "sectionId": missing_id })), + ] { + let error = section_request_error(&mut server, method, params).await?; + assert_eq!(error.error.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!( + error.error.message, + format!("thread section not found: {missing_id}") + ); + } + + Ok(()) +} diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index ed7b97f7ed..433b16b84a 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -48,6 +48,7 @@ mod remote_control; #[cfg(test)] pub(crate) mod test_support; mod thread_section_order; +mod thread_sections; mod threads; pub use external_agent_config_imports::ExternalAgentConfigImportDetailsRecord; diff --git a/codex-rs/state/src/runtime/thread_sections.rs b/codex-rs/state/src/runtime/thread_sections.rs new file mode 100644 index 0000000000..0b620cc295 --- /dev/null +++ b/codex-rs/state/src/runtime/thread_sections.rs @@ -0,0 +1,72 @@ +use super::StateRuntime; +use crate::PINNED_THREAD_SECTION_ID; +use crate::ThreadSection; +use uuid::Uuid; + +impl StateRuntime { + /// Create a custom thread section with a stable, server-assigned UUIDv7. + pub async fn create_thread_section(&self, name: &str) -> anyhow::Result { + let section = ThreadSection { + id: Uuid::now_v7().to_string(), + name: name.to_string(), + }; + + sqlx::query("INSERT INTO thread_sections (id, name) VALUES (?, ?)") + .bind(§ion.id) + .bind(§ion.name) + .execute(self.pool.as_ref()) + .await?; + + Ok(section) + } + + /// Rename a custom thread section without changing its stable identity. + pub async fn rename_thread_section( + &self, + id: &str, + name: &str, + ) -> anyhow::Result> { + if id == PINNED_THREAD_SECTION_ID { + anyhow::bail!("built-in pinned thread section cannot be renamed"); + } + + let section = sqlx::query_as::<_, (String, String)>( + "UPDATE thread_sections SET name = ? WHERE id = ? RETURNING id, name", + ) + .bind(name) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await?; + + Ok(section.map(|(id, name)| ThreadSection { id, name })) + } + + /// Delete a custom section and return its threads to the unsectioned list. + pub async fn delete_thread_section(&self, id: &str) -> anyhow::Result { + if id == PINNED_THREAD_SECTION_ID { + anyhow::bail!("built-in pinned thread section cannot be deleted"); + } + + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + sqlx::query( + "UPDATE threads SET section_position = NULL, section_entered_at_ms = NULL WHERE thread_section_id = ?", + ) + .bind(id) + .execute(&mut *tx) + .await?; + + let deleted = sqlx::query("DELETE FROM thread_sections WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await? + .rows_affected() + > 0; + tx.commit().await?; + + Ok(deleted) + } +} + +#[cfg(test)] +#[path = "thread_sections_tests.rs"] +mod tests; diff --git a/codex-rs/state/src/runtime/thread_sections_tests.rs b/codex-rs/state/src/runtime/thread_sections_tests.rs new file mode 100644 index 0000000000..d556742a7f --- /dev/null +++ b/codex-rs/state/src/runtime/thread_sections_tests.rs @@ -0,0 +1,115 @@ +use super::StateRuntime; +use crate::runtime::test_support::test_thread_metadata; +use crate::runtime::test_support::unique_temp_dir; +use anyhow::Result; +use codex_protocol::ThreadId; +use codex_utils_absolute_path::test_support::PathExt; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn deleting_custom_section_preserves_threads_and_clears_section_ordering() -> Result<()> { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init( + crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), + "test-provider".to_string(), + ) + .await?; + let deleted_section = runtime.create_thread_section("Research").await?; + let retained_section = runtime.create_thread_section("Planning").await?; + let first = ThreadId::new(); + let second = ThreadId::new(); + let retained = ThreadId::new(); + + for (thread_id, section_id) in [ + (first, &deleted_section.id), + (second, &deleted_section.id), + (retained, &retained_section.id), + ] { + runtime + .upsert_thread(&test_thread_metadata( + &codex_home, + thread_id, + codex_home.clone(), + )) + .await?; + runtime + .move_thread_to_section(thread_id, Some(section_id), /*before_thread_id*/ None) + .await?; + } + + assert!(runtime.delete_thread_section(&deleted_section.id).await?); + assert_eq!(runtime.get_thread_section(&deleted_section.id).await?, None); + + for thread_id in [first, second] { + let thread = runtime + .get_thread(thread_id) + .await? + .expect("section deletion must preserve its member threads"); + assert_eq!( + ( + thread.section, + thread.section_position, + thread.section_entered_at + ), + (None, None, None) + ); + } + + let retained_thread = runtime + .get_thread(retained) + .await? + .expect("unrelated section member should remain"); + assert_eq!(retained_thread.section, Some(retained_section)); + assert!(retained_thread.section_position.is_some()); + assert!(retained_thread.section_entered_at.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn concurrent_section_deletion_and_membership_moves_preserve_thread_invariants() -> Result<()> +{ + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init( + crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), + "test-provider".to_string(), + ) + .await?; + let section = runtime.create_thread_section("Research").await?; + let thread_id = ThreadId::new(); + runtime + .upsert_thread(&test_thread_metadata( + &codex_home, + thread_id, + codex_home.clone(), + )) + .await?; + + let (deleted, moved) = tokio::join!( + runtime.delete_thread_section(§ion.id), + runtime.move_thread_to_section( + thread_id, + Some(§ion.id), + /*before_thread_id*/ None + ), + ); + + assert!(deleted?); + if let Err(error) = moved { + assert!(error.to_string().contains("does not exist")); + } + let thread = runtime + .get_thread(thread_id) + .await? + .expect("concurrent section deletion must preserve the thread"); + assert_eq!( + ( + thread.section, + thread.section_position, + thread.section_entered_at + ), + (None, None, None) + ); + + Ok(()) +} diff --git a/codex-rs/thread-store/src/lib.rs b/codex-rs/thread-store/src/lib.rs index 444e3eea80..9cff38392c 100644 --- a/codex-rs/thread-store/src/lib.rs +++ b/codex-rs/thread-store/src/lib.rs @@ -10,6 +10,7 @@ mod live_thread; mod local; mod store; mod thread_metadata_sync; +mod thread_sections; mod types; pub use error::ThreadStoreError; @@ -22,6 +23,12 @@ pub use local::LocalThreadStore; pub use local::LocalThreadStoreConfig; pub use store::ThreadStore; pub use store::ThreadStoreFuture; +pub use thread_sections::CreateThreadSectionParams; +pub use thread_sections::DeleteThreadSectionParams; +pub use thread_sections::ListThreadSectionsParams; +pub use thread_sections::RenameThreadSectionParams; +pub use thread_sections::StoredThreadSection; +pub use thread_sections::StoredThreadSectionsPage; pub use types::AppendThreadItemsParams; pub use types::ArchiveThreadParams; pub use types::ArchiveThreadsParams; diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index a25cb74c78..42aae5fd06 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -14,6 +14,7 @@ mod rollout_lineage; mod search_threads; mod thread_history; mod thread_history_materialization; +mod thread_sections; mod unarchive_thread; mod update_thread_metadata; mod writer_lock; @@ -41,10 +42,13 @@ use crate::AppendThreadItemsParams; use crate::ArchiveThreadParams; use crate::ArchiveThreadsParams; use crate::CreateThreadParams; +use crate::CreateThreadSectionParams; use crate::DeleteThreadParams; +use crate::DeleteThreadSectionParams; use crate::DeleteThreadsParams; use crate::ItemPage; use crate::ListItemsParams; +use crate::ListThreadSectionsParams; use crate::ListThreadsParams; use crate::ListTurnsParams; use crate::LoadThreadHistoryParams; @@ -53,12 +57,15 @@ use crate::PrepareForkParams; use crate::PreparedFork; use crate::ReadThreadByRolloutPathParams; use crate::ReadThreadParams; +use crate::RenameThreadSectionParams; use crate::ResumeThreadParams; use crate::SearchThreadOccurrencesParams; use crate::SearchThreadsParams; use crate::StoredModelContext; use crate::StoredThread; use crate::StoredThreadHistory; +use crate::StoredThreadSection; +use crate::StoredThreadSectionsPage; use crate::ThreadOccurrenceSearchPage; use crate::ThreadPage; use crate::ThreadSearchPage; @@ -458,6 +465,38 @@ impl ThreadStore for LocalThreadStore { Box::pin(async move { list_threads::list_threads(self, params).await }) } + fn supports_thread_sections(&self) -> bool { + self.state_db.is_some() + } + + fn list_thread_sections( + &self, + params: ListThreadSectionsParams, + ) -> ThreadStoreFuture<'_, StoredThreadSectionsPage> { + Box::pin(async move { thread_sections::list_thread_sections(self, params).await }) + } + + fn create_thread_section( + &self, + params: CreateThreadSectionParams, + ) -> ThreadStoreFuture<'_, StoredThreadSection> { + Box::pin(async move { thread_sections::create_thread_section(self, params).await }) + } + + fn rename_thread_section( + &self, + params: RenameThreadSectionParams, + ) -> ThreadStoreFuture<'_, Option> { + Box::pin(async move { thread_sections::rename_thread_section(self, params).await }) + } + + fn delete_thread_section( + &self, + params: DeleteThreadSectionParams, + ) -> ThreadStoreFuture<'_, bool> { + Box::pin(async move { thread_sections::delete_thread_section(self, params).await }) + } + fn supports_paginated_history_lists(&self) -> bool { self.state_db.is_some() } diff --git a/codex-rs/thread-store/src/local/thread_sections.rs b/codex-rs/thread-store/src/local/thread_sections.rs new file mode 100644 index 0000000000..8a68d0f4f8 --- /dev/null +++ b/codex-rs/thread-store/src/local/thread_sections.rs @@ -0,0 +1,96 @@ +use super::LocalThreadStore; +use crate::CreateThreadSectionParams; +use crate::DeleteThreadSectionParams; +use crate::ListThreadSectionsParams; +use crate::RenameThreadSectionParams; +use crate::StoredThreadSection; +use crate::StoredThreadSectionsPage; +use crate::ThreadStoreError; +use crate::ThreadStoreResult; +use codex_rollout::StateDbHandle; + +pub(super) async fn list_thread_sections( + store: &LocalThreadStore, + params: ListThreadSectionsParams, +) -> ThreadStoreResult { + let state_db = state_db(store, "threadSection/list")?; + let page = state_db + .list_thread_sections(params.cursor.as_deref(), params.limit) + .await + .map_err(|err| section_error("list", err))?; + + Ok(StoredThreadSectionsPage { + sections: page + .sections + .into_iter() + .map(|section| StoredThreadSection { + id: section.id, + name: section.name, + }) + .collect(), + next_cursor: page.next_cursor, + }) +} + +pub(super) async fn create_thread_section( + store: &LocalThreadStore, + params: CreateThreadSectionParams, +) -> ThreadStoreResult { + let state_db = state_db(store, "threadSection/create")?; + let section = state_db + .create_thread_section(¶ms.name) + .await + .map_err(|err| section_error("create", err))?; + + Ok(StoredThreadSection { + id: section.id, + name: section.name, + }) +} + +pub(super) async fn rename_thread_section( + store: &LocalThreadStore, + params: RenameThreadSectionParams, +) -> ThreadStoreResult> { + let state_db = state_db(store, "threadSection/update")?; + let section = state_db + .rename_thread_section(¶ms.section_id, ¶ms.name) + .await + .map_err(|err| section_error("update", err))?; + + Ok(section.map(|section| StoredThreadSection { + id: section.id, + name: section.name, + })) +} + +pub(super) async fn delete_thread_section( + store: &LocalThreadStore, + params: DeleteThreadSectionParams, +) -> ThreadStoreResult { + let state_db = state_db(store, "threadSection/delete")?; + state_db + .delete_thread_section(¶ms.section_id) + .await + .map_err(|err| section_error("delete", err)) +} + +fn state_db<'store>( + store: &'store LocalThreadStore, + operation: &'static str, +) -> ThreadStoreResult<&'store StateDbHandle> { + store + .state_db + .as_ref() + .ok_or(ThreadStoreError::Unsupported { operation }) +} + +fn section_error(operation: &str, error: impl std::fmt::Display) -> ThreadStoreError { + ThreadStoreError::Internal { + message: format!("failed to {operation} thread section: {error}"), + } +} + +#[cfg(test)] +#[path = "thread_sections_tests.rs"] +mod tests; diff --git a/codex-rs/thread-store/src/local/thread_sections_tests.rs b/codex-rs/thread-store/src/local/thread_sections_tests.rs new file mode 100644 index 0000000000..9585942a86 --- /dev/null +++ b/codex-rs/thread-store/src/local/thread_sections_tests.rs @@ -0,0 +1,76 @@ +use super::LocalThreadStore; +use crate::CreateThreadSectionParams; +use crate::DeleteThreadSectionParams; +use crate::ListThreadSectionsParams; +use crate::RenameThreadSectionParams; +use crate::StoredThreadSection; +use crate::ThreadStore; +use crate::ThreadStoreError; +use crate::local::test_support::test_config; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn local_thread_sections_require_sqlite_and_preserve_section_identity() { + let home = tempfile::TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let unsupported_store = LocalThreadStore::new(config.clone(), /*state_db*/ None); + assert!(!unsupported_store.supports_thread_sections()); + assert!(matches!( + unsupported_store + .create_thread_section(CreateThreadSectionParams { + name: "Research".to_string(), + }) + .await, + Err(ThreadStoreError::Unsupported { + operation: "threadSection/create" + }) + )); + + let runtime = codex_state::StateRuntime::init( + config.sqlite.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime)); + assert!(store.supports_thread_sections()); + + let section = store + .create_thread_section(CreateThreadSectionParams { + name: "Research".to_string(), + }) + .await + .expect("create section"); + let sections = store + .list_thread_sections(ListThreadSectionsParams { + cursor: None, + limit: 10, + }) + .await + .expect("list sections"); + assert!(sections.sections.contains(§ion)); + + let renamed = store + .rename_thread_section(RenameThreadSectionParams { + section_id: section.id.clone(), + name: "Planning".to_string(), + }) + .await + .expect("rename section"); + assert_eq!( + renamed, + Some(StoredThreadSection { + id: section.id.clone(), + name: "Planning".to_string(), + }) + ); + + assert!( + store + .delete_thread_section(DeleteThreadSectionParams { + section_id: section.id, + }) + .await + .expect("delete section") + ); +} diff --git a/codex-rs/thread-store/src/store.rs b/codex-rs/thread-store/src/store.rs index b35365fcb7..357282a472 100644 --- a/codex-rs/thread-store/src/store.rs +++ b/codex-rs/thread-store/src/store.rs @@ -8,10 +8,13 @@ use crate::AppendThreadItemsParams; use crate::ArchiveThreadParams; use crate::ArchiveThreadsParams; use crate::CreateThreadParams; +use crate::CreateThreadSectionParams; use crate::DeleteThreadParams; +use crate::DeleteThreadSectionParams; use crate::DeleteThreadsParams; use crate::ItemPage; use crate::ListItemsParams; +use crate::ListThreadSectionsParams; use crate::ListThreadsParams; use crate::ListTurnsParams; use crate::LoadThreadHistoryParams; @@ -20,12 +23,15 @@ use crate::PrepareForkParams; use crate::PreparedFork; use crate::ReadThreadByRolloutPathParams; use crate::ReadThreadParams; +use crate::RenameThreadSectionParams; use crate::ResumeThreadParams; use crate::SearchThreadOccurrencesParams; use crate::SearchThreadsParams; use crate::StoredModelContext; use crate::StoredThread; use crate::StoredThreadHistory; +use crate::StoredThreadSection; +use crate::StoredThreadSectionsPage; use crate::ThreadOccurrenceSearchPage; use crate::ThreadPage; use crate::ThreadSearchPage; @@ -123,6 +129,59 @@ pub trait ThreadStore: Any + Send + Sync { /// Lists stored threads matching the supplied filters. fn list_threads(&self, params: ListThreadsParams) -> ThreadStoreFuture<'_, ThreadPage>; + /// Whether this store can discover and manage independently persisted thread sections. + fn supports_thread_sections(&self) -> bool { + false + } + + /// Lists independently persisted thread sections. + fn list_thread_sections( + &self, + _params: ListThreadSectionsParams, + ) -> ThreadStoreFuture<'_, StoredThreadSectionsPage> { + Box::pin(async { + Err(ThreadStoreError::Unsupported { + operation: "threadSection/list", + }) + }) + } + + /// Creates a custom thread section with a stable, server-assigned identity. + fn create_thread_section( + &self, + _params: CreateThreadSectionParams, + ) -> ThreadStoreFuture<'_, StoredThreadSection> { + Box::pin(async { + Err(ThreadStoreError::Unsupported { + operation: "threadSection/create", + }) + }) + } + + /// Renames a custom thread section, returning `None` when it does not exist. + fn rename_thread_section( + &self, + _params: RenameThreadSectionParams, + ) -> ThreadStoreFuture<'_, Option> { + Box::pin(async { + Err(ThreadStoreError::Unsupported { + operation: "threadSection/update", + }) + }) + } + + /// Deletes a custom thread section and reports whether it existed. + fn delete_thread_section( + &self, + _params: DeleteThreadSectionParams, + ) -> ThreadStoreFuture<'_, bool> { + Box::pin(async { + Err(ThreadStoreError::Unsupported { + operation: "threadSection/delete", + }) + }) + } + /// Whether paginated threads can hydrate durable history through turn and item lists. fn supports_paginated_history_lists(&self) -> bool { false diff --git a/codex-rs/thread-store/src/thread_sections.rs b/codex-rs/thread-store/src/thread_sections.rs new file mode 100644 index 0000000000..53a9641372 --- /dev/null +++ b/codex-rs/thread-store/src/thread_sections.rs @@ -0,0 +1,49 @@ +/// Parameters for listing independently persisted thread sections. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ListThreadSectionsParams { + /// Opaque cursor returned by a previous section listing. + pub cursor: Option, + /// Maximum number of sections to return. + pub limit: usize, +} + +/// Parameters for creating a thread section. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateThreadSectionParams { + /// User-facing section name. + pub name: String, +} + +/// Parameters for renaming a thread section. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RenameThreadSectionParams { + /// Stable identifier of the section to rename. + pub section_id: String, + /// Replacement user-facing section name. + pub name: String, +} + +/// Parameters for deleting a thread section. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeleteThreadSectionParams { + /// Stable identifier of the section to delete. + pub section_id: String, +} + +/// An independently persisted thread section and its user-facing name. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredThreadSection { + /// Stable, server-owned section identifier. + pub id: String, + /// User-facing section name. + pub name: String, +} + +/// A cursor-paginated page of independently persisted thread sections. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredThreadSectionsPage { + /// Sections returned for this page. + pub sections: Vec, + /// Opaque cursor to continue listing sections. + pub next_cursor: Option, +}