Skip to content

Bug: Consecutive Role::User Messages in Aggregated Developer Loop (Anthropic API 400) #6251

Description

@pavver

Problem Description

In the recently merged developer-loop aggregation feature (#6173 / #6239), the compaction pass collapses consecutive developer-tool steps into the first step, a placeholder, and the last step.

Currently, the placeholder is inserted into the message history as a standalone Message with role: Role::User containing the aggregation notice:

fn developer_loop_placeholder(elided: usize, tools: &[String]) -> Message {
    ...
    Message {
        role: Role::User,
        content: MessageContent::Text(text),
        pinned: false,
        timestamp: None,
    }
}

This leads to the following sequence in the compacted message list:

  1. Message (Role::User) — The tool result of the first retained step (pure ContentBlock::ToolResult).
  2. Message (Role::User) — The placeholder text message (pure ContentBlock::Text).
  3. Message (Role::Assistant) — The tool use of the last retained step.

When this history goes through the session repair pipeline in session_repair.rs, the same-role merge phase explicitly skips merging these consecutive user messages. This is because the first message consists solely of tool results (message_is_only_tool_results(last) is true), which blocks the merge:

if last.role == msg.role
    && !message_has_tool_use(last)
    && !message_is_only_tool_results(&msg)
    && !message_has_tool_use(&msg)
    && !message_is_only_tool_results(last) // <-- Returns false, skipping the merge

As a result, two consecutive user role messages are sent raw to the LLM drivers. While some provider APIs tolerate this, Anthropic's Messages API strictly rejects consecutive user messages, returning a 400 Bad Request validation error.


Proposed Solution: Embed the Placeholder in the Tool Result

Instead of creating a standalone Message for the aggregation notice, we can embed the placeholder notice directly into the ToolResult of the first (or last) retained step inside compactor.rs.

For example, if the first step's tool result was "ok", we append the aggregation note directly to its text content:

ok

[DEVELOPER LOOP AGGREGATED] 3 intermediate developer-tool step(s) elided during compaction. The first and last steps are retained for context.

Why this is the best approach:

  1. Zero Role-Alternation Issues: Since the note is part of the existing tool result block, no additional user message is inserted. The alternating roles sequence remains perfectly intact.
  2. OpenAI / Anthropic Compatibility: Appending the text directly to the tool result string works across all drivers. (Note: keeping them as separate blocks in one message is risky, as the OpenAI driver ignores Text parts in a user message if ToolResult blocks are present).
  3. Localized & Simple: Requires zero changes to the complex session_repair.rs pipeline or the driver translation layers.

Suggested Code Change in crates/librefang-runtime/src/compactor.rs

In aggregate_developer_loops:

        if steps.len() >= max_steps && steps.len() > 2 {
            let first = steps[0];
            let last = steps[steps.len() - 1];
            
            // 1. Keep the first step's ToolUse
            result.push(messages[first.0].clone());
            
            // 2. Append the aggregation note to the first step's ToolResult
            let mut first_result = messages[first.1].clone();
            let elided = steps.len() - 2;
            let tools = collect_dev_tool_names(messages, &steps[1..steps.len() - 1]);
            
            append_aggregation_note(&mut first_result, elided, &tools);
            result.push(first_result);
            
            // 3. Keep the last step
            result.push(messages[last.0].clone());
            result.push(messages[last.1].clone());
            i = j;

Verification & Proof (Failing Unit Test)

We wrote a unit test simulating a realistic scenario (a User request, a read-only file read loop, a developer mutating loop, and an assistant final response) to verify if the consecutive Role::User messages are indeed present after compaction and session repair.

    #[test]
    fn test_developer_loop_aggregation_causes_consecutive_user_roles() {
        let mut msgs = Vec::new();

        // 1. First message from User
        msgs.push(Message {
            role: Role::User,
            content: MessageContent::Text("perform refactoring".to_string()),
            pinned: false,
            timestamp: None,
        });

        // 2. Read file (Read-only tool, not a developer tool)
        // Assistant: requests file_read
        msgs.push(Message {
            role: Role::Assistant,
            content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
                id: "t_read".to_string(),
                name: "file_read".to_string(),
                input: serde_json::json!({ "path": "src/main.rs" }),
                provider_metadata: None,
            }]),
            pinned: false,
            timestamp: None,
        });
        // User: returns file content
        msgs.push(Message {
            role: Role::User,
            content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
                tool_use_id: "t_read".to_string(),
                tool_name: "file_read".to_string(),
                content: "fn main() { println!(\"hello\"); }".to_string(),
                is_error: false,
                status: Default::default(),
                approval_request_id: None,
            }]),
            pinned: false,
            timestamp: None,
        });

        // 3. Mutating edits sequence (Developer loop)
        // 4 steps of writing to file
        for k in 0..4 {
            msgs.push(dev_tool_use(&format!("t_write_{k}"), "file_write"));
            msgs.push(tool_result(&format!("t_write_{k}"), "file_write"));
        }

        // 4. Final Assistant response
        msgs.push(Message {
            role: Role::Assistant,
            content: MessageContent::Text("I have finished the refactoring".to_string()),
            pinned: false,
            timestamp: None,
        });

        // Run developer loop aggregation (threshold of 2 steps)
        aggregate_developer_loops(&mut msgs, 2);

        // Verify aggregation actually triggered and reduced the message count:
        // 1 (user) + 2 (read) + 8 (write steps) + 1 (assistant) = 12 messages.
        // After aggregating 4 write steps (8 messages) down to 5 messages:
        // 1 (user) + 2 (read) + 5 (aggregated writes) + 1 (assistant) = 9 messages.
        assert_eq!(msgs.len(), 9);

        // Run session repair
        let (repaired, _stats) = crate::session_repair::validate_and_repair_with_stats(&msgs);

        // Check for consecutive User-role messages.
        // The test MUST fail (panic) if consecutive user messages are found.
        let mut last_was_user = false;
        let mut consecutive_indices = None;

        for (idx, m) in repaired.iter().enumerate() {
            if m.role == Role::User {
                if last_was_user {
                    consecutive_indices = Some((idx - 1, idx));
                    break;
                }
                last_was_user = true;
            } else {
                last_was_user = false;
            }
        }

        if let Some((i1, i2)) = consecutive_indices {
            panic!(
                "FAIL: Found consecutive user messages at indices {} and {} in the repaired session!\n\
                 Message {}: {:?}\n\
                 Message {}: {:?}",
                i1, i2,
                i1, repaired[i1],
                i2, repaired[i2]
            );
        }
    }

Test Output

Running this test yields a panic indicating consecutive User messages:

failures:

---- compactor::tests::test_developer_loop_aggregation_causes_consecutive_user_roles stdout ----

thread 'compactor::tests::test_developer_loop_aggregation_causes_consecutive_user_roles' panicked at crates/librefang-runtime/src/compactor.rs:2460:13:
FAIL: Found consecutive user messages at indices 4 and 5 in the repaired session!
Message 4: Message { role: User, content: Blocks([ToolResult { tool_use_id: "t_write_0", tool_name: "file_write", content: "ok", is_error: false, status: Completed, approval_request_id: None }]), pinned: false, timestamp: None }
Message 5: Message { role: User, content: Text("[DEVELOPER LOOP AGGREGATED] 2 intermediate developer-tool step(s) elided during compaction (tools: file_write). The first and last steps are retained for context."), pinned: false, timestamp: None }

This confirms the issue and highlights the vulnerability in the current developer-loop aggregation strategy.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/apiREST/WS endpoints and dashboardenhancementNew feature or requesthas-prA pull request has been linked to this issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions