File size: 38,281 Bytes
2aabbd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d719cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2aabbd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d719cf
2aabbd0
 
 
1d719cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2aabbd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
"""超时失败时把上游 assistant 原话带回来(`upstream_note`)。

背景:生图超时的固定文案只会说「大概是被 GPT 拦了」,用户没法自己判断。
上游被拦时往往只回一段文字、既没调生图工具也没有图片资产,这段文字是唯一线索。

更关键的是:上游在 SSE 结束时就已经明说 `tool_invoked=false`(本轮没调用生图工具),
这种回合永远等不到图。再按 image gen 轮询只会把整段超时预算耗光(线上实测 82~97 秒),
所以这里要求「先做一次短探测取回原话、立刻收工」,轮询只作为探测失败时的兜底。
"""

from __future__ import annotations

import pytest

from services.image_task_service import _public_task
from services.openai_backend_api import (
    ImagePollTimeoutError,
    ImageResolutionResult,
    OpenAIBackendAPI,
)


def _backend_without_init() -> OpenAIBackendAPI:
    """只借方法,不触发网络/账号初始化。"""
    return OpenAIBackendAPI.__new__(OpenAIBackendAPI)


def _assistant(text: str, *, create_time: float, hidden: bool = False) -> dict:
    return {
        "message": {
            "author": {"role": "assistant"},
            "content": {"content_type": "text", "parts": [text]},
            "create_time": create_time,
            "metadata": {"is_visually_hidden_from_conversation": True} if hidden else {},
        }
    }


def test_latest_assistant_text_picks_newest_visible_message() -> None:
    api = _backend_without_init()
    conversation = {
        "mapping": {
            "u1": {
                "message": {
                    "author": {"role": "user"},
                    "content": {"content_type": "text", "parts": ["生成一张手机壁纸"]},
                    "create_time": 1,
                    "metadata": {},
                }
            },
            "a1": _assistant("第一次回复:正在生成。", create_time=2),
            "a2": _assistant("抱歉,我无法生成这张图片。", create_time=3),
        }
    }
    assert api._latest_assistant_text(conversation) == "抱歉,我无法生成这张图片。"


def test_latest_assistant_text_ignores_hidden_and_non_assistant() -> None:
    api = _backend_without_init()
    conversation = {
        "mapping": {
            "a1": _assistant("对用户可见的原话", create_time=2),
            "a2": _assistant("内部思考,不该给用户看", create_time=9, hidden=True),
            "t1": {
                "message": {
                    "author": {"role": "tool"},
                    "recipient": "image_gen",
                    "content": {"content_type": "text", "parts": ["工具输出"]},
                    "create_time": 10,
                    "metadata": {},
                }
            },
        }
    }
    assert api._latest_assistant_text(conversation) == "对用户可见的原话"


@pytest.mark.parametrize("payload", [None, {}, {"mapping": None}, {"mapping": {}}])
def test_latest_assistant_text_tolerates_empty_documents(payload) -> None:
    api = _backend_without_init()
    assert api._latest_assistant_text(payload) == ""


def test_poll_timeout_carries_upstream_text(monkeypatch) -> None:
    """轮询跑完预算仍没等到图片时,异常必须把最后一句话带出来。"""
    from services.config import config

    # 默认初始等待 10s(+抖动)会直接把小预算吃光,一次会话文档都拿不到;
    # 这里压掉等待和轮询间隔,让循环真的发起一轮请求。
    monkeypatch.setattr(type(config), "image_poll_initial_wait_secs", property(lambda self: 0.0))
    monkeypatch.setattr(type(config), "image_poll_interval_secs", property(lambda self: 0.05))

    api = _backend_without_init()
    conversation = {
        "mapping": {
            "a1": _assistant("我不能生成含有真实人物的图片。", create_time=5),
        }
    }

    def _fake_get_conversation(conversation_id: str) -> dict:
        return conversation

    monkeypatch.setattr(api, "_get_conversation", _fake_get_conversation, raising=False)

    with pytest.raises(ImagePollTimeoutError) as excinfo:
        api._poll_image_results("conv-upstream-text", timeout_secs=0.3)

    error = excinfo.value
    assert error.conversation_id == "conv-upstream-text"
    assert error.upstream_text == "我不能生成含有真实人物的图片。"


def test_public_task_exposes_upstream_note() -> None:
    item = _public_task(
        {
            "id": "task-1",
            "status": "error",
            "mode": "edit",
            "model": "gpt-image-2",
            "conversation_id": "cid-1",
            "error": "ChatGPT生图超时 (已等待 91 秒)。",
            "upstream_note": "抱歉,我现在无法生成这个内容。",
        }
    )
    assert item["upstream_note"] == "抱歉,我现在无法生成这个内容。"
    assert item["conversation_id"] == "cid-1"


