From c837cdbfa32c822a0c27e7a3795a1698f09e29cb Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 30 Sep 2026 18:05:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A9=E6=89=8B=E6=94=B9=E5=9B=BE=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=8A=E6=B2=A1=E8=AF=B4=E5=AE=8C=E5=BD=93=E6=88=90?= =?UTF-8?q?=E3=80=8C=E7=94=BB=E5=B8=83=E6=97=A0=E9=9C=80=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E3=80=8D=EF=BC=9A=E8=BE=93=E5=87=BA=E8=A2=AB=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E3=80=81=E6=88=96=E4=B8=80=E4=B8=AA=E6=93=8D=E4=BD=9C=E9=83=BD?= =?UTF-8?q?=E6=B2=A1=E7=BB=99=E6=97=B6=E6=8A=A5=E9=94=99=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=AF=B9=E7=9D=80=E5=8E=9F=E5=9B=BE=E8=87=AA=E6=9F=A5?= =?UTF-8?q?=E3=80=81=E4=BA=A4=E4=BB=98=EF=BC=9B=E6=94=B9=E5=9B=BE=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E4=BB=8E=208192=20=E6=8F=90=E5=88=B0=2032768=EF=BC=8C?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E6=96=B9=E4=B8=8D=E6=94=B6=E6=97=B6=E9=80=80?= =?UTF-8?q?=E5=9B=9E=208192=EF=BC=9B=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E4=B8=8D=E5=9C=A8=E6=80=9D=E8=80=83=E9=87=8C=E8=8D=89?= =?UTF-8?q?=E6=8B=9F=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/copilot.py | 100 +++++++- backend/tests/test_autofix_assist.py | 22 ++ backend/tests/test_copilot_truncation.py | 295 +++++++++++++++++++++++ 3 files changed, 411 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_copilot_truncation.py diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 85870718..58b4bbd1 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -756,6 +756,8 @@ async def generate( _STREAM_PROTOCOL = """\ 输出格式(严格遵守): - 每行一个完整的 JSON 对象,除此之外不输出任何东西——不要 markdown 围栏、不要解释文字、不要空行注释 +- 思考和输出共用同一份长度额度:思考里只定方案(加哪些节点、怎么连、每个节点干什么), + 不要在思考里把 JSON、提示词、表达式先写一遍——那些直接写进输出。额度用完时还没输出的改动就全丢了 - 可用操作: {"op":"plan","summary":"一句话说明打算怎么搭"} ← 第一行 {"op":"add_node","node":{"id":"...","type":"...","label":"中文标签","config":{...}}} @@ -913,6 +915,73 @@ def _apply_op(nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], op: return False +class CopilotIncomplete(Exception): + """模型这一轮没说完:输出被截断,或者一个操作都没给。画布不能按「无需修改」交付。""" + + def __init__(self, reason: str, *, hint: str = "", detail: str = "") -> None: + super().__init__(reason) + self.hint = hint or "画布未修改。可以重试,或把需求拆成几步分别提出" + self.detail = detail + + +def _cut_off(chunk: Any) -> str: + """这一块是不是在说「额度用完了」:OpenAI 系是 finish_reason=length,Anthropic 是 stop_reason=max_tokens。""" + meta = getattr(chunk, "response_metadata", None) or {} + reason = meta.get("finish_reason") or meta.get("stop_reason") + return str(reason) if reason in ("length", "max_tokens") else "" + + +#: 流式改图的输出额度。会思考的模型(DeepSeek、Claude 的 adaptive thinking)思考也算在里面: +#: 8192 时复杂的改图常常想完就没额度了,正文一个字没写(2026-09 部署实例上同类请求 5 轮坏了 4 轮) +COPILOT_MAX_TOKENS = 32768 +#: 服务方不收大额度时退回这个(原来的默认值) +_FALLBACK_MAX_TOKENS = 8192 +#: 服务方嫌额度太大时报错里会出现的字样:超过模型的输出上限(DeepSeek chat 系只到 8K), +#: 或者输入加额度超过上下文长度(本地 vLLM) +_BUDGET_WORDS = ("max_tokens", "max_completion_tokens", "max_output_tokens", "maximum context length") + + +def _budget_rejected(e: BaseException) -> bool: + status = getattr(e, "status_code", None) or getattr(getattr(e, "response", None), "status_code", None) + return status in (400, 422) and any(w in str(e).lower() for w in _BUDGET_WORDS) + + +class _BudgetFallback: + """先按大额度调;服务方一开口就嫌额度太大时按原来的额度重来,之后的调用(自查修正轮)都用小的。 + + 只在还没吐出任何东西时重来:这类拒绝都发生在请求刚发出去的时候,重来不会重复输出。 + """ + + def __init__(self, big: Any, small: Any) -> None: + self.model, self._small = big, small + + @property + def max_tokens(self) -> Any: + return getattr(self.model, "max_tokens", None) + + async def astream(self, messages: list[Any]): + started = False + try: + async for chunk in self.model.astream(messages): + started = True + yield chunk + return + except Exception as e: + if started or self.model is self._small or not _budget_rejected(e): + raise + self.model = self._small + async for chunk in self.model.astream(messages): + yield chunk + + +async def _op_model(session: AsyncSession, payload: GenerateIn) -> tuple[Any, str]: + """按操作流改图用的模型:额度见 COPILOT_MAX_TOKENS。""" + spec = await copilot_model_spec(session, payload, max_tokens=COPILOT_MAX_TOKENS) + big, model_id = await get_chat_model(session, spec) + small, _ = await get_chat_model(session, spec.model_copy(update={"max_tokens": _FALLBACK_MAX_TOKENS})) + return _BudgetFallback(big, small), model_id + + async def _iter_ops(model: Any, messages: list[Any]): """流式读模型输出,按行切出操作。done 之后即停——后面就算有闲话也不要了。 @@ -923,11 +992,15 @@ async def _iter_ops(model: Any, messages: list[Any]): 思考走 thinking_text()(Claude 4.6+ 的 thinking 块),不是"把非 JSON 行 当成思考":后者会把模型的 markdown 注释、协议跑偏时的乱输出一并当作思考 展示出来,反而误导。不支持 thinking 的模型就只有心跳,不硬凑。 + + 没等到 done 就因为额度用完停下的,抛 CopilotIncomplete:截断之前的操作照常转出去, + 由调用方决定整轮作废。 """ from app.engine.state import message_text as _text from app.engine.state import thinking_text buffer = "" + cut = "" async for chunk in model.astream(messages): reasoning = thinking_text(chunk) if reasoning: @@ -941,9 +1014,19 @@ async def _iter_ops(model: Any, messages: list[Any]): yield op if op.get("op") == "done": return + cut = _cut_off(chunk) or cut op = _parse_op_line(buffer) if op: yield op + if op.get("op") == "done": + return + if cut: + # 截断的流和说完了的流读起来一模一样:不在这里拦下,上面就会拿原图(或半张图) + # 去自查、交付,界面上写「无需修改」 + limit = getattr(model, "max_tokens", None) + raise CopilotIncomplete( + "模型的输出超出了长度上限,修改没有写完", + detail=f"finish_reason={cut}" + (f",本轮输出上限 {limit} token" if limit else "")) # 多久没动静就补一拍心跳。3 秒是"用户开始怀疑是不是卡了"的量级。 @@ -1553,8 +1636,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g user = _user_message(payload, patch=False) try: - spec_ = await copilot_model_spec(session, payload) - model, model_id = await get_chat_model(session, spec_) + model, model_id = await _op_model(session, payload) except ProviderNotConfigured as e: raise HTTPException(400, _unconfigured(e)) from e @@ -1598,6 +1680,14 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g skipped_types.append(str(bad_type)) if changed or kind in ("plan", "done"): yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n" + # 流正常结束,却没有 done、也没有一个操作能认(格式跑偏,每一行都被跳过): + # 往下走就是拿原图去自查、交付,界面上写「无需修改」——而模型什么都没说 + if all(o.get("op") == "plan" for o in seen_ops): + raise CopilotIncomplete("模型没有给出任何修改,也没有说明原因", + hint="画布未修改。可以重试,或把需求描述得更具体") + except CopilotIncomplete as e: + yield _sse({"op": "error", "message": f"助手本轮未完成:{e}", "hint": e.hint, "detail": e.detail}) + return except Exception as e: # noqa: BLE001 reason, hint = explain_error(e) yield _sse({"op": "error", "message": f"助手本轮未完成:{reason}", @@ -1784,9 +1874,8 @@ async def assist_publish_fix( out["summary"] = "没有阻止发布的错误,无需交给助手" return out try: - spec_ = await copilot_model_spec( + chat, _ = await _op_model( session, GenerateIn(instruction="发布前自动修复", provider=provider, model=model)) - chat, _ = await get_chat_model(session, spec_) except ProviderNotConfigured as e: out["summary"] = _unconfigured(e) return out @@ -1959,9 +2048,8 @@ async def assist_upgrade( known = {_sig(e) for e in base_errors} out["warnings"] = _kept_code(spec) try: - spec_ = await copilot_model_spec( + chat, _ = await _op_model( session, GenerateIn(instruction="升级为可追溯结构", provider=provider, model=model)) - chat, _ = await get_chat_model(session, spec_) except ProviderNotConfigured as e: out.update(ok=False, summary=_unconfigured(e)) return out diff --git a/backend/tests/test_autofix_assist.py b/backend/tests/test_autofix_assist.py index 4f57c9a6..88668f44 100644 --- a/backend/tests/test_autofix_assist.py +++ b/backend/tests/test_autofix_assist.py @@ -278,3 +278,25 @@ async def test_renaming_a_node_while_fixing_another_error_is_adopted(client, mon assert "复核团队(待拆成固定步骤)" in out["remaining"][0]["message"] or any( "复核团队(待拆成固定步骤)" in i["message"] for i in out["remaining"]) assert {(c["node_id"], c["field"]) for c in out["changes"]} == {("team", "label"), ("done", "contract")} + + +async def test_a_fix_cut_off_at_the_output_limit_is_not_adopted(client, monkeypatch): + """修正写到一半额度用完(finish_reason=length):哪怕已经写出来的那几条能让 error 变少,也不采纳半截修改。""" + from langchain_core.messages import AIMessageChunk + + model = script(monkeypatch, [{"op": "plan", "summary": "给出具节点补契约"}, + {"op": "update_node", "id": "done", "config": {"contract": CONTRACT}}]) + lines = model.lines + + async def cut_off(messages): + model.calls.append(messages) + for line in lines: + yield AIMessageChunk(content=line + "\n") + yield AIMessageChunk(content="", response_metadata={"finish_reason": "length"}) + + model.astream = cut_off + wf = await create(client, two_exits()) + out = await assist(client, wf) + assert out["assist"]["ok"] is False and "assist" not in out["applied"] + assert out["assist"]["summary"].startswith("助手本轮未完成:") and "长度上限" in out["assist"]["summary"] + assert out["graph"] == two_exits() diff --git a/backend/tests/test_copilot_truncation.py b/backend/tests/test_copilot_truncation.py new file mode 100644 index 00000000..c81a73e0 --- /dev/null +++ b/backend/tests/test_copilot_truncation.py @@ -0,0 +1,295 @@ +"""Copilot 没说完就不能按「无需修改」交付。 + +2026-09 部署实例上,复杂的改图需求(并行算同比、环比,再加一个单独的 agent)连续两次得到 +「已检查,画布无需修改 · 自查通过」。按同一模型、同一张底图、同一句指令重放:输出额度 8192 +全部用在思考上(finish_reason=length),正文一个字都没有。读流时不看 finish_reason,也不要求 +收到 done,于是自查对着原图报「通过」,final 交回原图,前端比出来没变化就说「无需修改」。 + +这里守几件事: +- 输出被截断(OpenAI 系的 finish_reason=length、Anthropic 的 stop_reason=max_tokens)要报错, + 不跑自查、不交 final; +- 流正常结束却一个操作都没给、也没有 done(格式跑偏,全被跳过了)同样报错; +- 模型明确说完了(有 done)但没改任何东西,照旧交付——那才是真的无需修改; +- 改图的额度要够思考之后还写得下操作;服务方一开口就嫌额度太大时退回 8192 重来。 +""" +from __future__ import annotations + +import json + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessageChunk + +from app.api import copilot as cp +from app.api.copilot import _iter_ops +from app.main import app + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def _node(nid: str, ntype: str, config: dict) -> dict: + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +#: 一张本来就能过自查的图:截断时自查对着它报「通过」,正是那次的样子 +BASE = { + "nodes": [ + _node("start", "input", {"fields": [{"name": "question"}]}), + _node("answer", "llm", {"prompt": "{{ input.question }}", "assign_to": "answer"}), + _node("out", "output", {"fields": [{"name": "答案", "value": "{{ vars.answer }}"}]}), + ], + "edges": [{"source": "start", "target": "answer"}, {"source": "answer", "target": "out"}], +} + + +def _line(op: dict) -> AIMessageChunk: + return AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n") + + +def _thinking(text: str) -> AIMessageChunk: + # OpenAI 兼容系(DeepSeek 经 LiteLLM)的思考走 reasoning_content + return AIMessageChunk(content="", additional_kwargs={"reasoning_content": text}) + + +def _openai_cut() -> AIMessageChunk: + return AIMessageChunk(content="", response_metadata={"finish_reason": "length"}) + + +def _anthropic_cut() -> AIMessageChunk: + return AIMessageChunk(content=[], response_metadata={"stop_reason": "max_tokens"}) + + +class _Scripted: + """按剧本吐 chunk 的模型。each 是每次调用吐的那一串;调用次数多于剧本时重复最后一串。""" + + def __init__(self, *each: list[AIMessageChunk], max_tokens: int | None = None) -> None: + self.each = each + self.calls = 0 + self.max_tokens = max_tokens + + async def astream(self, messages): + chunks = self.each[min(self.calls, len(self.each) - 1)] + self.calls += 1 + for chunk in chunks: + yield chunk + + +async def _events(client, monkeypatch, model, base: dict | None = BASE) -> list[dict]: + async def _model(*_a, **_k): + return model, "mock-fast" + + monkeypatch.setattr(cp, "get_chat_model", _model) + out: list[dict] = [] + async with client.stream("POST", "/api/copilot/generate-stream", + json={"instruction": "并行算同比和环比", "base_graph": base}) as r: + async for line in r.aiter_lines(): + if line.startswith("data: "): + out.append(json.loads(line[6:])) + return out + + +def _kinds(events: list[dict]) -> list[str]: + return [e["op"] for e in events if e["op"] not in ("thinking", "heartbeat", "model")] + + +# --------------------------------------------------------------------------- +# 读流这一层 +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("cut", [_openai_cut, _anthropic_cut], ids=["finish_reason", "stop_reason"]) +async def test_a_cut_off_stream_raises(cut): + model = _Scripted([_thinking("先想想怎么拆成并行分支……"), _line({"op": "plan", "summary": "拆成三支"}), + AIMessageChunk(content='{"op":"add_node","node":{"id":"yo'), cut()]) + got: list[dict] = [] + with pytest.raises(cp.CopilotIncomplete) as info: + async for op in _iter_ops(model, []): + got.append(op) + assert [o["op"] for o in got] == ["thinking", "plan"], "截断之前的操作照常转出去" + assert "长度上限" in str(info.value) + assert info.value.hint + + +async def test_done_that_lands_exactly_at_the_limit_is_complete(): + """最后一行 done 刚好写完(没来得及换行)就到了上限:模型已经说完了,不算截断。""" + model = _Scripted([_line({"op": "plan", "summary": "x"}), + AIMessageChunk(content='{"op":"done","explanation":"好了"}'), _openai_cut()]) + ops = [o async for o in _iter_ops(model, [])] + assert [o["op"] for o in ops] == ["plan", "done"] + + +async def test_a_normal_finish_is_not_a_cut(): + model = _Scripted([_line({"op": "plan", "summary": "x"}), _line({"op": "done", "explanation": "y"}), + AIMessageChunk(content="", response_metadata={"finish_reason": "stop"})]) + ops = [o async for o in _iter_ops(model, [])] + assert [o["op"] for o in ops] == ["plan", "done"] + + +# --------------------------------------------------------------------------- +# 画布改图:截断和空手而归都不能冒充「无需修改」 +# --------------------------------------------------------------------------- + +async def test_thinking_that_eats_the_whole_budget_is_an_error_not_unchanged(client, monkeypatch): + """就是截图里那一轮:只有思考、没有正文,额度用完。""" + model = _Scripted([_thinking("Let me design the modification. "), _thinking("report_ans has 4 incoming edges."), + _openai_cut()], max_tokens=8192) + events = await _events(client, monkeypatch, model) + + kinds = _kinds(events) + assert "final" not in kinds, "没说完的一轮不能把原图当成结果交回去" + assert "check" not in kinds, "自查对着原图报「通过」,是在替一个没发生的修改作证" + [err] = [e for e in events if e["op"] == "error"] + assert err["message"].startswith("助手本轮未完成:") and "长度上限" in err["message"] + assert "画布未修改" in err["hint"] + assert "length" in err["detail"] and "8192" in err["detail"] + + +async def test_a_cut_in_the_middle_of_the_ops_is_not_delivered_as_done(client, monkeypatch): + """写到一半被截断:半张图不能当成品交付,更不能自动开跑。""" + model = _Scripted([ + _line({"op": "plan", "summary": "加一支"}), + _line({"op": "add_node", "node": {"id": "yoy", "type": "llm", "label": "同比", + "config": {"prompt": "算同比", "assign_to": "yoy"}}}), + _anthropic_cut(), + ]) + events = await _events(client, monkeypatch, model) + kinds = _kinds(events) + assert "final" not in kinds + assert kinds[-1] == "error" + + +async def test_output_that_parses_to_nothing_is_an_error(client, monkeypatch): + """格式跑偏(整段放进 markdown 围栏、JSON 分了行),每一行都被跳过:一个操作都没有,也没有 done。""" + pretty = "```json\n" + json.dumps({"op": "add_node", "node": {"id": "yoy", "type": "llm"}}, indent=2) + "\n```\n" + model = _Scripted([AIMessageChunk(content=pretty), + AIMessageChunk(content="", response_metadata={"finish_reason": "stop"})]) + events = await _events(client, monkeypatch, model) + kinds = _kinds(events) + assert "final" not in kinds + [err] = [e for e in events if e["op"] == "error"] + assert err["message"].startswith("助手本轮未完成:") and "画布未修改" in err["hint"] + + +async def test_a_model_that_says_done_without_changes_is_still_unchanged(client, monkeypatch): + """模型看过、明确收尾、一处不改:这才是真的「无需修改」,照旧交付。""" + model = _Scripted([_line({"op": "plan", "summary": "现有的图已经满足"}), + _line({"op": "done", "explanation": "不需要改"})]) + events = await _events(client, monkeypatch, model) + final = events[-1] + assert final["op"] == "final" and final["explanation"] == "不需要改" + assert {n["id"] for n in final["graph"]["nodes"]} == {"start", "answer", "out"} + assert "error" not in _kinds(events) + + +async def test_a_cut_in_the_repair_round_is_reported(client, monkeypatch): + """自查交回去修的那一轮被截断:要说修正没完成,不能说「自查通过」。""" + broken = [ + _line({"op": "plan", "summary": "加个循环"}), + _line({"op": "add_node", "node": {"id": "lp", "type": "loop", "label": "循环", + "config": {"mode": "while", "condition": "foo(vars.x)", + "max_iterations": 2}}}), + _line({"op": "add_edge", "edge": {"source": "answer", "target": "lp"}}), + _line({"op": "add_edge", "edge": {"source": "lp", "target": "out", "sourceHandle": "done"}}), + _line({"op": "done", "explanation": "加好了"}), + ] + model = _Scripted(broken, [_thinking("修一下条件"), _openai_cut()]) + events = await _events(client, monkeypatch, model) + checks = [e for e in events if e["op"] == "check"] + assert checks[0]["status"] == "repairing" + assert any(c["status"] == "error" and "长度上限" in c["message"] for c in checks), checks + assert not any(c["status"] == "passed" for c in checks) + + +# --------------------------------------------------------------------------- +# 额度 +# --------------------------------------------------------------------------- + +async def test_the_stream_asks_for_a_budget_that_leaves_room_after_thinking(client, monkeypatch): + asked: list[int | None] = [] + + async def _model(_session, spec): + asked.append(spec.max_tokens) + return _Scripted([_line({"op": "plan", "summary": "x"}), _line({"op": "done", "explanation": "y"})]), "m" + + monkeypatch.setattr(cp, "get_chat_model", _model) + async with client.stream("POST", "/api/copilot/generate-stream", + json={"instruction": "改一下", "base_graph": BASE}) as r: + [line async for line in r.aiter_lines()] + assert asked and asked[0] >= 32768, asked + + +class _BudgetTooLarge(Exception): + """仿 openai / anthropic SDK 的 400:一发请求就被拒,还没吐出任何东西。""" + + status_code = 400 + + +class _Rejects: + def __init__(self, message: str) -> None: + self.message = message + self.calls = 0 + + async def astream(self, messages): + self.calls += 1 + raise _BudgetTooLarge(self.message) + yield # pragma: no cover - 让它成为异步生成器 + + +@pytest.mark.parametrize("message", [ + "Invalid max_tokens value, the valid range of max_tokens is [1, 8192]", + "max_tokens: 32768 > 8192, which is the maximum allowed number of output tokens", + "This model's maximum context length is 32768 tokens. However, you requested 40369 tokens " + "(7601 in the messages, 32768 in the completion).", +]) +async def test_a_rejected_budget_falls_back_to_the_old_one(client, monkeypatch, message): + big = _Rejects(message) + small = _Scripted([_line({"op": "plan", "summary": "x"}), + _line({"op": "add_node", "node": {"id": "yoy", "type": "llm", "label": "同比", + "config": {"prompt": "算同比", "assign_to": "yoy"}}}), + _line({"op": "add_edge", "edge": {"source": "answer", "target": "yoy"}}), + _line({"op": "add_edge", "edge": {"source": "yoy", "target": "out"}}), + _line({"op": "done", "explanation": "加好了"})]) + asked: list[int | None] = [] + + async def _model(_session, spec): + asked.append(spec.max_tokens) + return (big if spec.max_tokens and spec.max_tokens > 8192 else small), "m" + + monkeypatch.setattr(cp, "get_chat_model", _model) + events: list[dict] = [] + async with client.stream("POST", "/api/copilot/generate-stream", + json={"instruction": "加一支同比", "base_graph": BASE}) as r: + async for line in r.aiter_lines(): + if line.startswith("data: "): + events.append(json.loads(line[6:])) + assert 8192 in asked + assert big.calls == 1 and small.calls == 1 + final = events[-1] + assert final["op"] == "final" and "yoy" in {n["id"] for n in final["graph"]["nodes"]} + + +async def test_other_rejections_are_not_retried(client, monkeypatch): + """别的 400(模型名写错之类)照实报错,不拿小额度再撞一次。""" + big = _Rejects("model 'no-such-model' does not exist") + small = _Scripted([_line({"op": "done", "explanation": "不该到这里"})]) + + async def _model(_session, spec): + return (big if spec.max_tokens and spec.max_tokens > 8192 else small), "m" + + monkeypatch.setattr(cp, "get_chat_model", _model) + events: list[dict] = [] + async with client.stream("POST", "/api/copilot/generate-stream", + json={"instruction": "改一下", "base_graph": BASE}) as r: + async for line in r.aiter_lines(): + if line.startswith("data: "): + events.append(json.loads(line[6:])) + assert small.calls == 0 + assert events[-1]["op"] == "error" + + +def test_the_protocol_asks_not_to_draft_the_json_while_thinking(): + """思考里把整段 JSON 先写一遍,额度就去了一半:那一轮重放时思考的结尾正在逐字写口径卡的表达式。""" + assert "思考" in cp._STREAM_PROTOCOL and "不要在思考里" in cp._STREAM_PROTOCOL