Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions _plans/PREMATURE_COMPLETE_BUG.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,3 +531,69 @@ Codex avoids this class when using Responses because completion is anchored on `
- `cargo check`
- `cargo fmt --check`
- `git diff --check`

## 2026-08-31 Session Recurrence

### User-Visible Symptom

Session `bgwb0odvgy97qsr1joml2sc3` kept finishing with no error after the user said `continue`. The last visible assistant text was a preamble, then four `read`s of a local Expo project, then a reasoning-only step, then idle.

### `app.log` Evidence

Primary session id: `bgwb0odvgy97qsr1joml2sc3`. Model: `grok-4.6` via `https://cli-chat-proxy.grok.com` (`provider_kind=OpenAI`).

Relevant sequence:

- `02:29:50`: stream started (`turn_idx=5`, `input_messages=13`, `messages=179`, `agent_max_steps=None`).
- `02:30:03-02:30:06`: step 1 streamed unphased preamble text + four `read` tool calls. `assistant_message_phase=unknown`.
- `02:30:07`: `response.completed end_turn=None reasoning_items=1`. Tools executed successfully (`error_results=0`).
- `02:30:10`: step 2 started after tool results (`messages=189`).
- `02:30:13`: step 2 streamed reasoning only (`"Let me view the screenshots..."` in the persisted message). No text, no tool-call chunks.
- `02:30:14`: `response.completed end_turn=None reasoning_items=1`. Usage `output=137` (reasoning-sized; no leftover function-call budget).
- `02:30:14`: AISDK logged `provider_step_finish step=2 has_tool_call=false end_turn=None provider_finish_reason=unknown last_phase=unknown assistant_text_chars=0 action=finish preview=""`.
- `02:30:14`: crabcode marked the stream complete: `outcome=Exhausted`, `effective_outcome=Finished`, `stop_reason=Some(Finish)`. No Failed/Incomplete/Cancelled.

An earlier continue turn in the same session (`tq36osvr1zxhibna28rqeb3i`) finished even earlier: reasoning + preamble text, zero tools.

### Root Cause

Same finish gate as the 2026-05-28 phase-less incident, but on the xAI Responses transport:

1. `response.completed` is the terminal event. xAI/OpenAI Responses does not emit `ChunkType::End { reason }`, so `provider_finish_reason` stays `None` (logged `unknown`).
2. Message phases are also absent (`last_phase=unknown`).
3. `phase_less_ambiguous_requires_follow_up` only continues when `provider_finish_reason.is_some_and(|reason| !reason.is_final_assistant_stop())`. That path exists for Anthropic `end_turn`. For Responses, `None` fails `is_some_and`, so the step finishes.
4. Step 2 is stronger than the preamble case: tools were available, assistant text was empty, only reasoning arrived, `end_turn` was not `true`. AISDK still treated that as a real finish.

Not a dropped-tool-call proof for this run: step 1 streamed function calls live, and step 2's `output=137` matches reasoning-only. `response.completed` still does not log/apply `output[].type` besides reasoning items, so the next recurrence should capture `output_types`.

### Diagnostics Added

- `src/aisdk/providers/openai.rs`
- Log `openai-responses completed status=... end_turn=... incomplete_reason=... output_count=... output_types=[...]`.
- `src/aisdk/response.rs`
- `provider_step_finish` now includes `reasoning_chars`, `tools`, and `follow_up[end_turn= commentary= phase_less= empty_output=]`.

### Runtime Fix Applied 2026-08-31 (Grok Build empty resample)

Reverted the agent-loop reminder / preamble-continue hacks. Grok Build does not keep the turn alive by inspecting assistant prose or injecting a "please continue" user message.

Reference: `.devrefs/references/xai-org/grok-build/crates/codegen/xai-grok-sampler/src/actor/request_task.rs`

- `ConversationResponse::empty_reason()` is `ReasoningOnly` or `NoVisibleContent` when there is no assistant text and no tool calls.
- That completed payload is `AttemptOutcome::Empty`: retry the **same sampling request**, do not accept it as a finished turn, do not append it to the conversation.
- Content-filter empties are not retried.
- After the retry budget, the request fails (`SamplingError::EmptyResponse`); it is not `Finish`.
- Reminders are only for doom-loop recovery.

