Skip to content

feat(protocols): implement P3 EasyInputMessage.phase - #1281

Merged
slin1237 merged 3 commits into
mainfrom
feat/audit-p3-phase
Apr 21, 2026
Merged

slin1237 merged 3 commits into
mainfrom
feat/audit-p3-phase

Conversation

@slin1237

@slin1237 slin1237 commented Apr 21, 2026 •

Copy link
Copy Markdown
Member

Summary

Implements audit task P3: add the typed phase field (MessagePhase { Commentary, FinalAnswer }) to EasyInputMessage / ResponseInputOutputItem::Message / ResponseOutputItem::Message per spec, and thread it end-to-end through conversation persistence so phase survives multi-turn store + retrieve round-trips. Unblocks audit task I1 (which depends on the typed Message variant distinction).

What changed

Commits on branch (main..HEAD):

  • b4ca9343 — feat(protocols): implement P3 EasyInputMessage.phase (substance)
  • e2d308fc — refactor(protocols): drop dead new_message_with_phase helper (P3 cycle 2) (cycle-1 REJECT remediation — removed 0-callsite pub fn per §7)
  • 668cdcc5 — style(protocols): rustfmt remediation for P3 cycle-1 persistence_utils (cycle-2 fmt fix for a cycle-1 artifact caught by the cycle-2 Lead-requested gate expansion)

Diff totals (vs main): 13 files, +146 / -10.

  • crates/protocols/src/responses.rs: MessagePhase enum (commentary | final_answer with serde renames), phase: Option<MessagePhase> added to 3 message variants per spec, normalize_input_item forwards phase, new_message builder preserves signature (phase: None default), 2 existing constructor helpers threaded through.
  • crates/protocols/src/builders/responses/response.rs: 2 test-literal phase: None additions (compile-forced).
  • crates/mcp/src/core/session.rs: 2 test-literal phase: None additions in #[cfg(test)] block (compile-forced).
  • model_gateway/benches/routing_allocation_bench.rs: 1 phase: None in bench (verified compile-forced by revert → error[E0063]: missing field 'phase').
  • model_gateway/tests/spec/responses.rs: 2 test-literal phase: None additions (compile-forced).
  • model_gateway/src/routers/grpc/harmony/{processor.rs, responses/common.rs, responses/non_streaming.rs}: 5 phase: None additions (compile-forced).
  • model_gateway/src/routers/grpc/regular/responses/{common.rs, conversions.rs, streaming.rs}: 7 phase: None additions + load path via split_stored_message_content (compile-forced + AC-required).
  • model_gateway/src/routers/openai/responses/history.rs: load path via split_stored_message_content + 1 phase: None (AC-required + compile-forced).
  • model_gateway/src/routers/common/persistence_utils.rs: split_stored_message_content helper (3 callsites → §7 passes) + wrapping-object persistence to carry phase alongside content array + cycle-2 rustfmt on let-chain at L315.

Why

OpenAI Responses API spec requires phase ∈ {commentary, final_answer} on message items to distinguish intermediate scaffolding text from the final answer surface. smg previously had no such field, so round-tripping a spec-compliant assistant message through conversation storage lost the phase tag silently. This PR:

  • Adds the typed field to all three message variants.
  • Wraps stored messages as {"content":[...], "phase":"..."} when phase is present, with a backward-compat decode path for legacy bare-array rows (zero-schema-migration on the data-connector side; content column stays a Value blob).
  • Uses #[serde(default, skip_serializing_if = "Option::is_none")] so absent phase never emits "phase": null onto user/system messages (verified via 6/6 spec roundtrips including a dedicated user-role-no-phase fixture).

Storage wrapping was evaluated as the minimum-scope alternative to a data_connector schema migration (which would have been far larger blast radius). Legacy bare-array rows still decode via the new split_stored_message_content helper; no data migration required.

