Add thread section management APIs (#36380)

## What changed

- Add `threadSection/create`, `threadSection/update`, and `threadSection/delete` app-server methods, with generated protocol schemas and TypeScript bindings.
- Persist custom sections in SQLite with stable UUIDv7 identities, trim and validate display names, and prevent renaming or deleting the built-in pinned section.
- Delete sections transactionally and return their active and archived threads to the unsectioned list.
- Serialize section mutations with section listing so concurrent requests observe ordered updates.

## Testing

- Cover section persistence across restarts, ordered renames, member cleanup on deletion, invalid requests, unsupported stores, and thread-store/state-runtime behavior.

GitOrigin-RevId: dc9a4be283c21dbc93ab1829eafc4825d8df6575
This commit is contained in:
joeytrasatti-openai authored and copyberry committed 2026-07-31 19:08:45 +00:00
1 parent d62353a312
commit c42ea41ee0
38 files changed
+1862 -62

No files matched your search

@@ -4590,6 +4590,32 @@
], ],
"type": "string" "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": { "ThreadSectionListParams": {
"description": "Parameters for listing independently persisted thread sections.", "description": "Parameters for listing independently persisted thread sections.",
"properties": { "properties": {
@@ -4640,6 +4666,24 @@
], ],
"type": "object" "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": { "ThreadSetNameParams": {
"properties": { "properties": {
"name": { "name": {
@@ -5751,6 +5795,78 @@
"title": "ThreadSection/listRequest", "title": "ThreadSection/listRequest",
"type": "object" "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": { "properties": {
"id": { "id": {
@@ -684,6 +684,78 @@
"title": "ThreadSection/listRequest", "title": "ThreadSection/listRequest",
"type": "object" "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": { "properties": {
"id": { "id": {
@@ -20593,6 +20665,56 @@
], ],
"type": "object" "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": { "ThreadSectionListParams": {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for listing independently persisted thread sections.", "description": "Parameters for listing independently persisted thread sections.",
@@ -20676,6 +20798,40 @@
"title": "ThreadSectionMoveResponse", "title": "ThreadSectionMoveResponse",
"type": "object" "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": { "ThreadSetNameParams": {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"properties": { "properties": {
@@ -1834,6 +1834,78 @@
"title": "ThreadSection/listRequest", "title": "ThreadSection/listRequest",
"type": "object" "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": { "properties": {
"id": { "id": {
@@ -18357,6 +18429,56 @@
], ],
"type": "object" "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": { "ThreadSectionListParams": {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for listing independently persisted thread sections.", "description": "Parameters for listing independently persisted thread sections.",
@@ -18440,6 +18562,40 @@
"title": "ThreadSectionMoveResponse", "title": "ThreadSectionMoveResponse",
"type": "object" "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": { "ThreadSetNameParams": {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"properties": { "properties": {
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -78,8 +78,11 @@ import type { ThreadMetadataUpdateParams } from "./v2/ThreadMetadataUpdateParams
import type { ThreadReadParams } from "./v2/ThreadReadParams"; import type { ThreadReadParams } from "./v2/ThreadReadParams";
import type { ThreadResumeParams } from "./v2/ThreadResumeParams"; import type { ThreadResumeParams } from "./v2/ThreadResumeParams";
import type { ThreadRollbackParams } from "./v2/ThreadRollbackParams"; 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 { ThreadSectionListParams } from "./v2/ThreadSectionListParams";
import type { ThreadSectionMoveParams } from "./v2/ThreadSectionMoveParams"; import type { ThreadSectionMoveParams } from "./v2/ThreadSectionMoveParams";
import type { ThreadSectionUpdateParams } from "./v2/ThreadSectionUpdateParams";
import type { ThreadSetNameParams } from "./v2/ThreadSetNameParams"; import type { ThreadSetNameParams } from "./v2/ThreadSetNameParams";
import type { ThreadShellCommandParams } from "./v2/ThreadShellCommandParams"; import type { ThreadShellCommandParams } from "./v2/ThreadShellCommandParams";
import type { ThreadStartParams } from "./v2/ThreadStartParams"; import type { ThreadStartParams } from "./v2/ThreadStartParams";
@@ -93,4 +96,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta
/** /**
* Request from the client to the server. * 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: ReLine truncated 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: ModelProviderCapabiLine truncated
@@ -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, };
@@ -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, };
@@ -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, };
@@ -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<string, never>;
@@ -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, };
@@ -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, };
@@ -474,10 +474,16 @@ export type { ThreadRollbackResponse } from "./ThreadRollbackResponse";
export type { ThreadSearchResult } from "./ThreadSearchResult"; export type { ThreadSearchResult } from "./ThreadSearchResult";
export type { ThreadSearchSortKey } from "./ThreadSearchSortKey"; export type { ThreadSearchSortKey } from "./ThreadSearchSortKey";
export type { ThreadSection } from "./ThreadSection"; 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 { ThreadSectionListParams } from "./ThreadSectionListParams";
export type { ThreadSectionListResponse } from "./ThreadSectionListResponse"; export type { ThreadSectionListResponse } from "./ThreadSectionListResponse";
export type { ThreadSectionMoveParams } from "./ThreadSectionMoveParams"; export type { ThreadSectionMoveParams } from "./ThreadSectionMoveParams";
export type { ThreadSectionMoveResponse } from "./ThreadSectionMoveResponse"; export type { ThreadSectionMoveResponse } from "./ThreadSectionMoveResponse";
export type { ThreadSectionUpdateParams } from "./ThreadSectionUpdateParams";
export type { ThreadSectionUpdateResponse } from "./ThreadSectionUpdateResponse";
export type { ThreadSetNameParams } from "./ThreadSetNameParams"; export type { ThreadSetNameParams } from "./ThreadSetNameParams";
export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; export type { ThreadSetNameResponse } from "./ThreadSetNameResponse";
export type { ThreadSettings } from "./ThreadSettings"; export type { ThreadSettings } from "./ThreadSettings";
@@ -633,9 +633,24 @@ client_request_definitions! {
}, },
ThreadSectionList => "threadSection/list" { ThreadSectionList => "threadSection/list" {
params: v2::ThreadSectionListParams, params: v2::ThreadSectionListParams,
serialization: None, serialization: global_shared_read("thread-sections"),
response: v2::ThreadSectionListResponse, 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")] #[experimental("thread/search")]
ThreadSearch => "thread/search" { ThreadSearch => "thread/search" {
params: v2::ThreadSearchParams, params: v2::ThreadSearchParams,
@@ -1139,6 +1139,57 @@ pub struct ThreadSectionListResponse {
pub next_cursor: Option<String>, pub next_cursor: Option<String>,
} }
/// 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)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")] #[ts(export_to = "v2/")]
+3
View File
@@ -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/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. - `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/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/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/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. For loaded threads, experimental clients can use `canAcceptDirectInput` to determine whether `turn/start` and `turn/steer` are accepted; unloaded stored threads report `null` when that capability is unavailable.
- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`.
@@ -1120,6 +1120,15 @@ impl MessageProcessor {
ClientRequest::ThreadSectionList { params, .. } => { ClientRequest::ThreadSectionList { params, .. } => {
self.thread_processor.thread_section_list(params).await 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, .. } => { ClientRequest::ThreadSettingsUpdate { params, .. } => {
self.turn_processor self.turn_processor
.thread_settings_update(&request_id, params) .thread_settings_update(&request_id, params)
@@ -540,6 +540,7 @@ mod search;
mod thread_enrichment; mod thread_enrichment;
mod thread_fork_goal; mod thread_fork_goal;
mod thread_processor; mod thread_processor;
mod thread_sections;
mod token_usage_replay; mod token_usage_replay;
mod turn_processor; mod turn_processor;
mod windows_sandbox_processor; mod windows_sandbox_processor;
@@ -5,8 +5,6 @@ use super::*;
use crate::error_code::method_not_found; use crate::error_code::method_not_found;
use codex_app_server_protocol::SelectedCapabilityRoot; use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_app_server_protocol::ThreadSection; 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::ThreadSectionMoveParams;
use codex_app_server_protocol::ThreadSectionMoveResponse; use codex_app_server_protocol::ThreadSectionMoveResponse;
use codex_extension_api::ExtensionDataInit; 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::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadHistoryMode;
const THREAD_LIST_DEFAULT_LIMIT: usize = 25; pub(super) const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
const THREAD_LIST_MAX_LIMIT: usize = 100; pub(super) const THREAD_LIST_MAX_LIMIT: usize = 100;
const CODEX_TUI_CLIENT_NAME: &str = "codex-tui"; const CODEX_TUI_CLIENT_NAME: &str = "codex-tui";
const THREAD_ROLLBACK_DEPRECATION_SUMMARY: &str = const THREAD_ROLLBACK_DEPRECATION_SUMMARY: &str =
"thread/rollback is deprecated and will be removed soon"; "thread/rollback is deprecated and will be removed soon";
@@ -743,39 +741,6 @@ impl ThreadRequestProcessor {
.map(|response| Some(response.into())) .map(|response| Some(response.into()))
} }
pub(crate) async fn thread_section_list(
&self,
params: ThreadSectionListParams,
) -> Result<Option<ClientResponsePayload>, 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( pub(crate) async fn thread_search(
&self, &self,
params: ThreadSearchParams, params: ThreadSearchParams,
@@ -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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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}"))
}
}
}
@@ -87,6 +87,7 @@ mod thread_name_websocket;
mod thread_read; mod thread_read;
mod thread_resume; mod thread_resume;
mod thread_rollback; mod thread_rollback;
mod thread_sections;
mod thread_settings_update; mod thread_settings_update;
mod thread_shell_command; mod thread_shell_command;
mod thread_start; mod thread_start;
@@ -37,7 +37,10 @@ use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadResumeParams; 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::ThreadSectionListParams;
use codex_app_server_protocol::ThreadSectionUpdateParams;
use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartParams;
@@ -55,6 +58,7 @@ use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions; use codex_protocol::models::BaseInstructions;
use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::ThreadMemoryMode;
use codex_state::PINNED_THREAD_SECTION_ID;
use codex_thread_store::CreateThreadParams as StoreCreateThreadParams; use codex_thread_store::CreateThreadParams as StoreCreateThreadParams;
use codex_thread_store::InMemoryThreadStore; use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::ThreadPersistenceMetadata; 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); const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test] #[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 codex_home = TempDir::new()?;
let store_id = Uuid::new_v4().to_string(); 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)?; 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 _in_memory_store = InMemoryThreadStoreId { store_id };
let client = start_in_process_server(codex_home.path()).await?; let client = start_in_process_server(codex_home.path()).await?;
let error = client let section_id = Uuid::now_v7().to_string();
.request(ClientRequest::ThreadSectionList {
for request in [
ClientRequest::ThreadSectionList {
request_id: RequestId::Integer(1), request_id: RequestId::Integer(1),
params: ThreadSectionListParams::default(), params: ThreadSectionListParams::default(),
}) },
.await? ClientRequest::ThreadSectionCreate {
.expect_err("section discovery requires sqlite state"); 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.code, -32601);
assert_eq!( assert_eq!(
error.message, error.message,
"threadSection/list is unavailable without sqlite state" format!("{method} is unavailable without sqlite state")
); );
}
client.shutdown().await?; client.shutdown().await?;
assert!( assert_no_local_persistence_artifacts(codex_home.path())?;
!codex_home.path().join("state_5.sqlite").exists(),
"section discovery must not create local SQLite state"
);
assert!(
!codex_home.path().join("sessions").exists(),
"section discovery must not create local thread persistence"
);
Ok(()) 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:?}" "non-local thread persistence should not create sqlite artifacts: {sqlite_artifacts:?}"
); );
let mut entries = codex_home_entries(codex_home)?; let mut entries = codex_home_entries(codex_home)?;
// Bazel test runs may initialize shell snapshot storage under codex_home. // Host startup may leave sandbox migration markers, and Bazel test runs may
// That is not thread persistence; keep the assertion focused on rollout, // initialize shell snapshot storage. Neither is thread persistence.
// session, sqlite, and other unexpected thread-store artifacts. entries.remove(".sandbox_migration");
entries.remove("shell_snapshots"); entries.remove("shell_snapshots");
assert_eq!( assert_eq!(
entries, entries,
BTreeSet::from([ BTreeSet::from([
".sandbox_migration".to_string(),
"config.toml".to_string(), "config.toml".to_string(),
"installation_id".to_string(), "installation_id".to_string(),
"skills".to_string(), "skills".to_string(),
@@ -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<JSONRPCError> {
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(())
}
+1
View File
@@ -48,6 +48,7 @@ mod remote_control;
#[cfg(test)] #[cfg(test)]
pub(crate) mod test_support; pub(crate) mod test_support;
mod thread_section_order; mod thread_section_order;
mod thread_sections;
mod threads; mod threads;
pub use external_agent_config_imports::ExternalAgentConfigImportDetailsRecord; pub use external_agent_config_imports::ExternalAgentConfigImportDetailsRecord;
@@ -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<ThreadSection> {
let section = ThreadSection {
id: Uuid::now_v7().to_string(),
name: name.to_string(),
};
sqlx::query("INSERT INTO thread_sections (id, name) VALUES (?, ?)")
.bind(&section.id)
.bind(&section.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<Option<ThreadSection>> {
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<bool> {
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;
@@ -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(&section.id),
runtime.move_thread_to_section(
thread_id,
Some(&section.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(())
}
+7
View File
@@ -10,6 +10,7 @@ mod live_thread;
mod local; mod local;
mod store; mod store;
mod thread_metadata_sync; mod thread_metadata_sync;
mod thread_sections;
mod types; mod types;
pub use error::ThreadStoreError; pub use error::ThreadStoreError;
@@ -22,6 +23,12 @@ pub use local::LocalThreadStore;
pub use local::LocalThreadStoreConfig; pub use local::LocalThreadStoreConfig;
pub use store::ThreadStore; pub use store::ThreadStore;
pub use store::ThreadStoreFuture; 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::AppendThreadItemsParams;
pub use types::ArchiveThreadParams; pub use types::ArchiveThreadParams;
pub use types::ArchiveThreadsParams; pub use types::ArchiveThreadsParams;
+39
View File
@@ -14,6 +14,7 @@ mod rollout_lineage;
mod search_threads; mod search_threads;
mod thread_history; mod thread_history;
mod thread_history_materialization; mod thread_history_materialization;
mod thread_sections;
mod unarchive_thread; mod unarchive_thread;
mod update_thread_metadata; mod update_thread_metadata;
mod writer_lock; mod writer_lock;
@@ -41,10 +42,13 @@ use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams; use crate::ArchiveThreadParams;
use crate::ArchiveThreadsParams; use crate::ArchiveThreadsParams;
use crate::CreateThreadParams; use crate::CreateThreadParams;
use crate::CreateThreadSectionParams;
use crate::DeleteThreadParams; use crate::DeleteThreadParams;
use crate::DeleteThreadSectionParams;
use crate::DeleteThreadsParams; use crate::DeleteThreadsParams;
use crate::ItemPage; use crate::ItemPage;
use crate::ListItemsParams; use crate::ListItemsParams;
use crate::ListThreadSectionsParams;
use crate::ListThreadsParams; use crate::ListThreadsParams;
use crate::ListTurnsParams; use crate::ListTurnsParams;
use crate::LoadThreadHistoryParams; use crate::LoadThreadHistoryParams;
@@ -53,12 +57,15 @@ use crate::PrepareForkParams;
use crate::PreparedFork; use crate::PreparedFork;
use crate::ReadThreadByRolloutPathParams; use crate::ReadThreadByRolloutPathParams;
use crate::ReadThreadParams; use crate::ReadThreadParams;
use crate::RenameThreadSectionParams;
use crate::ResumeThreadParams; use crate::ResumeThreadParams;
use crate::SearchThreadOccurrencesParams; use crate::SearchThreadOccurrencesParams;
use crate::SearchThreadsParams; use crate::SearchThreadsParams;
use crate::StoredModelContext; use crate::StoredModelContext;
use crate::StoredThread; use crate::StoredThread;
use crate::StoredThreadHistory; use crate::StoredThreadHistory;
use crate::StoredThreadSection;
use crate::StoredThreadSectionsPage;
use crate::ThreadOccurrenceSearchPage; use crate::ThreadOccurrenceSearchPage;
use crate::ThreadPage; use crate::ThreadPage;
use crate::ThreadSearchPage; use crate::ThreadSearchPage;
@@ -458,6 +465,38 @@ impl ThreadStore for LocalThreadStore {
Box::pin(async move { list_threads::list_threads(self, params).await }) 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<StoredThreadSection>> {
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 { fn supports_paginated_history_lists(&self) -> bool {
self.state_db.is_some() self.state_db.is_some()
} }
@@ -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<StoredThreadSectionsPage> {
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<StoredThreadSection> {
let state_db = state_db(store, "threadSection/create")?;
let section = state_db
.create_thread_section(&params.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<Option<StoredThreadSection>> {
let state_db = state_db(store, "threadSection/update")?;
let section = state_db
.rename_thread_section(&params.section_id, &params.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<bool> {
let state_db = state_db(store, "threadSection/delete")?;
state_db
.delete_thread_section(&params.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;
@@ -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(&section));
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")
);
}
+59
View File
@@ -8,10 +8,13 @@ use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams; use crate::ArchiveThreadParams;
use crate::ArchiveThreadsParams; use crate::ArchiveThreadsParams;
use crate::CreateThreadParams; use crate::CreateThreadParams;
use crate::CreateThreadSectionParams;
use crate::DeleteThreadParams; use crate::DeleteThreadParams;
use crate::DeleteThreadSectionParams;
use crate::DeleteThreadsParams; use crate::DeleteThreadsParams;
use crate::ItemPage; use crate::ItemPage;
use crate::ListItemsParams; use crate::ListItemsParams;
use crate::ListThreadSectionsParams;
use crate::ListThreadsParams; use crate::ListThreadsParams;
use crate::ListTurnsParams; use crate::ListTurnsParams;
use crate::LoadThreadHistoryParams; use crate::LoadThreadHistoryParams;
@@ -20,12 +23,15 @@ use crate::PrepareForkParams;
use crate::PreparedFork; use crate::PreparedFork;
use crate::ReadThreadByRolloutPathParams; use crate::ReadThreadByRolloutPathParams;
use crate::ReadThreadParams; use crate::ReadThreadParams;
use crate::RenameThreadSectionParams;
use crate::ResumeThreadParams; use crate::ResumeThreadParams;
use crate::SearchThreadOccurrencesParams; use crate::SearchThreadOccurrencesParams;
use crate::SearchThreadsParams; use crate::SearchThreadsParams;
use crate::StoredModelContext; use crate::StoredModelContext;
use crate::StoredThread; use crate::StoredThread;
use crate::StoredThreadHistory; use crate::StoredThreadHistory;
use crate::StoredThreadSection;
use crate::StoredThreadSectionsPage;
use crate::ThreadOccurrenceSearchPage; use crate::ThreadOccurrenceSearchPage;
use crate::ThreadPage; use crate::ThreadPage;
use crate::ThreadSearchPage; use crate::ThreadSearchPage;
@@ -123,6 +129,59 @@ pub trait ThreadStore: Any + Send + Sync {
/// Lists stored threads matching the supplied filters. /// Lists stored threads matching the supplied filters.
fn list_threads(&self, params: ListThreadsParams) -> ThreadStoreFuture<'_, ThreadPage>; 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<StoredThreadSection>> {
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. /// Whether paginated threads can hydrate durable history through turn and item lists.
fn supports_paginated_history_lists(&self) -> bool { fn supports_paginated_history_lists(&self) -> bool {
false false
@@ -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<String>,
/// 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<StoredThreadSection>,
/// Opaque cursor to continue listing sections.
pub next_cursor: Option<String>,
}