mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
Version answer display instructions and restrict unsupported payloads (#60904)
## What changed - Replace free-form `AgentMessageDisplay` actions with a version `1` contract using `show`, `hide`, and `block` modes, with typed annotations, replacements, block reasons, and trusted-contact handling. - Discard invalid or unsupported display instructions and set `moderated` to `true` when reading moderation data in Rust and the Python SDK. - Update generated JSON schemas, TypeScript types, and Python SDK models to match the contract. ## Testing Add coverage for display variants, tolerance of extra fields, and unsupported-version or unknown-mode fallback. Update the final-answer moderation test and add Python coverage for thread history and item notifications. GitOrigin-RevId: ec64070002da67e8e377dccb3728806ac8b290e7
This commit is contained in:
@@ -612,6 +612,30 @@ def generate_v2_all(schema_dir: Path, *, format_output: bool = True) -> None:
|
||||
_preserve_thread_source_enum(out_path)
|
||||
_preserve_open_enum(out_path, "PlanType")
|
||||
_normalize_generated_timestamps(out_path)
|
||||
_add_answer_display_fallback(out_path)
|
||||
|
||||
|
||||
def _add_answer_display_fallback(out_path: Path) -> None:
|
||||
"""Keep items readable and restricted when their display instruction is unsupported."""
|
||||
source = out_path.read_text()
|
||||
header = "class AgentMessageModeration(BaseModel):"
|
||||
if source.count(header) != 1:
|
||||
raise RuntimeError("Generated SDK is missing a unique AgentMessageModeration class")
|
||||
source = source.replace(
|
||||
"from pydantic import ", "from pydantic import ValidationError, model_validator, ", 1
|
||||
)
|
||||
validator = """
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _read_display(cls, data: Any) -> Any:
|
||||
if not isinstance(data, dict) or "moderated" not in data or data.get("display") is None:
|
||||
return data
|
||||
try:
|
||||
return {**data, "display": AgentMessageDisplay.model_validate(data["display"])}
|
||||
except ValidationError:
|
||||
return {**data, "moderated": True, "display": None}
|
||||
"""
|
||||
out_path.write_text(source.replace(header, header + "\n" + validator, 1))
|
||||
|
||||
|
||||
def _preserve_inline_media_class_names(out_path: Path) -> None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# filename: codex_app_server_protocol.v2.schemas.json
|
||||
|
||||
from __future__ import annotations
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel
|
||||
from pydantic import ValidationError, model_validator, BaseModel, ConfigDict, Field, RootModel
|
||||
from typing import Annotated, Any, Literal
|
||||
from enum import Enum
|
||||
|
||||
@@ -149,18 +149,6 @@ class AgentMessageDeltaNotification(BaseModel):
|
||||
turn_id: Annotated[str, Field(alias="turnId")]
|
||||
|
||||
|
||||
class AgentMessageDisplay(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
action: Annotated[
|
||||
str, Field(description="Unknown actions must be left for the producing client to handle.")
|
||||
]
|
||||
block_type: Annotated[str | None, Field(alias="blockType")] = None
|
||||
check_trusted_contacts: Annotated[bool | None, Field(alias="checkTrustedContacts")] = None
|
||||
cyber_trusted_access: Annotated[bool | None, Field(alias="cyberTrustedAccess")] = None
|
||||
|
||||
|
||||
class InputTextAgentMessageInputContent(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -185,14 +173,6 @@ class AgentMessageInputContent(
|
||||
root: InputTextAgentMessageInputContent | EncryptedContentAgentMessageInputContent
|
||||
|
||||
|
||||
class AgentMessageModeration(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
display: AgentMessageDisplay | None = None
|
||||
moderated: bool
|
||||
|
||||
|
||||
class AgentPath(RootModel[str]):
|
||||
root: str
|
||||
|
||||
@@ -210,6 +190,17 @@ class AnalyticsConfig(BaseModel):
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class WellbeingPromptHelplineAnswerReplacement(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
kind: Literal["wellbeingPromptHelpline"]
|
||||
|
||||
|
||||
class AnswerReplacement(RootModel[WellbeingPromptHelplineAnswerReplacement]):
|
||||
root: WellbeingPromptHelplineAnswerReplacement
|
||||
|
||||
|
||||
class AppBranding(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -470,6 +461,32 @@ class BedrockProviderId(Enum):
|
||||
amazon_bedrock_runtime = "amazon-bedrock-runtime"
|
||||
|
||||
|
||||
class BlockReason1(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
kind: Literal["protection"]
|
||||
protection_type: Annotated[
|
||||
str,
|
||||
Field(
|
||||
alias="protectionType",
|
||||
description="Identifies the same moderation row that Chat writes on the live path.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class KindBlockReason(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
kind: Literal["cyber"]
|
||||
trusted_access: Annotated[bool | None, Field(alias="trustedAccess")] = None
|
||||
|
||||
|
||||
class BlockReason(RootModel[BlockReason1 | KindBlockReason]):
|
||||
root: BlockReason1 | KindBlockReason
|
||||
|
||||
|
||||
class BrowserUseAccessApprovalLifetime(Enum):
|
||||
turn = "turn"
|
||||
thread = "thread"
|
||||
@@ -2062,6 +2079,11 @@ class GuardianWarningNotification(BaseModel):
|
||||
]
|
||||
|
||||
|
||||
class HelplineSubject(Enum):
|
||||
answer = "answer"
|
||||
prompt = "prompt"
|
||||
|
||||
|
||||
class HookErrorInfo(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -6919,6 +6941,11 @@ class ToolExposureSurface(Enum):
|
||||
direct = "direct"
|
||||
|
||||
|
||||
class TrustedContacts(Enum):
|
||||
check = "check"
|
||||
skip = "skip"
|
||||
|
||||
|
||||
class TurnDiffUpdatedNotification(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -7342,6 +7369,36 @@ class AdditionalContextEntry(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class HideAgentMessageDisplay(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
mode: Annotated[Literal["hide"], Field(title="HideAgentMessageDisplayMode")]
|
||||
replacement: AnswerReplacement
|
||||
version: Literal[1]
|
||||
|
||||
|
||||
class BlockAgentMessageDisplay(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
mode: Annotated[Literal["block"], Field(title="BlockAgentMessageDisplayMode")]
|
||||
reason: BlockReason
|
||||
version: Literal[1]
|
||||
|
||||
|
||||
class AnswerAnnotation1(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
kind: Literal["wellbeingHelpline"]
|
||||
subject: HelplineSubject
|
||||
|
||||
|
||||
class AnswerAnnotation(RootModel[AnswerAnnotation1]):
|
||||
root: AnswerAnnotation1
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -10808,34 +10865,6 @@ class UserMessageThreadItem(BaseModel):
|
||||
type: Annotated[Literal["userMessage"], Field(title="UserMessageThreadItemType")]
|
||||
|
||||
|
||||
class AgentMessageThreadItem(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
completed_at_ms: Annotated[
|
||||
int | None,
|
||||
Field(
|
||||
alias="completedAtMs",
|
||||
description="Unix timestamp in milliseconds when the item completed, if recorded.",
|
||||
),
|
||||
] = None
|
||||
delivery: AgentMessageDelivery | None = None
|
||||
id: str
|
||||
memory_citation: Annotated[MemoryCitation | None, Field(alias="memoryCitation")] = None
|
||||
moderation: AgentMessageModeration | None = None
|
||||
phase: MessagePhase | None = None
|
||||
questions: list[AsyncUserInputQuestion] | None = None
|
||||
started_at_ms: Annotated[
|
||||
int | None,
|
||||
Field(
|
||||
alias="startedAtMs",
|
||||
description="Unix timestamp in milliseconds when the item started, if recorded.",
|
||||
),
|
||||
] = None
|
||||
text: str
|
||||
type: Annotated[Literal["agentMessage"], Field(title="AgentMessageThreadItemType")]
|
||||
|
||||
|
||||
class CommandExecutionThreadItem(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -11524,6 +11553,44 @@ class AccountSession(BaseModel):
|
||||
workspaces: list[AccountSessionWorkspace]
|
||||
|
||||
|
||||
class ShowAgentMessageDisplay(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
annotation: AnswerAnnotation | None = None
|
||||
mode: Annotated[Literal["show"], Field(title="ShowAgentMessageDisplayMode")]
|
||||
trusted_contacts: Annotated[TrustedContacts, Field(alias="trustedContacts")]
|
||||
version: Literal[1]
|
||||
|
||||
|
||||
class AgentMessageDisplay(
|
||||
RootModel[ShowAgentMessageDisplay | HideAgentMessageDisplay | BlockAgentMessageDisplay]
|
||||
):
|
||||
root: Annotated[
|
||||
ShowAgentMessageDisplay | HideAgentMessageDisplay | BlockAgentMessageDisplay,
|
||||
Field(description="How Chat should present a recovered answer."),
|
||||
]
|
||||
|
||||
|
||||
class AgentMessageModeration(BaseModel):
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _read_display(cls, data: Any) -> Any:
|
||||
if not isinstance(data, dict) or "moderated" not in data or data.get("display") is None:
|
||||
return data
|
||||
try:
|
||||
return {**data, "display": AgentMessageDisplay.model_validate(data["display"])}
|
||||
except ValidationError:
|
||||
return {**data, "moderated": True, "display": None}
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
display: AgentMessageDisplay | None = None
|
||||
moderated: bool
|
||||
|
||||
|
||||
class AppInfo(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -12517,6 +12584,34 @@ class SessionSource(RootModel[SessionSourceValue | CustomSessionSource | SubAgen
|
||||
root: SessionSourceValue | CustomSessionSource | SubAgentSessionSource
|
||||
|
||||
|
||||
class AgentMessageThreadItem(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
completed_at_ms: Annotated[
|
||||
int | None,
|
||||
Field(
|
||||
alias="completedAtMs",
|
||||
description="Unix timestamp in milliseconds when the item completed, if recorded.",
|
||||
),
|
||||
] = None
|
||||
delivery: AgentMessageDelivery | None = None
|
||||
id: str
|
||||
memory_citation: Annotated[MemoryCitation | None, Field(alias="memoryCitation")] = None
|
||||
moderation: AgentMessageModeration | None = None
|
||||
phase: MessagePhase | None = None
|
||||
questions: list[AsyncUserInputQuestion] | None = None
|
||||
started_at_ms: Annotated[
|
||||
int | None,
|
||||
Field(
|
||||
alias="startedAtMs",
|
||||
description="Unix timestamp in milliseconds when the item started, if recorded.",
|
||||
),
|
||||
] = None
|
||||
text: str
|
||||
type: Annotated[Literal["agentMessage"], Field(title="AgentMessageThreadItemType")]
|
||||
|
||||
|
||||
class FunctionCallOutputThreadItem(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
|
||||
@@ -142,3 +142,37 @@ def test_model_provider_errors_round_trip_wire_names(payload):
|
||||
|
||||
error = ModelProviderError.model_validate(payload)
|
||||
assert error.model_dump(by_alias=True) == payload
|
||||
|
||||
|
||||
def test_answer_display_is_read_in_thread_history_and_notifications():
|
||||
from openai_codex.generated.v2_all import ItemCompletedNotification, ThreadItemsListResponse
|
||||
|
||||
display = {
|
||||
"version": 1,
|
||||
"mode": "show",
|
||||
"trustedContacts": "check",
|
||||
"annotation": {"kind": "wellbeingHelpline", "subject": "answer"},
|
||||
}
|
||||
item = {
|
||||
"id": "a",
|
||||
"type": "agentMessage",
|
||||
"text": "answer",
|
||||
"moderation": {"moderated": True, "display": {**display, "additionalV1Field": True}},
|
||||
}
|
||||
history = ThreadItemsListResponse.model_validate({"data": [{"turnId": "u", "item": item}]})
|
||||
event = ItemCompletedNotification.model_validate(
|
||||
{"threadId": "t", "turnId": "u", "completedAtMs": 1, "item": item}
|
||||
)
|
||||
for envelope in (history.data[0].item, event.item):
|
||||
assert (
|
||||
envelope.root.moderation.display.model_dump(
|
||||
mode="json", by_alias=True, exclude_none=True
|
||||
)
|
||||
== display
|
||||
)
|
||||
item["moderation"] = {"moderated": False, "display": {"version": 2, "mode": "new"}}
|
||||
moderated = ItemCompletedNotification.model_validate(
|
||||
{"threadId": "t", "turnId": "u", "completedAtMs": 1, "item": item}
|
||||
).item.root.moderation
|
||||
assert moderated.moderated is True
|
||||
assert moderated.display is None
|
||||
|
||||
Reference in New Issue
Block a user