mindfork 0.10.1

A terminal AI chat written in Rust: local models via llama.cpp or OpenAI, Anthropic, Gemini and Grok in the cloud, with persistent memory, notes, RAG and tools.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
//! An HTTP client to the OpenAI Responses API (`POST /v1/responses`), implementing
//! [`EngineBackend`]. A protocol separate from Chat Completions
//! ([`OpenAiClient`](super::super::OpenAiClient)), same vendor: reasoning summaries,
//! `reasoning.effort`, `text.verbosity`, reasoning items for tool-use. See ADR 0004,
//! docs/research/openai-responses-client.md.
//!
//! Responses has no embeddings — [`Embedder`](crate::shared::api::contract::Embedder)
//! takes a separate source (ADR 0002), like Anthropic.

use anyhow::Result;
use async_stream::stream;
use eventsource_stream::Eventsource;
use futures_util::StreamExt;
use tokio_util::sync::CancellationToken;

use super::wire::{self, RespEvent, RespItem};
use crate::shared::api::contract::{
    ChatChunk, ChatRequest, ChatStream, EngineBackend, FinishReason, ThinkingRef, TokenUsage,
    ToolCallDelta, VisionSupport,
};
use crate::shared::api::error::{self, SUBJECT_RESPONSES};
use crate::shared::api::http;

/// A client to the OpenAI Responses API.
pub struct ResponsesClient {
    http: reqwest::Client,
    /// The base URL with a `/v1` suffix (the client appends `/responses`), e.g.
    /// `https://api.openai.com/v1`.
    base_url: String,
    api_key: String,
    model: String,
}

impl ResponsesClient {
    pub fn new(
        base_url: impl Into<String>,
        api_key: impl Into<String>,
        model: impl Into<String>,
    ) -> Self {
        let base_url = base_url.into().trim_end_matches('/').to_string();
        Self {
            http: http::engine_client(),
            base_url,
            api_key: api_key.into(),
            model: model.into(),
        }
    }
}

