mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: adding stream parser (#12666)
Add a stream parser to extract citations (and others) from a stream. This support cases where markers are split in differen tokens. Codex never manage to make this code work so everything was done manually. Please review correctly and do not touch this part of the code without a very clear understanding of it
This commit is contained in:
committed by
GitHub
Unverified
parent
5a9a5b51b2
commit
5441130e0a
+253
-97
@@ -42,6 +42,7 @@ use crate::stream_events_utils::HandleOutputCtx;
|
||||
use crate::stream_events_utils::handle_non_tool_response_item;
|
||||
use crate::stream_events_utils::handle_output_item_done;
|
||||
use crate::stream_events_utils::last_assistant_message_from_item;
|
||||
use crate::stream_events_utils::raw_assistant_output_text_from_item;
|
||||
use crate::terminal;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::turn_metadata::TurnMetadataState;
|
||||
@@ -92,6 +93,11 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_protocol::skill_approval::SkillApprovalResponse;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use codex_utils_stream_parser::AssistantTextChunk;
|
||||
use codex_utils_stream_parser::AssistantTextStreamParser;
|
||||
use codex_utils_stream_parser::ProposedPlanSegment;
|
||||
use codex_utils_stream_parser::extract_proposed_plan_text;
|
||||
use codex_utils_stream_parser::strip_citations;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::prelude::*;
|
||||
use futures::stream::FuturesOrdered;
|
||||
@@ -174,9 +180,6 @@ use crate::mentions::collect_explicit_app_ids;
|
||||
use crate::mentions::collect_tool_mentions_from_messages;
|
||||
use crate::network_policy_decision::execpolicy_network_rule_amendment;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
use crate::proposed_plan_parser::ProposedPlanParser;
|
||||
use crate::proposed_plan_parser::ProposedPlanSegment;
|
||||
use crate::proposed_plan_parser::extract_proposed_plan_text;
|
||||
use crate::protocol::AgentMessageContentDeltaEvent;
|
||||
use crate::protocol::AgentReasoningSectionBreakEvent;
|
||||
use crate::protocol::ApplyPatchApprovalRequestEvent;
|
||||
@@ -5618,39 +5621,9 @@ struct ProposedPlanItemState {
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
/// Per-item plan parsers so we can buffer text while detecting `<proposed_plan>`
|
||||
/// tags without ever mixing buffered lines across item ids.
|
||||
struct PlanParsers {
|
||||
assistant: HashMap<String, ProposedPlanParser>,
|
||||
}
|
||||
|
||||
impl PlanParsers {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
assistant: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_parser_mut(&mut self, item_id: &str) -> &mut ProposedPlanParser {
|
||||
self.assistant
|
||||
.entry(item_id.to_string())
|
||||
.or_insert_with(ProposedPlanParser::new)
|
||||
}
|
||||
|
||||
fn take_assistant_parser(&mut self, item_id: &str) -> Option<ProposedPlanParser> {
|
||||
self.assistant.remove(item_id)
|
||||
}
|
||||
|
||||
fn drain_assistant_parsers(&mut self) -> Vec<(String, ProposedPlanParser)> {
|
||||
self.assistant.drain().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated state used only while streaming a plan-mode response.
|
||||
/// Includes per-item parsers, deferred agent message bookkeeping, and the plan item lifecycle.
|
||||
struct PlanModeStreamState {
|
||||
/// Per-item parsers for assistant streams in plan mode.
|
||||
plan_parsers: PlanParsers,
|
||||
/// Agent message items started by the model but deferred until we see non-plan text.
|
||||
pending_agent_message_items: HashMap<String, TurnItem>,
|
||||
/// Agent message items whose start notification has been emitted.
|
||||
@@ -5664,7 +5637,6 @@ struct PlanModeStreamState {
|
||||
impl PlanModeStreamState {
|
||||
fn new(turn_id: &str) -> Self {
|
||||
Self {
|
||||
plan_parsers: PlanParsers::new(),
|
||||
pending_agent_message_items: HashMap::new(),
|
||||
started_agent_message_items: HashSet::new(),
|
||||
leading_whitespace_by_item: HashMap::new(),
|
||||
@@ -5673,6 +5645,56 @@ impl PlanModeStreamState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct AssistantMessageStreamParsers {
|
||||
plan_mode: bool,
|
||||
parsers_by_item: HashMap<String, AssistantTextStreamParser>,
|
||||
}
|
||||
|
||||
type ParsedAssistantTextDelta = AssistantTextChunk;
|
||||
|
||||
impl AssistantMessageStreamParsers {
|
||||
fn new(plan_mode: bool) -> Self {
|
||||
Self {
|
||||
plan_mode,
|
||||
parsers_by_item: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parser_mut(&mut self, item_id: &str) -> &mut AssistantTextStreamParser {
|
||||
let plan_mode = self.plan_mode;
|
||||
self.parsers_by_item
|
||||
.entry(item_id.to_string())
|
||||
.or_insert_with(|| AssistantTextStreamParser::new(plan_mode))
|
||||
}
|
||||
|
||||
fn seed_item_text(&mut self, item_id: &str, text: &str) -> ParsedAssistantTextDelta {
|
||||
if text.is_empty() {
|
||||
return ParsedAssistantTextDelta::default();
|
||||
}
|
||||
self.parser_mut(item_id).push_str(text)
|
||||
}
|
||||
|
||||
fn parse_delta(&mut self, item_id: &str, delta: &str) -> ParsedAssistantTextDelta {
|
||||
self.parser_mut(item_id).push_str(delta)
|
||||
}
|
||||
|
||||
fn finish_item(&mut self, item_id: &str) -> ParsedAssistantTextDelta {
|
||||
let Some(mut parser) = self.parsers_by_item.remove(item_id) else {
|
||||
return ParsedAssistantTextDelta::default();
|
||||
};
|
||||
parser.finish()
|
||||
}
|
||||
|
||||
fn drain_finished(&mut self) -> Vec<(String, ParsedAssistantTextDelta)> {
|
||||
let parsers_by_item = std::mem::take(&mut self.parsers_by_item);
|
||||
parsers_by_item
|
||||
.into_iter()
|
||||
.map(|(item_id, mut parser)| (item_id, parser.finish()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProposedPlanItemState {
|
||||
fn new(turn_id: &str) -> Self {
|
||||
Self {
|
||||
@@ -5875,35 +5897,68 @@ async fn handle_plan_segments(
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush any buffered proposed-plan segments when a specific assistant message ends.
|
||||
async fn flush_proposed_plan_segments_for_item(
|
||||
async fn emit_streamed_assistant_text_delta(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
state: &mut PlanModeStreamState,
|
||||
plan_mode_state: Option<&mut PlanModeStreamState>,
|
||||
item_id: &str,
|
||||
parsed: ParsedAssistantTextDelta,
|
||||
) {
|
||||
let Some(mut parser) = state.plan_parsers.take_assistant_parser(item_id) else {
|
||||
return;
|
||||
};
|
||||
let segments = parser.finish();
|
||||
if segments.is_empty() {
|
||||
if parsed.is_empty() {
|
||||
return;
|
||||
}
|
||||
handle_plan_segments(sess, turn_context, state, item_id, segments).await;
|
||||
if !parsed.citations.is_empty() {
|
||||
// Citation extraction is intentionally local for now; we strip citations from display text
|
||||
// but do not yet surface them in protocol events.
|
||||
let _citations = parsed.citations;
|
||||
}
|
||||
if let Some(state) = plan_mode_state {
|
||||
if !parsed.plan_segments.is_empty() {
|
||||
handle_plan_segments(sess, turn_context, state, item_id, parsed.plan_segments).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if parsed.visible_text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let event = AgentMessageContentDeltaEvent {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item_id: item_id.to_string(),
|
||||
delta: parsed.visible_text,
|
||||
};
|
||||
sess.send_event(turn_context, EventMsg::AgentMessageContentDelta(event))
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Flush any remaining assistant plan parsers when the response completes.
|
||||
async fn flush_proposed_plan_segments_all(
|
||||
/// Flush buffered assistant text parser state when an assistant message item ends.
|
||||
async fn flush_assistant_text_segments_for_item(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
state: &mut PlanModeStreamState,
|
||||
plan_mode_state: Option<&mut PlanModeStreamState>,
|
||||
parsers: &mut AssistantMessageStreamParsers,
|
||||
item_id: &str,
|
||||
) {
|
||||
for (item_id, mut parser) in state.plan_parsers.drain_assistant_parsers() {
|
||||
let segments = parser.finish();
|
||||
if segments.is_empty() {
|
||||
continue;
|
||||
}
|
||||
handle_plan_segments(sess, turn_context, state, &item_id, segments).await;
|
||||
let parsed = parsers.finish_item(item_id);
|
||||
emit_streamed_assistant_text_delta(sess, turn_context, plan_mode_state, item_id, parsed).await;
|
||||
}
|
||||
|
||||
/// Flush any remaining buffered assistant text parser state at response completion.
|
||||
async fn flush_assistant_text_segments_all(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
mut plan_mode_state: Option<&mut PlanModeStreamState>,
|
||||
parsers: &mut AssistantMessageStreamParsers,
|
||||
) {
|
||||
for (item_id, parsed) in parsers.drain_finished() {
|
||||
emit_streamed_assistant_text_delta(
|
||||
sess,
|
||||
turn_context,
|
||||
plan_mode_state.as_deref_mut(),
|
||||
&item_id,
|
||||
parsed,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5924,6 +5979,7 @@ async fn maybe_complete_plan_item_from_message(
|
||||
}
|
||||
}
|
||||
if let Some(plan_text) = extract_proposed_plan_text(&text) {
|
||||
let (plan_text, _citations) = strip_citations(&plan_text);
|
||||
if !state.plan_item_state.started {
|
||||
state.plan_item_state.start(sess, turn_context).await;
|
||||
}
|
||||
@@ -6112,6 +6168,7 @@ async fn try_run_sampling_request(
|
||||
let mut active_item: Option<TurnItem> = None;
|
||||
let mut should_emit_turn_diff = false;
|
||||
let plan_mode = turn_context.collaboration_mode.mode == ModeKind::Plan;
|
||||
let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode);
|
||||
let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id));
|
||||
let receiving_span = trace_span!("receiving_stream");
|
||||
let outcome: CodexResult<SamplingRequestResult> = loop {
|
||||
@@ -6151,20 +6208,21 @@ async fn try_run_sampling_request(
|
||||
ResponseEvent::Created => {}
|
||||
ResponseEvent::OutputItemDone(item) => {
|
||||
let previously_active_item = active_item.take();
|
||||
if let Some(state) = plan_mode_state.as_mut() {
|
||||
if let Some(previous) = previously_active_item.as_ref() {
|
||||
let item_id = previous.id();
|
||||
if matches!(previous, TurnItem::AgentMessage(_)) {
|
||||
flush_proposed_plan_segments_for_item(
|
||||
&sess,
|
||||
&turn_context,
|
||||
state,
|
||||
&item_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if handle_assistant_item_done_in_plan_mode(
|
||||
if let Some(previous) = previously_active_item.as_ref()
|
||||
&& matches!(previous, TurnItem::AgentMessage(_))
|
||||
{
|
||||
let item_id = previous.id();
|
||||
flush_assistant_text_segments_for_item(
|
||||
&sess,
|
||||
&turn_context,
|
||||
plan_mode_state.as_mut(),
|
||||
&mut assistant_message_stream_parsers,
|
||||
&item_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Some(state) = plan_mode_state.as_mut()
|
||||
&& handle_assistant_item_done_in_plan_mode(
|
||||
&sess,
|
||||
&turn_context,
|
||||
&item,
|
||||
@@ -6173,9 +6231,8 @@ async fn try_run_sampling_request(
|
||||
&mut last_agent_message,
|
||||
)
|
||||
.await
|
||||
{
|
||||
continue;
|
||||
}
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut ctx = HandleOutputCtx {
|
||||
@@ -6198,6 +6255,28 @@ async fn try_run_sampling_request(
|
||||
}
|
||||
ResponseEvent::OutputItemAdded(item) => {
|
||||
if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode).await {
|
||||
let mut turn_item = turn_item;
|
||||
let mut seeded_parsed: Option<ParsedAssistantTextDelta> = None;
|
||||
let mut seeded_item_id: Option<String> = None;
|
||||
if matches!(turn_item, TurnItem::AgentMessage(_))
|
||||
&& let Some(raw_text) = raw_assistant_output_text_from_item(&item)
|
||||
{
|
||||
let item_id = turn_item.id();
|
||||
let mut seeded =
|
||||
assistant_message_stream_parsers.seed_item_text(&item_id, &raw_text);
|
||||
if let TurnItem::AgentMessage(agent_message) = &mut turn_item {
|
||||
agent_message.content =
|
||||
vec![codex_protocol::items::AgentMessageContent::Text {
|
||||
text: if plan_mode {
|
||||
String::new()
|
||||
} else {
|
||||
std::mem::take(&mut seeded.visible_text)
|
||||
},
|
||||
}];
|
||||
}
|
||||
seeded_parsed = plan_mode.then_some(seeded);
|
||||
seeded_item_id = Some(item_id);
|
||||
}
|
||||
if let Some(state) = plan_mode_state.as_mut()
|
||||
&& matches!(turn_item, TurnItem::AgentMessage(_))
|
||||
{
|
||||
@@ -6208,6 +6287,20 @@ async fn try_run_sampling_request(
|
||||
} else {
|
||||
sess.emit_turn_item_started(&turn_context, &turn_item).await;
|
||||
}
|
||||
if let (Some(state), Some(item_id), Some(parsed)) = (
|
||||
plan_mode_state.as_mut(),
|
||||
seeded_item_id.as_deref(),
|
||||
seeded_parsed,
|
||||
) {
|
||||
emit_streamed_assistant_text_delta(
|
||||
&sess,
|
||||
&turn_context,
|
||||
Some(state),
|
||||
item_id,
|
||||
parsed,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
active_item = Some(turn_item);
|
||||
}
|
||||
}
|
||||
@@ -6237,9 +6330,13 @@ async fn try_run_sampling_request(
|
||||
token_usage,
|
||||
can_append: _,
|
||||
} => {
|
||||
if let Some(state) = plan_mode_state.as_mut() {
|
||||
flush_proposed_plan_segments_all(&sess, &turn_context, state).await;
|
||||
}
|
||||
flush_assistant_text_segments_all(
|
||||
&sess,
|
||||
&turn_context,
|
||||
plan_mode_state.as_mut(),
|
||||
&mut assistant_message_stream_parsers,
|
||||
)
|
||||
.await;
|
||||
sess.update_token_usage_info(&turn_context, token_usage.as_ref())
|
||||
.await;
|
||||
should_emit_turn_diff = true;
|
||||
@@ -6256,14 +6353,16 @@ async fn try_run_sampling_request(
|
||||
// UI will show a selection popup from the final ReviewOutput.
|
||||
if let Some(active) = active_item.as_ref() {
|
||||
let item_id = active.id();
|
||||
if let Some(state) = plan_mode_state.as_mut()
|
||||
&& matches!(active, TurnItem::AgentMessage(_))
|
||||
{
|
||||
let segments = state
|
||||
.plan_parsers
|
||||
.assistant_parser_mut(&item_id)
|
||||
.parse(&delta);
|
||||
handle_plan_segments(&sess, &turn_context, state, &item_id, segments).await;
|
||||
if matches!(active, TurnItem::AgentMessage(_)) {
|
||||
let parsed = assistant_message_stream_parsers.parse_delta(&item_id, &delta);
|
||||
emit_streamed_assistant_text_delta(
|
||||
&sess,
|
||||
&turn_context,
|
||||
plan_mode_state.as_mut(),
|
||||
&item_id,
|
||||
parsed,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
let event = AgentMessageContentDeltaEvent {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
@@ -6329,6 +6428,14 @@ async fn try_run_sampling_request(
|
||||
}
|
||||
};
|
||||
|
||||
flush_assistant_text_segments_all(
|
||||
&sess,
|
||||
&turn_context,
|
||||
plan_mode_state.as_mut(),
|
||||
&mut assistant_message_stream_parsers,
|
||||
)
|
||||
.await;
|
||||
|
||||
drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;
|
||||
|
||||
if should_emit_turn_diff {
|
||||
@@ -6346,23 +6453,10 @@ async fn try_run_sampling_request(
|
||||
}
|
||||
|
||||
pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<String> {
|
||||
responses.iter().rev().find_map(|item| {
|
||||
if let ResponseItem::Message { role, content, .. } = item {
|
||||
if role == "assistant" {
|
||||
content.iter().rev().find_map(|ci| {
|
||||
if let ContentItem::OutputText { text } = ci {
|
||||
Some(text.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
responses
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|item| last_assistant_message_from_item(item, false))
|
||||
}
|
||||
|
||||
use crate::memories::prompts::build_memory_tool_developer_instructions;
|
||||
@@ -6486,6 +6580,68 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() {
|
||||
let mut parsers = AssistantMessageStreamParsers::new(false);
|
||||
let item_id = "msg-1";
|
||||
|
||||
let seeded = parsers.seed_item_text(item_id, "hello <oai-mem-citation>doc");
|
||||
let parsed = parsers.parse_delta(item_id, "1</oai-mem-citation> world");
|
||||
let tail = parsers.finish_item(item_id);
|
||||
|
||||
assert_eq!(seeded.visible_text, "hello ");
|
||||
assert_eq!(seeded.citations, Vec::<String>::new());
|
||||
assert_eq!(parsed.visible_text, " world");
|
||||
assert_eq!(parsed.citations, vec!["doc1".to_string()]);
|
||||
assert_eq!(tail.visible_text, "");
|
||||
assert_eq!(tail.citations, Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tail() {
|
||||
let mut parsers = AssistantMessageStreamParsers::new(false);
|
||||
let item_id = "msg-1";
|
||||
|
||||
let seeded = parsers.seed_item_text(item_id, "hello <oai-mem-");
|
||||
let parsed = parsers.parse_delta(item_id, "citation>doc</oai-mem-citation> world");
|
||||
let tail = parsers.finish_item(item_id);
|
||||
|
||||
assert_eq!(seeded.visible_text, "hello ");
|
||||
assert_eq!(seeded.citations, Vec::<String>::new());
|
||||
assert_eq!(parsed.visible_text, " world");
|
||||
assert_eq!(parsed.citations, vec!["doc".to_string()]);
|
||||
assert_eq!(tail.visible_text, "");
|
||||
assert_eq!(tail.citations, Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_message_stream_parsers_seed_plan_parser_across_added_and_delta_boundaries() {
|
||||
let mut parsers = AssistantMessageStreamParsers::new(true);
|
||||
let item_id = "msg-1";
|
||||
|
||||
let seeded = parsers.seed_item_text(item_id, "Intro\n<proposed");
|
||||
let parsed = parsers.parse_delta(item_id, "_plan>\n- step\n</proposed_plan>\nOutro");
|
||||
let tail = parsers.finish_item(item_id);
|
||||
|
||||
assert_eq!(seeded.visible_text, "Intro\n");
|
||||
assert_eq!(
|
||||
seeded.plan_segments,
|
||||
vec![ProposedPlanSegment::Normal("Intro\n".to_string())]
|
||||
);
|
||||
assert_eq!(parsed.visible_text, "Outro");
|
||||
assert_eq!(
|
||||
parsed.plan_segments,
|
||||
vec![
|
||||
ProposedPlanSegment::ProposedPlanStart,
|
||||
ProposedPlanSegment::ProposedPlanDelta("- step\n".to_string()),
|
||||
ProposedPlanSegment::ProposedPlanEnd,
|
||||
ProposedPlanSegment::Normal("Outro".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(tail.visible_text, "");
|
||||
assert!(tail.plan_segments.is_empty());
|
||||
}
|
||||
|
||||
fn make_mcp_tool(
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
|
||||
@@ -56,13 +56,11 @@ mod message_history;
|
||||
mod model_provider_info;
|
||||
pub mod path_utils;
|
||||
pub mod personality_migration;
|
||||
mod proposed_plan_parser;
|
||||
mod sandbox_tags;
|
||||
pub mod sandboxing;
|
||||
mod session_prefix;
|
||||
mod shell_detect;
|
||||
mod stream_events_utils;
|
||||
mod tagged_block_parser;
|
||||
pub mod test_support;
|
||||
mod text_encoding;
|
||||
pub mod token_data;
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
use crate::tagged_block_parser::TagSpec;
|
||||
use crate::tagged_block_parser::TaggedLineParser;
|
||||
use crate::tagged_block_parser::TaggedLineSegment;
|
||||
|
||||
const OPEN_TAG: &str = "<proposed_plan>";
|
||||
const CLOSE_TAG: &str = "</proposed_plan>";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PlanTag {
|
||||
ProposedPlan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ProposedPlanSegment {
|
||||
Normal(String),
|
||||
ProposedPlanStart,
|
||||
ProposedPlanDelta(String),
|
||||
ProposedPlanEnd,
|
||||
}
|
||||
|
||||
/// Parser for `<proposed_plan>` blocks emitted in plan mode.
|
||||
///
|
||||
/// This is a thin wrapper around the generic line-based tag parser. It maps
|
||||
/// tag-aware segments into plan-specific segments for downstream consumers.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProposedPlanParser {
|
||||
parser: TaggedLineParser<PlanTag>,
|
||||
}
|
||||
|
||||
impl ProposedPlanParser {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
parser: TaggedLineParser::new(vec![TagSpec {
|
||||
open: OPEN_TAG,
|
||||
close: CLOSE_TAG,
|
||||
tag: PlanTag::ProposedPlan,
|
||||
}]),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse(&mut self, delta: &str) -> Vec<ProposedPlanSegment> {
|
||||
self.parser
|
||||
.parse(delta)
|
||||
.into_iter()
|
||||
.map(map_plan_segment)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn finish(&mut self) -> Vec<ProposedPlanSegment> {
|
||||
self.parser
|
||||
.finish()
|
||||
.into_iter()
|
||||
.map(map_plan_segment)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn map_plan_segment(segment: TaggedLineSegment<PlanTag>) -> ProposedPlanSegment {
|
||||
match segment {
|
||||
TaggedLineSegment::Normal(text) => ProposedPlanSegment::Normal(text),
|
||||
TaggedLineSegment::TagStart(PlanTag::ProposedPlan) => {
|
||||
ProposedPlanSegment::ProposedPlanStart
|
||||
}
|
||||
TaggedLineSegment::TagDelta(PlanTag::ProposedPlan, text) => {
|
||||
ProposedPlanSegment::ProposedPlanDelta(text)
|
||||
}
|
||||
TaggedLineSegment::TagEnd(PlanTag::ProposedPlan) => ProposedPlanSegment::ProposedPlanEnd,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn strip_proposed_plan_blocks(text: &str) -> String {
|
||||
let mut parser = ProposedPlanParser::new();
|
||||
let mut out = String::new();
|
||||
for segment in parser.parse(text).into_iter().chain(parser.finish()) {
|
||||
if let ProposedPlanSegment::Normal(delta) = segment {
|
||||
out.push_str(&delta);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn extract_proposed_plan_text(text: &str) -> Option<String> {
|
||||
let mut parser = ProposedPlanParser::new();
|
||||
let mut plan_text = String::new();
|
||||
let mut saw_plan_block = false;
|
||||
for segment in parser.parse(text).into_iter().chain(parser.finish()) {
|
||||
match segment {
|
||||
ProposedPlanSegment::ProposedPlanStart => {
|
||||
saw_plan_block = true;
|
||||
plan_text.clear();
|
||||
}
|
||||
ProposedPlanSegment::ProposedPlanDelta(delta) => {
|
||||
plan_text.push_str(&delta);
|
||||
}
|
||||
ProposedPlanSegment::ProposedPlanEnd | ProposedPlanSegment::Normal(_) => {}
|
||||
}
|
||||
}
|
||||
saw_plan_block.then_some(plan_text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ProposedPlanParser;
|
||||
use super::ProposedPlanSegment;
|
||||
use super::strip_proposed_plan_blocks;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn streams_proposed_plan_segments() {
|
||||
let mut parser = ProposedPlanParser::new();
|
||||
let mut segments = Vec::new();
|
||||
|
||||
for chunk in [
|
||||
"Intro text\n<prop",
|
||||
"osed_plan>\n- step 1\n",
|
||||
"</proposed_plan>\nOutro",
|
||||
] {
|
||||
segments.extend(parser.parse(chunk));
|
||||
}
|
||||
segments.extend(parser.finish());
|
||||
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![
|
||||
ProposedPlanSegment::Normal("Intro text\n".to_string()),
|
||||
ProposedPlanSegment::ProposedPlanStart,
|
||||
ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()),
|
||||
ProposedPlanSegment::ProposedPlanEnd,
|
||||
ProposedPlanSegment::Normal("Outro".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_non_tag_lines() {
|
||||
let mut parser = ProposedPlanParser::new();
|
||||
let mut segments = parser.parse(" <proposed_plan> extra\n");
|
||||
segments.extend(parser.finish());
|
||||
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![ProposedPlanSegment::Normal(
|
||||
" <proposed_plan> extra\n".to_string()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_unterminated_plan_block_on_finish() {
|
||||
let mut parser = ProposedPlanParser::new();
|
||||
let mut segments = parser.parse("<proposed_plan>\n- step 1\n");
|
||||
segments.extend(parser.finish());
|
||||
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![
|
||||
ProposedPlanSegment::ProposedPlanStart,
|
||||
ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()),
|
||||
ProposedPlanSegment::ProposedPlanEnd,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_tag_line_without_trailing_newline() {
|
||||
let mut parser = ProposedPlanParser::new();
|
||||
let mut segments = parser.parse("<proposed_plan>\n- step 1\n</proposed_plan>");
|
||||
segments.extend(parser.finish());
|
||||
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![
|
||||
ProposedPlanSegment::ProposedPlanStart,
|
||||
ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()),
|
||||
ProposedPlanSegment::ProposedPlanEnd,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_proposed_plan_blocks_from_text() {
|
||||
let text = "before\n<proposed_plan>\n- step\n</proposed_plan>\nafter";
|
||||
assert_eq!(strip_proposed_plan_blocks(text), "before\nafter");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_utils_stream_parser::strip_citations;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::codex::Session;
|
||||
@@ -11,17 +12,42 @@ use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::parse_turn_item;
|
||||
use crate::proposed_plan_parser::strip_proposed_plan_blocks;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::router::ToolRouter;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_utils_stream_parser::strip_proposed_plan_blocks;
|
||||
use futures::Future;
|
||||
use tracing::debug;
|
||||
use tracing::instrument;
|
||||
|
||||
fn strip_hidden_assistant_markup(text: &str, plan_mode: bool) -> String {
|
||||
let (without_citations, _citations) = strip_citations(text);
|
||||
if plan_mode {
|
||||
strip_proposed_plan_blocks(&without_citations)
|
||||
} else {
|
||||
without_citations
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn raw_assistant_output_text_from_item(item: &ResponseItem) -> Option<String> {
|
||||
if let ResponseItem::Message { role, content, .. } = item
|
||||
&& role == "assistant"
|
||||
{
|
||||
let combined = content
|
||||
.iter()
|
||||
.filter_map(|ci| match ci {
|
||||
codex_protocol::models::ContentItem::OutputText { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<String>();
|
||||
return Some(combined);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle a completed output item from the model stream, recording it and
|
||||
/// queuing any tool execution futures. This records items immediately so
|
||||
/// history and rollout stay in sync even if the turn is later cancelled.
|
||||
@@ -169,7 +195,7 @@ pub(crate) async fn handle_non_tool_response_item(
|
||||
| ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::WebSearchCall { .. } => {
|
||||
let mut turn_item = parse_turn_item(item)?;
|
||||
if plan_mode && let TurnItem::AgentMessage(agent_message) = &mut turn_item {
|
||||
if let TurnItem::AgentMessage(agent_message) = &mut turn_item {
|
||||
let combined = agent_message
|
||||
.content
|
||||
.iter()
|
||||
@@ -177,7 +203,7 @@ pub(crate) async fn handle_non_tool_response_item(
|
||||
codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(),
|
||||
})
|
||||
.collect::<String>();
|
||||
let stripped = strip_proposed_plan_blocks(&combined);
|
||||
let stripped = strip_hidden_assistant_markup(&combined, plan_mode);
|
||||
agent_message.content =
|
||||
vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }];
|
||||
}
|
||||
@@ -195,25 +221,15 @@ pub(crate) fn last_assistant_message_from_item(
|
||||
item: &ResponseItem,
|
||||
plan_mode: bool,
|
||||
) -> Option<String> {
|
||||
if let ResponseItem::Message { role, content, .. } = item
|
||||
&& role == "assistant"
|
||||
{
|
||||
let combined = content
|
||||
.iter()
|
||||
.filter_map(|ci| match ci {
|
||||
codex_protocol::models::ContentItem::OutputText { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<String>();
|
||||
if let Some(combined) = raw_assistant_output_text_from_item(item) {
|
||||
if combined.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return if plan_mode {
|
||||
let stripped = strip_proposed_plan_blocks(&combined);
|
||||
(!stripped.trim().is_empty()).then_some(stripped)
|
||||
} else {
|
||||
Some(combined)
|
||||
};
|
||||
let stripped = strip_hidden_assistant_markup(&combined, plan_mode);
|
||||
if stripped.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(stripped);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -248,3 +264,72 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::handle_non_tool_response_item;
|
||||
use super::last_assistant_message_from_item;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn assistant_output_text(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: Some("msg-1".to_string()),
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
end_turn: Some(true),
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_non_tool_response_item_strips_citations_from_assistant_message() {
|
||||
let item = assistant_output_text("hello<oai-mem-citation>doc1</oai-mem-citation> world");
|
||||
|
||||
let turn_item = handle_non_tool_response_item(&item, false)
|
||||
.await
|
||||
.expect("assistant message should parse");
|
||||
|
||||
let TurnItem::AgentMessage(agent_message) = turn_item else {
|
||||
panic!("expected agent message");
|
||||
};
|
||||
let text = agent_message
|
||||
.content
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(),
|
||||
})
|
||||
.collect::<String>();
|
||||
assert_eq!(text, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_assistant_message_from_item_strips_citations_and_plan_blocks() {
|
||||
let item = assistant_output_text(
|
||||
"before<oai-mem-citation>doc1</oai-mem-citation>\n<proposed_plan>\n- x\n</proposed_plan>\nafter",
|
||||
);
|
||||
|
||||
let message = last_assistant_message_from_item(&item, true)
|
||||
.expect("assistant text should remain after stripping");
|
||||
|
||||
assert_eq!(message, "before\nafter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_assistant_message_from_item_returns_none_for_citation_only_message() {
|
||||
let item = assistant_output_text("<oai-mem-citation>doc1</oai-mem-citation>");
|
||||
|
||||
assert_eq!(last_assistant_message_from_item(&item, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_assistant_message_from_item_returns_none_for_plan_only_hidden_message() {
|
||||
let item = assistant_output_text("<proposed_plan>\n- x\n</proposed_plan>");
|
||||
|
||||
assert_eq!(last_assistant_message_from_item(&item, true), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
//! Line-based tag block parsing for streamed text.
|
||||
//!
|
||||
//! The parser buffers each line until it can disprove that the line is a tag,
|
||||
//! which is required for tags that must appear alone on a line. For example,
|
||||
//! Proposed Plan output uses `<proposed_plan>` and `</proposed_plan>` tags
|
||||
//! on their own lines so clients can stream plan content separately.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct TagSpec<T> {
|
||||
pub(crate) open: &'static str,
|
||||
pub(crate) close: &'static str,
|
||||
pub(crate) tag: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum TaggedLineSegment<T> {
|
||||
Normal(String),
|
||||
TagStart(T),
|
||||
TagDelta(T, String),
|
||||
TagEnd(T),
|
||||
}
|
||||
|
||||
/// Stateful line parser that splits input into normal text vs tag blocks.
|
||||
///
|
||||
/// How it works:
|
||||
/// - While reading a line, we buffer characters until the line either finishes
|
||||
/// (`\n`) or stops matching any tag prefix (after `trim_start`).
|
||||
/// - If it stops matching a tag prefix, the buffered line is immediately
|
||||
/// emitted as text and we continue in "plain text" mode until the next
|
||||
/// newline.
|
||||
/// - When a full line is available, we compare it to the open/close tags; tag
|
||||
/// lines emit TagStart/TagEnd, otherwise the line is emitted as text.
|
||||
/// - `finish()` flushes any buffered line and auto-closes an unterminated tag,
|
||||
/// which keeps streaming resilient to missing closing tags.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TaggedLineParser<T>
|
||||
where
|
||||
T: Copy + Eq,
|
||||
{
|
||||
specs: Vec<TagSpec<T>>,
|
||||
active_tag: Option<T>,
|
||||
detect_tag: bool,
|
||||
line_buffer: String,
|
||||
}
|
||||
|
||||
impl<T> TaggedLineParser<T>
|
||||
where
|
||||
T: Copy + Eq,
|
||||
{
|
||||
pub(crate) fn new(specs: Vec<TagSpec<T>>) -> Self {
|
||||
Self {
|
||||
specs,
|
||||
active_tag: None,
|
||||
detect_tag: true,
|
||||
line_buffer: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a streamed delta into line-aware segments.
|
||||
pub(crate) fn parse(&mut self, delta: &str) -> Vec<TaggedLineSegment<T>> {
|
||||
let mut segments = Vec::new();
|
||||
let mut run = String::new();
|
||||
|
||||
for ch in delta.chars() {
|
||||
if self.detect_tag {
|
||||
if !run.is_empty() {
|
||||
self.push_text(std::mem::take(&mut run), &mut segments);
|
||||
}
|
||||
self.line_buffer.push(ch);
|
||||
if ch == '\n' {
|
||||
self.finish_line(&mut segments);
|
||||
continue;
|
||||
}
|
||||
let slug = self.line_buffer.trim_start();
|
||||
if slug.is_empty() || self.is_tag_prefix(slug) {
|
||||
continue;
|
||||
}
|
||||
// This line cannot be a tag line, so flush it immediately.
|
||||
let buffered = std::mem::take(&mut self.line_buffer);
|
||||
self.detect_tag = false;
|
||||
self.push_text(buffered, &mut segments);
|
||||
continue;
|
||||
}
|
||||
|
||||
run.push(ch);
|
||||
if ch == '\n' {
|
||||
self.push_text(std::mem::take(&mut run), &mut segments);
|
||||
self.detect_tag = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !run.is_empty() {
|
||||
self.push_text(run, &mut segments);
|
||||
}
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
/// Flush any buffered text and close an unterminated tag block.
|
||||
pub(crate) fn finish(&mut self) -> Vec<TaggedLineSegment<T>> {
|
||||
let mut segments = Vec::new();
|
||||
if !self.line_buffer.is_empty() {
|
||||
let buffered = std::mem::take(&mut self.line_buffer);
|
||||
let without_newline = buffered.strip_suffix('\n').unwrap_or(&buffered);
|
||||
let slug = without_newline.trim_start().trim_end();
|
||||
|
||||
if let Some(tag) = self.match_open(slug)
|
||||
&& self.active_tag.is_none()
|
||||
{
|
||||
push_segment(&mut segments, TaggedLineSegment::TagStart(tag));
|
||||
self.active_tag = Some(tag);
|
||||
} else if let Some(tag) = self.match_close(slug)
|
||||
&& self.active_tag == Some(tag)
|
||||
{
|
||||
push_segment(&mut segments, TaggedLineSegment::TagEnd(tag));
|
||||
self.active_tag = None;
|
||||
} else {
|
||||
// The buffered line never proved to be a tag line.
|
||||
self.push_text(buffered, &mut segments);
|
||||
}
|
||||
}
|
||||
if let Some(tag) = self.active_tag.take() {
|
||||
push_segment(&mut segments, TaggedLineSegment::TagEnd(tag));
|
||||
}
|
||||
self.detect_tag = true;
|
||||
segments
|
||||
}
|
||||
|
||||
fn finish_line(&mut self, segments: &mut Vec<TaggedLineSegment<T>>) {
|
||||
let line = std::mem::take(&mut self.line_buffer);
|
||||
let without_newline = line.strip_suffix('\n').unwrap_or(&line);
|
||||
let slug = without_newline.trim_start().trim_end();
|
||||
|
||||
if let Some(tag) = self.match_open(slug)
|
||||
&& self.active_tag.is_none()
|
||||
{
|
||||
push_segment(segments, TaggedLineSegment::TagStart(tag));
|
||||
self.active_tag = Some(tag);
|
||||
self.detect_tag = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(tag) = self.match_close(slug)
|
||||
&& self.active_tag == Some(tag)
|
||||
{
|
||||
push_segment(segments, TaggedLineSegment::TagEnd(tag));
|
||||
self.active_tag = None;
|
||||
self.detect_tag = true;
|
||||
return;
|
||||
}
|
||||
|
||||
self.detect_tag = true;
|
||||
self.push_text(line, segments);
|
||||
}
|
||||
|
||||
fn push_text(&self, text: String, segments: &mut Vec<TaggedLineSegment<T>>) {
|
||||
if let Some(tag) = self.active_tag {
|
||||
push_segment(segments, TaggedLineSegment::TagDelta(tag, text));
|
||||
} else {
|
||||
push_segment(segments, TaggedLineSegment::Normal(text));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tag_prefix(&self, slug: &str) -> bool {
|
||||
let slug = slug.trim_end();
|
||||
self.specs
|
||||
.iter()
|
||||
.any(|spec| spec.open.starts_with(slug) || spec.close.starts_with(slug))
|
||||
}
|
||||
|
||||
fn match_open(&self, slug: &str) -> Option<T> {
|
||||
self.specs
|
||||
.iter()
|
||||
.find(|spec| spec.open == slug)
|
||||
.map(|spec| spec.tag)
|
||||
}
|
||||
|
||||
fn match_close(&self, slug: &str) -> Option<T> {
|
||||
self.specs
|
||||
.iter()
|
||||
.find(|spec| spec.close == slug)
|
||||
.map(|spec| spec.tag)
|
||||
}
|
||||
}
|
||||
|
||||
fn push_segment<T>(segments: &mut Vec<TaggedLineSegment<T>>, segment: TaggedLineSegment<T>)
|
||||
where
|
||||
T: Copy + Eq,
|
||||
{
|
||||
match segment {
|
||||
TaggedLineSegment::Normal(delta) => {
|
||||
if delta.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(TaggedLineSegment::Normal(existing)) = segments.last_mut() {
|
||||
existing.push_str(&delta);
|
||||
return;
|
||||
}
|
||||
segments.push(TaggedLineSegment::Normal(delta));
|
||||
}
|
||||
TaggedLineSegment::TagDelta(tag, delta) => {
|
||||
if delta.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(TaggedLineSegment::TagDelta(existing_tag, existing)) = segments.last_mut()
|
||||
&& *existing_tag == tag
|
||||
{
|
||||
existing.push_str(&delta);
|
||||
return;
|
||||
}
|
||||
segments.push(TaggedLineSegment::TagDelta(tag, delta));
|
||||
}
|
||||
TaggedLineSegment::TagStart(tag) => {
|
||||
segments.push(TaggedLineSegment::TagStart(tag));
|
||||
}
|
||||
TaggedLineSegment::TagEnd(tag) => {
|
||||
segments.push(TaggedLineSegment::TagEnd(tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TagSpec;
|
||||
use super::TaggedLineParser;
|
||||
use super::TaggedLineSegment;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Tag {
|
||||
Block,
|
||||
}
|
||||
|
||||
fn parser() -> TaggedLineParser<Tag> {
|
||||
TaggedLineParser::new(vec![TagSpec {
|
||||
open: "<tag>",
|
||||
close: "</tag>",
|
||||
tag: Tag::Block,
|
||||
}])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffers_prefix_until_tag_is_decided() {
|
||||
let mut parser = parser();
|
||||
let mut segments = parser.parse("<t");
|
||||
segments.extend(parser.parse("ag>\nline\n</tag>\n"));
|
||||
segments.extend(parser.finish());
|
||||
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![
|
||||
TaggedLineSegment::TagStart(Tag::Block),
|
||||
TaggedLineSegment::TagDelta(Tag::Block, "line\n".to_string()),
|
||||
TaggedLineSegment::TagEnd(Tag::Block),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tag_lines_with_extra_text() {
|
||||
let mut parser = parser();
|
||||
let mut segments = parser.parse("<tag> extra\n");
|
||||
segments.extend(parser.finish());
|
||||
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![TaggedLineSegment::Normal("<tag> extra\n".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_unterminated_tag_on_finish() {
|
||||
let mut parser = parser();
|
||||
let mut segments = parser.parse("<tag>\nline\n");
|
||||
segments.extend(parser.finish());
|
||||
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![
|
||||
TaggedLineSegment::TagStart(Tag::Block),
|
||||
TaggedLineSegment::TagDelta(Tag::Block, "line\n".to_string()),
|
||||
TaggedLineSegment::TagEnd(Tag::Block),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_tags_with_trailing_whitespace() {
|
||||
let mut parser = parser();
|
||||
let mut segments = parser.parse("<tag> \nline\n</tag> \n");
|
||||
segments.extend(parser.finish());
|
||||
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![
|
||||
TaggedLineSegment::TagStart(Tag::Block),
|
||||
TaggedLineSegment::TagDelta(Tag::Block, "line\n".to_string()),
|
||||
TaggedLineSegment::TagEnd(Tag::Block),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_plain_text() {
|
||||
let mut parser = parser();
|
||||
let mut segments = parser.parse("plain text\n");
|
||||
segments.extend(parser.finish());
|
||||
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![TaggedLineSegment::Normal("plain text\n".to_string())]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user