def test_public_task_omits_upstream_note_when_absent() -> None:
    item = _public_task({"id": "task-2", "status": "error", "mode": "generate", "model": "gpt-image-2"})
    assert "upstream_note" not in item


def test_public_task_exposes_can_continue_waiting_only_when_set() -> None:
    terminal = _public_task(
        {"id": "task-3", "status": "error", "mode": "generate", "model": "gpt-image-2",
         "conversation_id": "cid-1", "can_continue_waiting": False}
    )
    assert terminal["can_continue_waiting"] is False
    # 没显式下发就不出现 —— 前台继续按旧的「有会话 id 就能继续等」规则走。
    legacy = _public_task({"id": "task-4", "status": "error", "mode": "generate", "model": "gpt-image-2"})
    assert "can_continue_waiting" not in legacy


def test_public_task_exposes_can_view_official_only_when_denied() -> None:
    """只有后端明确说「官网没有可核对的东西」时才下发 false(配额耗尽),前台据此收按钮。"""
    denied = _public_task(
        {"id": "task-5", "status": "error", "mode": "edit", "model": "gpt-image-2",
         "conversation_id": "cid-1", "error": QUOTA_TEXT, "can_view_official": False}
    )
    assert denied["can_view_official"] is False
    # 老任务 / 普通失败(超时、内容拒绝)没有这个字段 -> 前台保持原样给入口。
    legacy = _public_task(
        {"id": "task-6", "status": "error", "mode": "edit", "model": "gpt-image-2",
         "conversation_id": "cid-2", "error": "生图超时 (已等待 91 秒)。"}
    )
    assert "can_view_official" not in legacy


def test_latest_assistant_text_respects_cutoff_for_memory_turns() -> None:
    """多轮会话里不能把上一轮的回答当成本轮的结论。"""
    api = _backend_without_init()
    conversation = {
        "mapping": {
            "a_old": _assistant("上一轮的回答:图已生成。", create_time=100),
            "u_new": {
                "message": {
                    "author": {"role": "user"},
                    "content": {"content_type": "text", "parts": ["再来一张"]},
                    "create_time": 200,
                    "metadata": {},
                }
            },
        }
    }
    assert api._latest_assistant_text(conversation, min_create_time=190) == ""
    assert api._latest_assistant_text(conversation) == "上一轮的回答:图已生成。"


def test_latest_assistant_text_respects_after_message_id() -> None:
    api = _backend_without_init()
    conversation = {
        "mapping": {
            "u_prev": {
                "message": {
                    "author": {"role": "user"},
                    "content": {"content_type": "text", "parts": ["上一轮的提问"]},
                    "create_time": 90,
                    "metadata": {},
                }
            },
            "a_old": {**_assistant("上一轮的回答", create_time=100), "parent": "u_prev"},
            "u_old": {
                "message": {
                    "author": {"role": "user"},
                    "content": {"content_type": "text", "parts": ["这一次的提问"]},
                    "create_time": 200,
                    "metadata": {},
                }
            },
            "a_new": {**_assistant("这一轮的原话", create_time=300), "parent": "u_old"},
        }
    }
    assert api._latest_assistant_text(conversation, after_message_id="u_old") == "这一轮的原话"
    # 界标不在文档里(且没有时间界)时宁可不给,也别把上一轮的回答顶上来。
    assert api._latest_assistant_text(conversation, after_message_id="u_missing") == ""


def _terminal_doc(text: str = "请上传需要修改的图片。", *, create_time: float = 1791893410.45,
                  recipient: str = "all", content_type: str = "text", status: str = "finished_successfully",
                  is_complete: bool = True, children: list | None = None, hidden: bool = False) -> dict:
    """复刻真机取证 `6ace1f9f-…` 的会话形状:user → reasoning_recap → 文本叶子。"""
    return {
        "current_node": "leaf",
        "mapping": {
            "u1": {
                "parent": "root",
                "children": ["recap"],
                "message": {
                    "author": {"role": "user"},
                    "recipient": "all",
                    "content": {"content_type": "text", "parts": ["把图片上的文字改成英文"]},
                    "create_time": create_time - 8,
                    "status": "finished_successfully",
                    "metadata": {},
                },
            },
            "recap": {
                "parent": "u1",
                "children": ["leaf"],
                "message": {
                    "author": {"role": "assistant"},
                    "recipient": "all",
                    "content": {"content_type": "reasoning_recap", "parts": ["内部思考"]},
                    "create_time": create_time - 0.01,
                    "status": "finished_successfully",
                    "metadata": {"model_slug": "gpt-5-6-t-mini"},
                },
            },
            "leaf": {
                "parent": "recap",
                "children": children or [],
                "message": {
                    "author": {"role": "assistant"},
                    "recipient": recipient,
                    "content": {"content_type": content_type, "parts": [text]},
                    "create_time": create_time,
                    "status": status,
                    "metadata": {
                        "is_complete": is_complete,
                        "finish_details": {"type": "stop", "stop_tokens": [200002]},
                        **({"is_visually_hidden_from_conversation": True} if hidden else {}),
                    },
                },
            },
        },
    }


def test_terminal_text_answer_detects_real_shape() -> None:
    """真机形状(文本叶子 + is_complete + recipient=all)必须被认成终局。"""
    api = _backend_without_init()
    assert api._terminal_text_answer(_terminal_doc()) == "请上传需要修改的图片。"


@pytest.mark.parametrize(
    "override",
    [
        {"recipient": "image_gen"},          # 工具调用中间态
        {"content_type": "reasoning_recap"},  # 内部思考,不是给用户的回答
        {"status": "in_progress"},            # 还没结束
        {"is_complete": False},
        {"children": ["tool1"]},              # 还有子节点,回合在往下走
        {"hidden": True},                     # 对用户不可见
    ],
)
def test_terminal_text_answer_rejects_non_terminal_shapes(override) -> None:
    api = _backend_without_init()
    doc = _terminal_doc(**override)
    if override.get("is_complete") is False:
        doc["mapping"]["leaf"]["message"]["metadata"]["finish_details"] = {}
    assert api._terminal_text_answer(doc) == ""


def test_terminal_text_answer_ignores_previous_turn_leaf() -> None:
    """续写场景:叶子还是上一轮的终局文本时不能当成这一轮的回答。"""
    api = _backend_without_init()
    doc = _terminal_doc(create_time=100)
    assert api._terminal_text_answer(doc, min_create_time=190) == ""
    assert api._terminal_text_answer(doc, after_message_id="leaf") == ""


@pytest.mark.parametrize("payload", [None, {}, {"mapping": None}, {"mapping": {}, "current_node": "x"}])
def test_terminal_text_answer_tolerates_empty_documents(payload) -> None:
    api = _backend_without_init()
    assert api._terminal_text_answer(payload) == ""


REFUSAL_TEXT = "非常抱歉,生成的图片可能违反了关于与第三方内容相似性的防护限制。如果你认为此判断有误,请重试或修改提示语。"


def _refusal_doc(text: str = REFUSAL_TEXT) -> dict:
    """真机取证 `6ace48ab-…`(让 GPT 生成迪士尼壁纸被内容策略拦下)的会话形状:

    user → assistant(`recipient=<工具id>`, `content_type=code`, 生图工具调用) → assistant 文本叶子。

    ⚠️ 与 `_terminal_doc` 的关键差别:文本叶子的 ``metadata`` 里**没有**
    `is_complete` / `finish_details`,只有 ``is_error: true``(上游用「错误/拒绝」收尾)。
    """
    return {
        "current_node": "leaf",
        "mapping": {
            "u1": {
                "parent": "root",
                "children": ["tool"],
                "message": {
                    "author": {"role": "user"},
                    "recipient": "all",
                    "content": {"content_type": "text", "parts": ["生成一张迪士尼手机壁纸"]},
                    "create_time": 1791903914.23,
                    "status": "finished_successfully",
                    "metadata": {"message_source": None},
                },
            },
            "tool": {
                "parent": "u1",
                "children": ["leaf"],
                "message": {
                    "author": {"role": "assistant"},
                    "recipient": "t2uay3k.sj1i4kz",
                    "content": {"content_type": "code", "parts": [""]},
                    "create_time": 1791903917.13,
                    "status": "finished_successfully",
                    "metadata": {"can_save": True, "parent_id": "u1"},
                },
            },
            "leaf": {
                "parent": "tool",
                "children": [],
                "message": {
                    "author": {"role": "assistant"},
                    "recipient": "all",
                    "content": {"content_type": "text", "parts": [text]},
                    "create_time": 1791903935.45,
                    "status": "finished_successfully",
                    "metadata": {
                        "can_save": True,
                        "is_error": True,
                        "model_switcher_deny": False,
                        "permissions": {},
                        "turn_exchange_id": "t2uay3k",
                        "working_turn_id": "t2uay3k",
                    },
                },
            },
        },
    }


def test_terminal_text_answer_detects_refusal_error_leaf() -> None:
    """内容策略拒绝的叶子没有 is_complete/finish_details,只有 is_error=true,同样要认成终局。

    真机代价:这条 21 秒就出现的拒绝文案被判成「普通超时」,白轮询 86.8s / 91.8s / 30s 三次。
    """
    api = _backend_without_init()
    assert api._terminal_text_answer(_refusal_doc()) == REFUSAL_TEXT
    # 放宽只针对上游的显式错误标记:去掉 is_error 后(既无 is_complete 也无 finish_details)
    # 不能再认成终局,否则任意中途文本都会被当成结论。
    doc = _refusal_doc()
    doc["mapping"]["leaf"]["message"]["metadata"].pop("is_error")
    assert api._terminal_text_answer(doc) == ""


def test_poll_raises_text_only_answer_for_refusal_leaf(monkeypatch) -> None:
    """拒绝文案要在**第一次轮询**就带回去,不跑满预算。"""
    from services.config import config
    from services.openai_backend_api import ImageTextOnlyAnswer

    monkeypatch.setattr(type(config), "image_poll_initial_wait_secs", property(lambda self: 0.0))
    monkeypatch.setattr(type(config), "image_poll_interval_secs", property(lambda self: 5.0))

    api = _backend_without_init()
    calls = {"count": 0}

    def _fake_get_conversation(conversation_id: str) -> dict:
        calls["count"] += 1
        return _refusal_doc()

    monkeypatch.setattr(api, "_get_conversation", _fake_get_conversation, raising=False)

    with pytest.raises(ImageTextOnlyAnswer) as excinfo:
        api._poll_image_results("conv-refusal", timeout_secs=120.0)

    assert excinfo.value.text == REFUSAL_TEXT
    assert excinfo.value.conversation_id == "conv-refusal"
    assert calls["count"] == 1


def test_poll_detects_terminal_text_at_timeout_fallback(monkeypatch) -> None:
    """预算在两次轮询之间耗尽时,收尾补取的那一次也要认出终局文本,而不是报超时。

    真机形状:拒绝文案在 21s 出现,而 30s 预算的轮询停在 20s —— 差一点点就漏判。
    """
    import time as _time

    from services.config import config
    from services.openai_backend_api import ImageTextOnlyAnswer

    monkeypatch.setattr(type(config), "image_poll_initial_wait_secs", property(lambda self: 0.0))
    monkeypatch.setattr(type(config), "image_poll_interval_secs", property(lambda self: 0.05))

    api = _backend_without_init()
    monkeypatch.setattr(api, "_get_conversation", lambda cid: _refusal_doc(), raising=False)

    real_terminal = api._terminal_text_answer
    started = _time.time()

    def _late_terminal(conversation, **kwargs):
        # 模拟「文案还没落地」:预算耗尽前的每一次轮询都判不出来,只有收尾补取那一次能判出来。
        if _time.time() - started < 0.3:
            return ""
        return real_terminal(conversation, **kwargs)

    monkeypatch.setattr(api, "_terminal_text_answer", _late_terminal, raising=False)

    with pytest.raises(ImageTextOnlyAnswer) as excinfo:
        api._poll_image_results("conv-refusal-slow", timeout_secs=0.3)

    assert excinfo.value.text == REFUSAL_TEXT
    assert excinfo.value.conversation_id == "conv-refusal-slow"


def test_pool_turns_refusal_into_terminal_error(monkeypatch) -> None:
    """拒绝文案经号池层要变成「终局错误」:带 conversation_id + terminal_no_image。

    这样前台同时拿到两件事:卡片上有「查看官网」入口,且不会出现没用的「继续等待」按钮。
    """
    from services.protocol import conversation as conv

    class _StubBackend:
        def __init__(self, **kwargs) -> None:
            self.progress_callback = None

        def close(self) -> None:
            return None

    monkeypatch.setattr(conv.account_service, "get_available_access_token",
                        lambda *args, **kwargs: "tok-refusal", raising=False)
    monkeypatch.setattr(conv.account_service, "get_account",
                        lambda token: {"email": "refusal@duck.com"}, raising=False)
    monkeypatch.setattr(conv, "record_image_failure", lambda *args, **kwargs: None, raising=False)
    monkeypatch.setattr(conv, "OpenAIBackendAPI", _StubBackend, raising=False)
    # ⚠️ finally 里的会话隐藏/取证登记走的是**真实台账**(本机 config 开着取证):
    # 不拦住 register_conversation,假 conversation_id="cid-refusal" 会写进
    # data/share_evidence.json,worker 之后反复尝试给这条假会话创建分享链接。
    monkeypatch.setattr(
        "services.share_evidence_service.share_evidence_service.register_conversation",
        lambda **kwargs: None,
    )

    def _fake_stream(backend, request, index, total, **kwargs):
        yield conv.ImageOutput(
            kind="message",
            model=request.model,
            index=index,
            total=total,
            text=REFUSAL_TEXT,
            conversation_id="cid-refusal",
        )

    monkeypatch.setattr(conv, "stream_image_outputs", _fake_stream)

    with pytest.raises(conv.ImageGenerationError) as excinfo:
        list(conv._stream_image_outputs_with_pool(_request()))

    exc = excinfo.value
    assert exc.terminal_no_image is True
    assert exc.conversation_id == "cid-refusal"
    assert exc.code == "content_policy_violation"
    assert exc.can_view_official is True
    assert str(exc) == REFUSAL_TEXT


def test_quota_failure_task_hides_official_entry(tmp_path) -> None:
    """端到端:号池层抛出的「配额用完」错误要落进任务记录,并下发 can_view_official=False。

    只测单个函数会漏掉 `_update_task(**{...})` 那一层写入的拼写错误 —— 那会让整个功能静默失效
    (前端永远拿到 undefined = 照旧显示按钮)。
    """
    import time

    from services.image_task_service import ImageTaskService
    from services.protocol.conversation import ImageGenerationError

    def handler(_payload):
        raise ImageGenerationError(
            QUOTA_TEXT,
            status_code=429,
            error_type="rate_limit_error",
            code="image_quota_exhausted",
            conversation_id="cid-quota",
            terminal_no_image=True,
            can_view_official=False,
        )

    service = ImageTaskService(
        tmp_path / "image_tasks.json",
        generation_handler=handler,
        edit_handler=handler,
        retention_days_getter=lambda: 30,
    )
    owner = {"id": "owner-1", "name": "Owner", "role": "admin"}
    service.submit_generation(
        owner,
        client_task_id="quota-task",
        prompt="cat",
        model="gpt-image-2",
        size=None,
        base_url="http://local.test",
    )

    deadline = time.time() + 3
    item = None
    while time.time() < deadline:
        item = (service.list_tasks(owner, ["quota-task"]).get("items") or [None])[0]
        if item and item.get("status") == "error":
            break
        time.sleep(0.02)

    assert item and item.get("status") == "error", item
    assert item["can_view_official"] is False
    # 会话 id 仍然落库(排查/日志要用),只是前台不给入口。
    assert item["conversation_id"] == "cid-quota"


def test_pool_switches_account_when_quota_exhausted(monkeypatch) -> None:
    """配额用完的账号要当场移出号池 + 本轮换号重试:别把上游账号额度问题变成用户的失败。

    只把错误标成 no-official 是不够的:本地 quota 对免费号常常是「未知」(不计数),
    不主动移出池子的话这个账号会被后续每个请求反复挑中,每个用户都吃一次同样的报错。
    """
    from unittest import mock

    from services.protocol import conversation as conv

    switched_tokens: list[str] = []

    class _StubBackend:
        def __init__(self, access_token: str = "", **kwargs) -> None:
            self.access_token = access_token
            self.progress_callback = None

        def close(self) -> None:
            return None

    def _fake_get_token(*args, excluded_tokens=None, **kwargs):
        return "tok-alive" if "tok-dead" in set(excluded_tokens or set()) else "tok-dead"

    monkeypatch.setattr(conv.account_service, "get_available_access_token", _fake_get_token, raising=False)
    monkeypatch.setattr(
        conv.account_service,
        "get_account",
        lambda token: {"email": f"{token}@duck.com"},
        raising=False,
    )
    monkeypatch.setattr(conv, "record_image_failure", lambda *args, **kwargs: None, raising=False)
    monkeypatch.setattr(conv, "OpenAIBackendAPI", _StubBackend, raising=False)
    monkeypatch.setattr(
        "services.share_evidence_service.share_evidence_service.register_conversation",
        lambda **kwargs: None,
    )
    mark_quota_exhausted = mock.MagicMock()
    monkeypatch.setattr(conv.account_service, "mark_image_quota_exhausted", mark_quota_exhausted, raising=False)

    def _fake_stream(backend, request, index, total, **kwargs):
        if backend.access_token == "tok-dead":
            # 上游只回文字 = 终局结论:本账号配额用完。
            yield conv.ImageOutput(
                kind="message",
                model=request.model,
                index=index,
                total=total,
                text=QUOTA_TEXT,
                conversation_id="cid-dead",
            )
            return
        switched_tokens.append(backend.access_token)
        yield conv.ImageOutput(
            kind="result",
            model=request.model,
            index=index,
            total=total,
            data=[{"b64_json": "x"}],
            conversation_id="cid-alive",
        )

    monkeypatch.setattr(conv, "stream_image_outputs", _fake_stream)

    outputs = list(conv._stream_image_outputs_with_pool(_request()))

    # 换号成功:用户拿到图,而不是「Free 套餐额度用完」的报错。
    assert switched_tokens == ["tok-alive"]
    assert [item.kind for item in outputs] == ["result"]
    # 额度用完的账号被当场移出可用池(否则它会被后续每个请求反复挑中)。
    mark_quota_exhausted.assert_called_once()
    assert mark_quota_exhausted.call_args.args[0] == "tok-dead"


def test_pool_reports_upstream_quota_text_when_switch_budget_runs_out(monkeypatch) -> None:
    """连着撞到额度用尽的账号、换号预算用完后,出站的是**上游原话**。

    站长按这句原话配了「报错文案映射」(→ 大白话),所以出站文案不能被内部改写。
    """
    from unittest import mock

    from services.protocol import conversation as conv

    class _StubBackend:
        def __init__(self, access_token: str = "", **kwargs) -> None:
            self.access_token = access_token
            self.progress_callback = None

        def close(self) -> None:
            return None

    def _fake_get_token(*args, excluded_tokens=None, **kwargs):
        excluded = set(excluded_tokens or set())
        if len(excluded) >= 4:
            raise RuntimeError("no available image quota")
        return f"tok-{len(excluded) + 1}"

    monkeypatch.setattr(conv.account_service, "get_available_access_token", _fake_get_token, raising=False)
    monkeypatch.setattr(conv.account_service, "get_account", lambda token: {"email": "dead@duck.com"}, raising=False)
    monkeypatch.setattr(conv, "record_image_failure", lambda *args, **kwargs: None, raising=False)
    monkeypatch.setattr(conv, "OpenAIBackendAPI", _StubBackend, raising=False)
    monkeypatch.setattr(
        "services.share_evidence_service.share_evidence_service.register_conversation",
        lambda **kwargs: None,
    )
    monkeypatch.setattr(conv.account_service, "mark_image_quota_exhausted", mock.MagicMock(), raising=False)

    def _fake_stream(backend, request, index, total, **kwargs):
        yield conv.ImageOutput(
            kind="message",
            model=request.model,
            index=index,
            total=total,
            text=QUOTA_TEXT,
            conversation_id="cid-dead",
        )

    monkeypatch.setattr(conv, "stream_image_outputs", _fake_stream)

    with pytest.raises(conv.ImageGenerationError) as excinfo:
        list(conv._stream_image_outputs_with_pool(_request()))

    exc = excinfo.value
    assert exc.code == "image_quota_exhausted"
    assert exc.can_view_official is False
    # 换号次数有预算(至少 2 次尝试 = 至少换 1 次),不能把整池挨个 20s 试一遍把用户拖住。
    assert exc.account_email == "dead@duck.com"
    assert str(exc) == QUOTA_TEXT


def test_pool_reports_internal_quota_error_when_pool_drains(monkeypatch) -> None:
    """号池被掏空时要冒出内部英文 `no available image quota`——站长的报错文案规则按它配。

    ⚠️ 这串英文是「对外的内部哨兵」:api/support.py 按它回 429、log_service 按它标
    insufficient_quota、站长的「报错文案映射」按它换大白话。所以池空时**不能**用
    上游原话(那是另一条规则的事)把它替掉,否则规则永远匹配不上。
    """
    from unittest import mock

    from services.protocol import conversation as conv

    class _StubBackend:
        def __init__(self, access_token: str = "", **kwargs) -> None:
            self.access_token = access_token
            self.progress_callback = None

        def close(self) -> None:
            return None

    def _fake_get_token(*args, excluded_tokens=None, **kwargs):
        # 只有 1 个账号:它被移出池子后,号池就空了。
        if set(excluded_tokens or set()):
            raise RuntimeError("no available image quota")
        return "tok-only"

    monkeypatch.setattr(conv.account_service, "get_available_access_token", _fake_get_token, raising=False)
    monkeypatch.setattr(conv.account_service, "get_account", lambda token: {"email": "only@duck.com"}, raising=False)
    monkeypatch.setattr(conv, "record_image_failure", lambda *args, **kwargs: None, raising=False)
    monkeypatch.setattr(conv, "OpenAIBackendAPI", _StubBackend, raising=False)
    monkeypatch.setattr(
        "services.share_evidence_service.share_evidence_service.register_conversation",
        lambda **kwargs: None,
    )
    mark_quota_exhausted = mock.MagicMock()
    monkeypatch.setattr(conv.account_service, "mark_image_quota_exhausted", mark_quota_exhausted, raising=False)

    def _fake_stream(backend, request, index, total, **kwargs):
        yield conv.ImageOutput(
            kind="message",
            model=request.model,
            index=index,
            total=total,
            text=QUOTA_TEXT,
            conversation_id="cid-only",
        )

    monkeypatch.setattr(conv, "stream_image_outputs", _fake_stream)

    with pytest.raises(conv.ImageGenerationError) as excinfo:
        list(conv._stream_image_outputs_with_pool(_request()))

    assert "no available image quota" in str(excinfo.value)
    mark_quota_exhausted.assert_called_once()


