Compare commits

...

5 Commits

Author SHA1 Message Date
Andrei Eternal
796438281e tui_app_server: remove dead replay helpers 2026-03-17 12:28:12 -07:00
Andrei Eternal
7b8c000a73 tui_app_server: fix hook prompt replay build after rebase 2026-03-17 12:08:21 -07:00
Andrei Eternal
94d66dc421 codex: fix CI failure on PR #14867 2026-03-17 12:01:30 -07:00
Andrei Eternal
e837ce9e02 tui_app_server: handle hook prompt items 2026-03-17 12:01:30 -07:00
Andrei Eternal
9ad2b001f0 use a user message > developer message for prompt continuation
# Conflicts:
#	codex-rs/app-server/src/bespoke_event_handling.rs
#	codex-rs/core/src/contextual_user_message.rs
#	codex-rs/core/src/contextual_user_message_tests.rs
2026-03-17 11:59:50 -07:00
35 changed files with 1623 additions and 69 deletions

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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<string>, };

View File

@@ -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<UserInput>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
export type ThreadItem = { "type": "userMessage", id: string, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
/**
* The command to be executed.
*/

View File

@@ -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";

View File

@@ -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());
}
}

View File

@@ -4128,6 +4128,12 @@ pub enum ThreadItem {
UserMessage { id: String, content: Vec<UserInput> },
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
HookPrompt {
id: String,
fragments: Vec<HookPromptFragment>,
},
#[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<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, .. }
@@ -4370,6 +4385,14 @@ impl From<CoreTurnItem> 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<CoreTurnItem> for ThreadItem {
}
}
impl From<codex_protocol::items::HookPromptFragment> 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/")]

View File

@@ -110,6 +110,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;
@@ -1482,6 +1483,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 +1989,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<Mutex<ThreadState>>,
@@ -2750,6 +2802,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 +3838,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(())
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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<HookPromptItem> {
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;

View File

@@ -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" & <tides>"#,
"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" & <tides>"#.to_string(),
hook_run_ids: vec!["hook-run-1".to_string()],
}],
);
assert!(!text.contains("&quot;waves&quot; & <tides>"));
}

View File

@@ -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<TurnItem> {
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,

View File

@@ -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: "<environment_context>ctx</environment_context>".to_string(),
},
ContentItem::InputText {
text:
"<hook_prompt hook_run_id=\"hook-run-1\">Retry with care &amp; joy.</hook_prompt>"
.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 {

View File

@@ -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,49 @@ else:
Ok(())
}
fn rollout_developer_texts(text: &str) -> Result<Vec<String>> {
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::<Result<Vec<_>>>()?;
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<Vec<String>> {
let mut texts = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
@@ -78,11 +121,13 @@ fn rollout_developer_texts(text: &str) -> Result<Vec<String>> {
}
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 +135,16 @@ fn rollout_developer_texts(text: &str) -> Result<Vec<String>> {
Ok(texts)
}
fn request_hook_prompt_texts(
request: &core_test_support::responses::ResponsesRequest,
) -> Vec<String> {
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<Vec<serde_json::Value>> {
fs::read_to_string(home.join("stop_hook_log.jsonl"))
.context("read stop hook log")?
@@ -147,23 +202,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 +230,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 +310,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(())

View File

@@ -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<String>,
pub should_block: bool,
pub block_reason: Option<String>,
pub continuation_prompt: Option<String>,
pub continuation_fragments: Vec<HookPromptFragment>,
}
#[derive(Debug, Default, PartialEq, Eq)]
@@ -43,7 +44,7 @@ struct StopHandlerData {
stop_reason: Option<String>,
should_block: bool,
block_reason: Option<String>,
continuation_prompt: Option<String>,
continuation_fragments: Vec<HookPromptFragment>,
}
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"),
],
}
);
}

View File

@@ -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<UserInput>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
pub struct HookPromptItem {
pub id: String,
pub fragments: Vec<HookPromptFragment>,
}
#[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<String>,
}
const HOOK_PROMPT_OPEN_TAG_PREFIX: &str = "<hook_prompt";
const HOOK_PROMPT_CLOSE_TAG: &str = "</hook_prompt>";
const HOOK_PROMPT_RUN_ID_ATTR: &str = "hook_run_id";
const HOOK_PROMPT_RUN_IDS_ATTR: &str = "hook_run_ids";
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
#[serde(tag = "type")]
#[ts(tag = "type")]
@@ -195,6 +217,161 @@ impl UserMessageItem {
}
}
impl HookPromptItem {
pub fn from_fragments(id: Option<&String>, fragments: Vec<HookPromptFragment>) -> 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<String>, hook_run_id: impl Into<String>) -> Self {
Self {
text: text.into(),
hook_run_ids: vec![hook_run_id.into()],
}
}
}
pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option<ResponseItem> {
let content = fragments
.iter()
.filter(|fragment| !fragment.hook_run_ids.is_empty())
.filter_map(|fragment| {
serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_ids)
.map(|text| ContentItem::InputText { text })
})
.collect::<Vec<_>>();
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<HookPromptItem> {
let fragments = content
.iter()
.map(|content_item| {
let ContentItem::InputText { text } = content_item else {
return None;
};
parse_hook_prompt_fragment(text)
})
.collect::<Option<Vec<_>>>()?;
if fragments.is_empty() {
return None;
}
Some(HookPromptItem::from_fragments(id, fragments))
}
pub fn parse_hook_prompt_fragment(text: &str) -> Option<HookPromptFragment> {
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]) -> Option<String> {
let escaped_text = escape_hook_prompt_xml(text);
match hook_run_ids {
[hook_run_id] => Some(format!(
r#"<hook_prompt {HOOK_PROMPT_RUN_ID_ATTR}="{hook_run_id}">{escaped_text}</hook_prompt>"#,
hook_run_id = escape_hook_prompt_xml(hook_run_id),
)),
_ => {
let encoded_hook_run_ids = serde_json::to_string(hook_run_ids).ok()?;
Some(format!(
r#"<hook_prompt {HOOK_PROMPT_RUN_IDS_ATTR}="{hook_run_ids}">{escaped_text}</hook_prompt>"#,
hook_run_ids = escape_hook_prompt_xml(&encoded_hook_run_ids),
))
}
}
}
fn parse_hook_prompt_attribute(open_tag: &str, attribute_name: &str) -> Option<String> {
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<Vec<String>> {
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::<Vec<String>>(&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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn unescape_hook_prompt_xml(text: &str) -> String {
text.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&")
}
impl AgentMessageItem {
pub fn new(content: &[AgentMessageContent]) -> Self {
Self {
@@ -266,6 +443,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 +456,7 @@ impl TurnItem {
pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {
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 +466,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#"<hook_prompt hook_run_id="hook-run-1">Retry with tests.</hook_prompt>"#,
)
.expect("legacy hook prompt");
assert_eq!(
parsed,
HookPromptFragment {
text: "Retry with tests.".to_string(),
hook_run_ids: vec!["hook-run-1".to_string()],
}
);
}
}

View File

@@ -497,6 +497,7 @@ fn turn_snapshot_events(
}),
);
}
TurnItem::HookPrompt(_) => {}
}
}
@@ -615,6 +616,7 @@ fn thread_item_to_core(item: &ThreadItem) -> Option<TurnItem> {
| ThreadItem::McpToolCall { .. }
| ThreadItem::DynamicToolCall { .. }
| ThreadItem::CollabAgentToolCall { .. }
| ThreadItem::HookPrompt { .. }
| ThreadItem::ImageView { .. }
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. } => {