#[async_trait::async_trait]
impl EngineBackend for ResponsesClient {
    async fn chat_stream(&self, req: ChatRequest, cancel: CancellationToken) -> Result<ChatStream> {
        let body = wire::build_request(&req, &self.model, true);
        let url = format!("{}/responses", self.base_url);

        let request = self.http.post(&url).bearer_auth(&self.api_key).json(&body);
        let Some(response) = http::send_cancellable(request, &cancel).await? else {
            return Ok(http::cancelled_stream());
        };
        let response = error::check_status(SUBJECT_RESPONSES, response).await?;

        let mut events = response.bytes_stream().eventsource();

        let s = stream! {
            // Whether there was at least one tool call — Responses doesn't send finish_reason,
            // the reason is inferred from a function_call item actually appearing in the stream.
            let mut saw_tool_call = false;
            loop {
                tokio::select! {
                    biased;
                    _ = cancel.cancelled() => {
                        yield ChatChunk::Finished(FinishReason::Cancelled);
                        break;
                    }
                    next = events.next() => {
                        match next {
                            None => {
                                yield ChatChunk::Finished(FinishReason::Stop);
                                break;
                            }
                            Some(Err(err)) => {
                                let message = error::chain_text(&err);
                                tracing::warn!(error = %message, "SSE stream error (openai responses)");
                                for chunk in ChatChunk::failure(message, true) { yield chunk; }
                                break;
                            }
                            Some(Ok(event)) => {
                                if event.data == "[DONE]" {
                                    yield ChatChunk::Finished(FinishReason::Stop);
                                    break;
                                }
                                match serde_json::from_str::<RespEvent>(&event.data) {
                                    Ok(RespEvent::OutputTextDelta { delta }) if !delta.is_empty() => {
                                        yield ChatChunk::Text(delta);
                                    }
                                    Ok(
                                        RespEvent::ReasoningSummaryDelta { delta }
                                        | RespEvent::ReasoningTextDelta { delta },
                                    ) if !delta.is_empty() => {
                                        yield ChatChunk::Thoughts(delta);
                                    }
                                    Ok(RespEvent::OutputItemAdded {
                                        output_index,
                                        item: RespItem::FunctionCall { call_id, name },
                                    }) => {
                                        saw_tool_call = true;
                                        yield ChatChunk::ToolCall(ToolCallDelta { thought_signature: None,
                                            index: output_index,
                                            id: Some(call_id),
                                            name: Some(name),
                                            arguments: String::new(),
                                        });
                                    }
                                    Ok(RespEvent::FunctionArgsDelta { output_index, delta }) => {
                                        yield ChatChunk::ToolCall(ToolCallDelta { thought_signature: None,
                                            index: output_index,
                                            id: None,
                                            name: None,
                                            arguments: delta,
                                        });
                                    }
                                    // A reasoning item is done — carries encrypted_content
                                    // (requested via include). Accumulated for resending on tool-use.
                                    Ok(RespEvent::OutputItemDone {
                                        item: RespItem::Reasoning { id, encrypted_content: Some(enc) },
                                        ..
                                    }) => {
                                        yield ChatChunk::ThoughtsSignature(ThinkingRef {
                                            id: Some(id),
                                            signature: enc,
                                        });
                                    }
                                    Ok(RespEvent::Completed { response }) => {
                                        if let Some(u) = response.usage {
                                            yield ChatChunk::Usage(TokenUsage {
                                                prompt_tokens: u.input_tokens,
                                                completion_tokens: u.output_tokens,
                                                reasoning_tokens: u.output_tokens_details.reasoning_tokens,
                                                prefill: None,
                                            });
                                        }
                                        let reason = if saw_tool_call {
                                            FinishReason::ToolCalls
                                        } else {
                                            FinishReason::Stop
                                        };
                                        yield ChatChunk::Finished(reason);
                                        break;
                                    }
                                    Ok(RespEvent::Incomplete { response }) => {
                                        // Cut off by a limit (`max_output_tokens`) or by the
                                        // content filter — the same event, told apart by
                                        // `incomplete_details.reason`. Read as a limit whatever
                                        // the reason once, it offered `/continue` into the
                                        // filter that had just stopped the reply.
                                        let (usage, details) = response
                                            .map(|r| (r.usage, r.incomplete_details))
                                            .unwrap_or_default();
                                        if let Some(u) = usage {
                                            yield ChatChunk::Usage(TokenUsage {
                                                prompt_tokens: u.input_tokens,
                                                completion_tokens: u.output_tokens,
                                                reasoning_tokens: u.output_tokens_details.reasoning_tokens,
                                                prefill: None,
                                            });
                                        }
                                        yield ChatChunk::Finished(wire::RespIncomplete::finish_reason(details.as_ref()));
                                        break;
                                    }
                                    Ok(RespEvent::Failed { response }) => {
                                        let e = response.and_then(|r| r.error).unwrap_or_default();
                                        let code = e.code.unwrap_or_default();
                                        let transient = error::stream_error_transient(&code, None);
                                        tracing::warn!(
                                            %code,
                                            transient,
                                            message = %e.message,
                                            "openai responses failed mid-stream"
                                        );
                                        let message = error::stream_error_text(&code, &e.message);
                                        for chunk in ChatChunk::failure(message, transient) { yield chunk; }
                                        break;
                                    }
                                    Ok(RespEvent::Error { code, message }) => {
                                        let code = code.unwrap_or_default();
                                        let message = message.unwrap_or_default();
                                        let transient = error::stream_error_transient(&code, None);
                                        tracing::warn!(
                                            %code,
                                            transient,
                                            %message,
                                            "openai responses error event"
                                        );
                                        let message = error::stream_error_text(&code, &message);
                                        for chunk in ChatChunk::failure(message, transient) { yield chunk; }
                                        break;
                                    }
                                    // Other events (created/in_progress/part.added/…) and
                                    // added-reasoning (without encrypted) — ignored.
                                    Ok(_) => {}
                                    Err(err) => {
                                        tracing::warn!(error = %err, data = %event.data, "failed to parse responses SSE chunk");
                                    }
                                }
                            }
                        }
                    }
                }
            }
        };

        Ok(Box::pin(s))
    }

    /// OpenAI takes images on every current model, so the answer is static.
    ///
    /// Deliberately **not** a model-name allowlist: a hardcoded list of vision
    /// models goes stale the week after it is written and then lies confidently —
    /// the trap docs/research/grok-xai-provider.md recorded for reasoning detection.
    /// A genuinely text-only model returns a clear provider error on send, which is
    /// a far better failure than refusing an attach on a guess.
    async fn vision(&self) -> VisionSupport {
        VisionSupport::Supported
    }
}

/// A manual smoke against the real OpenAI Responses API. Marked `#[ignore]` — not in CI.
/// Run: `MINDFORK_OPENAI_KEY=sk-... cargo test responses -- --ignored --nocapture`.
#[cfg(test)]
mod tests {
    use super::*;
    use crate::shared::api::sse_stub::{self, collect, serve};

    async fn turn(events: &'static [&'static str]) -> Vec<ChatChunk> {
        let client = ResponsesClient::new(serve(events), "k", "gpt-x");
        collect(
            client
                .chat_stream(sse_stub::hello(), CancellationToken::new())
                .await
                .unwrap(),
        )
        .await
    }

    /// The defect: an incomplete response was a length cut whatever its reason, so a
    /// reply the filter stopped was announced as hitting the limit and offered
    /// `/continue` — into the same filter (docs/research/content-filter-finish.md).
    #[tokio::test]
    async fn an_incomplete_response_the_filter_stopped_ends_as_filtered() {
        let chunks = turn(&[
            r#"{"type":"response.output_text.delta","delta":"Step one"}"#,
            r#"{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"content_filter"},"usage":{"input_tokens":5,"output_tokens":2}}}"#,
        ])
        .await;
        assert!(
            chunks.contains(&ChatChunk::Text("Step one".into())),
            "{chunks:?}"
        );
        assert!(
            chunks
                .iter()
                .any(|c| matches!(c, ChatChunk::Usage(u) if u.completion_tokens == 2)),
            "the usage still lands: {chunks:?}"
        );
        assert_eq!(
            chunks.last(),
            Some(&ChatChunk::Finished(FinishReason::Filtered))
        );
    }

    /// The limit it was always read as stays a limit — and so does an incomplete
    /// response that names no reason at all.
    #[tokio::test]
    async fn an_incomplete_response_at_the_token_limit_is_still_a_length_cut() {
        for events in [
            &[
                r#"{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}"#,
            ] as &'static [&'static str],
            &[r#"{"type":"response.incomplete"}"#],
        ] {
            let chunks = turn(events).await;
            assert_eq!(
                chunks.last(),
                Some(&ChatChunk::Finished(FinishReason::Length)),
                "{events:?}"
            );
        }
    }
}

#[cfg(test)]
mod ignored_smoke {
    use super::*;
    use crate::entities::sampling::{ReasoningEffort, SamplingConfig, Verbosity};
    use crate::shared::api::contract::{ApiMessage, ApiToolCall, ThinkingBlock, ToolSchema};
    use crate::shared::api::{ThinkingAccumulator, ToolCallAccumulator};

    fn client_from_env() -> Option<ResponsesClient> {
        let key = std::env::var("MINDFORK_OPENAI_KEY").ok()?;
        let model = std::env::var("MINDFORK_OPENAI_MODEL").unwrap_or_else(|_| "gpt-5.5".into());
        Some(ResponsesClient::new(
            "https://api.openai.com/v1",
            key,
            model,
        ))
    }

    #[tokio::test]
    #[ignore = "requires MINDFORK_OPENAI_KEY (live OpenAI Responses API)"]
    async fn simple_generation() {
        let Some(client) = client_from_env() else {
            eprintln!("skip: MINDFORK_OPENAI_KEY not set");
            return;
        };
        let req = ChatRequest {
            continue_final: false,
            system: Some("You are a helpful assistant.".into()),
            messages: vec![ApiMessage::user("Reply with exactly: pong")],
            sampling: SamplingConfig {
                max_tokens: Some(2048),
                ..Default::default()
            },
            tools: vec![],
        };
        let mut stream = client.chat_stream(req, Default::default()).await.unwrap();
        let mut text = String::new();
        let mut finish = None;
        while let Some(chunk) = stream.next().await {
            match chunk {
                ChatChunk::Text(t) => text.push_str(&t),
                ChatChunk::Finished(r) => {
                    finish = Some(r);
                    break;
                }
                ChatChunk::Error { message, .. } => {
                    eprintln!("engine error: {message}");
                }
                _ => {}
            }
        }
        assert!(!text.is_empty(), "expected non-empty response");
        assert!(matches!(
            finish,
            Some(FinishReason::Stop | FinishReason::Length)
        ));
    }

    /// Image input (spec §9.10): an `input_image` part reaches the model and is
    /// described. Verified live before the wire was written — see
    /// docs/research/multimodal-images.md §2.2.
    #[tokio::test]
    #[ignore = "requires MINDFORK_OPENAI_KEY (live OpenAI Responses API)"]
    async fn image_input_is_described() {
        let Some(client) = client_from_env() else {
            eprintln!("skip: MINDFORK_OPENAI_KEY not set");
            return;
        };
        let req = ChatRequest {
            continue_final: false,
            system: None,
            messages: vec![
                ApiMessage::user(crate::shared::api::VISION_PROMPT).with_images(vec![
                    crate::shared::api::ApiImage::new(
                        "image/png",
                        &crate::shared::api::blue_square_png_base64(),
                        None,
                    ),
                ]),
            ],
            // Generous: a reasoning model spends this budget before any text, and a cap
            // that starves it reads as "the image was not seen" (docs/lessons.md §3).
            sampling: SamplingConfig {
                max_tokens: Some(4096),
                ..Default::default()
            },
            tools: vec![],
        };
        let mut stream = client.chat_stream(req, Default::default()).await.unwrap();
        let mut text = String::new();
        while let Some(chunk) = stream.next().await {
            match chunk {
                ChatChunk::Text(t) => text.push_str(&t),
                ChatChunk::Error { message, .. } => eprintln!("engine error: {message}"),
                ChatChunk::Finished(_) => break,
                _ => {}
            }
        }
        eprintln!("openai vision reply: {text}");
        crate::shared::api::assert_sees_blue_square(&text, "openai");
    }

    /// Reasoning summary: with `thinking=true`+`verbosity`, "thoughts" (Thoughts)
    /// and the final reply arrive. Requires a reasoning model (gpt-5.x). `max_tokens` is generous —
    /// reasoning tokens eat into the reply budget.
    #[tokio::test]
    #[ignore = "requires MINDFORK_OPENAI_KEY (live OpenAI Responses API)"]
    async fn reasoning_summary_streams_thoughts() {
        let Some(client) = client_from_env() else {
            eprintln!("skip: MINDFORK_OPENAI_KEY not set");
            return;
        };
        let req = ChatRequest {
            continue_final: false,
            system: None,
            messages: vec![ApiMessage::user(
                "Think step by step: what is 17 * 23? Show brief reasoning.",
            )],
            sampling: SamplingConfig {
                max_tokens: Some(4096),
                thinking: Some(true),
                reasoning_effort: Some(ReasoningEffort::Medium),
                verbosity: Some(Verbosity::Low),
                ..Default::default()
            },
            tools: vec![],
        };
        let mut stream = client.chat_stream(req, Default::default()).await.unwrap();
        let mut thoughts = String::new();
        let mut text = String::new();
        while let Some(chunk) = stream.next().await {
            match chunk {
                ChatChunk::Thoughts(t) => thoughts.push_str(&t),
                ChatChunk::Text(t) => text.push_str(&t),
                ChatChunk::Finished(_) => break,
                ChatChunk::Error { message, .. } => {
                    eprintln!("engine error: {message}");
                }
                _ => {}
            }
        }
        // On a trivial task the summary might be absent — check at least the reply.
        assert!(
            !text.is_empty(),
            "expected final answer, thoughts={thoughts:?}"
        );
    }

    /// Tool-use round-trip: the first round gives a call + a reasoning item (id+encrypted);
    /// the second resends the reasoning item before its function_call and the result —
    /// OpenAI must not return an error.
    #[tokio::test]
    #[ignore = "requires MINDFORK_OPENAI_KEY (live OpenAI Responses API)"]
    async fn tool_use_round_trips_reasoning_item() {
        let Some(client) = client_from_env() else {
            eprintln!("skip: MINDFORK_OPENAI_KEY not set");
            return;
        };
        let tool = ToolSchema {
            name: "get_weather".into(),
            description: "Get the current weather for a city.".into(),
            parameters: serde_json::json!({
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            }),
        };
        let sampling = SamplingConfig {
            max_tokens: Some(4096),
            thinking: Some(true),
            reasoning_effort: Some(ReasoningEffort::High),
            ..Default::default()
        };
        let prompt = "Reason briefly which of Paris or Berlin is the capital of France, \
             then call get_weather for that city.";
        let round1 = ChatRequest {
            continue_final: false,
            system: None,
            messages: vec![ApiMessage::user(prompt)],
            sampling: sampling.clone(),
            tools: vec![tool.clone()],
        };
        let mut stream = client
            .chat_stream(round1, Default::default())
            .await
            .unwrap();
        let mut acc = ToolCallAccumulator::default();
        let mut thinking_id = None;
        let mut enc = String::new();
        let mut reason = FinishReason::Stop;
        while let Some(chunk) = stream.next().await {
            match chunk {
                ChatChunk::ToolCall(d) => acc.push(d),
                ChatChunk::ThoughtsSignature(r) => {
                    thinking_id = r.id;
                    enc = r.signature;
                }
                ChatChunk::Finished(r) => {
                    reason = r;
                    break;
                }
                ChatChunk::Error { message, .. } => {
                    eprintln!("engine error: {message}");
                }
                _ => {}
            }
        }
        assert_eq!(
            reason,
            FinishReason::ToolCalls,
            "model should call the tool"
        );
        let calls = acc.finish();
        assert!(!calls.is_empty(), "expected a tool call");
        let call = &calls[0];

        let round2 = ChatRequest {
            continue_final: false,
            system: None,
            messages: vec![
                ApiMessage::user(prompt),
                ApiMessage::assistant_tool_calls(
                    "",
                    vec![ApiToolCall {
                        thought_signature: None,
                        id: call.id.clone(),
                        name: call.name.clone(),
                        arguments: call.arguments.clone(),
                    }],
                )
                .with_thinking_blocks(vec![ThinkingBlock {
                    text: String::new(),
                    signature: enc,
                    id: thinking_id,
                }]),
                ApiMessage::tool(&call.id, "18°C, sunny"),
            ],
            sampling,
            tools: vec![tool],
        };
        let mut stream = client
            .chat_stream(round2, Default::default())
            .await
            .unwrap();
        let mut text = String::new();
        let mut finish = None;
        while let Some(chunk) = stream.next().await {
            match chunk {
                ChatChunk::Text(t) => text.push_str(&t),
                ChatChunk::Finished(r) => {
                    finish = Some(r);
                    break;
                }
                ChatChunk::Error { message, .. } => {
                    eprintln!("engine error: {message}");
                }
                _ => {}
            }
        }
        assert!(
            matches!(finish, Some(FinishReason::Stop | FinishReason::Length)),
            "second round must succeed, got {finish:?}"
        );
        assert!(
            !text.is_empty(),
            "expected a final answer after tool result"
        );
    }

