mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
Add thread metadata
This commit is contained in:
@@ -2554,6 +2554,15 @@
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"description": "Configuration overrides for the forked thread, if any.",
|
||||
"type": [
|
||||
@@ -2736,6 +2745,16 @@
|
||||
],
|
||||
"description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value."
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Replace the stored client-defined metadata for this thread.",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -3032,6 +3051,15 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -2244,6 +2244,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
|
||||
@@ -12023,6 +12023,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
@@ -12211,6 +12219,15 @@
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"description": "Configuration overrides for the forked thread, if any.",
|
||||
"type": [
|
||||
@@ -13151,6 +13168,16 @@
|
||||
],
|
||||
"description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value."
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Replace the stored client-defined metadata for this thread.",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -13712,6 +13739,15 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -9770,6 +9770,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
@@ -9958,6 +9966,15 @@
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"description": "Configuration overrides for the forked thread, if any.",
|
||||
"type": [
|
||||
@@ -10898,6 +10915,16 @@
|
||||
],
|
||||
"description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value."
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Replace the stored client-defined metadata for this thread.",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -11459,6 +11486,15 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -126,6 +126,15 @@
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"description": "Configuration overrides for the forked thread, if any.",
|
||||
"type": [
|
||||
|
||||
@@ -1056,6 +1056,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
|
||||
@@ -814,6 +814,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
|
||||
@@ -40,6 +40,16 @@
|
||||
],
|
||||
"description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value."
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Replace the stored client-defined metadata for this thread.",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -814,6 +814,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
|
||||
@@ -814,6 +814,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
|
||||
@@ -1056,6 +1056,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
|
||||
@@ -814,6 +814,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
|
||||
@@ -156,6 +156,15 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -1056,6 +1056,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
|
||||
@@ -814,6 +814,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
|
||||
@@ -814,6 +814,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Arbitrary client-defined metadata.",
|
||||
"type": "object"
|
||||
},
|
||||
"modelProvider": {
|
||||
"description": "Model provider used for this thread (for example, 'openai').",
|
||||
"type": "string"
|
||||
|
||||
@@ -63,6 +63,10 @@ gitInfo: GitInfo | null,
|
||||
* Optional user-facing thread title.
|
||||
*/
|
||||
name: string | null,
|
||||
/**
|
||||
* Arbitrary client-defined metadata.
|
||||
*/
|
||||
metadata: { [key in string]?: string },
|
||||
/**
|
||||
* Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read`
|
||||
* (when `includeTurns` is true) responses.
|
||||
|
||||
@@ -27,7 +27,7 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier
|
||||
* Override where approval requests are routed for review on this thread
|
||||
* and subsequent turns.
|
||||
*/
|
||||
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /**
|
||||
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, metadata?: { [key in string]?: string } | null, ephemeral?: boolean, /**
|
||||
* If true, persist additional rollout EventMsg variants required to
|
||||
* reconstruct a richer thread history on subsequent resume/fork/read.
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
import type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoUpdateParams";
|
||||
|
||||
export type ThreadMetadataUpdateParams = { threadId: string,
|
||||
/**
|
||||
* Replace the stored client-defined metadata for this thread.
|
||||
*/
|
||||
metadata?: { [key in string]?: string } | null,
|
||||
/**
|
||||
* Patch the stored Git metadata for this thread.
|
||||
* Omit a field to leave it unchanged, set it to `null` to clear it, or
|
||||
|
||||
@@ -12,7 +12,7 @@ export type ThreadStartParams = {model?: string | null, modelProvider?: string |
|
||||
* Override where approval requests are routed for review on this thread
|
||||
* and subsequent turns.
|
||||
*/
|
||||
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /**
|
||||
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, metadata?: { [key in string]?: string } | null, ephemeral?: boolean | null, /**
|
||||
* If true, opt into emitting raw Responses API items on the event stream.
|
||||
* This is for internal use only (e.g. Codex Cloud).
|
||||
*/
|
||||
|
||||
@@ -2586,6 +2586,8 @@ pub struct ThreadStartParams {
|
||||
#[ts(optional = nullable)]
|
||||
pub personality: Option<Personality>,
|
||||
#[ts(optional = nullable)]
|
||||
pub metadata: Option<BTreeMap<String, String>>,
|
||||
#[ts(optional = nullable)]
|
||||
pub ephemeral: Option<bool>,
|
||||
#[experimental("thread/start.dynamicTools")]
|
||||
#[ts(optional = nullable)]
|
||||
@@ -2778,6 +2780,8 @@ pub struct ThreadForkParams {
|
||||
pub base_instructions: Option<String>,
|
||||
#[ts(optional = nullable)]
|
||||
pub developer_instructions: Option<String>,
|
||||
#[ts(optional = nullable)]
|
||||
pub metadata: Option<BTreeMap<String, String>>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub ephemeral: bool,
|
||||
/// If true, persist additional rollout EventMsg variants required to
|
||||
@@ -2904,6 +2908,9 @@ pub struct ThreadSetNameResponse {}
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadMetadataUpdateParams {
|
||||
pub thread_id: String,
|
||||
/// Replace the stored client-defined metadata for this thread.
|
||||
#[ts(optional = nullable)]
|
||||
pub metadata: Option<BTreeMap<String, String>>,
|
||||
/// Patch the stored Git metadata for this thread.
|
||||
/// Omit a field to leave it unchanged, set it to `null` to clear it, or
|
||||
/// provide a string to replace the stored value.
|
||||
@@ -3620,6 +3627,9 @@ pub struct Thread {
|
||||
pub git_info: Option<GitInfo>,
|
||||
/// Optional user-facing thread title.
|
||||
pub name: Option<String>,
|
||||
/// Arbitrary client-defined metadata.
|
||||
#[serde(default)]
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
/// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read`
|
||||
/// (when `includeTurns` is true) responses.
|
||||
/// For all other responses and notifications returning a Thread,
|
||||
|
||||
@@ -123,13 +123,13 @@ Example with notification opt-out:
|
||||
|
||||
## API Overview
|
||||
|
||||
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread.
|
||||
- `thread/start` — create a new thread; accepts optional client-defined `metadata` (`string -> string`, up to 16 entries with keys/values <= 512 chars), emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for that thread.
|
||||
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
|
||||
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread.
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. Accepts optional client-defined `metadata` plus `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread.
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded, plus any stored client `metadata`.
|
||||
- `thread/loaded/list` — list the thread ids currently loaded in memory.
|
||||
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
|
||||
- `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, plus any stored client `metadata`.
|
||||
- `thread/metadata/update` — replace stored client `metadata` and/or patch stored `gitInfo` fields in sqlite, then return the refreshed `thread`.
|
||||
- `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`).
|
||||
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success and emits `thread/archived`.
|
||||
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`.
|
||||
@@ -201,6 +201,7 @@ Start a fresh thread when you need a new Codex conversation.
|
||||
"sandbox": "workspaceWrite",
|
||||
"personality": "friendly",
|
||||
"serviceName": "my_app_server_client", // optional metrics tag (`service_name`)
|
||||
"metadata": { "client": "vscode", "surface": "chat" },
|
||||
// Experimental: requires opt-in
|
||||
"dynamicTools": [
|
||||
{
|
||||
@@ -222,7 +223,8 @@ Start a fresh thread when you need a new Codex conversation.
|
||||
"id": "thr_123",
|
||||
"preview": "",
|
||||
"modelProvider": "openai",
|
||||
"createdAt": 1730910000
|
||||
"createdAt": 1730910000,
|
||||
"metadata": { "client": "vscode", "surface": "chat" }
|
||||
}
|
||||
} }
|
||||
{ "method": "thread/started", "params": { "thread": { … } } }
|
||||
@@ -244,10 +246,14 @@ Example:
|
||||
{ "id": 11, "result": { "thread": { "id": "thr_123", … } } }
|
||||
```
|
||||
|
||||
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
|
||||
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. You can also set replacement client `metadata` on the fork. Pass `ephemeral: true` when the fork should stay in-memory only:
|
||||
|
||||
```json
|
||||
{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } }
|
||||
{ "method": "thread/fork", "id": 12, "params": {
|
||||
"threadId": "thr_123",
|
||||
"metadata": { "origin": "review" },
|
||||
"ephemeral": true
|
||||
} }
|
||||
{ "id": 12, "result": { "thread": { "id": "thr_456", … } } }
|
||||
{ "method": "thread/started", "params": { "thread": { … } } }
|
||||
```
|
||||
@@ -338,7 +344,7 @@ If this was the last subscriber, the server unloads the thread and emits `thread
|
||||
|
||||
### Example: Read a thread
|
||||
|
||||
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
|
||||
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available, plus stored client `metadata` when present.
|
||||
|
||||
```json
|
||||
{ "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } }
|
||||
@@ -356,25 +362,38 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl
|
||||
|
||||
### Example: Update stored thread metadata
|
||||
|
||||
Use `thread/metadata/update` to patch sqlite-backed metadata for a thread without resuming it. Today this supports persisted `gitInfo`; omitted fields are left unchanged, while explicit `null` clears a stored value.
|
||||
Use `thread/metadata/update` to update sqlite-backed metadata for a thread without resuming it. `metadata` replaces the stored client-defined key/value map. `gitInfo` remains patch-style: omitted fields are left unchanged, while explicit `null` clears a stored value.
|
||||
|
||||
```json
|
||||
{ "method": "thread/metadata/update", "id": 24, "params": {
|
||||
"threadId": "thr_123",
|
||||
"gitInfo": { "branch": "feature/sidebar-pr" }
|
||||
"metadata": { "client": "desktop", "surface": "composer" }
|
||||
} }
|
||||
{ "id": 24, "result": {
|
||||
"thread": {
|
||||
"id": "thr_123",
|
||||
"metadata": { "client": "desktop", "surface": "composer" }
|
||||
}
|
||||
} }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "method": "thread/metadata/update", "id": 25, "params": {
|
||||
"threadId": "thr_123",
|
||||
"gitInfo": { "branch": "feature/sidebar-pr" }
|
||||
} }
|
||||
{ "id": 25, "result": {
|
||||
"thread": {
|
||||
"id": "thr_123",
|
||||
"gitInfo": { "sha": null, "branch": "feature/sidebar-pr", "originUrl": null }
|
||||
}
|
||||
} }
|
||||
|
||||
{ "method": "thread/metadata/update", "id": 25, "params": {
|
||||
{ "method": "thread/metadata/update", "id": 26, "params": {
|
||||
"threadId": "thr_123",
|
||||
"gitInfo": { "branch": null }
|
||||
} }
|
||||
{ "id": 25, "result": {
|
||||
{ "id": 26, "result": {
|
||||
"thread": {
|
||||
"id": "thr_123",
|
||||
"gitInfo": null
|
||||
|
||||
@@ -1892,6 +1892,7 @@ impl CodexMessageProcessor {
|
||||
service_name,
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
metadata,
|
||||
dynamic_tools,
|
||||
mock_experimental_field: _mock_experimental_field,
|
||||
experimental_raw_events,
|
||||
@@ -1899,6 +1900,13 @@ impl CodexMessageProcessor {
|
||||
ephemeral,
|
||||
persist_extended_history,
|
||||
} = params;
|
||||
let thread_metadata = match metadata.map(validate_thread_metadata).transpose() {
|
||||
Ok(metadata) => metadata.unwrap_or_default(),
|
||||
Err(message) => {
|
||||
self.send_invalid_request_error(request_id, message).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut typesafe_overrides = self.build_thread_config_overrides(
|
||||
model,
|
||||
model_provider,
|
||||
@@ -1936,6 +1944,7 @@ impl CodexMessageProcessor {
|
||||
dynamic_tools,
|
||||
persist_extended_history,
|
||||
service_name,
|
||||
thread_metadata,
|
||||
experimental_raw_events,
|
||||
request_trace,
|
||||
)
|
||||
@@ -2009,6 +2018,7 @@ impl CodexMessageProcessor {
|
||||
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
|
||||
persist_extended_history: bool,
|
||||
service_name: Option<String>,
|
||||
thread_metadata: BTreeMap<String, String>,
|
||||
experimental_raw_events: bool,
|
||||
request_trace: Option<W3cTraceContext>,
|
||||
) {
|
||||
@@ -2081,11 +2091,11 @@ impl CodexMessageProcessor {
|
||||
Ok(new_conv) => {
|
||||
let NewThread {
|
||||
thread_id,
|
||||
thread,
|
||||
thread: started_thread,
|
||||
session_configured,
|
||||
..
|
||||
} = new_conv;
|
||||
let config_snapshot = thread
|
||||
let config_snapshot = started_thread
|
||||
.config_snapshot()
|
||||
.instrument(tracing::info_span!(
|
||||
"app_server.thread_start.config_snapshot",
|
||||
@@ -2097,6 +2107,32 @@ impl CodexMessageProcessor {
|
||||
&config_snapshot,
|
||||
session_configured.rollout_path.clone(),
|
||||
);
|
||||
thread.metadata = thread_metadata.clone();
|
||||
if let (Some(state_db_ctx), Some(rollout_path)) = (
|
||||
started_thread.state_db(),
|
||||
session_configured.rollout_path.as_ref(),
|
||||
) && let Err(message) = persist_thread_metadata_from_snapshot(
|
||||
state_db_ctx.as_ref(),
|
||||
thread_id,
|
||||
rollout_path.as_path(),
|
||||
&config_snapshot,
|
||||
&thread_metadata,
|
||||
)
|
||||
.await
|
||||
{
|
||||
listener_task_context
|
||||
.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message,
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-attach a thread listener when starting a thread.
|
||||
Self::log_listener_attach_result(
|
||||
@@ -2434,8 +2470,16 @@ impl CodexMessageProcessor {
|
||||
) {
|
||||
let ThreadMetadataUpdateParams {
|
||||
thread_id,
|
||||
metadata,
|
||||
git_info,
|
||||
} = params;
|
||||
let metadata = match metadata.map(validate_thread_metadata).transpose() {
|
||||
Ok(metadata) => metadata,
|
||||
Err(message) => {
|
||||
self.send_invalid_request_error(request_id, message).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let thread_uuid = match ThreadId::from_string(&thread_id) {
|
||||
Ok(id) => id,
|
||||
@@ -2446,21 +2490,26 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
let Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha,
|
||||
branch,
|
||||
origin_url,
|
||||
}) = git_info
|
||||
else {
|
||||
if metadata.is_none() && git_info.is_none() {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
"gitInfo must include at least one field".to_string(),
|
||||
"metadata or gitInfo must include at least one field".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
if sha.is_none() && branch.is_none() && origin_url.is_none() {
|
||||
let (sha, branch, origin_url) = git_info
|
||||
.map(
|
||||
|ThreadMetadataGitInfoUpdateParams {
|
||||
sha,
|
||||
branch,
|
||||
origin_url,
|
||||
}| (sha, branch, origin_url),
|
||||
)
|
||||
.unwrap_or((None, None, None));
|
||||
|
||||
if metadata.is_none() && sha.is_none() && branch.is_none() && origin_url.is_none() {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
"gitInfo must include at least one field".to_string(),
|
||||
@@ -2540,36 +2589,77 @@ impl CodexMessageProcessor {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let updated = match state_db_ctx
|
||||
.update_thread_git_info(
|
||||
thread_uuid,
|
||||
git_sha.as_ref().map(|value| value.as_deref()),
|
||||
git_branch.as_ref().map(|value| value.as_deref()),
|
||||
git_origin_url.as_ref().map(|value| value.as_deref()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated) => updated,
|
||||
Err(err) => {
|
||||
if let Some(metadata) = metadata.as_ref() {
|
||||
let metadata_json = match serde_json::to_string(metadata) {
|
||||
Ok(metadata_json) => metadata_json,
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to serialize thread metadata for {thread_uuid}: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let updated = match state_db_ctx
|
||||
.update_thread_metadata_json(thread_uuid, metadata_json.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(updated) => updated,
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to update thread metadata for {thread_uuid}: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !updated {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to update thread metadata for {thread_uuid}: {err}"),
|
||||
format!("thread metadata disappeared before update completed: {thread_uuid}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !updated {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("thread metadata disappeared before update completed: {thread_uuid}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(summary) =
|
||||
read_summary_from_state_db_context_by_thread_id(Some(&state_db_ctx), thread_uuid).await
|
||||
if git_sha.is_some() || git_branch.is_some() || git_origin_url.is_some() {
|
||||
let updated = match state_db_ctx
|
||||
.update_thread_git_info(
|
||||
thread_uuid,
|
||||
git_sha.as_ref().map(|value| value.as_deref()),
|
||||
git_branch.as_ref().map(|value| value.as_deref()),
|
||||
git_origin_url.as_ref().map(|value| value.as_deref()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated) => updated,
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to update thread metadata for {thread_uuid}: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !updated {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("thread metadata disappeared before update completed: {thread_uuid}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(persisted_metadata) = read_thread_metadata_from_state_db_context_by_thread_id(
|
||||
Some(&state_db_ctx),
|
||||
thread_uuid,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
@@ -2579,7 +2669,8 @@ impl CodexMessageProcessor {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut thread = summary_to_thread(summary);
|
||||
let mut thread = summary_to_thread(summary_from_thread_metadata(&persisted_metadata));
|
||||
merge_persisted_thread_metadata(&mut thread, &persisted_metadata);
|
||||
self.attach_thread_name(thread_uuid, &mut thread).await;
|
||||
thread.status = resolve_thread_status(
|
||||
self.thread_watch_manager
|
||||
@@ -3140,12 +3231,22 @@ impl CodexMessageProcessor {
|
||||
let mut threads = Vec::with_capacity(summaries.len());
|
||||
let mut thread_ids = HashSet::with_capacity(summaries.len());
|
||||
let mut status_ids = Vec::with_capacity(summaries.len());
|
||||
let state_db_ctx = get_state_db(&self.config).await;
|
||||
|
||||
for summary in summaries {
|
||||
let conversation_id = summary.conversation_id;
|
||||
thread_ids.insert(conversation_id);
|
||||
|
||||
let thread = summary_to_thread(summary);
|
||||
let mut thread = summary_to_thread(summary);
|
||||
if let Some(persisted_metadata) =
|
||||
read_thread_metadata_from_state_db_context_by_thread_id(
|
||||
state_db_ctx.as_ref(),
|
||||
conversation_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
merge_persisted_thread_metadata(&mut thread, &persisted_metadata);
|
||||
}
|
||||
status_ids.push(thread.id.clone());
|
||||
threads.push((conversation_id, thread));
|
||||
}
|
||||
@@ -3253,12 +3354,15 @@ impl CodexMessageProcessor {
|
||||
|
||||
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
|
||||
let loaded_thread_state_db = loaded_thread.as_ref().and_then(|thread| thread.state_db());
|
||||
let db_summary = if let Some(state_db_ctx) = loaded_thread_state_db.as_ref() {
|
||||
read_summary_from_state_db_context_by_thread_id(Some(state_db_ctx), thread_uuid).await
|
||||
let db_metadata = if let Some(state_db_ctx) = loaded_thread_state_db.as_ref() {
|
||||
read_thread_metadata_from_state_db_context_by_thread_id(Some(state_db_ctx), thread_uuid)
|
||||
.await
|
||||
} else {
|
||||
read_summary_from_state_db_by_thread_id(&self.config, thread_uuid).await
|
||||
read_thread_metadata_from_state_db_by_thread_id(&self.config, thread_uuid).await
|
||||
};
|
||||
let mut rollout_path = db_summary.as_ref().map(|summary| summary.path.clone());
|
||||
let mut rollout_path = db_metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.rollout_path.clone());
|
||||
if rollout_path.is_none() || include_turns {
|
||||
rollout_path =
|
||||
match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string())
|
||||
@@ -3283,17 +3387,31 @@ impl CodexMessageProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
if include_turns && rollout_path.is_none() && db_summary.is_some() {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to locate rollout for thread {thread_uuid}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
if include_turns && rollout_path.is_none() {
|
||||
if loaded_thread.is_some() {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
format!(
|
||||
"thread {thread_uuid} is not materialized yet; includeTurns is unavailable before first user message"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if db_metadata.is_some() {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to locate rollout for thread {thread_uuid}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut thread = if let Some(summary) = db_summary {
|
||||
summary_to_thread(summary)
|
||||
let mut thread = if let Some(persisted_metadata) = db_metadata.as_ref() {
|
||||
let mut thread = summary_to_thread(summary_from_thread_metadata(persisted_metadata));
|
||||
merge_persisted_thread_metadata(&mut thread, persisted_metadata);
|
||||
thread
|
||||
} else if let Some(rollout_path) = rollout_path.as_ref() {
|
||||
let fallback_provider = self.config.model_provider_id.as_str();
|
||||
match read_summary_from_rollout(rollout_path, fallback_provider).await {
|
||||
@@ -3980,9 +4098,17 @@ impl CodexMessageProcessor {
|
||||
config: cli_overrides,
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
metadata,
|
||||
ephemeral,
|
||||
persist_extended_history,
|
||||
} = params;
|
||||
let thread_metadata = match metadata.map(validate_thread_metadata).transpose() {
|
||||
Ok(metadata) => metadata.unwrap_or_default(),
|
||||
Err(message) => {
|
||||
self.send_invalid_request_error(request_id, message).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (rollout_path, source_thread_id) = if let Some(path) = path {
|
||||
(path, None)
|
||||
@@ -4130,6 +4256,7 @@ impl CodexMessageProcessor {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fork_config_snapshot = forked_thread.config_snapshot().await;
|
||||
|
||||
// Auto-attach a conversation listener when forking a thread.
|
||||
Self::log_listener_attach_result(
|
||||
@@ -4168,10 +4295,9 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let config_snapshot = forked_thread.config_snapshot().await;
|
||||
// forked thread names do not inherit the source thread name
|
||||
let mut thread =
|
||||
build_thread_from_snapshot(thread_id, &config_snapshot, /*path*/ None);
|
||||
build_thread_from_snapshot(thread_id, &fork_config_snapshot, /*path*/ None);
|
||||
let history_items = match read_rollout_items_from_rollout(rollout_path.as_path()).await
|
||||
{
|
||||
Ok(items) => items,
|
||||
@@ -4200,6 +4326,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
thread
|
||||
};
|
||||
thread.metadata = thread_metadata.clone();
|
||||
|
||||
if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref()
|
||||
&& let Err(message) = populate_thread_turns(
|
||||
@@ -4212,6 +4339,20 @@ impl CodexMessageProcessor {
|
||||
self.send_internal_error(request_id, message).await;
|
||||
return;
|
||||
}
|
||||
if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref()
|
||||
&& let Some(state_db_ctx) = forked_thread.state_db()
|
||||
&& let Err(message) = persist_thread_metadata_from_snapshot(
|
||||
state_db_ctx.as_ref(),
|
||||
thread_id,
|
||||
fork_rollout_path.as_path(),
|
||||
&fork_config_snapshot,
|
||||
&thread_metadata,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.send_internal_error(request_id, message).await;
|
||||
return;
|
||||
}
|
||||
|
||||
self.thread_watch_manager
|
||||
.upsert_thread_silently(thread.clone())
|
||||
@@ -7983,25 +8124,42 @@ async fn read_history_cwd_from_state_db(
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_thread_metadata_from_state_db_by_thread_id(
|
||||
config: &Config,
|
||||
thread_id: ThreadId,
|
||||
) -> Option<ThreadMetadata> {
|
||||
let state_db_ctx = get_state_db(config).await;
|
||||
read_thread_metadata_from_state_db_context_by_thread_id(state_db_ctx.as_ref(), thread_id).await
|
||||
}
|
||||
|
||||
async fn read_thread_metadata_from_state_db_context_by_thread_id(
|
||||
state_db_ctx: Option<&StateDbHandle>,
|
||||
thread_id: ThreadId,
|
||||
) -> Option<ThreadMetadata> {
|
||||
let state_db_ctx = state_db_ctx?;
|
||||
|
||||
match state_db_ctx.get_thread(thread_id).await {
|
||||
Ok(Some(metadata)) => Some(metadata),
|
||||
Ok(None) | Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_summary_from_state_db_by_thread_id(
|
||||
config: &Config,
|
||||
thread_id: ThreadId,
|
||||
) -> Option<ConversationSummary> {
|
||||
let state_db_ctx = get_state_db(config).await;
|
||||
read_summary_from_state_db_context_by_thread_id(state_db_ctx.as_ref(), thread_id).await
|
||||
read_thread_metadata_from_state_db_by_thread_id(config, thread_id)
|
||||
.await
|
||||
.map(|metadata| summary_from_thread_metadata(&metadata))
|
||||
}
|
||||
|
||||
async fn read_summary_from_state_db_context_by_thread_id(
|
||||
state_db_ctx: Option<&StateDbHandle>,
|
||||
thread_id: ThreadId,
|
||||
) -> Option<ConversationSummary> {
|
||||
let state_db_ctx = state_db_ctx?;
|
||||
|
||||
let metadata = match state_db_ctx.get_thread(thread_id).await {
|
||||
Ok(Some(metadata)) => metadata,
|
||||
Ok(None) | Err(_) => return None,
|
||||
};
|
||||
Some(summary_from_thread_metadata(&metadata))
|
||||
read_thread_metadata_from_state_db_context_by_thread_id(state_db_ctx, thread_id)
|
||||
.await
|
||||
.map(|metadata| summary_from_thread_metadata(&metadata))
|
||||
}
|
||||
|
||||
async fn summary_from_thread_list_item(
|
||||
@@ -8294,18 +8452,29 @@ async fn load_thread_summary_for_rollout(
|
||||
)
|
||||
})?;
|
||||
if let Some(persisted_metadata) = persisted_metadata {
|
||||
merge_mutable_thread_metadata(
|
||||
&mut thread,
|
||||
summary_to_thread(summary_from_thread_metadata(persisted_metadata)),
|
||||
);
|
||||
} else if let Some(summary) = read_summary_from_state_db_by_thread_id(config, thread_id).await {
|
||||
merge_mutable_thread_metadata(&mut thread, summary_to_thread(summary));
|
||||
merge_persisted_thread_metadata(&mut thread, persisted_metadata);
|
||||
} else if let Some(persisted_metadata) =
|
||||
read_thread_metadata_from_state_db_by_thread_id(config, thread_id).await
|
||||
{
|
||||
merge_persisted_thread_metadata(&mut thread, &persisted_metadata);
|
||||
}
|
||||
Ok(thread)
|
||||
}
|
||||
|
||||
fn merge_mutable_thread_metadata(thread: &mut Thread, persisted_thread: Thread) {
|
||||
thread.git_info = persisted_thread.git_info;
|
||||
fn merge_persisted_thread_metadata(thread: &mut Thread, persisted_metadata: &ThreadMetadata) {
|
||||
thread.git_info = if persisted_metadata.git_sha.is_none()
|
||||
&& persisted_metadata.git_branch.is_none()
|
||||
&& persisted_metadata.git_origin_url.is_none()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(ApiGitInfo {
|
||||
sha: persisted_metadata.git_sha.clone(),
|
||||
branch: persisted_metadata.git_branch.clone(),
|
||||
origin_url: persisted_metadata.git_origin_url.clone(),
|
||||
})
|
||||
};
|
||||
thread.metadata = parse_thread_metadata_json(persisted_metadata.metadata_json.as_str());
|
||||
}
|
||||
|
||||
fn preview_from_rollout_items(items: &[RolloutItem]) -> String {
|
||||
@@ -8398,6 +8567,7 @@ fn build_thread_from_snapshot(
|
||||
source: config_snapshot.session_source.clone().into(),
|
||||
git_info: None,
|
||||
name: None,
|
||||
metadata: BTreeMap::new(),
|
||||
turns: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -8440,10 +8610,78 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
|
||||
source: source.into(),
|
||||
git_info,
|
||||
name: None,
|
||||
metadata: BTreeMap::new(),
|
||||
turns: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_thread_metadata_json(metadata_json: &str) -> BTreeMap<String, String> {
|
||||
serde_json::from_str(metadata_json).unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn persist_thread_metadata_from_snapshot(
|
||||
state_db_ctx: &StateRuntime,
|
||||
thread_id: ThreadId,
|
||||
rollout_path: &Path,
|
||||
config_snapshot: &ThreadConfigSnapshot,
|
||||
thread_metadata: &BTreeMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let metadata_json = serde_json::to_string(thread_metadata)
|
||||
.map_err(|err| format!("failed to serialize thread metadata for {thread_id}: {err}"))?;
|
||||
|
||||
let mut builder = ThreadMetadataBuilder::new(
|
||||
thread_id,
|
||||
rollout_path.to_path_buf(),
|
||||
Utc::now(),
|
||||
config_snapshot.session_source.clone(),
|
||||
);
|
||||
builder.model_provider = Some(config_snapshot.model_provider_id.clone());
|
||||
builder.cwd = config_snapshot.cwd.clone();
|
||||
builder.cli_version = Some(env!("CARGO_PKG_VERSION").to_string());
|
||||
builder.sandbox_policy = config_snapshot.sandbox_policy.clone();
|
||||
builder.approval_mode = config_snapshot.approval_policy;
|
||||
builder.metadata_json = metadata_json.clone();
|
||||
|
||||
let metadata = builder.build(config_snapshot.model_provider_id.as_str());
|
||||
let inserted = state_db_ctx
|
||||
.insert_thread_if_absent(&metadata)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create thread metadata for {thread_id}: {err}"))?;
|
||||
if !inserted {
|
||||
let updated = state_db_ctx
|
||||
.update_thread_metadata_json(thread_id, metadata_json.as_str())
|
||||
.await
|
||||
.map_err(|err| format!("failed to persist thread metadata for {thread_id}: {err}"))?;
|
||||
if !updated {
|
||||
return Err(format!(
|
||||
"thread metadata disappeared before metadata persisted: {thread_id}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_thread_metadata(
|
||||
metadata: BTreeMap<String, String>,
|
||||
) -> std::result::Result<BTreeMap<String, String>, String> {
|
||||
if metadata.len() > 16 {
|
||||
return Err("metadata must include at most 16 entries".to_string());
|
||||
}
|
||||
|
||||
for (key, value) in &metadata {
|
||||
if key.chars().count() > 512 {
|
||||
return Err(format!("metadata key exceeds 512 characters: {key}"));
|
||||
}
|
||||
if value.chars().count() > 512 {
|
||||
return Err(format!(
|
||||
"metadata value exceeds 512 characters for key: {key}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -802,6 +802,7 @@ mod tests {
|
||||
source,
|
||||
git_info: None,
|
||||
name: None,
|
||||
metadata: std::collections::BTreeMap::new(),
|
||||
turns: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
personality: None,
|
||||
metadata: None,
|
||||
ephemeral: None,
|
||||
dynamic_tools: None,
|
||||
mock_experimental_field: None,
|
||||
|
||||
@@ -29,6 +29,7 @@ use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
@@ -75,9 +76,12 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let metadata = BTreeMap::from([("origin".to_string(), "sidebar".to_string())]);
|
||||
|
||||
let fork_id = mcp
|
||||
.send_thread_fork_request(ThreadForkParams {
|
||||
thread_id: conversation_id.clone(),
|
||||
metadata: Some(metadata.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -99,6 +103,11 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
Some(&Value::Null),
|
||||
"forked threads do not inherit a name; expected `name: null`"
|
||||
);
|
||||
assert_eq!(
|
||||
thread_json.get("metadata"),
|
||||
Some(&json!({ "origin": "sidebar" })),
|
||||
"forked threads should serialize client metadata"
|
||||
);
|
||||
|
||||
let after_contents = std::fs::read_to_string(&original_path)?;
|
||||
assert_eq!(
|
||||
@@ -110,6 +119,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
assert_eq!(thread.preview, preview);
|
||||
assert_eq!(thread.model_provider, "mock_provider");
|
||||
assert_eq!(thread.status, ThreadStatus::Idle);
|
||||
assert_eq!(thread.metadata, metadata);
|
||||
let thread_path = thread.path.clone().expect("thread path");
|
||||
assert!(thread_path.is_absolute());
|
||||
assert_ne!(thread_path, original_path);
|
||||
@@ -170,6 +180,11 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
Some(&Value::Null),
|
||||
"thread/started must serialize `name: null` when unset"
|
||||
);
|
||||
assert_eq!(
|
||||
started_thread_json.get("metadata"),
|
||||
Some(&json!({ "origin": "sidebar" })),
|
||||
"thread/started must serialize client metadata for forks"
|
||||
);
|
||||
let started: ThreadStartedNotification =
|
||||
serde_json::from_value(notif.params.expect("params must be present"))?;
|
||||
assert_eq!(started.thread, thread);
|
||||
|
||||
@@ -3,6 +3,7 @@ use app_test_support::McpProcess;
|
||||
use app_test_support::create_fake_rollout;
|
||||
use app_test_support::create_fake_rollout_with_source;
|
||||
use app_test_support::create_final_assistant_message_sse_response;
|
||||
use app_test_support::create_mock_responses_server_repeating_assistant;
|
||||
use app_test_support::create_mock_responses_server_sequence;
|
||||
use app_test_support::rollout_path;
|
||||
use app_test_support::to_response;
|
||||
@@ -33,6 +34,7 @@ use codex_protocol::protocol::SubAgentSource;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::fs::FileTimes;
|
||||
use std::fs::OpenOptions;
|
||||
@@ -311,6 +313,36 @@ stream_max_retries = 0
|
||||
)
|
||||
}
|
||||
|
||||
fn create_runtime_sqlite_config(
|
||||
codex_home: &std::path::Path,
|
||||
server_uri: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
suppress_unstable_features_warning = true
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[features]
|
||||
sqlite = true
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -591,6 +623,74 @@ sqlite = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_returns_persisted_metadata() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_runtime_sqlite_config(codex_home.path(), &server.uri())?;
|
||||
|
||||
let state_db =
|
||||
codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into())
|
||||
.await?;
|
||||
state_db.mark_backfill_complete(None).await?;
|
||||
|
||||
let mut mcp = init_mcp(codex_home.path()).await?;
|
||||
let metadata = BTreeMap::from([("surface".to_string(), "picker".to_string())]);
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
metadata: Some(metadata.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
let turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "materialize thread".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let ThreadListResponse { data, .. } = list_threads(
|
||||
&mut mcp,
|
||||
None,
|
||||
Some(10),
|
||||
Some(vec!["mock_provider".to_string()]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let listed = data
|
||||
.into_iter()
|
||||
.find(|listed_thread| listed_thread.id == thread.id)
|
||||
.expect("thread/list should include the started thread");
|
||||
|
||||
assert_eq!(listed.metadata, metadata);
|
||||
assert_eq!(listed.status, ThreadStatus::Idle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -26,6 +26,7 @@ use codex_protocol::protocol::GitInfo as RolloutGitInfo;
|
||||
use codex_state::StateRuntime;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -59,6 +60,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread()
|
||||
let update_id = mcp
|
||||
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
|
||||
thread_id: thread.id.clone(),
|
||||
metadata: None,
|
||||
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha: None,
|
||||
branch: Some(Some("feature/sidebar-pr".to_string())),
|
||||
@@ -124,6 +126,75 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_metadata_update_replaces_metadata_and_returns_updated_thread() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let initial_metadata = BTreeMap::from([
|
||||
("client".to_string(), "desktop".to_string()),
|
||||
("surface".to_string(), "composer".to_string()),
|
||||
]);
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
metadata: Some(initial_metadata),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
let updated_metadata = BTreeMap::from([("client".to_string(), "vscode".to_string())]);
|
||||
let update_id = mcp
|
||||
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
|
||||
thread_id: thread.id.clone(),
|
||||
metadata: Some(updated_metadata.clone()),
|
||||
git_info: None,
|
||||
})
|
||||
.await?;
|
||||
let update_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(update_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadMetadataUpdateResponse { thread: updated } =
|
||||
to_response::<ThreadMetadataUpdateResponse>(update_resp)?;
|
||||
|
||||
assert_eq!(updated.id, thread.id);
|
||||
assert_eq!(updated.metadata, updated_metadata);
|
||||
assert_eq!(updated.git_info, None);
|
||||
|
||||
let read_id = mcp
|
||||
.send_thread_read_request(ThreadReadParams {
|
||||
thread_id: thread.id,
|
||||
include_turns: false,
|
||||
})
|
||||
.await?;
|
||||
let read_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread: read } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
|
||||
assert_eq!(
|
||||
read.metadata,
|
||||
BTreeMap::from([("client".to_string(), "vscode".to_string())])
|
||||
);
|
||||
assert_eq!(read.git_info, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -149,6 +220,7 @@ async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> {
|
||||
let update_id = mcp
|
||||
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
|
||||
thread_id: thread.id,
|
||||
metadata: None,
|
||||
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha: None,
|
||||
branch: None,
|
||||
@@ -193,6 +265,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() -
|
||||
let update_id = mcp
|
||||
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
|
||||
thread_id: thread_id.clone(),
|
||||
metadata: None,
|
||||
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha: None,
|
||||
branch: Some(Some("feature/stored-thread".to_string())),
|
||||
@@ -273,6 +346,7 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary(
|
||||
let update_id = mcp
|
||||
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
|
||||
thread_id: thread_id.clone(),
|
||||
metadata: None,
|
||||
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha: None,
|
||||
branch: Some(Some("feature/loaded-thread".to_string())),
|
||||
@@ -336,6 +410,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread()
|
||||
let update_id = mcp
|
||||
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
|
||||
thread_id: thread_id.clone(),
|
||||
metadata: None,
|
||||
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha: None,
|
||||
branch: Some(Some("feature/archived-thread".to_string())),
|
||||
@@ -392,6 +467,7 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> {
|
||||
let update_id = mcp
|
||||
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
|
||||
thread_id: thread_id.clone(),
|
||||
metadata: None,
|
||||
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha: Some(None),
|
||||
branch: Some(None),
|
||||
|
||||
@@ -29,6 +29,7 @@ use codex_protocol::user_input::TextElement;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
@@ -201,6 +202,50 @@ async fn thread_read_loaded_thread_returns_precomputed_path_before_materializati
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_read_returns_persisted_metadata() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_sqlite_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let metadata = BTreeMap::from([("client".to_string(), "desktop".to_string())]);
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
metadata: Some(metadata.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
let read_id = mcp
|
||||
.send_thread_read_request(ThreadReadParams {
|
||||
thread_id: thread.id.clone(),
|
||||
include_turns: false,
|
||||
})
|
||||
.await?;
|
||||
let read_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread: read } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
|
||||
assert_eq!(read.id, thread.id);
|
||||
assert_eq!(read.metadata, metadata);
|
||||
assert_eq!(read.status, ThreadStatus::Idle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -497,3 +542,30 @@ stream_max_retries = 0
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_sqlite_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
suppress_unstable_features_warning = true
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[features]
|
||||
sqlite = true
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -376,6 +376,7 @@ stream_max_retries = 0
|
||||
let update_id = mcp
|
||||
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
|
||||
thread_id: thread_id.clone(),
|
||||
metadata: None,
|
||||
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha: None,
|
||||
branch: Some(Some("feature/pr-branch".to_string())),
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
@@ -48,10 +49,13 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let metadata = BTreeMap::from([("client".to_string(), "vscode".to_string())]);
|
||||
|
||||
// Start a v2 thread with an explicit model override.
|
||||
let req_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("gpt-5.1".to_string()),
|
||||
metadata: Some(metadata.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -83,6 +87,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
|
||||
"new persistent threads should not be ephemeral"
|
||||
);
|
||||
assert_eq!(thread.status, ThreadStatus::Idle);
|
||||
assert_eq!(thread.metadata, metadata);
|
||||
let thread_path = thread.path.clone().expect("thread path should be present");
|
||||
assert!(thread_path.is_absolute(), "thread path should be absolute");
|
||||
assert!(
|
||||
@@ -105,6 +110,11 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
|
||||
Some(false),
|
||||
"new persistent threads should serialize `ephemeral: false`"
|
||||
);
|
||||
assert_eq!(
|
||||
thread_json.get("metadata"),
|
||||
Some(&json!({ "client": "vscode" })),
|
||||
"new threads should serialize client metadata"
|
||||
);
|
||||
assert_eq!(thread.name, None);
|
||||
|
||||
// A corresponding thread/started notification should arrive.
|
||||
@@ -146,6 +156,11 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
|
||||
Some(false),
|
||||
"thread/started should serialize `ephemeral: false` for new persistent threads"
|
||||
);
|
||||
assert_eq!(
|
||||
started_thread_json.get("metadata"),
|
||||
Some(&json!({ "client": "vscode" })),
|
||||
"thread/started should serialize client metadata"
|
||||
);
|
||||
let started: ThreadStartedNotification =
|
||||
serde_json::from_value(notif.params.expect("params must be present"))?;
|
||||
assert_eq!(started.thread, thread);
|
||||
@@ -295,6 +310,39 @@ async fn thread_start_ephemeral_remains_pathless() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_rejects_metadata_over_limit() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let metadata = (0..17)
|
||||
.map(|idx| (format!("key-{idx}"), format!("value-{idx}")))
|
||||
.collect();
|
||||
let req_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
metadata: Some(metadata),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(req_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(
|
||||
err.error.message,
|
||||
"metadata must include at most 16 entries"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_fails_when_required_mcp_server_fails_to_initialize() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
|
||||
@@ -40,6 +40,7 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe
|
||||
git_sha: None,
|
||||
git_branch: Some("main".to_string()),
|
||||
git_origin_url: None,
|
||||
metadata_json: "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1837,6 +1837,7 @@ mod tests {
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: None,
|
||||
metadata: std::collections::BTreeMap::new(),
|
||||
turns: vec![
|
||||
codex_app_server_protocol::Turn {
|
||||
id: "turn-1".to_string(),
|
||||
@@ -1952,6 +1953,7 @@ mod tests {
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: Some("thread".to_string()),
|
||||
metadata: std::collections::BTreeMap::new(),
|
||||
turns: vec![],
|
||||
},
|
||||
model: "gpt-5.4".to_string(),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE threads ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}';
|
||||
@@ -422,6 +422,7 @@ mod tests {
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
metadata_json: "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ pub struct ThreadMetadata {
|
||||
pub git_branch: Option<String>,
|
||||
/// The git origin URL, if known.
|
||||
pub git_origin_url: Option<String>,
|
||||
/// Arbitrary client-defined thread metadata stored as canonical JSON.
|
||||
pub metadata_json: String,
|
||||
}
|
||||
|
||||
/// Builder data required to construct [`ThreadMetadata`] without parsing filenames.
|
||||
@@ -138,6 +140,8 @@ pub struct ThreadMetadataBuilder {
|
||||
pub git_branch: Option<String>,
|
||||
/// The git origin URL, if known.
|
||||
pub git_origin_url: Option<String>,
|
||||
/// Arbitrary client-defined thread metadata stored as canonical JSON.
|
||||
pub metadata_json: String,
|
||||
}
|
||||
|
||||
impl ThreadMetadataBuilder {
|
||||
@@ -166,6 +170,7 @@ impl ThreadMetadataBuilder {
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
metadata_json: "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +213,7 @@ impl ThreadMetadataBuilder {
|
||||
git_sha: self.git_sha.clone(),
|
||||
git_branch: self.git_branch.clone(),
|
||||
git_origin_url: self.git_origin_url.clone(),
|
||||
metadata_json: self.metadata_json.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,6 +301,9 @@ impl ThreadMetadata {
|
||||
if self.git_origin_url != other.git_origin_url {
|
||||
diffs.push("git_origin_url");
|
||||
}
|
||||
if self.metadata_json != other.metadata_json {
|
||||
diffs.push("metadata_json");
|
||||
}
|
||||
diffs
|
||||
}
|
||||
}
|
||||
@@ -327,6 +336,7 @@ pub(crate) struct ThreadRow {
|
||||
git_sha: Option<String>,
|
||||
git_branch: Option<String>,
|
||||
git_origin_url: Option<String>,
|
||||
metadata_json: String,
|
||||
}
|
||||
|
||||
impl ThreadRow {
|
||||
@@ -354,6 +364,7 @@ impl ThreadRow {
|
||||
git_sha: row.try_get("git_sha")?,
|
||||
git_branch: row.try_get("git_branch")?,
|
||||
git_origin_url: row.try_get("git_origin_url")?,
|
||||
metadata_json: row.try_get("metadata_json")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -385,6 +396,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
|
||||
git_sha,
|
||||
git_branch,
|
||||
git_origin_url,
|
||||
metadata_json,
|
||||
} = row;
|
||||
Ok(Self {
|
||||
id: ThreadId::try_from(id)?,
|
||||
@@ -410,6 +422,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
|
||||
git_sha,
|
||||
git_branch,
|
||||
git_origin_url,
|
||||
metadata_json,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -478,6 +491,7 @@ mod tests {
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
metadata_json: "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +520,7 @@ mod tests {
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
metadata_json: "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,8 @@ SELECT
|
||||
archived_at,
|
||||
git_sha,
|
||||
git_branch,
|
||||
git_origin_url
|
||||
git_origin_url,
|
||||
metadata_json
|
||||
FROM threads
|
||||
LEFT JOIN stage1_outputs
|
||||
ON stage1_outputs.thread_id = threads.id
|
||||
|
||||
@@ -65,5 +65,6 @@ pub(super) fn test_thread_metadata(
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
metadata_json: "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ SELECT
|
||||
archived_at,
|
||||
git_sha,
|
||||
git_branch,
|
||||
git_origin_url
|
||||
git_origin_url,
|
||||
metadata_json
|
||||
FROM threads
|
||||
WHERE id = ?
|
||||
"#,
|
||||
@@ -364,7 +365,8 @@ SELECT
|
||||
archived_at,
|
||||
git_sha,
|
||||
git_branch,
|
||||
git_origin_url
|
||||
git_origin_url,
|
||||
metadata_json
|
||||
FROM threads
|
||||
"#,
|
||||
);
|
||||
@@ -467,8 +469,9 @@ INSERT INTO threads (
|
||||
git_sha,
|
||||
git_branch,
|
||||
git_origin_url,
|
||||
metadata_json,
|
||||
memory_mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
@@ -500,6 +503,7 @@ ON CONFLICT(id) DO NOTHING
|
||||
.bind(metadata.git_sha.as_deref())
|
||||
.bind(metadata.git_branch.as_deref())
|
||||
.bind(metadata.git_origin_url.as_deref())
|
||||
.bind(metadata.metadata_json.as_str())
|
||||
.bind("enabled")
|
||||
.execute(self.pool.as_ref())
|
||||
.await?;
|
||||
@@ -563,6 +567,19 @@ WHERE id = ?
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn update_thread_metadata_json(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
metadata_json: &str,
|
||||
) -> anyhow::Result<bool> {
|
||||
let result = sqlx::query("UPDATE threads SET metadata_json = ? WHERE id = ?")
|
||||
.bind(metadata_json)
|
||||
.bind(thread_id.to_string())
|
||||
.execute(self.pool.as_ref())
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn upsert_thread_with_creation_memory_mode(
|
||||
&self,
|
||||
metadata: &crate::ThreadMetadata,
|
||||
@@ -594,8 +611,9 @@ INSERT INTO threads (
|
||||
git_sha,
|
||||
git_branch,
|
||||
git_origin_url,
|
||||
metadata_json,
|
||||
memory_mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
rollout_path = excluded.rollout_path,
|
||||
created_at = excluded.created_at,
|
||||
@@ -618,7 +636,8 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
archived_at = excluded.archived_at,
|
||||
git_sha = excluded.git_sha,
|
||||
git_branch = excluded.git_branch,
|
||||
git_origin_url = excluded.git_origin_url
|
||||
git_origin_url = excluded.git_origin_url,
|
||||
metadata_json = excluded.metadata_json
|
||||
"#,
|
||||
)
|
||||
.bind(metadata.id.to_string())
|
||||
@@ -649,6 +668,7 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
.bind(metadata.git_sha.as_deref())
|
||||
.bind(metadata.git_branch.as_deref())
|
||||
.bind(metadata.git_origin_url.as_deref())
|
||||
.bind(metadata.metadata_json.as_str())
|
||||
.bind(creation_memory_mode.unwrap_or("enabled"))
|
||||
.execute(self.pool.as_ref())
|
||||
.await?;
|
||||
|
||||
@@ -7418,6 +7418,7 @@ guardian_approval = true
|
||||
agent_role: Some("explorer".to_string()),
|
||||
git_info: None,
|
||||
name: Some("agent thread".to_string()),
|
||||
metadata: std::collections::BTreeMap::new(),
|
||||
turns: Vec::new(),
|
||||
},
|
||||
}),
|
||||
@@ -7494,6 +7495,7 @@ guardian_approval = true
|
||||
agent_role: Some("explorer".to_string()),
|
||||
git_info: None,
|
||||
name: Some("agent thread".to_string()),
|
||||
metadata: std::collections::BTreeMap::new(),
|
||||
turns: Vec::new(),
|
||||
},
|
||||
}),
|
||||
@@ -9080,6 +9082,7 @@ guardian_approval = true
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: None,
|
||||
metadata: std::collections::BTreeMap::new(),
|
||||
turns: Vec::new(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1262,6 +1262,7 @@ mod tests {
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: None,
|
||||
metadata: std::collections::BTreeMap::new(),
|
||||
turns: vec![Turn {
|
||||
id: "turn-1".to_string(),
|
||||
items: vec![ThreadItem::CommandExecution {
|
||||
@@ -1428,6 +1429,7 @@ mod tests {
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: Some("restore".to_string()),
|
||||
metadata: std::collections::BTreeMap::new(),
|
||||
turns: vec![
|
||||
Turn {
|
||||
id: "turn-complete".to_string(),
|
||||
|
||||
@@ -1157,6 +1157,7 @@ mod tests {
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: None,
|
||||
metadata: std::collections::BTreeMap::new(),
|
||||
turns: vec![Turn {
|
||||
id: "turn-1".to_string(),
|
||||
items: vec![
|
||||
|
||||
@@ -2574,6 +2574,7 @@ mod tests {
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: Some(String::from("Named thread")),
|
||||
metadata: std::collections::BTreeMap::new(),
|
||||
turns: Vec::new(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user