def test_pool_marks_quota_notice_as_no_official_evidence(monkeypatch) -> None:
    """账号生图配额用完时不能再挂「查看官网对话」入口。

    真机报错(HF 日志,账号 defy-scruffy-armed@duck.com,20s 失败):
    「你已达到 Free 套餐的图像生成请求上限。上限将在 23小时 后重置,届时可创建更多图像。」
    上游这一轮**什么都没生成**,点「查看官网」进去只有同一句提示 → 纯噪声。
    同时它也不该被谎称成 content_policy_violation(那会让这个账号逃过配额校验)。
    """
    from services.protocol import conversation as conv

    class _StubBackend:
        def __init__(self, **kwargs) -> None:
            self.progress_callback = None

        def close(self) -> None:
            return None

    monkeypatch.setattr(conv.account_service, "get_available_access_token",
                        lambda *args, **kwargs: "tok-quota", raising=False)
    monkeypatch.setattr(conv.account_service, "get_account",
                        lambda token: {"email": "quota@duck.com"}, raising=False)
    monkeypatch.setattr(conv, "record_image_failure", lambda *args, **kwargs: None, raising=False)
    monkeypatch.setattr(conv, "OpenAIBackendAPI", _StubBackend, raising=False)
    monkeypatch.setattr(
        "services.share_evidence_service.share_evidence_service.register_conversation",
        lambda **kwargs: None,
    )

    def _fake_stream(backend, request, index, total, **kwargs):
        yield conv.ImageOutput(
            kind="message",
            model=request.model,
            index=index,
            total=total,
            text=QUOTA_TEXT,
            conversation_id="cid-quota",
        )

    monkeypatch.setattr(conv, "stream_image_outputs", _fake_stream)

    with pytest.raises(conv.ImageGenerationError) as excinfo:
        list(conv._stream_image_outputs_with_pool(_request()))

    exc = excinfo.value
    assert exc.terminal_no_image is True
    assert exc.can_view_official is False
    assert exc.code == "image_quota_exhausted"
    assert exc.status_code == 429
    assert exc.conversation_id == "cid-quota"
    assert str(exc) == QUOTA_TEXT


QUOTA_TEXT = "你已达到 Free 套餐的图像生成请求上限。上限将在 23小时 后重置,届时可创建更多图像。"


@pytest.mark.parametrize(
    "message",
    [
        QUOTA_TEXT,
        # 套餐名/时长变化、只剩后半句时同样要认出来。
        "你已达到 Plus 套餐的图像生成请求上限。",
        "上限将在 1小时 后重置,届时可创建更多图像。",
        # 英文原话(账号 locale 为英文时)。
        "You've reached the Free plan's limit for image generation requests. "
        "The limit will reset in 23 hours.",
        "image generation request limit reached",
    ],
)
def test_is_image_quota_exhausted_message_matches_quota_notices(message: str) -> None:
    from services.protocol.conversation import is_image_quota_exhausted_message

    assert is_image_quota_exhausted_message(message) is True


@pytest.mark.parametrize(
    "message",
    [
        # 内容策略拒绝:官网会话里**有原话可核对**,「查看官网对话」必须保留。
        REFUSAL_TEXT,
        # 上传限流是另一类(换号即可),不是生图配额用完。
        "你已达到文件上传上限。请17小时 内重试。",
        # 空值 / 普通超时文案。
        "",
        "生图超时 (已等待 91 秒)。",
    ],
)
def test_is_image_quota_exhausted_message_ignores_other_failures(message: str) -> None:
    from services.protocol.conversation import is_image_quota_exhausted_message

    assert is_image_quota_exhausted_message(message) is False


def test_poll_raises_text_only_answer_instead_of_waiting(monkeypatch) -> None:
    """终局纯文字要在**第一次轮询**就抛出来,不能把 120s 预算跑满。"""
    from services.config import config
    from services.openai_backend_api import ImageTextOnlyAnswer

    monkeypatch.setattr(type(config), "image_poll_initial_wait_secs", property(lambda self: 0.0))
    monkeypatch.setattr(type(config), "image_poll_interval_secs", property(lambda self: 5.0))

    api = _backend_without_init()
    calls = {"count": 0}

    def _fake_get_conversation(conversation_id: str) -> dict:
        calls["count"] += 1
        return _terminal_doc("请上传需要修改的图片。")

    monkeypatch.setattr(api, "_get_conversation", _fake_get_conversation, raising=False)

    with pytest.raises(ImageTextOnlyAnswer) as excinfo:
        api._poll_image_results("conv-text-only", timeout_secs=120.0)

    assert excinfo.value.text == "请上传需要修改的图片。"
    assert excinfo.value.conversation_id == "conv-text-only"
    assert calls["count"] == 1


def test_poll_keeps_waiting_when_turn_not_terminal(monkeypatch) -> None:
    """回合还没结束(叶子有子节点)时必须继续轮询,等到真超时。"""
    from services.config import config

    monkeypatch.setattr(type(config), "image_poll_initial_wait_secs", property(lambda self: 0.0))
    monkeypatch.setattr(type(config), "image_poll_interval_secs", property(lambda self: 0.05))

    api = _backend_without_init()
    doc = _terminal_doc(children=["tool1"])
    monkeypatch.setattr(api, "_get_conversation", lambda cid: doc, raising=False)

    with pytest.raises(ImagePollTimeoutError) as excinfo:
        api._poll_image_results("conv-still-running", timeout_secs=0.3)

    assert excinfo.value.conversation_id == "conv-still-running"


def _request(**overrides):
    from services.protocol.conversation import ConversationRequest

    return ConversationRequest(
        prompt="生成一张手机壁纸",
        model="gpt-image-2",
        message_as_error=True,
        **overrides,
    )


def _stub_stream_events(monkeypatch, *, turn_use_case: str = "image gen") -> None:
    from services.protocol import conversation as conv

    def _fake_events(*args, **kwargs):
        yield {
            "type": "conversation.done",
            "text": "",
            "conversation_id": "cid-9",
            "parent_message_id": "",
            "file_ids": [],
            "sediment_ids": [],
            "blocked": False,
            "tool_invoked": False,
            "turn_use_case": turn_use_case,
            "done": True,
        }

    monkeypatch.setattr(conv, "conversation_events", _fake_events)


def test_stream_returns_upstream_text_when_turn_ended_with_text(monkeypatch) -> None:
    """轮询识别出「终局纯文字」-> 直接把它当结果返回(前台显示上游原话)。"""
    from services.openai_backend_api import ImageTextOnlyAnswer
    from services.protocol import conversation as conv

    _stub_stream_events(monkeypatch)
    api = _backend_without_init()

    def _fake_resolve(*args, **kwargs):
        raise ImageTextOnlyAnswer("请上传需要修改的图片。", conversation_id="cid-9")

    monkeypatch.setattr(api, "resolve_conversation_image_result", _fake_resolve, raising=False)

    outputs = list(conv.stream_image_outputs(api, _request()))

    assert [output.kind for output in outputs] == ["message"]
    assert outputs[0].text == "请上传需要修改的图片。"
    # 会话 id 必须带出去,前台才有「查看官网」入口。
    assert outputs[0].conversation_id == "cid-9"


def test_stream_still_polls_and_returns_nothing_when_no_text(monkeypatch) -> None:
    """没有终局文字时行为不变:照常轮询,轮询返回空就什么都不产出。"""
    from services.protocol import conversation as conv

    _stub_stream_events(monkeypatch)
    api = _backend_without_init()
    resolved: list[str] = []

    def _fake_resolve(conversation_id, file_ids, sediment_ids, **kwargs):
        resolved.append(conversation_id)
        return ImageResolutionResult(urls=[], message_id="")

    monkeypatch.setattr(api, "resolve_conversation_image_result", _fake_resolve, raising=False)

    outputs = list(conv.stream_image_outputs(api, _request()))

    assert resolved == ["cid-9"]
    assert outputs == []