diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 14908dbb1f..5c11e35f16 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1180,6 +1180,24 @@ ], "type": "string" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "HookRunStatus": { "enum": [ "running", @@ -2168,6 +2186,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e370546dc2..125bdd3353 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7983,6 +7983,24 @@ ], "type": "string" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "HookRunStatus": { "enum": [ "running", @@ -11986,6 +12004,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/v2/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b069d3e5e7..dc6b1ae476 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4727,6 +4727,24 @@ ], "type": "string" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "HookRunStatus": { "enum": [ "running", @@ -9746,6 +9764,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index b447fc3397..bf0e900c49 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -257,6 +257,24 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -439,6 +457,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 1b02f44188..4e469ae8cc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -257,6 +257,24 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -439,6 +457,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index b8e83ba34e..367ed94785 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -371,6 +371,24 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -553,6 +571,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index e57c84b463..2bb6baa67c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -456,6 +456,24 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1033,6 +1051,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f9f9430550..604027fd0a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -394,6 +394,24 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -791,6 +809,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index c652c1cb44..253eb8f644 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -394,6 +394,24 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -791,6 +809,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index b0ca838cde..7051cda27b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -394,6 +394,24 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -791,6 +809,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 6809f9715b..c100579940 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -456,6 +456,24 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1033,6 +1051,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 2288caa508..e247d483a8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -394,6 +394,24 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -791,6 +809,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index e994a2b009..6b4d6a77cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -456,6 +456,24 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1033,6 +1051,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 3eabf9eebc..79f6f8bb30 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -394,6 +394,24 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -791,6 +809,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index f6738ff216..0a6e4be4a2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -394,6 +394,24 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -791,6 +809,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 079a81ad04..5432a7abc3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -371,6 +371,24 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -553,6 +571,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 17f04c51d8..610f0f1a8d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -371,6 +371,24 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -553,6 +571,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 59171e42d0..e836b8a43c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -371,6 +371,24 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunIds", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -553,6 +571,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookPromptFragment.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookPromptFragment.ts new file mode 100644 index 0000000000..031baea641 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookPromptFragment.ts @@ -0,0 +1,5 @@ +// 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. + +export type HookPromptFragment = { text: string, hookRunIds: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index bcc81c0251..47a43bdca5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -12,6 +12,7 @@ import type { CommandExecutionStatus } from "./CommandExecutionStatus"; import type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem"; import type { DynamicToolCallStatus } from "./DynamicToolCallStatus"; import type { FileUpdateChange } from "./FileUpdateChange"; +import type { HookPromptFragment } from "./HookPromptFragment"; import type { McpToolCallError } from "./McpToolCallError"; import type { McpToolCallResult } from "./McpToolCallResult"; import type { McpToolCallStatus } from "./McpToolCallStatus"; @@ -19,7 +20,7 @@ import type { PatchApplyStatus } from "./PatchApplyStatus"; import type { UserInput } from "./UserInput"; import type { WebSearchAction } from "./WebSearchAction"; -export type ThreadItem = { "type": "userMessage", id: string, content: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, +export type ThreadItem = { "type": "userMessage", id: string, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, /** * The command to be executed. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 1529bbea88..f0a00e0af3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -126,6 +126,7 @@ export type { HookExecutionMode } from "./HookExecutionMode"; export type { HookHandlerType } from "./HookHandlerType"; export type { HookOutputEntry } from "./HookOutputEntry"; export type { HookOutputEntryKind } from "./HookOutputEntryKind"; +export type { HookPromptFragment } from "./HookPromptFragment"; export type { HookRunStatus } from "./HookRunStatus"; export type { HookRunSummary } from "./HookRunSummary"; export type { HookScope } from "./HookScope"; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 9866459c66..6e11c97db1 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -18,6 +18,7 @@ use crate::protocol::v2::TurnError; use crate::protocol::v2::TurnStatus; use crate::protocol::v2::UserInput; use crate::protocol::v2::WebSearchAction; +use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::MessagePhase; use codex_protocol::protocol::AgentReasoningEvent; use codex_protocol::protocol::AgentReasoningRawContentEvent; @@ -182,12 +183,37 @@ impl ThreadHistoryBuilder { match item { RolloutItem::EventMsg(event) => self.handle_event(event), RolloutItem::Compacted(payload) => self.handle_compacted(payload), - RolloutItem::TurnContext(_) - | RolloutItem::SessionMeta(_) - | RolloutItem::ResponseItem(_) => {} + RolloutItem::ResponseItem(item) => self.handle_response_item(item), + RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {} } } + fn handle_response_item(&mut self, item: &codex_protocol::models::ResponseItem) { + let codex_protocol::models::ResponseItem::Message { + role, content, id, .. + } = item + else { + return; + }; + + if role != "user" { + return; + } + + let Some(hook_prompt) = parse_hook_prompt_message(id.as_ref(), content) else { + return; + }; + + self.ensure_turn().items.push(ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(crate::protocol::v2::HookPromptFragment::from) + .collect(), + }); + } + fn handle_user_message(&mut self, payload: &UserMessageEvent) { // User messages should stay in explicitly opened turns. For backward // compatibility with older streams that did not open turns explicitly, @@ -271,6 +297,7 @@ impl ThreadHistoryBuilder { ); } codex_protocol::items::TurnItem::UserMessage(_) + | codex_protocol::items::TurnItem::HookPrompt(_) | codex_protocol::items::TurnItem::AgentMessage(_) | codex_protocol::items::TurnItem::Reasoning(_) | codex_protocol::items::TurnItem::WebSearch(_) @@ -291,6 +318,7 @@ impl ThreadHistoryBuilder { ); } codex_protocol::items::TurnItem::UserMessage(_) + | codex_protocol::items::TurnItem::HookPrompt(_) | codex_protocol::items::TurnItem::AgentMessage(_) | codex_protocol::items::TurnItem::Reasoning(_) | codex_protocol::items::TurnItem::WebSearch(_) @@ -1136,8 +1164,10 @@ mod tests { use super::*; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; + use codex_protocol::items::HookPromptFragment as CoreHookPromptFragment; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::items::UserMessageItem as CoreUserMessageItem; + use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::parse_command::ParsedCommand; @@ -2608,4 +2638,80 @@ mod tests { }) ); } + + #[test] + fn rebuilds_hook_prompt_items_from_rollout_response_items() { + let hook_prompt = build_hook_prompt_message(&[ + CoreHookPromptFragment::from_single_hook("Retry with tests.", "hook-run-1"), + CoreHookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"), + ]) + .expect("hook prompt message"); + let items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::ResponseItem(hook_prompt), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + })), + ]; + + let turns = build_turns_from_rollout_items(&items); + + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::HookPrompt { + id: turns[0].items[1].id().to_string(), + fragments: vec![ + crate::protocol::v2::HookPromptFragment { + text: "Retry with tests.".into(), + hook_run_ids: vec!["hook-run-1".into()], + }, + crate::protocol::v2::HookPromptFragment { + text: "Then summarize cleanly.".into(), + hook_run_ids: vec!["hook-run-2".into()], + }, + ], + } + ); + } + + #[test] + fn ignores_plain_user_response_items_in_rollout_replay() { + let items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::ResponseItem(codex_protocol::models::ResponseItem::Message { + id: Some("msg-1".into()), + role: "user".into(), + content: vec![codex_protocol::models::ContentItem::InputText { + text: "plain text".into(), + }], + end_turn: None, + phase: None, + }), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + })), + ]; + + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert!(turns[0].items.is_empty()); + } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e2316d8e78..a34dbd8b62 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4128,6 +4128,12 @@ pub enum ThreadItem { UserMessage { id: String, content: Vec }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] + HookPrompt { + id: String, + fragments: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] AgentMessage { id: String, text: String, @@ -4257,10 +4263,19 @@ pub enum ThreadItem { ContextCompaction { id: String }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct HookPromptFragment { + pub text: String, + pub hook_run_ids: Vec, +} + impl ThreadItem { pub fn id(&self) -> &str { match self { ThreadItem::UserMessage { id, .. } + | ThreadItem::HookPrompt { id, .. } | ThreadItem::AgentMessage { id, .. } | ThreadItem::Plan { id, .. } | ThreadItem::Reasoning { id, .. } @@ -4370,6 +4385,14 @@ impl From for ThreadItem { id: user.id, content: user.content.into_iter().map(UserInput::from).collect(), }, + CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(HookPromptFragment::from) + .collect(), + }, CoreTurnItem::AgentMessage(agent) => { let text = agent .content @@ -4411,6 +4434,15 @@ impl From for ThreadItem { } } +impl From for HookPromptFragment { + fn from(value: codex_protocol::items::HookPromptFragment) -> Self { + Self { + text: value.text, + hook_run_ids: value.hook_run_ids, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4f4f995e2c..ad4cd2125c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -110,6 +110,8 @@ use codex_core::sandboxing::intersect_permission_profiles; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; +use codex_protocol::items::parse_hook_prompt_message; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; @@ -1482,6 +1484,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::RawResponseItem(raw_response_item_event) => { + maybe_emit_hook_prompt_item_completed( + api_version, + conversation_id, + &event_turn_id, + &raw_response_item_event.item, + &outgoing, + ) + .await; maybe_emit_raw_response_item_completed( api_version, conversation_id, @@ -1980,6 +1990,49 @@ async fn maybe_emit_raw_response_item_completed( .await; } +async fn maybe_emit_hook_prompt_item_completed( + api_version: ApiVersion, + conversation_id: ThreadId, + turn_id: &str, + item: &codex_protocol::models::ResponseItem, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + let ApiVersion::V2 = api_version else { + return; + }; + + let codex_protocol::models::ResponseItem::Message { + role, content, id, .. + } = item + else { + return; + }; + + if role != "user" { + return; + } + + let Some(hook_prompt) = parse_hook_prompt_message(id.as_ref(), content) else { + return; + }; + + let notification = ItemCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id: turn_id.to_string(), + item: ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(codex_app_server_protocol::HookPromptFragment::from) + .collect(), + }, + }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(notification)) + .await; +} + async fn find_and_remove_turn_summary( _conversation_id: ThreadId, thread_state: &Arc>, @@ -2750,6 +2803,8 @@ mod tests { use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; + use codex_protocol::items::HookPromptFragment; + use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; @@ -3784,4 +3839,59 @@ mod tests { assert!(rx.try_recv().is_err(), "no messages expected"); Ok(()) } + + #[tokio::test] + async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let conversation_id = ThreadId::new(); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id, + ); + let item = build_hook_prompt_message(&[ + HookPromptFragment::from_single_hook("Retry with tests.", "hook-run-1"), + HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"), + ]) + .expect("hook prompt message"); + + maybe_emit_hook_prompt_item_completed( + ApiVersion::V2, + conversation_id, + "turn-1", + &item, + &outgoing, + ) + .await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted( + notification, + )) => { + assert_eq!(notification.thread_id, conversation_id.to_string()); + assert_eq!(notification.turn_id, "turn-1"); + assert_eq!( + notification.item, + ThreadItem::HookPrompt { + id: notification.item.id().to_string(), + fragments: vec![ + codex_app_server_protocol::HookPromptFragment { + text: "Retry with tests.".into(), + hook_run_ids: vec!["hook-run-1".into()], + }, + codex_app_server_protocol::HookPromptFragment { + text: "Then summarize cleanly.".into(), + hook_run_ids: vec!["hook-run-2".into()], + }, + ], + } + ); + } + other => bail!("unexpected message: {other:?}"), + } + assert!(rx.try_recv().is_err(), "no extra messages expected"); + Ok(()) + } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8ffe1d3bd1..f252cf2199 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -86,6 +86,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::PlanItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; +use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::BaseInstructions; use codex_protocol::models::PermissionProfile; @@ -5812,13 +5813,12 @@ pub(crate) async fn run_turn( .await; } if stop_outcome.should_block { - if let Some(continuation_prompt) = stop_outcome.continuation_prompt.clone() + if let Some(hook_prompt_message) = + build_hook_prompt_message(&stop_outcome.continuation_fragments) { - let developer_message: ResponseItem = - DeveloperInstructions::new(continuation_prompt).into(); sess.record_conversation_items( &turn_context, - std::slice::from_ref(&developer_message), + std::slice::from_ref(&hook_prompt_message), ) .await; stop_hook_active = true; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 9439d8125e..28be574318 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -196,7 +196,7 @@ pub(crate) async fn process_compacted_history( /// - `developer` messages because remote output can include stale/duplicated /// instruction content. /// - non-user-content `user` messages (session prefix/instruction wrappers), -/// keeping only real user messages as parsed by `parse_turn_item`. +/// while preserving real user messages and persisted hook prompts. /// /// This intentionally keeps: /// - `assistant` messages (future remote compaction models may emit them) @@ -208,7 +208,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ResponseItem::Message { role, .. } if role == "user" => { matches!( crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(_)) + Some(TurnItem::UserMessage(_) | TurnItem::HookPrompt(_)) ) } ResponseItem::Message { role, .. } if role == "assistant" => true, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 4d7f4c558a..a2768a82f5 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -177,8 +177,7 @@ impl ContextManager { /// Returns true when a tool image was replaced, false otherwise. pub(crate) fn replace_last_turn_images(&mut self, placeholder: &str) -> bool { let Some(index) = self.items.iter().rposition(|item| { - matches!(item, ResponseItem::FunctionCallOutput { .. }) - || matches!(item, ResponseItem::Message { role, .. } if role == "user") + matches!(item, ResponseItem::FunctionCallOutput { .. }) || is_user_turn_boundary(item) }) else { return false; }; @@ -200,7 +199,7 @@ impl ContextManager { } replaced } - ResponseItem::Message { role, .. } if role == "user" => false, + ResponseItem::Message { .. } => false, _ => false, } } @@ -250,11 +249,7 @@ impl ContextManager { fn get_non_last_reasoning_items_tokens(&self) -> i64 { // Get reasoning items excluding all the ones after the last user message. - let Some(last_user_index) = self - .items - .iter() - .rposition(|item| matches!(item, ResponseItem::Message { role, .. } if role == "user")) - else { + let Some(last_user_index) = self.items.iter().rposition(is_user_turn_boundary) else { return 0; }; diff --git a/codex-rs/core/src/contextual_user_message.rs b/codex-rs/core/src/contextual_user_message.rs index f7612fe8ed..4df05f0da1 100644 --- a/codex-rs/core/src/contextual_user_message.rs +++ b/codex-rs/core/src/contextual_user_message.rs @@ -1,3 +1,5 @@ +use codex_protocol::items::HookPromptItem; +use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; @@ -94,10 +96,7 @@ const CONTEXTUAL_USER_FRAGMENTS: &[ContextualUserFragmentDefinition] = &[ SUBAGENT_NOTIFICATION_FRAGMENT, ]; -pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool { - let ContentItem::InputText { text } = content_item else { - return false; - }; +fn is_standard_contextual_user_text(text: &str) -> bool { CONTEXTUAL_USER_FRAGMENTS .iter() .any(|definition| definition.matches_text(text)) @@ -118,6 +117,40 @@ pub(crate) fn is_memory_excluded_contextual_user_fragment(content_item: &Content AGENTS_MD_FRAGMENT.matches_text(text) || SKILL_FRAGMENT.matches_text(text) } +pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool { + let ContentItem::InputText { text } = content_item else { + return false; + }; + parse_hook_prompt_fragment(text).is_some() || is_standard_contextual_user_text(text) +} + +pub(crate) fn parse_visible_hook_prompt_message( + id: Option<&String>, + content: &[ContentItem], +) -> Option { + let mut fragments = Vec::new(); + + for content_item in content { + let ContentItem::InputText { text } = content_item else { + return None; + }; + if let Some(fragment) = parse_hook_prompt_fragment(text) { + fragments.push(fragment); + continue; + } + if is_standard_contextual_user_text(text) { + continue; + } + return None; + } + + if fragments.is_empty() { + return None; + } + + Some(HookPromptItem::from_fragments(id, fragments)) +} + #[cfg(test)] #[path = "contextual_user_message_tests.rs"] mod tests; diff --git a/codex-rs/core/src/contextual_user_message_tests.rs b/codex-rs/core/src/contextual_user_message_tests.rs index 1fc6de9a82..6fe2a2df21 100644 --- a/codex-rs/core/src/contextual_user_message_tests.rs +++ b/codex-rs/core/src/contextual_user_message_tests.rs @@ -1,4 +1,6 @@ use super::*; +use codex_protocol::items::HookPromptFragment; +use codex_protocol::items::build_hook_prompt_message; #[test] fn detects_environment_context_fragment() { @@ -61,3 +63,36 @@ fn classifies_memory_excluded_fragments() { ); } } + +#[test] +fn detects_hook_prompt_fragment_and_roundtrips_escaping() { + let message = build_hook_prompt_message(&[HookPromptFragment::from_single_hook( + r#"Retry with "waves" & "#, + "hook-run-1", + )]) + .expect("hook prompt message"); + + let ResponseItem::Message { content, .. } = message else { + panic!("expected hook prompt response item"); + }; + + let [content_item] = content.as_slice() else { + panic!("expected a single content item"); + }; + + assert!(is_contextual_user_fragment(content_item)); + + let ContentItem::InputText { text } = content_item else { + panic!("expected input text content item"); + }; + let parsed = parse_visible_hook_prompt_message(None, content.as_slice()) + .expect("visible hook prompt"); + assert_eq!( + parsed.fragments, + vec![HookPromptFragment { + text: r#"Retry with "waves" & "#.to_string(), + hook_run_ids: vec!["hook-run-1".to_string()], + }], + ); + assert!(!text.contains(""waves" & ")); +} diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 72372b24cd..dddc6f5a32 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -19,6 +19,7 @@ use tracing::warn; use uuid::Uuid; use crate::contextual_user_message::is_contextual_user_fragment; +use crate::contextual_user_message::parse_visible_hook_prompt_message; use crate::web_search::web_search_action_detail; pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool { @@ -95,7 +96,9 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { phase, .. } => match role.as_str() { - "user" => parse_user_message(content).map(TurnItem::UserMessage), + "user" => parse_visible_hook_prompt_message(id.as_ref(), content) + .map(TurnItem::HookPrompt) + .or_else(|| parse_user_message(content).map(TurnItem::UserMessage)), "assistant" => Some(TurnItem::AgentMessage(parse_agent_message( id.as_ref(), content, diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 7a9b7076be..f783e6ebcd 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -1,7 +1,9 @@ use super::parse_turn_item; use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::HookPromptFragment; use codex_protocol::items::TurnItem; use codex_protocol::items::WebSearchItem; +use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; @@ -208,6 +210,67 @@ fn skips_user_instructions_and_env() { } } +#[test] +fn parses_hook_prompt_message_as_distinct_turn_item() { + let item = build_hook_prompt_message(&[HookPromptFragment::from_single_hook( + "Retry with exactly the phrase meow meow meow.", + "hook-run-1", + )]) + .expect("hook prompt message"); + + let turn_item = parse_turn_item(&item).expect("expected hook prompt turn item"); + + match turn_item { + TurnItem::HookPrompt(hook_prompt) => { + assert_eq!(hook_prompt.fragments.len(), 1); + assert_eq!( + hook_prompt.fragments[0], + HookPromptFragment { + text: "Retry with exactly the phrase meow meow meow.".to_string(), + hook_run_ids: vec!["hook-run-1".to_string()], + } + ); + } + other => panic!("expected TurnItem::HookPrompt, got {other:?}"), + } +} + +#[test] +fn parses_hook_prompt_and_hides_other_contextual_fragments() { + let item = ResponseItem::Message { + id: Some("msg-1".to_string()), + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "ctx".to_string(), + }, + ContentItem::InputText { + text: + "Retry with care & joy." + .to_string(), + }, + ], + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected hook prompt turn item"); + + match turn_item { + TurnItem::HookPrompt(hook_prompt) => { + assert_eq!(hook_prompt.id, "msg-1"); + assert_eq!( + hook_prompt.fragments, + vec![HookPromptFragment { + text: "Retry with care & joy.".to_string(), + hook_run_ids: vec!["hook-run-1".to_string()], + }] + ); + } + other => panic!("expected TurnItem::HookPrompt, got {other:?}"), + } +} + #[test] fn parses_agent_message() { let item = ResponseItem::Message { diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index a6c28aed26..deafe8d554 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -4,6 +4,7 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; use codex_core::features::Feature; +use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::RolloutItem; @@ -69,7 +70,50 @@ else: Ok(()) } -fn rollout_developer_texts(text: &str) -> Result> { +fn write_parallel_stop_hooks(home: &Path, prompts: &[&str]) -> Result<()> { + let hook_entries = prompts + .iter() + .enumerate() + .map(|(index, prompt)| { + let script_path = home.join(format!("stop_hook_{index}.py")); + let script = format!( + r#"import json +import sys + +payload = json.load(sys.stdin) +if payload["stop_hook_active"]: + print(json.dumps({{"systemMessage": "done"}})) +else: + print(json.dumps({{"decision": "block", "reason": {prompt:?}}})) +"#, + prompt = prompt, + ); + fs::write(&script_path, script).with_context(|| { + format!( + "write stop hook script fixture at {}", + script_path.display() + ) + })?; + Ok(serde_json::json!({ + "type": "command", + "command": format!("python3 {}", script_path.display()), + })) + }) + .collect::>>()?; + + let hooks = serde_json::json!({ + "hooks": { + "Stop": [{ + "hooks": hook_entries, + }] + } + }); + + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + +fn rollout_hook_prompt_texts(text: &str) -> Result> { let mut texts = Vec::new(); for line in text.lines() { let trimmed = line.trim(); @@ -78,11 +122,13 @@ fn rollout_developer_texts(text: &str) -> Result> { } let rollout: RolloutLine = serde_json::from_str(trimmed).context("parse rollout line")?; if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rollout.item - && role == "developer" + && role == "user" { for item in content { - if let ContentItem::InputText { text } = item { - texts.push(text); + if let ContentItem::InputText { text } = item + && let Some(fragment) = parse_hook_prompt_fragment(&text) + { + texts.push(fragment.text); } } } @@ -90,6 +136,16 @@ fn rollout_developer_texts(text: &str) -> Result> { Ok(texts) } +fn request_hook_prompt_texts( + request: &core_test_support::responses::ResponsesRequest, +) -> Vec { + request + .message_input_texts("user") + .into_iter() + .filter_map(|text| parse_hook_prompt_fragment(&text).map(|fragment| fragment.text)) + .collect() +} + fn read_stop_hook_inputs(home: &Path) -> Result> { fs::read_to_string(home.join("stop_hook_log.jsonl")) .context("read stop hook log")? @@ -147,23 +203,18 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> { let requests = responses.requests(); assert_eq!(requests.len(), 3); - assert!( - requests[1] - .message_input_texts("developer") - .contains(&FIRST_CONTINUATION_PROMPT.to_string()), - "second request should include the first continuation prompt", + assert_eq!( + request_hook_prompt_texts(&requests[1]), + vec![FIRST_CONTINUATION_PROMPT.to_string()], + "second request should include the first continuation prompt as user hook context", ); - assert!( - requests[2] - .message_input_texts("developer") - .contains(&FIRST_CONTINUATION_PROMPT.to_string()), - "third request should retain the first continuation prompt from history", - ); - assert!( - requests[2] - .message_input_texts("developer") - .contains(&SECOND_CONTINUATION_PROMPT.to_string()), - "third request should include the second continuation prompt", + assert_eq!( + request_hook_prompt_texts(&requests[2]), + vec![ + FIRST_CONTINUATION_PROMPT.to_string(), + SECOND_CONTINUATION_PROMPT.to_string(), + ], + "third request should retain hook prompts in user history", ); let hook_inputs = read_stop_hook_inputs(test.codex_home_path())?; @@ -180,13 +231,13 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> { let rollout_path = test.codex.rollout_path().expect("rollout path"); let rollout_text = fs::read_to_string(&rollout_path)?; - let developer_texts = rollout_developer_texts(&rollout_text)?; + let hook_prompt_texts = rollout_hook_prompt_texts(&rollout_text)?; assert!( - developer_texts.contains(&FIRST_CONTINUATION_PROMPT.to_string()), + hook_prompt_texts.contains(&FIRST_CONTINUATION_PROMPT.to_string()), "rollout should persist the first continuation prompt", ); assert!( - developer_texts.contains(&SECOND_CONTINUATION_PROMPT.to_string()), + hook_prompt_texts.contains(&SECOND_CONTINUATION_PROMPT.to_string()), "rollout should persist the second continuation prompt", ); @@ -260,11 +311,76 @@ async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<() resumed.submit_turn("and now continue").await?; let resumed_request = resumed_response.single_request(); - assert!( - resumed_request - .message_input_texts("developer") - .contains(&FIRST_CONTINUATION_PROMPT.to_string()), - "resumed request should keep the persisted continuation prompt in history", + assert_eq!( + request_hook_prompt_texts(&resumed_request), + vec![FIRST_CONTINUATION_PROMPT.to_string()], + "resumed request should keep the persisted continuation prompt in user history", + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "draft one"), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "final draft"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_parallel_stop_hooks( + home, + &[FIRST_CONTINUATION_PROMPT, SECOND_CONTINUATION_PROMPT], + ) { + panic!("failed to write parallel stop hook fixtures: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn("hello again").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + request_hook_prompt_texts(&requests[1]), + vec![ + FIRST_CONTINUATION_PROMPT.to_string(), + SECOND_CONTINUATION_PROMPT.to_string(), + ], + "second request should receive one user hook prompt message with both fragments", + ); + + let rollout_path = test.codex.rollout_path().expect("rollout path"); + let rollout_text = fs::read_to_string(&rollout_path)?; + assert_eq!( + rollout_hook_prompt_texts(&rollout_text)?, + vec![ + FIRST_CONTINUATION_PROMPT.to_string(), + SECOND_CONTINUATION_PROMPT.to_string(), + ], + "rollout should preserve both hook prompt fragments in order", ); Ok(()) diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index ef3ab89a36..b5626c6919 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use codex_protocol::ThreadId; +use codex_protocol::items::HookPromptFragment; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookOutputEntry; @@ -34,7 +35,7 @@ pub struct StopOutcome { pub stop_reason: Option, pub should_block: bool, pub block_reason: Option, - pub continuation_prompt: Option, + pub continuation_fragments: Vec, } #[derive(Debug, Default, PartialEq, Eq)] @@ -43,7 +44,7 @@ struct StopHandlerData { stop_reason: Option, should_block: bool, block_reason: Option, - continuation_prompt: Option, + continuation_fragments: Vec, } pub(crate) fn preview( @@ -77,7 +78,7 @@ pub(crate) async fn run( stop_reason: None, should_block: false, block_reason: None, - continuation_prompt: None, + continuation_fragments: Vec::new(), }; } @@ -118,7 +119,7 @@ pub(crate) async fn run( stop_reason: aggregate.stop_reason, should_block: aggregate.should_block, block_reason: aggregate.block_reason, - continuation_prompt: aggregate.continuation_prompt, + continuation_fragments: aggregate.continuation_fragments, } } @@ -240,6 +241,14 @@ fn parse_completed( turn_id, run: dispatcher::completed_summary(handler, &run_result, status, entries), }; + let continuation_fragments = continuation_prompt + .map(|prompt| { + vec![HookPromptFragment::from_single_hook( + prompt, + completed.run.id.clone(), + )] + }) + .unwrap_or_default(); dispatcher::ParsedHandler { completed, @@ -248,7 +257,7 @@ fn parse_completed( stop_reason, should_block, block_reason, - continuation_prompt, + continuation_fragments, }, } } @@ -267,12 +276,14 @@ fn aggregate_results<'a>( } else { None }; - let continuation_prompt = if should_block { - join_block_text(results.iter().copied(), |result| { - result.continuation_prompt.as_deref() - }) + let continuation_fragments = if should_block { + results + .iter() + .filter(|result| result.should_block) + .flat_map(|result| result.continuation_fragments.clone()) + .collect() } else { - None + Vec::new() }; StopHandlerData { @@ -280,7 +291,7 @@ fn aggregate_results<'a>( stop_reason, should_block, block_reason, - continuation_prompt, + continuation_fragments, } } @@ -336,7 +347,7 @@ fn serialization_failure_outcome( stop_reason: None, should_block: false, block_reason: None, - continuation_prompt: None, + continuation_fragments: Vec::new(), } } @@ -350,6 +361,8 @@ mod tests { use codex_protocol::protocol::HookRunStatus; use pretty_assertions::assert_eq; + use codex_protocol::items::HookPromptFragment; + use super::StopHandlerData; use super::aggregate_results; use super::parse_completed; @@ -375,7 +388,10 @@ mod tests { stop_reason: None, should_block: true, block_reason: Some("retry with tests".to_string()), - continuation_prompt: Some("retry with tests".to_string()), + continuation_fragments: vec![HookPromptFragment { + text: "retry with tests".to_string(), + hook_run_ids: vec![parsed.completed.run.id.clone()], + }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); @@ -419,7 +435,7 @@ mod tests { stop_reason: Some("done".to_string()), should_block: false, block_reason: None, - continuation_prompt: None, + continuation_fragments: Vec::new(), } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped); @@ -440,7 +456,10 @@ mod tests { stop_reason: None, should_block: true, block_reason: Some("retry with tests".to_string()), - continuation_prompt: Some("retry with tests".to_string()), + continuation_fragments: vec![HookPromptFragment { + text: "retry with tests".to_string(), + hook_run_ids: vec![parsed.completed.run.id.clone()], + }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); @@ -509,14 +528,18 @@ mod tests { stop_reason: None, should_block: true, block_reason: Some("first".to_string()), - continuation_prompt: Some("first".to_string()), + continuation_fragments: vec![HookPromptFragment::from_single_hook( + "first", "run-1", + )], }, &StopHandlerData { should_stop: false, stop_reason: None, should_block: true, block_reason: Some("second".to_string()), - continuation_prompt: Some("second".to_string()), + continuation_fragments: vec![HookPromptFragment::from_single_hook( + "second", "run-2", + )], }, ]); @@ -527,7 +550,10 @@ mod tests { stop_reason: None, should_block: true, block_reason: Some("first\n\nsecond".to_string()), - continuation_prompt: Some("first\n\nsecond".to_string()), + continuation_fragments: vec![ + HookPromptFragment::from_single_hook("first", "run-1"), + HookPromptFragment::from_single_hook("second", "run-2"), + ], } ); } diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index f200fe6f75..2a8f54aee5 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -1,4 +1,6 @@ +use crate::models::ContentItem; use crate::models::MessagePhase; +use crate::models::ResponseItem; use crate::models::WebSearchAction; use crate::protocol::AgentMessageEvent; use crate::protocol::AgentReasoningEvent; @@ -21,6 +23,7 @@ use ts_rs::TS; #[ts(tag = "type")] pub enum TurnItem { UserMessage(UserMessageItem), + HookPrompt(HookPromptItem), AgentMessage(AgentMessageItem), Plan(PlanItem), Reasoning(ReasoningItem), @@ -35,6 +38,25 @@ pub struct UserMessageItem { pub content: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] +pub struct HookPromptItem { + pub id: String, + pub fragments: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct HookPromptFragment { + pub text: String, + pub hook_run_ids: Vec, +} + +const HOOK_PROMPT_OPEN_TAG_PREFIX: &str = ", fragments: Vec) -> Self { + Self { + id: id + .cloned() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + fragments, + } + } +} + +impl HookPromptFragment { + pub fn from_single_hook(text: impl Into, hook_run_id: impl Into) -> Self { + Self { + text: text.into(), + hook_run_ids: vec![hook_run_id.into()], + } + } +} + +pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option { + let content = fragments + .iter() + .filter(|fragment| !fragment.hook_run_ids.is_empty()) + .map(|fragment| { + ContentItem::InputText { + text: serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_ids), + } + }) + .collect::>(); + + if content.is_empty() { + return None; + } + + Some(ResponseItem::Message { + id: Some(uuid::Uuid::new_v4().to_string()), + role: "user".to_string(), + content, + end_turn: None, + phase: None, + }) +} + +pub fn parse_hook_prompt_message( + id: Option<&String>, + content: &[ContentItem], +) -> Option { + let fragments = content + .iter() + .map(|content_item| { + let ContentItem::InputText { text } = content_item else { + return None; + }; + parse_hook_prompt_fragment(text) + }) + .collect::>>()?; + + if fragments.is_empty() { + return None; + } + + Some(HookPromptItem::from_fragments(id, fragments)) +} + +pub fn parse_hook_prompt_fragment(text: &str) -> Option { + let trimmed = text.trim(); + if !trimmed.starts_with(HOOK_PROMPT_OPEN_TAG_PREFIX) + || !trimmed.ends_with(HOOK_PROMPT_CLOSE_TAG) + { + return None; + } + + let open_tag_end = trimmed.find('>')?; + let open_tag = &trimmed[..=open_tag_end]; + let hook_run_ids = parse_hook_prompt_hook_run_ids(open_tag)?; + if hook_run_ids.is_empty() { + return None; + } + + let body_start = open_tag_end + 1; + let body_end = trimmed.len().checked_sub(HOOK_PROMPT_CLOSE_TAG.len())?; + if body_end < body_start { + return None; + } + let body = &trimmed[body_start..body_end]; + + Some(HookPromptFragment { + text: unescape_hook_prompt_xml(body), + hook_run_ids, + }) +} + +fn serialize_hook_prompt_fragment(text: &str, hook_run_ids: &[String]) -> String { + let escaped_text = escape_hook_prompt_xml(text); + match hook_run_ids { + [hook_run_id] => format!( + r#"{escaped_text}"#, + hook_run_id = escape_hook_prompt_xml(hook_run_id), + ), + _ => { + let encoded_hook_run_ids = + serde_json::to_string(hook_run_ids).expect("hook prompt ids should serialize"); + format!( + r#"{escaped_text}"#, + hook_run_ids = escape_hook_prompt_xml(&encoded_hook_run_ids), + ) + } + } +} + +fn parse_hook_prompt_attribute(open_tag: &str, attribute_name: &str) -> Option { + let marker = format!(r#"{attribute_name}=""#); + let start = open_tag.find(&marker)? + marker.len(); + let value_end = open_tag[start..].find('"')?; + Some(unescape_hook_prompt_xml( + &open_tag[start..start + value_end], + )) +} + +fn parse_hook_prompt_hook_run_ids(open_tag: &str) -> Option> { + if let Some(encoded_hook_run_ids) = + parse_hook_prompt_attribute(open_tag, HOOK_PROMPT_RUN_IDS_ATTR) + { + let hook_run_ids = serde_json::from_str::>(&encoded_hook_run_ids).ok()?; + if hook_run_ids.iter().any(|hook_run_id| hook_run_id.trim().is_empty()) { + return None; + } + return Some(hook_run_ids); + } + + let hook_run_id = parse_hook_prompt_attribute(open_tag, HOOK_PROMPT_RUN_ID_ATTR)?; + if hook_run_id.trim().is_empty() { + return None; + } + Some(vec![hook_run_id]) +} + +fn escape_hook_prompt_xml(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn unescape_hook_prompt_xml(text: &str) -> String { + text.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + impl AgentMessageItem { pub fn new(content: &[AgentMessageContent]) -> Self { Self { @@ -266,6 +442,7 @@ impl TurnItem { pub fn id(&self) -> String { match self { TurnItem::UserMessage(item) => item.id.clone(), + TurnItem::HookPrompt(item) => item.id.clone(), TurnItem::AgentMessage(item) => item.id.clone(), TurnItem::Plan(item) => item.id.clone(), TurnItem::Reasoning(item) => item.id.clone(), @@ -278,6 +455,7 @@ impl TurnItem { pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { match self { TurnItem::UserMessage(item) => vec![item.as_legacy_event()], + TurnItem::HookPrompt(_) => Vec::new(), TurnItem::AgentMessage(item) => item.as_legacy_events(), TurnItem::Plan(_) => Vec::new(), TurnItem::WebSearch(item) => vec![item.as_legacy_event()], @@ -287,3 +465,42 @@ impl TurnItem { } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn hook_prompt_roundtrips_multiple_hook_run_ids() { + let original = HookPromptFragment { + text: "Retry with care & joy.".to_string(), + hook_run_ids: vec!["hook-run-1".to_string(), "hook-run-2".to_string()], + }; + let message = + build_hook_prompt_message(std::slice::from_ref(&original)).expect("hook prompt"); + + let ResponseItem::Message { content, .. } = message else { + panic!("expected hook prompt message"); + }; + + let parsed = parse_hook_prompt_message(None, &content).expect("parsed hook prompt"); + assert_eq!(parsed.fragments, vec![original]); + } + + #[test] + fn hook_prompt_parses_legacy_single_hook_run_id() { + let parsed = parse_hook_prompt_fragment( + r#"Retry with tests."#, + ) + .expect("legacy hook prompt"); + + assert_eq!( + parsed, + HookPromptFragment { + text: "Retry with tests.".to_string(), + hook_run_ids: vec!["hook-run-1".to_string()], + } + ); + } +}