Crabcode now resamples the same provider step (rollback streamed reasoning) when a terminal response has no text and no tool calls.

Preamble text with no tools (`I'll pull the screenshot language next…`) is still a completed assistant message, same as Grok Build: `empty_reason` is None when content is non-empty. That is prompt/model behavior, not a sampler retry.

Validation:

- `cargo test resamples_reasoning_only_completed_response_like_grok_build`
- `cargo test resamples_no_visible_content_completed_response`
- `cargo test empty_response_exhaustion_fails_instead_of_finish`
- `cargo test content_filter_empty_does_not_resample`
- `cargo test hosted_tool_only_completed_response_is_not_empty`
- `cargo test phase_less_text_without_finish_metadata_still_finishes`
2 changes: 2 additions & 0 deletions src/aisdk/chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub enum ChunkType {
ResponseCompleted {
end_turn: Option<bool>,
reasoning_items: Vec<ReasoningReplayItem>,
doom_loop_triggers: Vec<String>,
},
Retry(crate::retry::RetryStatus),
StreamRollback {
Expand Down Expand Up @@ -56,6 +57,7 @@ impl ChunkType {
Self::ResponseCompleted {
end_turn,
reasoning_items: Vec::new(),
doom_loop_triggers: Vec::new(),
}
}
}
Expand Down
127 changes: 114 additions & 13 deletions src/aisdk/providers/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1548,23 +1548,19 @@ fn response_sse_data_to_chunk(data: &str) -> Option<Result<ChunkType>> {
if let Some(usage) = resp.get("usage") {
log_openai_responses_usage(usage);
}
log_openai_responses_completed(resp);
Some(Ok(ChunkType::ResponseCompleted {
end_turn: resp.get("end_turn").and_then(|value| value.as_bool()),
reasoning_items: reasoning_items_from_response_output(resp),
doom_loop_triggers: doom_loop_triggers_from(resp),
}))
}
// Grok Build / cli-chat-proxy: `response.doom_loop_check` with
// `doom_loop_check.triggers` like `tail_repetition:8@thinking`.
// `.devrefs/references/xai-org/grok-build/crates/codegen/xai-grok-sampler/src/doom_loop.rs`
// Also present on `response.completed` (`doom_loop_check` field).
// `.devrefs/references/xai-org/grok-build/crates/codegen/xai-grok-sampling-types/src/doom_loop.rs`
"response.doom_loop_check" => {
let triggers = value
.pointer("/doom_loop_check/triggers")
.and_then(|value| value.as_array())
.into_iter()
.flatten()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>()
.join(",");
let triggers = doom_loop_triggers_from(&value).join(",");
Some(Ok(ChunkType::Metadata(format!(
"doom_loop_check triggers={triggers}"
))))
Expand Down Expand Up @@ -1629,6 +1625,68 @@ fn log_openai_responses_usage(usage: &serde_json::Value) {
));
}

/// Attribute a `response.completed` payload: status, incomplete reason, and
/// output item types. Needed when a turn finishes with no error after
/// reasoning-only / empty assistant output.
fn log_openai_responses_completed(response: &serde_json::Value) {
let status = response
.get("status")
.and_then(|value| value.as_str())
.unwrap_or("unknown");
let end_turn = response.get("end_turn").and_then(|value| value.as_bool());
let incomplete_reason = response
.get("incomplete_details")
.and_then(|details| details.get("reason"))
.and_then(|value| value.as_str())
.unwrap_or("none");
let (output_count, output_types) = summarize_response_output_types(response);

let doom_loop_triggers = doom_loop_triggers_from(response);
let doom_loop = if doom_loop_triggers.is_empty() {
"none".to_string()
} else {
doom_loop_triggers.join(",")
};

crate::log::log(&format!(
"openai-responses completed status={status} end_turn={end_turn:?} incomplete_reason={incomplete_reason} output_count={output_count} output_types=[{output_types}] doom_loop_check={doom_loop}"
));
}

