diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dfa174a2c..b488f94cd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2338,6 +2338,7 @@ dependencies = [ "icu_locale_core", "icu_provider", "pretty_assertions", + "quick-xml", "schemars 0.8.22", "serde", "serde_json", @@ -7264,6 +7265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", + "serde", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0cac1be8b..edcd98fbb 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -232,6 +232,7 @@ portable-pty = "0.9.0" predicates = "3" pretty_assertions = "1.4.1" pulldown-cmark = "0.10" +quick-xml = "0.38.4" rand = "0.9" ratatui = "0.29.0" ratatui-macros = "0.6.0" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5626d698a..79f497d9a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1201,6 +1201,21 @@ ], "type": "string" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "HookRunStatus": { "enum": [ "running", @@ -2257,6 +2272,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 e20953a23..38f0d3a91 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 @@ -7971,6 +7971,21 @@ ], "type": "string" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "HookRunStatus": { "enum": [ "running", @@ -11954,6 +11969,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 cb7b2c3a0..313494c67 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 @@ -4715,6 +4715,21 @@ ], "type": "string" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "HookRunStatus": { "enum": [ "running", @@ -9714,6 +9729,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 f3505efe6..3b9746620 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -266,6 +266,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -496,6 +511,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 cfb2fa930..b77b34536 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -266,6 +266,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -496,6 +511,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 9ecf2f399..7f4a2b1f4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -380,6 +380,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -610,6 +625,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 9aeaed165..44734226d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -465,6 +465,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1103,6 +1118,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 932e0ec9a..766fe48ce 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,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 c231066a7..1ef137f9e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,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 f120b4e91..3b7726c42 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,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 bf38037e9..ba42df4ac 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -465,6 +465,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1103,6 +1118,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 b27c8ee94..bb9dcbdd9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,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 77d0fd94a..ba7138320 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -465,6 +465,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1103,6 +1118,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 87932ae6a..53806b272 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,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 b2ded079f..3430d24e3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,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 0a1527f4f..40ce73e52 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -380,6 +380,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -610,6 +625,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 b7accf4c2..954321c16 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -380,6 +380,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -610,6 +625,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 6653cc81d..66ce68373 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -380,6 +380,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -610,6 +625,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 000000000..2c6b18acb --- /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, hookRunId: string, }; 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 280f862a3..f1f864ae4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -13,6 +13,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"; @@ -21,7 +22,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, memoryCitation: MemoryCitation | 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, memoryCitation: MemoryCitation | 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 3dcf98ae3..27cbd842f 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 128e2a3ce..d7482b10c 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; @@ -184,12 +185,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, @@ -281,6 +307,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(_) @@ -301,6 +328,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(_) @@ -1149,8 +1177,10 @@ mod tests { use crate::protocol::v2::CommandExecutionSource; 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; @@ -2645,4 +2675,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_id: "hook-run-1".into(), + }, + crate::protocol::v2::HookPromptFragment { + text: "Then summarize cleanly.".into(), + hook_run_id: "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 b534510b7..1d31986e3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4127,6 +4127,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, @@ -4260,10 +4266,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_id: String, +} + impl ThreadItem { pub fn id(&self) -> &str { match self { ThreadItem::UserMessage { id, .. } + | ThreadItem::HookPrompt { id, .. } | ThreadItem::AgentMessage { id, .. } | ThreadItem::Plan { id, .. } | ThreadItem::Reasoning { id, .. } @@ -4373,6 +4388,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 @@ -4415,6 +4438,15 @@ impl From for ThreadItem { } } +impl From for HookPromptFragment { + fn from(value: codex_protocol::items::HookPromptFragment) -> Self { + Self { + text: value.text, + hook_run_id: value.hook_run_id, + } + } +} + #[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 780c06a52..26e0e8bb1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -111,6 +111,7 @@ 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::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; @@ -1484,6 +1485,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, @@ -1989,6 +1998,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>, @@ -2760,6 +2812,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; @@ -3794,4 +3848,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_id: "hook-run-1".into(), + }, + codex_app_server_protocol::HookPromptFragment { + text: "Then summarize cleanly.".into(), + hook_run_id: "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/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index e235ee6b6..6232763c8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -13,7 +13,6 @@ use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; use codex_app_server_protocol::ClientInfo; -use codex_app_server_protocol::CollabAgentState; use codex_app_server_protocol::CollabAgentStatus; use codex_app_server_protocol::CollabAgentTool; use codex_app_server_protocol::CollabAgentToolCallStatus; @@ -1826,16 +1825,18 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() assert_eq!(prompt, Some(CHILD_PROMPT.to_string())); assert_eq!(model, Some(REQUESTED_MODEL.to_string())); assert_eq!(reasoning_effort, Some(REQUESTED_REASONING_EFFORT)); - assert_eq!( - agents_states, - HashMap::from([( - receiver_thread_id, - CollabAgentState { - status: CollabAgentStatus::PendingInit, - message: None, - }, - )]) + let agent_state = agents_states + .get(&receiver_thread_id) + .expect("spawn completion should include child agent state"); + assert!( + matches!( + agent_state.status, + CollabAgentStatus::PendingInit | CollabAgentStatus::Running + ), + "child agent should still be initializing or already running, got {:?}", + agent_state.status ); + assert_eq!(agent_state.message, None); let turn_completed = timeout(DEFAULT_READ_TIMEOUT, async { loop { @@ -2008,16 +2009,18 @@ config_file = "./custom-role.toml" assert_eq!(prompt, Some(CHILD_PROMPT.to_string())); assert_eq!(model, Some(ROLE_MODEL.to_string())); assert_eq!(reasoning_effort, Some(ROLE_REASONING_EFFORT)); - assert_eq!( - agents_states, - HashMap::from([( - receiver_thread_id, - CollabAgentState { - status: CollabAgentStatus::PendingInit, - message: None, - }, - )]) + let agent_state = agents_states + .get(&receiver_thread_id) + .expect("spawn completion should include child agent state"); + assert!( + matches!( + agent_state.status, + CollabAgentStatus::PendingInit | CollabAgentStatus::Running + ), + "child agent should still be initializing or already running, got {:?}", + agent_state.status ); + assert_eq!(agent_state.message, None); let turn_completed = timeout(DEFAULT_READ_TIMEOUT, async { loop { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8197c8cb6..76ef376d1 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -87,6 +87,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; @@ -5734,13 +5735,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 9439d8125..28be57431 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 d09e100fe..f990a80dc 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 f7612fe8e..4df05f0da 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 1fc6de9a8..f71ca35f6 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_id: "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 7a9cdb390..ad776d142 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 { @@ -100,7 +101,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 7a9b7076b..553550d74 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_id: "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_id: "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 793b4fedb..0334db8b4 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::EventMsg; @@ -82,6 +83,48 @@ else: Ok(()) } +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:?}}})) +"# + ); + 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 write_user_prompt_submit_hook( home: &Path, blocked_prompt: &str, @@ -168,7 +211,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: Ok(()) } -fn rollout_developer_texts(text: &str) -> Result> { +fn rollout_hook_prompt_texts(text: &str) -> Result> { let mut texts = Vec::new(); for line in text.lines() { let trimmed = line.trim(); @@ -177,11 +220,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); } } } @@ -189,6 +234,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")? @@ -298,23 +353,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())?; @@ -356,13 +406,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", ); @@ -481,11 +531,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/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 639561179..860d83fe9 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -683,6 +683,8 @@ async fn remote_models_do_not_append_removed_builtin_presets() -> Result<()> { 1, "expected a single /models request" ); + // Keep the mock server alive until after async assertions complete. + drop(server); Ok(()) } diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 837f287af..3d94e321c 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; @@ -36,7 +37,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)] @@ -45,7 +46,7 @@ struct StopHandlerData { stop_reason: Option, should_block: bool, block_reason: Option, - continuation_prompt: Option, + continuation_fragments: Vec, } pub(crate) fn preview( @@ -72,7 +73,7 @@ pub(crate) async fn run( stop_reason: None, should_block: false, block_reason: None, - continuation_prompt: None, + continuation_fragments: Vec::new(), }; } @@ -115,7 +116,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, } } @@ -239,6 +240,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, @@ -247,7 +256,7 @@ fn parse_completed( stop_reason, should_block, block_reason, - continuation_prompt, + continuation_fragments, }, } } @@ -269,15 +278,14 @@ fn aggregate_results<'a>( } else { None }; - let continuation_prompt = if should_block { - common::join_text_chunks( - results - .iter() - .filter_map(|result| result.continuation_prompt.clone()) - .collect(), - ) + 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 { @@ -285,7 +293,7 @@ fn aggregate_results<'a>( stop_reason, should_block, block_reason, - continuation_prompt, + continuation_fragments, } } @@ -296,7 +304,7 @@ fn serialization_failure_outcome(hook_events: Vec) -> StopOu stop_reason: None, should_block: false, block_reason: None, - continuation_prompt: None, + continuation_fragments: Vec::new(), } } @@ -310,6 +318,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; @@ -335,7 +345,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_id: parsed.completed.run.id.clone(), + }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); @@ -379,7 +392,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); @@ -400,7 +413,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_id: parsed.completed.run.id.clone(), + }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); @@ -469,14 +485,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", + )], }, ]); @@ -487,7 +507,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/Cargo.toml b/codex-rs/protocol/Cargo.toml index b0f199466..11efa9d37 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -19,6 +19,7 @@ codex-utils-image = { workspace = true } icu_decimal = { workspace = true } icu_locale_core = { workspace = true } icu_provider = { workspace = true, features = ["sync"] } +quick-xml = { workspace = true, features = ["serialize"] } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 08e50b954..36c8cdbae 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -1,5 +1,7 @@ use crate::memory_citation::MemoryCitation; +use crate::models::ContentItem; use crate::models::MessagePhase; +use crate::models::ResponseItem; use crate::models::WebSearchAction; use crate::protocol::AgentMessageEvent; use crate::protocol::AgentReasoningEvent; @@ -12,6 +14,8 @@ use crate::protocol::WebSearchEndEvent; use crate::user_input::ByteRange; use crate::user_input::TextElement; use crate::user_input::UserInput; +use quick_xml::de::from_str as from_xml_str; +use quick_xml::se::to_string as to_xml_string; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -22,6 +26,7 @@ use ts_rs::TS; #[ts(tag = "type")] pub enum TurnItem { UserMessage(UserMessageItem), + HookPrompt(HookPromptItem), AgentMessage(AgentMessageItem), Plan(PlanItem), Reasoning(ReasoningItem), @@ -36,6 +41,29 @@ 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_id: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename = "hook_prompt")] +struct HookPromptXml { + #[serde(rename = "@hook_run_id")] + hook_run_id: String, + #[serde(rename = "$text")] + text: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] #[serde(tag = "type")] #[ts(tag = "type")] @@ -199,6 +227,91 @@ impl UserMessageItem { } } +impl HookPromptItem { + pub fn from_fragments(id: Option<&String>, 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_id: hook_run_id.into(), + } + } +} + +pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option { + let content = fragments + .iter() + .filter(|fragment| !fragment.hook_run_id.trim().is_empty()) + .filter_map(|fragment| { + serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_id) + .map(|text| ContentItem::InputText { text }) + }) + .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(); + let HookPromptXml { text, hook_run_id } = from_xml_str::(trimmed).ok()?; + if hook_run_id.trim().is_empty() { + return None; + } + + Some(HookPromptFragment { text, hook_run_id }) +} + +fn serialize_hook_prompt_fragment(text: &str, hook_run_id: &str) -> Option { + if hook_run_id.trim().is_empty() { + return None; + } + to_xml_string(&HookPromptXml { + text: text.to_string(), + hook_run_id: hook_run_id.to_string(), + }) + .ok() +} + impl AgentMessageItem { pub fn new(content: &[AgentMessageContent]) -> Self { Self { @@ -272,6 +385,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(), @@ -284,6 +398,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()], @@ -293,3 +408,41 @@ impl TurnItem { } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn hook_prompt_roundtrips_multiple_fragments() { + let original = vec![ + HookPromptFragment::from_single_hook("Retry with care & joy.", "hook-run-1"), + HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"), + ]; + let message = build_hook_prompt_message(&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, 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_id: "hook-run-1".to_string(), + } + ); + } +} diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 262cd4216..2f3118a8b 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -870,6 +870,7 @@ fn turn_snapshot_events( }), ); } + TurnItem::HookPrompt(_) => {} } } @@ -1010,6 +1011,7 @@ fn thread_item_to_core(item: &ThreadItem) -> Option { | ThreadItem::McpToolCall { .. } | ThreadItem::DynamicToolCall { .. } | ThreadItem::CollabAgentToolCall { .. } + | ThreadItem::HookPrompt { .. } | ThreadItem::ImageView { .. } | ThreadItem::EnteredReviewMode { .. } | ThreadItem::ExitedReviewMode { .. } => { diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index f91ebbaea..d76751c14 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -5733,6 +5733,7 @@ impl ChatWidget { ThreadItem::ContextCompaction { .. } => { self.on_agent_message("Context compacted".to_owned()); } + ThreadItem::HookPrompt { .. } => {} ThreadItem::CollabAgentToolCall { id, tool,