    /// The next round of a request, as the app would send it: `Ok((text, calls))`
    /// — the reply's text and how many tool calls it made — or the engine's
    /// refusal (a status error before the stream, or an error chunk).
    async fn next_round(
        client: &ResponsesClient,
        req: ChatRequest,
    ) -> Result<(String, usize), String> {
        let mut stream = client
            .chat_stream(req, Default::default())
            .await
            .map_err(|e| format!("{e:#}"))?;
        let mut text = String::new();
        let mut acc = ToolCallAccumulator::default();
        while let Some(chunk) = stream.next().await {
            match chunk {
                ChatChunk::Text(t) => text.push_str(&t),
                ChatChunk::ToolCall(d) => acc.push(d),
                ChatChunk::Error { message, .. } => return Err(message),
                ChatChunk::Finished(_) => break,
                _ => {}
            }
        }
        Ok((text, acc.finish().len()))
    }

    /// The multi-item echo (docs/journal/engine.md): gpt-5.6 answers a
    /// reasoning-heavy brief with two to five reasoning items in about half its
    /// replies, and every one must go back as its own item, in order. Streams the
    /// brief that produced the shape in the field — verbatim, with the search tool
    /// the sub-agent had — until such a reply arrives (eight tries at most: one item
    /// eight times in a row is a 0.4% event at the measured rate), then sends the
    /// next request through the app's own wire with the blocks the loop's
    /// accumulator produced, and — the control arm — the fused shape the loop
    /// produced before the fix (one item, the last id, every ciphertext
    /// concatenated), which the API rejected with `invalid_encrypted_content`
    /// when this was written.
    /// Run: `MINDFORK_OPENAI_KEY=... MINDFORK_OPENAI_MODEL=gpt-5.6 cargo test
    /// several_reasoning_items -- --ignored --nocapture`.
    #[tokio::test]
    #[ignore = "requires MINDFORK_OPENAI_KEY (live OpenAI Responses API)"]
    async fn several_reasoning_items_round_trip_each_as_its_own_item() {
        let Some(client) = client_from_env() else {
            eprintln!("skip: MINDFORK_OPENAI_KEY not set");
            return;
        };
        let tool = ToolSchema {
            name: "web_search".into(),
            description: "Search the web: a list of results with title, url and snippet. \
                          Several independent queries go in one reply as several calls."
                .into(),
            parameters: serde_json::json!({
                "type": "object",
                "properties": {"query": {"type": "string", "description": "The search query."}},
                "required": ["query"],
            }),
        };
        let sampling = SamplingConfig {
            max_tokens: Some(8192),
            thinking: Some(true),
            reasoning_effort: Some(ReasoningEffort::Medium),
            ..Default::default()
        };
        // The sub-agent brief that produced the shape in the field, verbatim: a
        // Russian brief with a search tool at hand (the tool-less English probes of
        // the same day gave one item per reply).
        let system = "Ты — исследователь феноменологии Гуссерля. Анализируй строго, различая  \
            собственные тексты Гуссерля, обоснованную реконструкцию и спекуляцию. Не  \
            приписывай философу знакомства с квантовой механикой или тезисом  \
            квантового бессмертия. Пиши по-русски, содержательно и без театральной  \
            имитации его голоса.";
        let prompt = "Разбери идею квантового бессмертия с позиций философии Эдмунда Гуссерля.  \
            Сначала кратко и точно определи сам мысленный эксперимент и его спорные  \
            физические предпосылки (многомировая интерпретация, квантовое  \
            самоубийство, антропный/селекционный эффект). Затем исследуй через  \
            эпохе, трансцендентальную субъективность, внутреннее сознание времени,  \
            конституирование собственного тела и смерти, интерсубъективность.  \
            Ответь: может ли невозможность пережить собственное небытие служить  \
            аргументом за субъективное бессмертие? Где происходит подмена между  \
            феноменологической неданностью смерти и онтологическим продолжением  \
            жизни? Дай структурированный вывод и обозначь пределы реконструкции.";
        let mut refs = Vec::new();
        let mut calls = Vec::new();
        let mut text = String::new();
        for attempt in 1..=8 {
            let round1 = ChatRequest {
                continue_final: false,
                system: Some(system.into()),
                messages: vec![ApiMessage::user(prompt)],
                sampling: sampling.clone(),
                tools: vec![tool.clone()],
            };
            let mut stream = client
                .chat_stream(round1, Default::default())
                .await
                .unwrap();
            let mut thinking = ThinkingAccumulator::default();
            let mut acc = ToolCallAccumulator::default();
            text.clear();
            while let Some(chunk) = stream.next().await {
                match chunk {
                    ChatChunk::Text(t) => text.push_str(&t),
                    ChatChunk::ToolCall(d) => acc.push(d),
                    ChatChunk::ThoughtsSignature(r) => thinking.push(r),
                    ChatChunk::Error { message, .. } => panic!("round 1 failed: {message}"),
                    ChatChunk::Finished(_) => break,
                    _ => {}
                }
            }
            refs = thinking.finish();
            calls = acc.finish();
            eprintln!(
                "attempt {attempt}: {} reasoning item(s), {} call(s)",
                refs.len(),
                calls.len()
            );
            if refs.len() >= 2 {
                break;
            }
        }
        assert!(
            refs.len() >= 2,
            "eight replies with a single reasoning item — rerun (a 0.4% event at the measured rate)"
        );
        assert!(
            refs.iter().all(|r| r.id.is_some()),
            "every Responses reasoning item carries an id"
        );

        // The next request as the loop builds it: the assistant turn with its
        // blocks, then the tool outputs (stubs) — or, for a text reply, a follow-up.
        let history = |thinking: Vec<ThinkingBlock>| {
            let mut messages = vec![ApiMessage::user(prompt)];
            if calls.is_empty() {
                messages.push(ApiMessage::assistant(text.clone()).with_thinking_blocks(thinking));
                messages.push(ApiMessage::user(
                    "Спасибо. Теперь одним абзацем: в чём главный разрыв?",
                ));
            } else {
                messages.push(
                    ApiMessage::assistant_tool_calls(text.clone(), calls.clone())
                        .with_thinking_blocks(thinking),
                );
                for c in &calls {
                    messages.push(ApiMessage::tool(
                        &c.id,
                        "Результаты поиска (1): 1. заглушка — https://example.org — фрагмент.",
                    ));
                }
            }
            ChatRequest {
                continue_final: false,
                system: Some(system.into()),
                messages,
                sampling: sampling.clone(),
                tools: vec![tool.clone()],
            }
        };

        // The fixed path: every item, in order, each under its own id.
        let fixed: Vec<ThinkingBlock> = refs
            .iter()
            .map(|r| ThinkingBlock {
                text: String::new(),
                signature: r.signature.clone(),
                id: r.id.clone(),
            })
            .collect();
        let (answer, more_calls) = next_round(&client, history(fixed))
            .await
            .unwrap_or_else(|e| panic!("the multi-item echo was rejected: {e}"));
        assert!(
            !answer.is_empty() || more_calls > 0,
            "expected an answer or another round of calls after the echo"
        );
        eprintln!(
            "fixed path: {} items echoed after {} call(s); next reply: {} chars, {} call(s)",
            refs.len(),
            calls.len(),
            answer.len(),
            more_calls
        );

        // The control arm: the shape the loop produced before the fix. The API
        // rejected it when this was written; should it ever stop, the list stays the
        // documented contract ("pass back all reasoning items, untouched"), so the
        // arm reports rather than fails.
        let fused = vec![ThinkingBlock {
            text: String::new(),
            signature: refs.iter().map(|r| r.signature.as_str()).collect(),
            id: refs.last().and_then(|r| r.id.clone()),
        }];
        match next_round(&client, history(fused)).await {
            Err(e) => {
                assert!(
                    e.contains("invalid_encrypted_content"),
                    "the fused item was refused, but not as invalid_encrypted_content: {e}"
                );
                eprintln!(
                    "control arm: the fused item is rejected (invalid_encrypted_content), as before the fix"
                );
            }
            Ok(_) => eprintln!(
                "control arm: the API now accepts a fused item — the list stays the contract"
            ),
        }
    }
}