fn doom_loop_triggers_from(value: &serde_json::Value) -> Vec<String> {
value
.pointer("/doom_loop_check/triggers")
.or_else(|| value.pointer("/response/doom_loop_check/triggers"))
.and_then(|value| value.as_array())
.into_iter()
.flatten()
.filter_map(|value| value.as_str())
.map(str::to_string)
.collect()
}

fn summarize_response_output_types(response: &serde_json::Value) -> (usize, String) {
let Some(output) = response.get("output").and_then(|value| value.as_array()) else {
return (0, String::new());
};

let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for item in output {
let item_type = item
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("unknown");
*counts.entry(item_type).or_default() += 1;
}

let output_types = counts
.into_iter()
.map(|(item_type, count)| format!("{item_type}={count}"))
.collect::<Vec<_>>()
.join(",");
(output.len(), output_types)
}

fn responses_provider_error_message(value: &serde_json::Value, fallback: &str) -> String {
let code = response_error_field(value, "code");
let message = response_error_field(value, "message");
Expand Down Expand Up @@ -2348,10 +2406,10 @@ mod tests {
add_responses_lite_header, build_openai_messages, build_websocket_request_body,
fresh_websocket_request_body, is_client_tool_call_event, openai_chunk_is_terminal,
request_snapshot_from_body, response_sse_data_to_chunk, responses_function_call_chunk,
websocket_connection_is_idle, websocket_continuation_mode_after_idle_policy,
websocket_continuation_mode_from_state, OpenAI, OpenAIResponseSnapshot,
OpenAIWebsocketState, WebsocketContinuationMode, WebsocketStreamProgress,
OPENAI_CODEX_WINDOW_ID_HEADER, OPENAI_RESPONSES_LITE_HEADER,
summarize_response_output_types, websocket_connection_is_idle,
websocket_continuation_mode_after_idle_policy, websocket_continuation_mode_from_state,
OpenAI, OpenAIResponseSnapshot, OpenAIWebsocketState, WebsocketContinuationMode,
WebsocketStreamProgress, OPENAI_CODEX_WINDOW_ID_HEADER, OPENAI_RESPONSES_LITE_HEADER,
OPENAI_RESPONSES_LITE_WS_METADATA_KEY, OPENAI_WEBSOCKET_FAILURES_BEFORE_FALLBACK,
OPENAI_WEBSOCKET_IDLE_MAX,
};
Expand Down Expand Up @@ -2913,6 +2971,49 @@ mod tests {
}
}

#[test]
fn response_completed_captures_terminal_doom_loop_triggers() {
let chunk = response_sse_data_to_chunk(
r#"{"type":"response.completed","response":{"output":[{"type":"reasoning","id":"rs_1"}],"doom_loop_check":{"triggers":["tail_repetition:8@thinking"]}}}"#,
)
.expect("expected terminal chunk");

match chunk {
Ok(ChunkType::ResponseCompleted {
doom_loop_triggers, ..
}) => {
assert_eq!(
doom_loop_triggers,
vec!["tail_repetition:8@thinking".to_string()]
);
}
other => panic!("expected ResponseCompleted, got {other:?}"),
}
}

#[test]
fn summarize_response_output_types_counts_reasoning_and_function_calls() {
let response = serde_json::json!({
"output": [
{"type": "reasoning", "id": "rs_1"},
{"type": "function_call", "call_id": "call_1", "name": "read"},
{"type": "function_call", "call_id": "call_2", "name": "read"},
]
});
let (count, types) = summarize_response_output_types(&response);
assert_eq!(count, 3);
assert_eq!(types, "function_call=2,reasoning=1");
}

#[test]
fn summarize_response_output_types_empty_when_missing_output() {
let response = serde_json::json!({"status": "completed"});
assert_eq!(
summarize_response_output_types(&response),
(0, String::new())
);
}

#[test]
fn doom_loop_check_sse_becomes_metadata() {
let chunk = response_sse_data_to_chunk(
Expand Down
Loading
Loading