Skip to content

Commit cdeeb9f

Browse files
committed
Hotfix: session.read writes provider-state text as plain text
Since 0.3.220, assistant text on OpenAI Responses, ChatGPT, Gemini and Code Assist is stored as a text_with_state block: the text plus opaque provider state such as the Responses phase. session.read wrote that canonical block onto the wire, so a caller expecting a text block read a different block type, and Core stringified it, so every OpenAI worker's final report was unreadable. session.read now writes each such block as a plain text block with the same text and no provider state. The log is unchanged, so re-reading an affected session returns the text. run.result already joined the text correctly.
1 parent 67ea15d commit cdeeb9f

4 files changed

Lines changed: 123 additions & 22 deletions

File tree

‎Cargo.lock‎

Lines changed: 21 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ members = [
4242
# checkouts are used only to build integration-test rig binaries.
4343

4444
[workspace.package]
45-
version = "0.3.220"
45+
version = "0.3.221"
4646
edition = "2021"
4747
rust-version = "1.96"
4848
license = "MIT"

‎crates/broca-wire/src/lib.rs‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,12 +758,41 @@ pub enum SendResult {
758758
},
759759
}
760760

761+
/// Serialize `message` as callers read it: every `text_with_state` block becomes a plain `text`
762+
/// block with the same text, and every other block is written unchanged.
763+
fn serialize_caller_message<S: serde::Serializer>(
764+
message: &broca_protocol::Message,
765+
serializer: S,
766+
) -> Result<S::Ok, S::Error> {
767+
use broca_protocol::ContentBlock;
768+
if !message
769+
.content
770+
.iter()
771+
.any(|block| matches!(block, ContentBlock::TextWithState { .. }))
772+
{
773+
return message.serialize(serializer);
774+
}
775+
let mut caller = message.clone();
776+
for block in &mut caller.content {
777+
if let ContentBlock::TextWithState { text, .. } = block {
778+
*block = ContentBlock::Text {
779+
text: std::mem::take(text),
780+
};
781+
}
782+
}
783+
caller.serialize(serializer)
784+
}
785+
761786
/// One message in a `session.read` page.
762787
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
763788
pub struct SessionReadMessage {
764789
pub ordinal: u64,
765790
/// Message-coordinate spelling used by consumers for stable message badges.
766791
pub mid: String,
792+
/// Written in the caller-facing vocabulary: assistant text that carries provider state
793+
/// (`text_with_state`) goes out as a plain `text` block. The state is opaque bytes broca
794+
/// keeps only to send back to the provider; a caller reading the transcript wants the text.
795+
#[serde(serialize_with = "serialize_caller_message")]
767796
pub message: broca_protocol::Message,
768797
/// The producing run, not the page's latest run. Historical/imported messages with
769798
/// no recoverable producer omit this member; legacy replies without it still decode.
@@ -3796,3 +3825,62 @@ mod session_class_tests {
37963825
}
37973826
}
37983827
}
3828+
3829+
#[cfg(test)]
3830+
mod caller_message_tests {
3831+
use super::*;
3832+
use broca_protocol::content::{ContentBlock, Role, TextProviderFormat, TextProviderState};
3833+
use serde_json::json;
3834+
3835+
fn read_message(content: Vec<ContentBlock>) -> SessionReadMessage {
3836+
SessionReadMessage {
3837+
ordinal: 3,
3838+
mid: "m3".into(),
3839+
message: broca_protocol::Message::new(Role::Assistant, content),
3840+
run: None,
3841+
tool_calls: Vec::new(),
3842+
}
3843+
}
3844+
3845+
/// A caller reads the transcript's text, never the provider bytes kept to send back. A
3846+
/// worker's final reply carrying a ChatGPT phase must reach the caller as plain text, or a
3847+
/// parser looking for its text sees a serialized envelope instead.
3848+
#[test]
3849+
fn text_with_provider_state_reaches_callers_as_plain_text() {
3850+
let message = read_message(vec![
3851+
ContentBlock::Text {
3852+
text: "before ".into(),
3853+
},
3854+
ContentBlock::TextWithState {
3855+
text: "<result>done</result>".into(),
3856+
provider_state: TextProviderState {
3857+
format: TextProviderFormat::OpenAiResponsesChatgpt,
3858+
data: r#"{"id":"msg_1","phase":"final_answer"}"#.into(),
3859+
},
3860+
},
3861+
]);
3862+
let wire = serde_json::to_value(&message).unwrap();
3863+
assert_eq!(
3864+
wire["message"]["content"],
3865+
json!([
3866+
{"type": "text", "text": "before "},
3867+
{"type": "text", "text": "<result>done</result>"},
3868+
])
3869+
);
3870+
assert!(!wire.to_string().contains("provider_state"));
3871+
assert!(!wire.to_string().contains("final_answer"));
3872+
}
3873+
3874+
/// Messages without provider state serialize exactly as the canonical message does, so the
3875+
/// conversion never moves bytes of an ordinary page.
3876+
#[test]
3877+
fn messages_without_provider_state_are_unchanged() {
3878+
let message = read_message(vec![ContentBlock::Text {
3879+
text: "plain".into(),
3880+
}]);
3881+
assert_eq!(
3882+
serde_json::to_value(&message).unwrap()["message"],
3883+
serde_json::to_value(&message.message).unwrap()
3884+
);
3885+
}
3886+
}

‎mutations.toml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16232,3 +16232,16 @@ expect_red = ["framing::tests::provider_text_state_is_must_understand_before_rec
1623216232
file = "crates/broca-wal/src/record.rs"
1623316233
old = 'let base = if self.carries_provider_text_state() {'
1623416234
new = 'let base = if self.carries_provider_text_state() && false { // NON-VACUITY BREAK: omit reader feature'
16235+
16236+
[[control]]
16237+
id = "session-read-text-with-state-as-caller-text"
16238+
guards = "session.read writes assistant text that carries provider state as a plain text block, so callers parsing the transcript read the text, never the serialized provider envelope"
16239+
runner = "cargo"
16240+
package = "broca-wire"
16241+
target = "--lib"
16242+
select = "expected"
16243+
test_file = "crates/broca-wire/src/lib.rs"
16244+
expect_red = ["caller_message_tests::text_with_provider_state_reaches_callers_as_plain_text"]
16245+
file = "crates/broca-wire/src/lib.rs"
16246+
old = ' #[serde(serialize_with = "serialize_caller_message")]'
16247+
new = ' // NON-VACUITY BREAK: write the canonical block, provider state included.'

0 commit comments

Comments
 (0)