Add app-server APIs for stored thread attachments (#44564)

## What changed

- Add `thread/attachment/add`, `thread/attachment/list`, and `thread/attachment/remove` to manage durable resource references without loading the owning thread or changing conversation history.
- Identify attachments by thread, `attachmentType`, and `identityKey`. Repeated adds return the existing attachment; repeated removals succeed without emitting another update. Listing supports cursor pagination.
- Broadcast `thread/attachment/updated` after creation or deletion, after responding to the requester. Serialize attachment mutations with thread lifecycle operations and reject stores that do not support attachments.
- Update protocol schemas, TypeScript and Python bindings, documentation, and TUI notification handling so attachment updates do not add untracked threads to the agent picker.

## Testing

Add coverage for unloaded threads, pagination, idempotent addition and removal, reattachment, invalid inputs, unsupported stores, response-before-notification ordering across clients, and TUI notification routing.

GitOrigin-RevId: 0d29cf903afd4e931a70a9cf96300475213b34aa
This commit is contained in:
joeytrasatti-openai
2026-09-10 15:52:09 +00:00
committed by copyberry
parent 94697375cb
commit 3319d9b296
47 changed files with 2341 additions and 6 deletions
@@ -4119,6 +4119,74 @@
],
"type": "object"
},
"ThreadAttachmentAddParams": {
"description": "Parameters for creating or locating an attachment on its owning thread.",
"properties": {
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"payload": true,
"threadId": {
"type": "string"
}
},
"required": [
"attachmentType",
"identityKey",
"payload",
"threadId"
],
"type": "object"
},
"ThreadAttachmentListParams": {
"description": "Parameters for listing attachments from one thread.",
"properties": {
"cursor": {
"type": [
"string",
"null"
]
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadAttachmentRemoveParams": {
"description": "Parameters for deleting an attachment by its stable thread-local identity.",
"properties": {
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"attachmentType",
"identityKey",
"threadId"
],
"type": "object"
},
"ThreadCompactStartParams": {
"properties": {
"threadId": {
@@ -6055,6 +6123,78 @@
"title": "Thread/metadata/updateRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/attachment/add"
],
"title": "Thread/attachment/addRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAttachmentAddParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachment/addRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/attachment/list"
],
"title": "Thread/attachment/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAttachmentListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachment/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/attachment/remove"
],
"title": "Thread/attachment/removeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAttachmentRemoveParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachment/removeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4323,6 +4323,42 @@
],
"type": "object"
},
"ThreadAttachmentOperation": {
"description": "The persisted attachment change represented by a notification.",
"enum": [
"created",
"deleted"
],
"type": "string"
},
"ThreadAttachmentUpdatedNotification": {
"description": "Notification published after a thread attachment is created or deleted.",
"properties": {
"attachmentId": {
"type": "string"
},
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"operation": {
"$ref": "#/definitions/ThreadAttachmentOperation"
},
"threadId": {
"type": "string"
}
},
"required": [
"attachmentId",
"attachmentType",
"identityKey",
"operation",
"threadId"
],
"type": "object"
},
"ThreadClosedNotification": {
"properties": {
"threadId": {
@@ -6942,6 +6978,26 @@
"title": "Thread/name/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/attachment/updated"
],
"title": "Thread/attachment/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAttachmentUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/attachment/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -492,6 +492,78 @@
"title": "Thread/metadata/updateRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/attachment/add"
],
"title": "Thread/attachment/addRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadAttachmentAddParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachment/addRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/attachment/list"
],
"title": "Thread/attachment/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadAttachmentListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachment/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/attachment/remove"
],
"title": "Thread/attachment/removeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadAttachmentRemoveParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachment/removeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4678,6 +4750,26 @@
"title": "Thread/name/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/attachment/updated"
],
"title": "Thread/attachment/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadAttachmentUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/attachment/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -20553,6 +20645,200 @@
"title": "ThreadArchivedNotification",
"type": "object"
},
"ThreadAttachment": {
"description": "An independently persisted attachment associated with a thread.",
"properties": {
"attachmentType": {
"type": "string"
},
"createdAt": {
"format": "int64",
"type": "integer"
},
"id": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"payload": true
},
"required": [
"attachmentType",
"createdAt",
"id",
"identityKey",
"payload"
],
"type": "object"
},
"ThreadAttachmentAddOutcome": {
"description": "Result of attempting to associate an attachment with a thread.",
"enum": [
"created",
"existing"
],
"type": "string"
},
"ThreadAttachmentAddParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for creating or locating an attachment on its owning thread.",
"properties": {
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"payload": true,
"threadId": {
"type": "string"
}
},
"required": [
"attachmentType",
"identityKey",
"payload",
"threadId"
],
"title": "ThreadAttachmentAddParams",
"type": "object"
},
"ThreadAttachmentAddResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "The created or existing attachment.",
"properties": {
"attachment": {
"$ref": "#/definitions/v2/ThreadAttachment"
},
"outcome": {
"$ref": "#/definitions/v2/ThreadAttachmentAddOutcome"
}
},
"required": [
"attachment",
"outcome"
],
"title": "ThreadAttachmentAddResponse",
"type": "object"
},
"ThreadAttachmentListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for listing attachments from one thread.",
"properties": {
"cursor": {
"type": [
"string",
"null"
]
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadAttachmentListParams",
"type": "object"
},
"ThreadAttachmentListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "One page of attachments associated with the requested thread.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/ThreadAttachment"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ThreadAttachmentListResponse",
"type": "object"
},
"ThreadAttachmentOperation": {
"description": "The persisted attachment change represented by a notification.",
"enum": [
"created",
"deleted"
],
"type": "string"
},
"ThreadAttachmentRemoveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for deleting an attachment by its stable thread-local identity.",
"properties": {
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"attachmentType",
"identityKey",
"threadId"
],
"title": "ThreadAttachmentRemoveParams",
"type": "object"
},
"ThreadAttachmentRemoveResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Successful deletion does not return additional attachment data.",
"title": "ThreadAttachmentRemoveResponse",
"type": "object"
},
"ThreadAttachmentUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Notification published after a thread attachment is created or deleted.",
"properties": {
"attachmentId": {
"type": "string"
},
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"operation": {
"$ref": "#/definitions/v2/ThreadAttachmentOperation"
},
"threadId": {
"type": "string"
}
},
"required": [
"attachmentId",
"attachmentType",
"identityKey",
"operation",
"threadId"
],
"title": "ThreadAttachmentUpdatedNotification",
"type": "object"
},
"ThreadClosedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -2022,6 +2022,78 @@
"title": "Thread/metadata/updateRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/attachment/add"
],
"title": "Thread/attachment/addRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAttachmentAddParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachment/addRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/attachment/list"
],
"title": "Thread/attachment/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAttachmentListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachment/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/attachment/remove"
],
"title": "Thread/attachment/removeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAttachmentRemoveParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachment/removeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -15819,6 +15891,26 @@
"title": "Thread/name/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/attachment/updated"
],
"title": "Thread/attachment/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAttachmentUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/attachment/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -18267,6 +18359,200 @@
"title": "ThreadArchivedNotification",
"type": "object"
},
"ThreadAttachment": {
"description": "An independently persisted attachment associated with a thread.",
"properties": {
"attachmentType": {
"type": "string"
},
"createdAt": {
"format": "int64",
"type": "integer"
},
"id": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"payload": true
},
"required": [
"attachmentType",
"createdAt",
"id",
"identityKey",
"payload"
],
"type": "object"
},
"ThreadAttachmentAddOutcome": {
"description": "Result of attempting to associate an attachment with a thread.",
"enum": [
"created",
"existing"
],
"type": "string"
},
"ThreadAttachmentAddParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for creating or locating an attachment on its owning thread.",
"properties": {
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"payload": true,
"threadId": {
"type": "string"
}
},
"required": [
"attachmentType",
"identityKey",
"payload",
"threadId"
],
"title": "ThreadAttachmentAddParams",
"type": "object"
},
"ThreadAttachmentAddResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "The created or existing attachment.",
"properties": {
"attachment": {
"$ref": "#/definitions/ThreadAttachment"
},
"outcome": {
"$ref": "#/definitions/ThreadAttachmentAddOutcome"
}
},
"required": [
"attachment",
"outcome"
],
"title": "ThreadAttachmentAddResponse",
"type": "object"
},
"ThreadAttachmentListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for listing attachments from one thread.",
"properties": {
"cursor": {
"type": [
"string",
"null"
]
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadAttachmentListParams",
"type": "object"
},
"ThreadAttachmentListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "One page of attachments associated with the requested thread.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/ThreadAttachment"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ThreadAttachmentListResponse",
"type": "object"
},
"ThreadAttachmentOperation": {
"description": "The persisted attachment change represented by a notification.",
"enum": [
"created",
"deleted"
],
"type": "string"
},
"ThreadAttachmentRemoveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for deleting an attachment by its stable thread-local identity.",
"properties": {
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"attachmentType",
"identityKey",
"threadId"
],
"title": "ThreadAttachmentRemoveParams",
"type": "object"
},
"ThreadAttachmentRemoveResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Successful deletion does not return additional attachment data.",
"title": "ThreadAttachmentRemoveResponse",
"type": "object"
},
"ThreadAttachmentUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Notification published after a thread attachment is created or deleted.",
"properties": {
"attachmentId": {
"type": "string"
},
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"operation": {
"$ref": "#/definitions/ThreadAttachmentOperation"
},
"threadId": {
"type": "string"
}
},
"required": [
"attachmentId",
"attachmentType",
"identityKey",
"operation",
"threadId"
],
"title": "ThreadAttachmentUpdatedNotification",
"type": "object"
},
"ThreadClosedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -0,0 +1,24 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for creating or locating an attachment on its owning thread.",
"properties": {
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"payload": true,
"threadId": {
"type": "string"
}
},
"required": [
"attachmentType",
"identityKey",
"payload",
"threadId"
],
"title": "ThreadAttachmentAddParams",
"type": "object"
}
@@ -0,0 +1,55 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ThreadAttachment": {
"description": "An independently persisted attachment associated with a thread.",
"properties": {
"attachmentType": {
"type": "string"
},
"createdAt": {
"format": "int64",
"type": "integer"
},
"id": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"payload": true
},
"required": [
"attachmentType",
"createdAt",
"id",
"identityKey",
"payload"
],
"type": "object"
},
"ThreadAttachmentAddOutcome": {
"description": "Result of attempting to associate an attachment with a thread.",
"enum": [
"created",
"existing"
],
"type": "string"
}
},
"description": "The created or existing attachment.",
"properties": {
"attachment": {
"$ref": "#/definitions/ThreadAttachment"
},
"outcome": {
"$ref": "#/definitions/ThreadAttachmentAddOutcome"
}
},
"required": [
"attachment",
"outcome"
],
"title": "ThreadAttachmentAddResponse",
"type": "object"
}
@@ -0,0 +1,28 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for listing attachments from one thread.",
"properties": {
"cursor": {
"type": [
"string",
"null"
]
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadAttachmentListParams",
"type": "object"
}
@@ -0,0 +1,52 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ThreadAttachment": {
"description": "An independently persisted attachment associated with a thread.",
"properties": {
"attachmentType": {
"type": "string"
},
"createdAt": {
"format": "int64",
"type": "integer"
},
"id": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"payload": true
},
"required": [
"attachmentType",
"createdAt",
"id",
"identityKey",
"payload"
],
"type": "object"
}
},
"description": "One page of attachments associated with the requested thread.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/ThreadAttachment"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ThreadAttachmentListResponse",
"type": "object"
}
@@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for deleting an attachment by its stable thread-local identity.",
"properties": {
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"attachmentType",
"identityKey",
"threadId"
],
"title": "ThreadAttachmentRemoveParams",
"type": "object"
}
@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Successful deletion does not return additional attachment data.",
"title": "ThreadAttachmentRemoveResponse",
"type": "object"
}
@@ -0,0 +1,40 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ThreadAttachmentOperation": {
"description": "The persisted attachment change represented by a notification.",
"enum": [
"created",
"deleted"
],
"type": "string"
}
},
"description": "Notification published after a thread attachment is created or deleted.",
"properties": {
"attachmentId": {
"type": "string"
},
"attachmentType": {
"type": "string"
},
"identityKey": {
"type": "string"
},
"operation": {
"$ref": "#/definitions/ThreadAttachmentOperation"
},
"threadId": {
"type": "string"
}
},
"required": [
"attachmentId",
"attachmentType",
"identityKey",
"operation",
"threadId"
],
"title": "ThreadAttachmentUpdatedNotification",
"type": "object"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 { JsonValue } from "../serde_json/JsonValue";
/**
* An independently persisted attachment associated with a thread.
*/
export type ThreadAttachment = { id: string, attachmentType: string, identityKey: string, payload: JsonValue, createdAt: number, };
@@ -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.
/**
* Result of attempting to associate an attachment with a thread.
*/
export type ThreadAttachmentAddOutcome = "created" | "existing";
@@ -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 { JsonValue } from "../serde_json/JsonValue";
/**
* Parameters for creating or locating an attachment on its owning thread.
*/
export type ThreadAttachmentAddParams = { threadId: string, attachmentType: string, identityKey: string, payload: JsonValue, };
@@ -0,0 +1,10 @@
// 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 { ThreadAttachment } from "./ThreadAttachment";
import type { ThreadAttachmentAddOutcome } from "./ThreadAttachmentAddOutcome";
/**
* The created or existing attachment.
*/
export type ThreadAttachmentAddResponse = { outcome: ThreadAttachmentAddOutcome, attachment: ThreadAttachment, };
@@ -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.
/**
* Parameters for listing attachments from one thread.
*/
export type ThreadAttachmentListParams = { threadId: string, cursor?: string | null, limit?: number | null, };
@@ -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 { ThreadAttachment } from "./ThreadAttachment";
/**
* One page of attachments associated with the requested thread.
*/
export type ThreadAttachmentListResponse = { data: Array<ThreadAttachment>, nextCursor: string | null, };
@@ -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.
/**
* The persisted attachment change represented by a notification.
*/
export type ThreadAttachmentOperation = "created" | "deleted";
@@ -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.
/**
* Parameters for deleting an attachment by its stable thread-local identity.
*/
export type ThreadAttachmentRemoveParams = { threadId: string, attachmentType: string, identityKey: 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 attachment data.
*/
export type ThreadAttachmentRemoveResponse = Record<string, never>;
@@ -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 { ThreadAttachmentOperation } from "./ThreadAttachmentOperation";
/**
* Notification published after a thread attachment is created or deleted.
*/
export type ThreadAttachmentUpdatedNotification = { threadId: string, attachmentType: string, identityKey: string, attachmentId: string, operation: ThreadAttachmentOperation, };
@@ -469,6 +469,16 @@ export type { ThreadApproveGuardianDeniedActionResponse } from "./ThreadApproveG
export type { ThreadArchiveParams } from "./ThreadArchiveParams";
export type { ThreadArchiveResponse } from "./ThreadArchiveResponse";
export type { ThreadArchivedNotification } from "./ThreadArchivedNotification";
export type { ThreadAttachment } from "./ThreadAttachment";
export type { ThreadAttachmentAddOutcome } from "./ThreadAttachmentAddOutcome";
export type { ThreadAttachmentAddParams } from "./ThreadAttachmentAddParams";
export type { ThreadAttachmentAddResponse } from "./ThreadAttachmentAddResponse";
export type { ThreadAttachmentListParams } from "./ThreadAttachmentListParams";
export type { ThreadAttachmentListResponse } from "./ThreadAttachmentListResponse";
export type { ThreadAttachmentOperation } from "./ThreadAttachmentOperation";
export type { ThreadAttachmentRemoveParams } from "./ThreadAttachmentRemoveParams";
export type { ThreadAttachmentRemoveResponse } from "./ThreadAttachmentRemoveResponse";
export type { ThreadAttachmentUpdatedNotification } from "./ThreadAttachmentUpdatedNotification";
export type { ThreadClosedNotification } from "./ThreadClosedNotification";
export type { ThreadCompactStartParams } from "./ThreadCompactStartParams";
export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse";
@@ -670,6 +670,21 @@ client_request_definitions! {
serialization: thread_id(params.thread_id),
response: v2::ThreadMetadataUpdateResponse,
},
ThreadAttachmentAdd => "thread/attachment/add" {
params: v2::ThreadAttachmentAddParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadAttachmentAddResponse,
},
ThreadAttachmentList => "thread/attachment/list" {
params: v2::ThreadAttachmentListParams,
serialization: None,
response: v2::ThreadAttachmentListResponse,
},
ThreadAttachmentRemove => "thread/attachment/remove" {
params: v2::ThreadAttachmentRemoveParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadAttachmentRemoveResponse,
},
ThreadSectionMove => "thread/section/move" {
params: v2::ThreadSectionMoveParams,
serialization: thread_id(params.thread_id),
@@ -1901,6 +1916,7 @@ server_notification_definitions! {
ThreadReverted => "thread/reverted" (v2::ThreadRevertedNotification),
SkillsChanged => "skills/changed" (v2::SkillsChangedNotification),
ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification),
ThreadAttachmentUpdated => "thread/attachment/updated" (v2::ThreadAttachmentUpdatedNotification),
ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification),
ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification),
#[experimental("thread/queue/changed")]
@@ -31,6 +31,7 @@ mod realtime;
mod remote_control;
mod review;
mod thread;
mod thread_attachment;
mod thread_data;
mod thread_usage;
mod turn;
@@ -69,6 +70,7 @@ pub use remote_control::*;
pub use review::*;
pub use shared::*;
pub use thread::*;
pub use thread_attachment::*;
pub use thread_data::*;
pub use thread_usage::*;
pub use turn::*;
@@ -0,0 +1,107 @@
//! Wire contracts for managing the current set of attachments on a thread.
use crate::JsonSchema;
use crate::TS;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
/// An independently persisted attachment associated with a thread.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachment {
pub id: String,
pub attachment_type: String,
pub identity_key: String,
pub payload: JsonValue,
#[ts(type = "number")]
pub created_at: i64,
}
/// Parameters for creating or locating an attachment on its owning thread.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentAddParams {
pub thread_id: String,
pub attachment_type: String,
pub identity_key: String,
pub payload: JsonValue,
}
/// Result of attempting to associate an attachment with a thread.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadAttachmentAddOutcome {
Created,
Existing,
}
/// The created or existing attachment.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentAddResponse {
pub outcome: ThreadAttachmentAddOutcome,
pub attachment: ThreadAttachment,
}
/// Parameters for listing attachments from one thread.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentListParams {
pub thread_id: String,
#[ts(optional = nullable)]
pub cursor: Option<String>,
#[ts(optional = nullable)]
pub limit: Option<u32>,
}
/// One page of attachments associated with the requested thread.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentListResponse {
pub data: Vec<ThreadAttachment>,
pub next_cursor: Option<String>,
}
/// Parameters for deleting an attachment by its stable thread-local identity.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentRemoveParams {
pub thread_id: String,
pub attachment_type: String,
pub identity_key: String,
}
/// Successful deletion does not return additional attachment data.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentRemoveResponse {}
/// The persisted attachment change represented by a notification.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadAttachmentOperation {
Created,
Deleted,
}
/// Notification published after a thread attachment is created or deleted.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentUpdatedNotification {
pub thread_id: String,
pub attachment_type: String,
pub identity_key: String,
pub attachment_id: String,
pub operation: ThreadAttachmentOperation,
}
+63
View File
@@ -50,3 +50,66 @@ If `model_providers.amazon-bedrock.aws.credential_export` is configured, Bedrock
Bedrock login return an error without changing configuration or saved credentials. Remove the
exporter configuration before selecting another credential source. `aws.credential_export` and
`aws.profile` cannot be configured together.
## Stored thread attachments
- `thread/attachment/add` — add a durable resource reference to a stored thread without loading it. Repeated writes with the same attachment type and identity key return the existing attachment.
- `thread/attachment/list` — list attachments for one stored thread in a cursor-paginated request, including a thread that is not loaded.
- `thread/attachment/remove` — remove an attachment by its thread, attachment type, and identity key; returns `{}`.
- `thread/attachment/updated` — notification broadcast after an attachment is created or removed; contains the thread, attachment identity, attachment id, and operation.
### Example: Manage stored thread attachments
Attachments record the resources currently associated with a thread, independently of conversation history. Clients can add, remove, and list attachments for one stored thread at a time without resuming those threads. Adding or removing an attachment does not create or delete the underlying resource or rewrite history. An attachment is idempotently identified by its thread, `attachmentType`, and `identityKey`. For pull requests, clients should reuse the canonical application identity `JSON.stringify([canonicalHostname, lowercaseOwner, lowercaseRepository, pullRequestNumber])` so addition and removal agree across surfaces.
```json
{ "method": "thread/attachment/add", "id": 20, "params": {
"threadId": "thr_123",
"attachmentType": "pull_request",
"identityKey": "[\"github.com\",\"openai\",\"codex\",123]",
"payload": { "url": "https://github.com/openai/codex/pull/123" }
} }
{ "id": 20, "result": {
"outcome": "created",
"attachment": {
"id": "01984de2-8f74-7c91-a3b2-5c5e937cf318",
"attachmentType": "pull_request",
"identityKey": "[\"github.com\",\"openai\",\"codex\",123]",
"payload": { "url": "https://github.com/openai/codex/pull/123" },
"createdAt": 1750000000
}
} }
{ "method": "thread/attachment/list", "id": 21, "params": {
"threadId": "thr_123",
"limit": 100
} }
{ "id": 21, "result": {
"data": [{
"id": "01984de2-8f74-7c91-a3b2-5c5e937cf318",
"attachmentType": "pull_request",
"identityKey": "[\"github.com\",\"openai\",\"codex\",123]",
"payload": { "url": "https://github.com/openai/codex/pull/123" },
"createdAt": 1750000000
}],
"nextCursor": null
} }
{ "method": "thread/attachment/remove", "id": 22, "params": {
"threadId": "thr_123",
"attachmentType": "pull_request",
"identityKey": "[\"github.com\",\"openai\",\"codex\",123]"
} }
{ "id": 22, "result": {} }
{ "method": "thread/attachment/updated", "params": {
"threadId": "thr_123",
"attachmentType": "pull_request",
"identityKey": "[\"github.com\",\"openai\",\"codex\",123]",
"attachmentId": "01984de2-8f74-7c91-a3b2-5c5e937cf318",
"operation": "deleted"
} }
```
`thread/attachment/list` accepts one `threadId` and returns at most 100 attachments per page, ordered by creation time and attachment id. Continue with `nextCursor` and the same `threadId` until the cursor is `null`. Each thread can retain up to 100 attachments. Removing an attachment frees a slot for a new attachment.
Attachment creation and deletion requests using the same thread ID are serialized across connections. The requesting client receives its response before the compact update is broadcast, and duplicate creates or absent deletes do not emit updates. Deleting the owning thread removes its attachments under the same lifecycle exclusion; queued attachment mutations then report that the thread was not found.
+13 -1
View File
@@ -114,6 +114,7 @@ fn server_notification_requires_delivery(notification: &ServerNotification) -> b
ServerNotification::TurnCompleted(_)
| ServerNotification::ThreadQueueChanged(_)
| ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ThreadAttachmentUpdated(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::ItemCompleted(ItemCompletedNotification {
item: ThreadItem::AgentMessage {
@@ -803,6 +804,8 @@ mod tests {
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::SessionSource as ApiSessionSource;
use codex_app_server_protocol::ThreadAttachmentOperation;
use codex_app_server_protocol::ThreadAttachmentUpdatedNotification;
use codex_app_server_protocol::ThreadQueueChangedNotification;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
@@ -1004,7 +1007,7 @@ mod tests {
}
#[test]
fn guaranteed_delivery_helpers_cover_terminal_server_notifications() {
fn guaranteed_delivery_helpers_cover_required_server_notifications() {
assert!(server_notification_requires_delivery(
&ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: "thread-1".to_string(),
@@ -1033,6 +1036,15 @@ mod tests {
},
)
));
assert!(server_notification_requires_delivery(
&ServerNotification::ThreadAttachmentUpdated(ThreadAttachmentUpdatedNotification {
thread_id: "thread-1".to_string(),
attachment_type: "pull_request".to_string(),
identity_key: r#"["github.com","openai","codex",123]"#.to_string(),
attachment_id: "attachment-1".to_string(),
operation: ThreadAttachmentOperation::Deleted,
})
));
assert!(server_notification_requires_delivery(
&ServerNotification::ItemCompleted(ItemCompletedNotification {
item: ThreadItem::AgentMessage {
@@ -1384,6 +1384,19 @@ impl MessageProcessor {
ClientRequest::ThreadMetadataUpdate { params, .. } => {
self.thread_processor.thread_metadata_update(params).await
}
ClientRequest::ThreadAttachmentAdd { params, .. } => {
self.thread_processor
.thread_attachment_add(request_id.clone(), params)
.await
}
ClientRequest::ThreadAttachmentList { params, .. } => {
self.thread_processor.thread_attachment_list(params).await
}
ClientRequest::ThreadAttachmentRemove { params, .. } => {
self.thread_processor
.thread_attachment_remove(request_id.clone(), params)
.await
}
ClientRequest::ThreadSectionMove { params, .. } => {
self.thread_processor.thread_section_move(params).await
}
@@ -64,6 +64,7 @@ pub(crate) fn without_notification_media(notification: ServerNotification) -> Se
| ServerNotification::ThreadReverted(_)
| ServerNotification::SkillsChanged(_)
| ServerNotification::ThreadNameUpdated(_)
| ServerNotification::ThreadAttachmentUpdated(_)
| ServerNotification::ThreadGoalUpdated(_)
| ServerNotification::ThreadGoalCleared(_)
| ServerNotification::ThreadQueueChanged(_)
@@ -559,6 +559,7 @@ mod process_exec_processor;
mod projects;
mod remote_control_processor;
mod search;
mod thread_attachments;
mod thread_enrichment;
mod thread_fork_goal;
mod thread_input;
@@ -0,0 +1,243 @@
//! Attachment RPC handling; committed mutations respond before broadcasting updates.
use super::thread_processor::THREAD_LIST_DEFAULT_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 crate::outgoing_message::ConnectionRequestId;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadAttachment;
use codex_app_server_protocol::ThreadAttachmentAddOutcome;
use codex_app_server_protocol::ThreadAttachmentAddParams;
use codex_app_server_protocol::ThreadAttachmentAddResponse;
use codex_app_server_protocol::ThreadAttachmentListParams;
use codex_app_server_protocol::ThreadAttachmentListResponse;
use codex_app_server_protocol::ThreadAttachmentOperation;
use codex_app_server_protocol::ThreadAttachmentRemoveParams;
use codex_app_server_protocol::ThreadAttachmentRemoveResponse;
use codex_app_server_protocol::ThreadAttachmentUpdatedNotification;
use codex_protocol::ThreadId;
use codex_state::MAX_THREAD_ATTACHMENT_IDENTITY_KEY_BYTES;
use codex_state::MAX_THREAD_ATTACHMENT_LIST_PAGE_SIZE;
use codex_state::MAX_THREAD_ATTACHMENT_PAYLOAD_BYTES;
use codex_state::MAX_THREAD_ATTACHMENT_TYPE_BYTES;
use codex_thread_store::AddThreadAttachmentOutcome;
use codex_thread_store::AddThreadAttachmentParams;
use codex_thread_store::ListThreadAttachmentsParams;
use codex_thread_store::RemoveThreadAttachmentOutcome;
use codex_thread_store::RemoveThreadAttachmentParams;
use codex_thread_store::ThreadAttachment as StoredThreadAttachment;
use codex_thread_store::ThreadStoreError;
impl ThreadRequestProcessor {
pub(crate) async fn thread_attachment_add(
&self,
request_id: ConnectionRequestId,
params: ThreadAttachmentAddParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
const OPERATION: &str = "thread/attachment/add";
self.ensure_thread_attachments_supported(OPERATION)?;
validate_attachment_identity(&params.attachment_type, &params.identity_key)?;
let payload_bytes = serde_json::to_vec(&params.payload)
.map_err(|error| invalid_params(format!("invalid attachment payload: {error}")))?;
if payload_bytes.len() > MAX_THREAD_ATTACHMENT_PAYLOAD_BYTES {
return Err(invalid_params(format!(
"attachment payload must not exceed {MAX_THREAD_ATTACHMENT_PAYLOAD_BYTES} bytes"
)));
}
let thread_id = parse_attachment_thread_id(&params.thread_id)?;
let result = self
.thread_store
.add_thread_attachment(AddThreadAttachmentParams {
thread_id,
attachment_type: params.attachment_type,
identity_key: params.identity_key,
payload: params.payload,
})
.await
.map_err(|error| thread_attachment_store_error(OPERATION, error))?;
let (response, notification) = match result {
AddThreadAttachmentOutcome::Created(attachment) => {
let notification = ThreadAttachmentUpdatedNotification {
thread_id: attachment.thread_id.to_string(),
attachment_type: attachment.attachment_type.clone(),
identity_key: attachment.identity_key.clone(),
attachment_id: attachment.id.clone(),
operation: ThreadAttachmentOperation::Created,
};
(
ThreadAttachmentAddResponse {
outcome: ThreadAttachmentAddOutcome::Created,
attachment: api_thread_attachment(attachment),
},
Some(notification),
)
}
AddThreadAttachmentOutcome::Existing(attachment) => (
ThreadAttachmentAddResponse {
outcome: ThreadAttachmentAddOutcome::Existing,
attachment: api_thread_attachment(attachment),
},
None,
),
};
self.outgoing.send_response(request_id, response).await;
if let Some(notification) = notification {
self.outgoing
.send_server_notification(ServerNotification::ThreadAttachmentUpdated(notification))
.await;
}
Ok(None)
}
pub(crate) async fn thread_attachment_list(
&self,
params: ThreadAttachmentListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
const OPERATION: &str = "thread/attachment/list";
self.ensure_thread_attachments_supported(OPERATION)?;
let thread_id = parse_attachment_thread_id(&params.thread_id)?;
let limit = params
.limit
.map(|limit| limit as usize)
.unwrap_or(THREAD_LIST_DEFAULT_LIMIT)
.clamp(1, MAX_THREAD_ATTACHMENT_LIST_PAGE_SIZE);
let page = self
.thread_store
.list_thread_attachments(ListThreadAttachmentsParams {
thread_id,
cursor: params.cursor,
limit,
})
.await
.map_err(|error| thread_attachment_store_error(OPERATION, error))?;
Ok(Some(
ThreadAttachmentListResponse {
data: page
.attachments
.into_iter()
.map(api_thread_attachment)
.collect(),
next_cursor: page.next_cursor,
}
.into(),
))
}
pub(crate) async fn thread_attachment_remove(
&self,
request_id: ConnectionRequestId,
params: ThreadAttachmentRemoveParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
const OPERATION: &str = "thread/attachment/remove";
self.ensure_thread_attachments_supported(OPERATION)?;
validate_attachment_identity(&params.attachment_type, &params.identity_key)?;
let thread_id = parse_attachment_thread_id(&params.thread_id)?;
let result = self
.thread_store
.remove_thread_attachment(RemoveThreadAttachmentParams {
thread_id,
attachment_type: params.attachment_type,
identity_key: params.identity_key,
})
.await
.map_err(|error| thread_attachment_store_error(OPERATION, error))?;
self.outgoing
.send_response(request_id, ThreadAttachmentRemoveResponse {})
.await;
if let RemoveThreadAttachmentOutcome::Removed(attachment) = result {
self.outgoing
.send_server_notification(ServerNotification::ThreadAttachmentUpdated(
ThreadAttachmentUpdatedNotification {
thread_id: attachment.thread_id.to_string(),
attachment_type: attachment.attachment_type,
identity_key: attachment.identity_key,
attachment_id: attachment.id,
operation: ThreadAttachmentOperation::Deleted,
},
))
.await;
}
Ok(None)
}
fn ensure_thread_attachments_supported(
&self,
operation: &'static str,
) -> Result<(), JSONRPCErrorError> {
if self.thread_store.supports_thread_attachments() {
Ok(())
} else {
Err(unsupported_thread_attachment_operation(operation))
}
}
}
fn validate_attachment_identity(
attachment_type: &str,
identity_key: &str,
) -> Result<(), JSONRPCErrorError> {
if attachment_type.trim().is_empty() {
return Err(invalid_params("attachmentType must not be empty"));
}
if attachment_type.len() > MAX_THREAD_ATTACHMENT_TYPE_BYTES {
return Err(invalid_params(format!(
"attachmentType must not exceed {MAX_THREAD_ATTACHMENT_TYPE_BYTES} bytes"
)));
}
if identity_key.trim().is_empty() {
return Err(invalid_params("identityKey must not be empty"));
}
if identity_key.len() > MAX_THREAD_ATTACHMENT_IDENTITY_KEY_BYTES {
return Err(invalid_params(format!(
"identityKey must not exceed {MAX_THREAD_ATTACHMENT_IDENTITY_KEY_BYTES} bytes"
)));
}
Ok(())
}
fn parse_attachment_thread_id(thread_id: &str) -> Result<ThreadId, JSONRPCErrorError> {
ThreadId::from_string(thread_id)
.map_err(|error| invalid_params(format!("invalid thread id: {error}")))
}
fn api_thread_attachment(attachment: StoredThreadAttachment) -> ThreadAttachment {
ThreadAttachment {
id: attachment.id,
attachment_type: attachment.attachment_type,
identity_key: attachment.identity_key,
payload: attachment.payload,
created_at: attachment.created_at,
}
}
fn unsupported_thread_attachment_operation(operation: &'static str) -> JSONRPCErrorError {
method_not_found(format!(
"{operation} is not supported by the configured thread store"
))
}
fn thread_attachment_store_error(
operation: &'static str,
error: ThreadStoreError,
) -> JSONRPCErrorError {
match error {
ThreadStoreError::Unsupported { .. } => unsupported_thread_attachment_operation(operation),
ThreadStoreError::InvalidRequest { message } => invalid_params(message),
ThreadStoreError::ThreadNotFound { thread_id } => {
invalid_params(format!("thread not found: {thread_id}"))
}
error @ (ThreadStoreError::Conflict { .. } | ThreadStoreError::Internal { .. }) => {
internal_error(format!("failed to process {operation}: {error}"))
}
}
}
@@ -105,6 +105,7 @@ mod session_end;
mod skills_list;
mod sleep;
mod thread_archive;
mod thread_attachments;
mod thread_delete;
mod thread_environments;
mod thread_fork;
@@ -31,6 +31,9 @@ use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadAttachmentAddParams;
use codex_app_server_protocol::ThreadAttachmentListParams;
use codex_app_server_protocol::ThreadAttachmentRemoveParams;
use codex_app_server_protocol::ThreadDeleteParams;
use codex_app_server_protocol::ThreadDeleteResponse;
use codex_app_server_protocol::ThreadHistoryMode;
@@ -187,6 +190,58 @@ async fn thread_start_defaults_to_legacy_without_history_list_support() -> Resul
Ok(())
}
#[tokio::test]
async fn thread_attachment_operations_without_sqlite_return_method_not_found() -> Result<()> {
let codex_home = TempDir::new()?;
let store_id = Uuid::new_v4().to_string();
create_config_toml_with_thread_store(codex_home.path(), "http://127.0.0.1:1", &store_id)?;
let _in_memory_store = InMemoryThreadStoreId { store_id };
let client = start_in_process_server(codex_home.path()).await?;
for request in [
ClientRequest::ThreadAttachmentAdd {
request_id: RequestId::Integer(1),
params: ThreadAttachmentAddParams {
thread_id: "not-a-thread-id".to_string(),
attachment_type: " ".to_string(),
identity_key: " ".to_string(),
payload: serde_json::json!({}),
},
},
ClientRequest::ThreadAttachmentList {
request_id: RequestId::Integer(2),
params: ThreadAttachmentListParams {
thread_id: uuid::Uuid::now_v7().to_string(),
cursor: None,
limit: None,
},
},
ClientRequest::ThreadAttachmentRemove {
request_id: RequestId::Integer(3),
params: ThreadAttachmentRemoveParams {
thread_id: "not-a-thread-id".to_string(),
attachment_type: " ".to_string(),
identity_key: " ".to_string(),
},
},
] {
let method = request.method_name();
let error = client
.request(request)
.await?
.expect_err("attachment management is unsupported by this store");
assert_eq!(error.code, -32601);
assert_eq!(
error.message,
format!("{method} is not supported by the configured thread store")
);
}
client.shutdown().await?;
assert_no_local_persistence_artifacts(codex_home.path())?;
Ok(())
}
#[tokio::test]
async fn thread_start_rejects_paginated_history_without_list_support() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -0,0 +1,506 @@
//! Public RPC coverage for durable thread attachment membership and notifications.
use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT;
use super::connection_handling_websocket::connect_websocket;
use super::connection_handling_websocket::read_jsonrpc_message;
use super::connection_handling_websocket::read_notification_for_method;
use super::connection_handling_websocket::read_response_for_id;
use super::connection_handling_websocket::send_initialize_request;
use super::connection_handling_websocket::send_request;
use super::connection_handling_websocket::spawn_websocket_server;
use anyhow::Context;
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 app_test_support::to_response;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadAttachment;
use codex_app_server_protocol::ThreadAttachmentAddOutcome;
use codex_app_server_protocol::ThreadAttachmentAddParams;
use codex_app_server_protocol::ThreadAttachmentAddResponse;
use codex_app_server_protocol::ThreadAttachmentListParams;
use codex_app_server_protocol::ThreadAttachmentListResponse;
use codex_app_server_protocol::ThreadAttachmentOperation;
use codex_app_server_protocol::ThreadAttachmentRemoveParams;
use codex_app_server_protocol::ThreadAttachmentRemoveResponse;
use codex_app_server_protocol::ThreadAttachmentUpdatedNotification;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_features::Feature;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
#[tokio::test]
async fn thread_attachments_support_unloaded_listing_and_idempotent_attachment() -> 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 unrelated_thread = create_fake_rollout(
codex_home.path(),
"2025-01-06T10-00-00",
"2025-01-06T10:00:00Z",
"Unrelated 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 loaded: ThreadLoadedListResponse = server
.request(|request_id| ClientRequest::ThreadLoadedList {
request_id,
params: ThreadLoadedListParams::default(),
})
.await?;
assert_eq!(loaded.data, Vec::<String>::new());
let first_params = ThreadAttachmentAddParams {
thread_id: first_thread.clone(),
attachment_type: "pull_request".to_string(),
identity_key: r#"["github.com","openai","codex",123]"#.to_string(),
payload: json!({ "url": "https://github.com/openai/codex/pull/123" }),
};
let first: ThreadAttachmentAddResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentAdd {
request_id,
params: first_params.clone(),
})
.await?;
assert_eq!(first.outcome, ThreadAttachmentAddOutcome::Created);
let first_attachment = first.attachment;
let created_notification: ThreadAttachmentUpdatedNotification = timeout(
DEFAULT_READ_TIMEOUT,
server.read_notification("thread/attachment/updated"),
)
.await??;
assert_eq!(
created_notification,
ThreadAttachmentUpdatedNotification {
thread_id: first_thread.clone(),
attachment_type: first_attachment.attachment_type.clone(),
identity_key: first_attachment.identity_key.clone(),
attachment_id: first_attachment.id.clone(),
operation: ThreadAttachmentOperation::Created,
}
);
let duplicate: ThreadAttachmentAddResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentAdd {
request_id,
params: first_params,
})
.await?;
assert_eq!(
duplicate,
ThreadAttachmentAddResponse {
outcome: ThreadAttachmentAddOutcome::Existing,
attachment: first_attachment.clone(),
}
);
let second: ThreadAttachmentAddResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentAdd {
request_id,
params: ThreadAttachmentAddParams {
thread_id: first_thread.clone(),
attachment_type: "pull_request".to_string(),
identity_key: r#"["github.com","openai","codex",456]"#.to_string(),
payload: json!({ "url": "https://github.com/openai/codex/pull/456" }),
},
})
.await?;
let second_attachment = second.attachment;
let unrelated: ThreadAttachmentAddResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentAdd {
request_id,
params: ThreadAttachmentAddParams {
thread_id: unrelated_thread,
attachment_type: "pull_request".to_string(),
identity_key: r#"["github.com","openai","codex",789]"#.to_string(),
payload: json!({ "url": "https://github.com/openai/codex/pull/789" }),
},
})
.await?;
assert_eq!(unrelated.outcome, ThreadAttachmentAddOutcome::Created);
let first_page: ThreadAttachmentListResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentList {
request_id,
params: ThreadAttachmentListParams {
thread_id: first_thread.clone(),
cursor: None,
limit: Some(1),
},
})
.await?;
assert_eq!(first_page.data.len(), 1);
let second_page: ThreadAttachmentListResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentList {
request_id,
params: ThreadAttachmentListParams {
thread_id: first_thread.clone(),
cursor: first_page.next_cursor.clone(),
limit: Some(1),
},
})
.await?;
assert_eq!(second_page.next_cursor, None);
let mut actual = first_page.data;
actual.extend(second_page.data);
let expected = vec![first_attachment, second_attachment];
assert_eq!(actual, expected);
let empty: ThreadAttachmentListResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentList {
request_id,
params: ThreadAttachmentListParams {
thread_id: uuid::Uuid::now_v7().to_string(),
cursor: None,
limit: Some(10),
},
})
.await?;
assert_eq!(empty.data, Vec::<ThreadAttachment>::new());
Ok(())
}
#[tokio::test]
async fn attachment_removal_is_idempotent_and_allows_reattachment() -> 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 thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-06T08-00-00",
"2025-01-06T08:00:00Z",
"Stored 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 create_params = ThreadAttachmentAddParams {
thread_id: thread_id.clone(),
attachment_type: "pull_request".to_string(),
identity_key: r#"["github.com","openai","codex",123]"#.to_string(),
payload: json!({ "url": "https://github.com/openai/codex/pull/123" }),
};
let created: ThreadAttachmentAddResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentAdd {
request_id,
params: create_params.clone(),
})
.await?;
assert_eq!(created.outcome, ThreadAttachmentAddOutcome::Created);
let created_notification: ThreadAttachmentUpdatedNotification = timeout(
DEFAULT_READ_TIMEOUT,
server.read_notification("thread/attachment/updated"),
)
.await??;
assert_eq!(created_notification.attachment_id, created.attachment.id);
let delete_params = ThreadAttachmentRemoveParams {
thread_id: thread_id.to_ascii_uppercase(),
attachment_type: create_params.attachment_type.clone(),
identity_key: create_params.identity_key.clone(),
};
let _: ThreadAttachmentRemoveResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentRemove {
request_id,
params: delete_params.clone(),
})
.await?;
let removed_notification: ThreadAttachmentUpdatedNotification = timeout(
DEFAULT_READ_TIMEOUT,
server.read_notification("thread/attachment/updated"),
)
.await??;
assert_eq!(
removed_notification,
ThreadAttachmentUpdatedNotification {
thread_id: thread_id.clone(),
attachment_type: delete_params.attachment_type.clone(),
identity_key: delete_params.identity_key.clone(),
attachment_id: created.attachment.id,
operation: ThreadAttachmentOperation::Deleted,
}
);
let _: ThreadAttachmentRemoveResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentRemove {
request_id,
params: delete_params,
})
.await?;
let listed: ThreadAttachmentListResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentList {
request_id,
params: ThreadAttachmentListParams {
thread_id,
cursor: None,
limit: Some(10),
},
})
.await?;
assert_eq!(listed.data, Vec::<ThreadAttachment>::new());
let restored: ThreadAttachmentAddResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentAdd {
request_id,
params: create_params,
})
.await?;
assert_eq!(restored.outcome, ThreadAttachmentAddOutcome::Created);
let restored_notification: ThreadAttachmentUpdatedNotification = timeout(
DEFAULT_READ_TIMEOUT,
server.read_notification("thread/attachment/updated"),
)
.await??;
assert_eq!(
restored_notification.operation,
ThreadAttachmentOperation::Created
);
assert_eq!(restored_notification.attachment_id, restored.attachment.id);
Ok(())
}
#[tokio::test]
async fn thread_attachment_requests_reject_invalid_identities_and_cursors() -> 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?;
for (method, params) in [
(
"thread/attachment/add",
json!({
"threadId": "not-a-thread-id",
"attachmentType": "pull_request",
"identityKey": r#"["github.com","openai","codex",123]"#,
"payload": {}
}),
),
(
"thread/attachment/add",
json!({
"threadId": uuid::Uuid::now_v7().to_string(),
"attachmentType": " ",
"identityKey": r#"["github.com","openai","codex",123]"#,
"payload": {}
}),
),
(
"thread/attachment/remove",
json!({
"threadId": uuid::Uuid::now_v7().to_string(),
"attachmentType": "pull_request",
"identityKey": " "
}),
),
("thread/attachment/list", json!({ "threadId": "invalid" })),
(
"thread/attachment/list",
json!({
"threadId": uuid::Uuid::now_v7().to_string(),
"cursor": "invalid-cursor"
}),
),
] {
let request_id = server.send_raw_request(method, Some(params)).await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
server.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32602);
}
Ok(())
}
#[tokio::test]
async fn thread_attachment_mutations_respond_before_broadcasting_updates_to_multiple_clients()
-> 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 thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-06T08-00-00",
"2025-01-06T08:00:00Z",
"Stored thread",
Some("mock_provider"),
/*git_info*/ None,
)?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let result = async {
let mut first_client = connect_websocket(bind_addr).await?;
let mut second_client = connect_websocket(bind_addr).await?;
send_initialize_request(&mut first_client, /*id*/ 1, "attachment-client-one").await?;
timeout(
DEFAULT_READ_TIMEOUT,
read_response_for_id(&mut first_client, /*id*/ 1),
)
.await??;
send_initialize_request(&mut second_client, /*id*/ 2, "attachment-client-two").await?;
timeout(
DEFAULT_READ_TIMEOUT,
read_response_for_id(&mut second_client, /*id*/ 2),
)
.await??;
send_request(
&mut first_client,
"thread/list",
/*id*/ 3,
Some(json!({ "limit": 20 })),
)
.await?;
let _: ThreadListResponse =
to_response(read_response_for_id(&mut first_client, /*id*/ 3).await?)?;
send_request(
&mut first_client,
"thread/attachment/add",
/*id*/ 4,
Some(json!({
"threadId": thread_id.to_uppercase(),
"attachmentType": "pull_request",
"identityKey": r#"["github.com","openai","codex",123]"#,
"payload": { "url": "https://github.com/openai/codex/pull/123" }
})),
)
.await?;
let first_message = read_jsonrpc_message(&mut first_client).await?;
let JSONRPCMessage::Response(response) = first_message else {
anyhow::bail!("attachment creation must respond before notifying the requester");
};
assert_eq!(response.id, RequestId::Integer(4));
let created: ThreadAttachmentAddResponse = to_response(response)?;
let attachment = created.attachment;
let first_notification =
read_notification_for_method(&mut first_client, "thread/attachment/updated").await?;
let first_notification: ThreadAttachmentUpdatedNotification = serde_json::from_value(
first_notification
.params
.context("attachment notification")?,
)?;
let second_notification =
read_notification_for_method(&mut second_client, "thread/attachment/updated").await?;
let second_notification: ThreadAttachmentUpdatedNotification = serde_json::from_value(
second_notification
.params
.context("attachment notification")?,
)?;
assert_eq!(
first_notification,
ThreadAttachmentUpdatedNotification {
thread_id: thread_id.clone(),
attachment_type: "pull_request".to_string(),
identity_key: r#"["github.com","openai","codex",123]"#.to_string(),
attachment_id: attachment.id.clone(),
operation: ThreadAttachmentOperation::Created,
}
);
assert_eq!(first_notification, second_notification);
send_request(
&mut second_client,
"thread/attachment/remove",
/*id*/ 5,
Some(json!({
"threadId": format!("{{{thread_id}}}"),
"attachmentType": "pull_request",
"identityKey": r#"["github.com","openai","codex",123]"#
})),
)
.await?;
let first_message = read_jsonrpc_message(&mut second_client).await?;
let JSONRPCMessage::Response(response) = first_message else {
anyhow::bail!("attachment deletion must respond before notifying the requester");
};
assert_eq!(response.id, RequestId::Integer(5));
let _: ThreadAttachmentRemoveResponse = to_response(response)?;
let first_notification =
read_notification_for_method(&mut first_client, "thread/attachment/updated").await?;
let first_notification: ThreadAttachmentUpdatedNotification = serde_json::from_value(
first_notification
.params
.context("attachment notification")?,
)?;
let second_notification =
read_notification_for_method(&mut second_client, "thread/attachment/updated").await?;
let second_notification: ThreadAttachmentUpdatedNotification = serde_json::from_value(
second_notification
.params
.context("attachment notification")?,
)?;
assert_eq!(
first_notification,
ThreadAttachmentUpdatedNotification {
thread_id,
attachment_type: "pull_request".to_string(),
identity_key: r#"["github.com","openai","codex",123]"#.to_string(),
attachment_id: attachment.id,
operation: ThreadAttachmentOperation::Deleted,
}
);
assert_eq!(first_notification, second_notification);
Ok(())
}
.await;
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
result
}
@@ -59,6 +59,9 @@ pub(super) fn server_notification_thread_target(
ServerNotification::ThreadNameUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadAttachmentUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadProjectUpdated(notification) => {
Some(notification.thread_id.as_str())
}
@@ -231,6 +234,8 @@ mod tests {
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadAttachmentOperation;
use codex_app_server_protocol::ThreadAttachmentUpdatedNotification;
use codex_app_server_protocol::ThreadSettings;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
use codex_app_server_protocol::WarningNotification;
@@ -352,4 +357,21 @@ mod tests {
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
#[test]
fn thread_attachment_updated_notifications_route_to_threads() {
let thread_id = ThreadId::new();
let notification =
ServerNotification::ThreadAttachmentUpdated(ThreadAttachmentUpdatedNotification {
thread_id: thread_id.to_string(),
attachment_type: "pull_request".to_string(),
identity_key: r#"["github.com","openai","codex",123]"#.to_string(),
attachment_id: "attachment-1".to_string(),
operation: ThreadAttachmentOperation::Deleted,
});
let target = server_notification_thread_target(&notification);
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
}
+28 -1
View File
@@ -135,6 +135,8 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadArchivedNotification;
use codex_app_server_protocol::ThreadAttachmentOperation;
use codex_app_server_protocol::ThreadAttachmentUpdatedNotification;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadSettings;
@@ -1976,7 +1978,8 @@ async fn archived_untracked_threads_do_not_appear_in_agent_picker() -> Result<()
app.chat_widget.config_ref(),
))
.await?;
let primary_thread_id = ThreadId::new();
let primary_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
app.enqueue_primary_thread_session(
test_thread_session(primary_thread_id, test_path_buf("/tmp/project")),
Vec::new(),
@@ -1996,8 +1999,32 @@ async fn archived_untracked_threads_do_not_appear_in_agent_picker() -> Result<()
assert!(!app.thread_event_channels.contains_key(&archived_thread_id));
let attachment_thread_id = ThreadId::new();
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
ServerNotification::ThreadAttachmentUpdated(ThreadAttachmentUpdatedNotification {
thread_id: attachment_thread_id.to_string(),
attachment_type: "pull_request".to_string(),
identity_key: r#"["github.com","openai","codex",123]"#.to_string(),
attachment_id: "attachment-1".to_string(),
operation: ThreadAttachmentOperation::Deleted,
}),
)),
)
.await;
assert!(
!app.thread_event_channels
.contains_key(&attachment_thread_id)
);
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_app_snapshot!(
"untracked_thread_notifications_agent_picker",
render_bottom_popup(&app.chat_widget, /*width*/ 80)
);
assert_eq!(
app.agent_navigation.ordered_thread_ids(),
vec![primary_thread_id]
+12
View File
@@ -282,6 +282,7 @@ impl ThreadEventStore {
| ServerNotification::FileChangePatchUpdated(_)
| ServerNotification::ServerRequestResolved(_)
| ServerNotification::McpToolCallProgress(_)
| ServerNotification::ThreadAttachmentUpdated(_)
| ServerNotification::ThreadRealtimeItemAdded(_)
| ServerNotification::ThreadRealtimeOutputAudioDelta(_)
| ServerNotification::ThreadRealtimeSdp(_)
@@ -646,6 +647,8 @@ mod tests {
use codex_app_server_protocol::McpToolCallProgressNotification;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ThreadAttachmentOperation;
use codex_app_server_protocol::ThreadAttachmentUpdatedNotification;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use codex_app_server_protocol::ThreadRealtimeOutputAudioDeltaNotification;
use codex_app_server_protocol::TurnCompletedNotification;
@@ -902,6 +905,15 @@ mod tests {
let large_payload = "x".repeat(1024 * 1024);
for _ in 0..32 {
store.push_notification_ref(&ServerNotification::ThreadAttachmentUpdated(
ThreadAttachmentUpdatedNotification {
thread_id: thread_id.to_string(),
attachment_type: "pullRequest".to_string(),
identity_key: "openai/codex#1".to_string(),
attachment_id: "attachment-1".to_string(),
operation: ThreadAttachmentOperation::Created,
},
));
store.push_notification_ref(&ServerNotification::McpToolCallProgress(
McpToolCallProgressNotification {
thread_id: thread_id.to_string(),
+3 -1
View File
@@ -1166,7 +1166,9 @@ impl App {
}
if matches!(
notification,
ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ThreadArchived(_)
ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ThreadArchived(_)
| ServerNotification::ThreadAttachmentUpdated(_)
) && self.primary_thread_id.is_some()
&& self.primary_thread_id != Some(thread_id)
&& !self.thread_event_channels.contains_key(&thread_id)
+1
View File
@@ -313,6 +313,7 @@ impl ChatWidget {
| ServerNotification::ThreadArchived(_)
| ServerNotification::ThreadDeleted(_)
| ServerNotification::ThreadUnarchived(_)
| ServerNotification::ThreadAttachmentUpdated(_)
| ServerNotification::RawResponseItemCompleted(_)
| ServerNotification::RawResponseCompleted(_)
| ServerNotification::CommandExecOutputDelta(_)
@@ -0,0 +1,10 @@
---
source: tui/src/app/tests.rs
expression: "render_bottom_popup(&app.chat_widget, 80)"
---
Subagents
Select an agent to watch. ⌥ + ← previous, ⌥ + → next.
› 1. • Main [default] (current) 00000000-0000-0000-0000-000000000123
Press enter to confirm or esc to go back
@@ -54,6 +54,7 @@ from .v2_all import SkillsChangedNotification
from .v2_all import StrictReviewRequiredNotification
from .v2_all import TerminalInteractionNotification
from .v2_all import ThreadArchivedNotification
from .v2_all import ThreadAttachmentUpdatedNotification
from .v2_all import ThreadClosedNotification
from .v2_all import ThreadDeletedNotification
from .v2_all import ThreadGoalClearedNotification
@@ -135,6 +136,7 @@ KnownNotificationPayload: TypeAlias = (
| StrictReviewRequiredNotification
| TerminalInteractionNotification
| ThreadArchivedNotification
| ThreadAttachmentUpdatedNotification
| ThreadClosedNotification
| ThreadDeletedNotification
| ThreadGoalClearedNotification
@@ -216,6 +218,7 @@ NOTIFICATION_MODELS: dict[str, type[KnownNotificationPayload]] = {
"serverRequest/resolved": ServerRequestResolvedNotification,
"skills/changed": SkillsChangedNotification,
"thread/archived": ThreadArchivedNotification,
"thread/attachment/updated": ThreadAttachmentUpdatedNotification,
"thread/closed": ThreadClosedNotification,
"thread/compacted": ContextCompactedNotification,
"thread/deleted": ThreadDeletedNotification,
@@ -5034,6 +5034,89 @@ class ThreadArchivedNotification(BaseModel):
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadAttachment(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
attachment_type: Annotated[str, Field(alias="attachmentType")]
created_at: Annotated[int, Field(alias="createdAt")]
id: str
identity_key: Annotated[str, Field(alias="identityKey")]
payload: Any
class ThreadAttachmentAddOutcome(Enum):
created = "created"
existing = "existing"
class ThreadAttachmentAddParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
attachment_type: Annotated[str, Field(alias="attachmentType")]
identity_key: Annotated[str, Field(alias="identityKey")]
payload: Any
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadAttachmentAddResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
attachment: ThreadAttachment
outcome: ThreadAttachmentAddOutcome
class ThreadAttachmentListParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
cursor: str | None = None
limit: Annotated[int | None, Field(ge=0)] = None
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadAttachmentListResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
data: list[ThreadAttachment]
next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None
class ThreadAttachmentOperation(Enum):
created = "created"
deleted = "deleted"
class ThreadAttachmentRemoveParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
attachment_type: Annotated[str, Field(alias="attachmentType")]
identity_key: Annotated[str, Field(alias="identityKey")]
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadAttachmentRemoveResponse(BaseModel):
pass
model_config = ConfigDict(
populate_by_name=True,
)
class ThreadAttachmentUpdatedNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
attachment_id: Annotated[str, Field(alias="attachmentId")]
attachment_type: Annotated[str, Field(alias="attachmentType")]
identity_key: Annotated[str, Field(alias="identityKey")]
operation: ThreadAttachmentOperation
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadClosedNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -6638,6 +6721,39 @@ class ThreadMetadataUpdateRequest(BaseModel):
params: ThreadMetadataUpdateParams
class ThreadAttachmentAddRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: RequestId
method: Annotated[
Literal["thread/attachment/add"], Field(title="Thread/attachment/addRequestMethod")
]
params: ThreadAttachmentAddParams
class ThreadAttachmentListRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: RequestId
method: Annotated[
Literal["thread/attachment/list"], Field(title="Thread/attachment/listRequestMethod")
]
params: ThreadAttachmentListParams
class ThreadAttachmentRemoveRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: RequestId
method: Annotated[
Literal["thread/attachment/remove"], Field(title="Thread/attachment/removeRequestMethod")
]
params: ThreadAttachmentRemoveParams
class ThreadSectionMoveRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -8776,6 +8892,24 @@ class ThreadNameUpdatedServerNotification(BaseModel):
params: ThreadNameUpdatedNotification
class ThreadAttachmentUpdatedServerNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
emitted_at_ms: Annotated[
int | None,
Field(
alias="emittedAtMs",
description="Unix timestamp (in milliseconds) when app-server emitted this notification.",
),
] = None
method: Annotated[
Literal["thread/attachment/updated"],
Field(title="Thread/attachment/updatedNotificationMethod"),
]
params: ThreadAttachmentUpdatedNotification
class ThreadGoalClearedServerNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -11957,6 +12091,9 @@ class ClientRequest(
| ThreadGoalGetRequest
| ThreadGoalClearRequest
| ThreadMetadataUpdateRequest
| ThreadAttachmentAddRequest
| ThreadAttachmentListRequest
| ThreadAttachmentRemoveRequest
| ThreadSectionMoveRequest
| ThreadUnarchiveRequest
| ThreadCompactStartRequest
@@ -12062,6 +12199,9 @@ class ClientRequest(
| ThreadGoalGetRequest
| ThreadGoalClearRequest
| ThreadMetadataUpdateRequest
| ThreadAttachmentAddRequest
| ThreadAttachmentListRequest
| ThreadAttachmentRemoveRequest
| ThreadSectionMoveRequest
| ThreadUnarchiveRequest
| ThreadCompactStartRequest
@@ -12336,6 +12476,7 @@ class ServerNotification(
| ThreadRevertedServerNotification
| SkillsChangedServerNotification
| ThreadNameUpdatedServerNotification
| ThreadAttachmentUpdatedServerNotification
| ThreadGoalUpdatedServerNotification
| ThreadGoalClearedServerNotification
| ThreadQueueChangedServerNotification
@@ -12423,6 +12564,7 @@ class ServerNotification(
| ThreadRevertedServerNotification
| SkillsChangedServerNotification
| ThreadNameUpdatedServerNotification
| ThreadAttachmentUpdatedServerNotification
| ThreadGoalUpdatedServerNotification
| ThreadGoalClearedServerNotification
| ThreadQueueChangedServerNotification