Verification

  • cargo check --workspace --tests --benches clean (isolated CARGO_TARGET_DIR=/tmp/p3-c2-lead-target to avoid worktree cache collision)
  • cargo test -p openai-protocol → 82/82 (55 + 8 + 18 + 1)
  • cargo test -p smg --lib → 553/0 (4 ignored)
  • cargo test -p smg-mcp --lib → 177/0
  • cargo clippy -p openai-protocol -p smg -p smg-mcp --lib --bins --tests -- -D warnings clean
  • cargo +nightly fmt --all -- --check silent (cycle-2 commit B addresses cycle-1 persistence_utils.rs:315 let-chain artifact)
  • Tech Lead cycle-1 spec-roundtrip fixtures (6/6 pass): commentary/final_answer positive, user-simple-no-phase, tagged-user-no-phase, SimpleInputMessage-with-phase, unknown-phase="thinking" → rejects at deserialize (no silent #[serde(other)] swallow)
  • Tech Lead cycle-2 re-verified same fixtures post-deletion → still byte-identical
  • Bench edit revert test: routing_allocation_bench.rs:95 without phase: None → error[E0063]: missing field 'phase' in initializer of ResponseInputOutputItem → confirmed compile-forced
  • split_stored_message_content has 3 prod callsites (persistence_utils internal + openai/responses/history.rs:156 + grpc/regular/responses/common.rs:279) → §7 "no new helper used by one callsite" PASSES
  • new_message_with_phase removed (was 0 callsites; cycle-1 §7 blocker) → grep -rn 'new_message_with_phase' zero hits
  • Codex review: unavailable (harness skill permission; Lead proceeded solo per playbook §8 fallback after own fixtures passed both cycles)

Blast radius

13 files. Breakdown: protocols schema (2: responses.rs, builders/responses/response.rs), gateway routers + persistence (6), tests + bench (3: mcp session, bench, spec test), rustfmt cleanup (1: persistence_utils.rs).

Every non-responses.rs edit verified compile-forced via revert-and-test, or justified as AC-required (persistence wrapping for "phase survives store+retrieve"). No forbidden files touched.

Out of scope

  • Runtime population of phase from upstream SGLang/vLLM harmonizer → future follow-up tied to backend-specific structural tag emission.
  • Strict role-gating on the untyped Message variant (spec distinguishes tagged Message user/system/developer vs tagged ResponseOutputMessage assistant-with-phase) → I1's scope, not P3.
  • Schema migration in data_connector to make phase a first-class column → evaluated and rejected as over-scope; wrapping-object persistence with legacy decode is the minimum path.

Refs: audit task P3 · .claude/_audit/responses-api-gap-audit.md

Summary by CodeRabbit

  • New Features

    • Messages now include an optional phase: Commentary or FinalAnswer for clearer response classification.
  • Chores

    • Serialization, persistence, loading, and conversion updated across response pipelines to carry phase information.
  • Tests

    • Updated unit tests and benchmarks to include the new message phase field.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions github-actions Bot added grpc gRPC client and router changes mcp MCP related changes benchmarks Benchmark changes tests Test changes protocols Protocols crate changes model-gateway Model gateway crate changes openai OpenAI router changes labels Apr 21, 2026
@coderabbitai

coderabbitai Bot commented Apr 21, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6ef02036-8cde-46ce-8002-c1038b7b0947

📥 Commits

Reviewing files that changed from the base of the PR and between 668cdcc and 2a3588e.

📒 Files selected for processing (13)
  • crates/mcp/src/core/session.rs
  • crates/protocols/src/builders/responses/response.rs
  • crates/protocols/src/responses.rs
  • model_gateway/benches/routing_allocation_bench.rs
  • model_gateway/src/routers/common/persistence_utils.rs
  • model_gateway/src/routers/grpc/harmony/processor.rs
  • model_gateway/src/routers/grpc/harmony/responses/common.rs
  • model_gateway/src/routers/grpc/harmony/responses/non_streaming.rs
  • model_gateway/src/routers/grpc/regular/responses/common.rs
  • model_gateway/src/routers/grpc/regular/responses/conversions.rs
  • model_gateway/src/routers/grpc/regular/responses/streaming.rs
  • model_gateway/src/routers/openai/responses/history.rs
  • model_gateway/tests/spec/responses.rs

📝 Walkthrough

Walkthrough

A new public MessagePhase enum (Commentary, FinalAnswer) was added and an optional phase: Option<MessagePhase> field was added to message-related protocol types. Persistence and router code were updated to read/write phase (including legacy content handling), and tests/benchmarks were updated to set phase: None.

Changes

Cohort / File(s) Summary
Core Protocol Definitions
crates/protocols/src/responses.rs
Add public MessagePhase enum and optional phase: Option<MessagePhase> to ResponseInputOutputItem::Message, ResponseInputOutputItem::SimpleInputMessage, and ResponseOutputItem::Message. Propagate phase in normalize_input_item and set phase: None in ResponseOutputItem::new_message.
Persistence & Serialization
model_gateway/src/routers/common/persistence_utils.rs
Add pub fn split_stored_message_content(raw: Value) -> (Value, Option<MessagePhase>). Update item_to_json, extract_input_items, and item_to_new_conversation_item to support wrapped { content, phase } and to hoist/store phase.
Regular Router Response Builders
model_gateway/src/routers/grpc/regular/responses/common.rs, .../conversions.rs, .../streaming.rs
Load stored messages via split_stored_message_content to set phase when present; explicitly set phase: None for synthesized user/assistant messages and for constructed output messages.
Harmony Router Response Builders
model_gateway/src/routers/grpc/harmony/processor.rs, .../responses/common.rs, .../responses/non_streaming.rs
Explicitly set phase: None when building ResponseInputOutputItem::Message/SimpleInputMessage in request/response construction paths.
OpenAI Router Message History
model_gateway/src/routers/openai/responses/history.rs
Use split_stored_message_content to extract content and optional phase when deserializing stored "message" items; set phase: stored_phase and set phase: None for synthetic current user messages.
Tests & Benchmarks
crates/mcp/src/core/session.rs, crates/protocols/src/builders/responses/response.rs, model_gateway/tests/spec/responses.rs, model_gateway/benches/routing_allocation_bench.rs
Update test fixtures and benchmark inputs to include phase: None for Message/SimpleInputMessage literals to match new type shape.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Router
  participant Persistence as Persistence_Utils
  participant DB

  Client->>Router: request (load/build conversation)
  Router->>Persistence: fetch stored items
  Persistence->>DB: read stored conversation items
  DB-->>Persistence: stored item (legacy or {content, phase})
  Persistence->>Persistence: split_stored_message_content -> (content_value, stored_phase)
  Persistence-->>Router: deserialized content + stored_phase
  Router->>Client: constructed ResponseInputOutputItem (phase set or None)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • CatherineSue
  • key4ng

Poem

🐇 I hopped through code with a curious cheer,
A phase in each message now whispered near,
Commentary, answer, or gentle None,
Through storage and routers their journey's begun,
Hooray — messages clearer, hop hop, good cheer!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: implementing the phase field for P3 EasyInputMessage, which aligns with the PR's core objective of adding a typed optional phase field to message variants.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/audit-p3-phase

Comment @coderabbitai help to get the list of available commands and usage tips.

@mergify

mergify Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Hi @slin1237, this PR has merge conflicts that must be resolved before it can be merged. Please rebase your branch:

git fetch origin main
git rebase origin/main
# resolve any conflicts, then:
git push --force-with-lease

@mergify mergify Bot added the needs-rebase PR has merge conflicts that need to be resolved label Apr 21, 2026
Add an `Option<MessagePhase>` field carrying `"commentary" | "final_answer"`
to the three message variants that map to OpenAI's `EasyInputMessage` and
`ResponseOutputMessage`:

- `crates/protocols/src/responses.rs`:
  - New `MessagePhase` enum (`Commentary` / `FinalAnswer`,
    `#[serde(rename_all = "snake_case")]`).
  - Add `phase: Option<MessagePhase>` to
    `ResponseInputOutputItem::SimpleInputMessage` (spec `EasyInputMessage`),
    `ResponseInputOutputItem::Message` (so assistant replays round-trip
    phase from the output side), and `ResponseOutputItem::Message`
    (spec `ResponseOutputMessage`).
  - All three fields `#[serde(default, skip_serializing_if = "Option::is_none")]`
    so absent-on-wire stays absent and existing clients keep working.
  - `normalize_input_item` forwards phase when converting SimpleInputMessage
    into the typed Message variant.
  - `ResponseOutputItem::new_message` keeps its existing signature (phase
    defaults to `None`); added `new_message_with_phase` for callers that
    need to set it explicitly.

- `crates/protocols/src/builders/responses/response.rs`: update the two
  struct literals in the builder tests.

- `crates/mcp/src/core/session.rs`: update the two struct literals in the
  session ordering test.

- `model_gateway/src/routers/common/persistence_utils.rs`:
  - `extract_input_items` carries `phase` into the normalized message JSON
    for `SimpleInputMessage`.
  - `item_to_new_conversation_item` stores message items as
    `{content: [...], phase: "..."}` when phase is present, and leaves the
    legacy bare-array shape untouched otherwise so existing rows deserialize.
  - `item_to_json` recognizes both shapes when reconstructing a message
    item and hoists `phase` back to the item level.
  - New `pub fn split_stored_message_content(Value) -> (Value, Option<MessagePhase>)`
    helper shared with router load paths.

- `model_gateway/src/routers/openai/responses/history.rs`: load path for
  conversation items uses `split_stored_message_content` and populates the
  new `phase` field; existing text-only append paths pass `phase: None`.

- `model_gateway/src/routers/grpc/regular/responses/common.rs`: same
  treatment for the gRPC-regular load path (`load_conversation_history`)
  plus the three text → `Message` literals in that module.

- `model_gateway/src/routers/grpc/regular/responses/{streaming,conversions}.rs`,
  `model_gateway/src/routers/grpc/harmony/responses/{common,non_streaming}.rs`,
  `model_gateway/src/routers/grpc/harmony/processor.rs`,
  `model_gateway/benches/routing_allocation_bench.rs`,
  `model_gateway/tests/spec/responses.rs`: add `phase: None` to the
  remaining struct literals so the crate continues to compile.

The Responses API spec (verified against
`.claude/_audit/openai-responses-api-spec.md`, lines 102-111 and 119-135)
declares an optional `phase` discriminator on `EasyInputMessage` and
`ResponseOutputMessage`. `crates/protocols/src/responses.rs` had zero
references to `phase`, so any phase value supplied by a client was
dropped on the way in and never re-emitted to the upstream model.

For gpt-5.3-codex+ this is a quality regression: the spec note
explicitly states that dropping `phase` across turns causes latency /
quality degradation because the model uses it to separate commentary
reasoning from the final answer. P3 restores the field on the wire and
through the gateway's persistence layer so that a phase attached to a
stored assistant message survives store → list-items → re-inject as
input across subsequent turns.

- `MessagePhase` is a plain `Copy` enum with serde `snake_case` renaming,
  matching the `"commentary" | "final_answer"` wire values from the spec.
- `phase` is `Option<_>` with `#[serde(default, skip_serializing_if)]`
  on every added field so backward-compat is preserved for both
  serialization (absent when `None`) and deserialization (absent JSON
  → `None`).
- Conversation-item storage uses a `Value` column; rather than migrate
  the schema, messages that carry phase are stored as an object
  `{"content": [...], "phase": "..."}`. Legacy rows that stored just the
  content array continue to deserialize via `split_stored_message_content`,
  which detects the object shape and otherwise returns the raw input.
- All consumer call sites in the gateway use `..` patterns and are
  unaffected; only struct-literal constructors needed updates.

- `CARGO_TARGET_DIR=/tmp/p3-target cargo check --workspace --tests --benches`
  — clean (isolated target dir per tech-lead environment advisory).
- `CARGO_TARGET_DIR=/tmp/p3-target cargo clippy -p smg -p openai-protocol
  -p smg-mcp --lib --bins --tests -- -D warnings` — clean.
- `cargo test -p openai-protocol` — 82/82 pass (55 + 8 + 18 + 1 doc).
- `cargo test -p smg --lib` — 553/553 pass.
- `cargo test -p smg-mcp --lib` — 177/177 pass.

Refs: P3
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
…e 2)

Tech Lead P3 cycle-1 REJECT: `pub fn new_message_with_phase` had zero
callsites — §7 "No new helper used by one callsite. Inline." Removed.
Callers that need to set phase use the struct-literal pattern directly,
matching existing constructor patterns in the file. All other P3 substance
preserved: phase field across 3 variants, persistence migration, all
6/6 spec roundtrips still green.

Refs: P3
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Addresses a cycle-1 fmt artifact missed in P3 Tech Lead review: the
let-chain at persistence_utils.rs:315 collapses to a single line under
nightly rustfmt. Pure whitespace normalization; no semantic change.

Refs: P3
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
@slin1237
slin1237 force-pushed the feat/audit-p3-phase branch from 668cdcc to 2a3588e Compare April 21, 2026 05:40
@mergify mergify Bot removed the needs-rebase PR has merge conflicts that need to be resolved label Apr 21, 2026
@slin1237
slin1237 merged commit e7bca99 into main Apr 21, 2026
47 checks passed
@slin1237
slin1237 deleted the feat/audit-p3-phase branch April 21, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmarks Benchmark changes grpc gRPC client and router changes mcp MCP related changes model-gateway Model gateway crate changes openai OpenAI router changes protocols Protocols crate changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant