Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
danielhanchen
5afd6c1841 studio: drop "now let me know" false positive + lazy text-block on boundary
PR 5549 cycle-15 codex review surfaced two P2 issues:

1. Trailing-plan regex matched "Now let me know if you want another
   example" (the closer "let me know" was negative-lookahead-guarded
   only on the bare alternative, not on "now let me"). Refactor to a
   single `(?:now\s+)?let me(?!\s+know\b)` alternative so both
   prefixes share the same guard.

2. Anthropic adapter opened a new text block immediately on the
   iteration boundary marker. If the reprompted iteration started
   with a tool_call rather than text, that just-opened text block was
   immediately closed, emitting a zero-length text content block
   between intent text and tool_use. Defer the open: close the
   current block + bump block_index + reset cursor at the boundary,
   and let `_handle_content` lazy-open when real text arrives.
2026-05-19 05:36:27 +00:00
danielhanchen
a1d79b761e studio: trim verbose comments in auto-continue + boundary plumbing 2026-05-19 04:35:39 +00:00
danielhanchen
bb33346642 studio: drop redundant boundary flag on post-tool status, do reset at tool_end
Codex 02:42Z on #5549 caught that flagging the post-tool empty-status
event with boundary=True (cycle-12 commit 610c387) double-handles the
cursor reset in the Anthropic streaming path.

AnthropicStreamEmitter._handle_tool_end already:
  - closes the open tool_use block,
  - emits tool_result,
  - increments block_index,
  - opens a fresh text block, and
  - resets _prev_text = "".

When llama_cpp.py then yielded boundary=True on the very next event,
_handle_boundary fired _close_block + _open_text_block on that freshly
opened (still-empty) text block. Result: every tool call produced a
spurious content_block_stop + content_block_start pair before the
post-tool model text streamed.

Fix:

  - llama_cpp.py: drop boundary=True from the post-tool status emit
    (line ~4768). Keep boundary=True only at the auto-continue site
    (line ~4500), which has no preceding tool_end to do the cursor
    work.
  - routes/inference.py OpenAI-compat tool stream: mirror the
    Anthropic semantics by resetting prev_text on BOTH tool_start AND
    tool_end, so the post-tool empty-status no longer needs to do it.

Add backend/tests/test_anthropic_messages.py::TestAnthropicStreamEmitter::
test_post_tool_empty_status_does_not_double_close as a regression
test: content -> tool_start -> tool_end -> empty status -> content
must not bump block_index past tool_end's increment, and the post-tool
content must land in the text block tool_end opened.

95 tests pass across the anthropic + trailing-plan suites.
2026-05-19 04:35:39 +00:00
danielhanchen
0cc69de210 studio: scope agentic-loop boundary marker so normal completions stream cleanly
Codex P2 on #5549 flagged that the cycle-5 cursor-reset (efa43c4) keyed
on every empty-status event, but generate_chat_completion_with_tools
emits empty status events in five places, only two of which are real
iteration boundaries:

  - Line 4497: emitted right before `continue` after a re-prompt /
    auto-continue. The next iteration starts a fresh assistant turn.
    BOUNDARY: reset cursor.
  - Line 4766: emitted right before `continue` after a tool call. The
    next iteration regenerates with tool results in history. BOUNDARY:
    reset cursor.
  - Line 4501: emitted at metadata-yield after normal streaming. Stream
    is about to end, no new iteration follows. NOT a boundary.
  - Line 4584: emitted in DRAINING-no-tool-call fallback path. Stream
    is about to end with buffered content_accum. NOT a boundary.
  - Line 4794: emitted at the final exit of the generator. NOT a
    boundary.

Treating all five as boundaries gave every Anthropic-streaming response
an extra content_block_stop + content_block_start pair around its final
text and around every tool call.

Fix by tagging the two real boundary sites with `"boundary": True` and
tightening both the Anthropic emitter (`anthropic_compat.py`) and the
OpenAI-compat tool path + Anthropic non-streaming path
(`routes/inference.py`) to reset the cumulative-text cursor only when
that flag is set. Plain empty-status events keep their existing badge-
clear semantics on the frontend (`tool_status` SSE with content "").

Add two regression tests in
`backend/tests/test_anthropic_messages.py::TestAnthropicStreamEmitter`:

  - test_boundary_flag_closes_block_and_resets_cursor: a boundary=True
    status closes the open text block and the next content delta
    streams from zero.
  - test_empty_status_without_boundary_does_not_close_block: a plain
    empty status leaves block_index unchanged and the next content
    delta is diffed against the previous text length.

107 tests pass across the four anthropic + trailing-plan test files.
2026-05-19 04:35:39 +00:00
danielhanchen
7dbe18ab51 studio: line-anchor trailing-plan list items and ship a backend pytest
While drafting backend/tests/test_trailing_plan.py for the changes
landed in b4e0985, the new tests surfaced a deeper false-positive the
earlier regex tightening missed.

For input "Let me explain:\n1. The function returns 42.\n\nThat's the
answer." the previous `(?:\s*(?:[-*•]|\d+\.)\s+[^\n]+\n?)+` allowed the
regex engine to backtrack and treat the in-prose "42." substring as a
second list-item marker: iter 1 consumed "1. The function returns 4"
and iter 2 consumed "2.\n\nThat's the answer." (with `\s+` greedily
crossing the empty-line break). The pattern then satisfied `\s*\Z` and
the buffer fired a spurious `Continue.` retry on what was already a
fully-formed answer.

Tighten the per-item boundary:

  - `[ \t]*` before the marker (no newlines): forces the marker to sit
    at the start of its own line. A mid-prose "42." cannot satisfy this
    because the engine cannot rewind past the preceding `\n` without
    invalidating the previous iteration's `[^\n]+\n` close.
  - `[ \t]+` between the marker and content: blocks an `\s+`-driven
    cross-newline reach into a closing paragraph.
  - `(?:\n|\Z)` at end of item: a real line break OR end of buffer.
    Preserves the "list at EOB with no trailing newline" case while
    eliminating the backtrack route.

Land backend/tests/test_trailing_plan.py at the same time, covering:

  - `_TRAILING_PLAN_INTENT` "let me know" closer exclusion (cycle-3 fix).
  - `_TRAILING_PLAN_LIST` correctly fires on genuine trailing lists
    (dash, asterisk, unicode bullet, numeric).
  - `_TRAILING_PLAN_LIST` does NOT fire on list + closing paragraph,
    list + closing sentence, list embedded mid-text, or the "42."
    in-prose digit case.
  - `_TRAILING_PLAN_COLON` fires on bare trailing intent-colons only.
  - `_trailing_plan_hit` composite cases.
  - The 600-char window slicing.

31 cases, all pass. Pins the regex behaviour against future regressions
inside the repo (the prior pin script lived only in the probing
workspace, not the studio tree).
2026-05-19 04:35:39 +00:00
pre-commit-ci[bot]
66d59e01d0 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-19 04:35:39 +00:00
danielhanchen
fd53dab931 studio: tighten trailing-plan list anchor + grow tool-iter cap on demand
Codex P2 review on #5549 surfaced two related risks in the auto-continue
plumbing:

1. `_TRAILING_PLAN_LIST` was compiled with `(?ims)`. The `m` flag makes
   the terminal `\s*$` match end-of-line, so a complete answer like
   "Here's my plan:\n- a\n- b\n\nDone, that should work." still matched
   the list-block sub-pattern and tripped a spurious `Continue.` retry.
   Drop the `m` (and the unused `s`) flag and re-anchor with `\Z` so the
   list pattern only fires when the list is genuinely the last thing in
   the buffer.

2. The agent loop pre-reserved `_MAX_REPROMPTS + _MAX_CONTINUES` (= 6)
   extra iterations on top of the caller's `max_tool_iterations`
   unconditionally. That weakens the caller-provided budget: a turn
   that never trips the reprompt or continue path could still run up
   to N+6 full iterations and execute their tool calls.

   Switch the bound to a dynamic cap that grows only as reprompts /
   continues are actually consumed: `iteration < max_tool_iterations +
   _reprompt_count + _continue_count`. With both counters at zero the
   loop honors the caller cap exactly; once a continue or reprompt
   fires it earns its own slot back.

   Implemented with `itertools.count()` so the existing `continue`
   statements in the loop body keep their semantics.

Regex behaviour pinned by `scripts/r6_trailing_plan_regex_test.py`
(updated separately for the new list-tail case).
2026-05-19 04:35:39 +00:00
danielhanchen
25acc2062e studio: reset Anthropic adapter cursor on auto-continue boundary
Codex P2 on #5549 flagged that the auto-continue branch yields only
a `{"type":"status","text":""}` event between turns; the Anthropic
streaming emitter (`AnthropicStreamEmitter`) and the non-streaming
tool path (`_anthropic_tool_non_streaming`) both ignore `status`
events, so their cumulative-text cursor still holds the previous
turn's full length when the continuation starts streaming. Shorter
continuations get dropped entirely and longer ones lose their prefix.

Treat the empty-text status as an auto-continue boundary in both
paths:

  - `AnthropicStreamEmitter`: close any open text block, open a fresh
    one (matches the `tool_end` reset pattern), and clear `_prev_text`.
  - `_anthropic_tool_non_streaming`: clear `prev_text` so the next
    `content` event's diff baseline is empty.

Non-empty status events (tool progress text) keep their existing
no-op semantics.
2026-05-19 04:35:39 +00:00
danielhanchen
8503fb33ae studio: exclude "let me know" from trailing-plan auto-continue
Codex review on #5549 flagged that endings like
"If you need anything else, let me know." match the trailing-plan
intent pattern (the regex matches "let me <anything>." at end of
buffer). On a finished, user-facing closing this fires the auto-
continue branch up to three times, costing latency / tokens and
appending unrelated text after the response.

Add a negative lookahead so "let me" only counts as a mid-plan signal
when it is NOT immediately followed by "know". Other intent phrases
("now let me", "i'll now", "i'm going to", "i will now", "let's now")
already require a planning verb so they are unaffected.

Verified against `scripts/r6_trailing_plan_regex_test.py`: closing
"let me know" variants no longer match; "let me clone/check/run …"
still does.
2026-05-19 04:35:39 +00:00
pre-commit-ci[bot]
af31c54d02 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-19 04:35:39 +00:00
Daniel Han
3a118e5df0 studio: auto-continue when model stops mid-plan
The existing intent-signal re-prompt fires only when tools are armed
and the response is short. Models often stop mid-plan in other shapes
too: a trailing "Let me clone the repo.", a "Let me ...:" header
followed by a numbered list, or a bare trailing colon. When this
happens the turn ends with the structured workload only partially
delivered.

Add a neutral "Continue." nudge that runs alongside the tool-coercive
re-prompt:

- _TRAILING_PLAN_INTENT, _TRAILING_PLAN_LIST, _TRAILING_PLAN_COLON
  cover the three observed shapes, scanned over the last 600 chars.
- _trailing_plan_hit() returns True if any of them match.
- _MAX_CONTINUES (3) is independent of _MAX_REPROMPTS so the two
  paths cannot starve each other.
- _continue_count threads through the agentic loop; auto-continue
  fires with "Continue." regardless of tool armament.

Regex tested against the patterns above plus negative controls
(complete sentences, benign "let me" earlier in the buffer) before
landing.
2026-05-19 04:35:39 +00:00
5 changed files with 360 additions and 31 deletions

View file

@ -253,9 +253,28 @@ class AnthropicStreamEmitter:
elif etype == "metadata": elif etype == "metadata":
self._usage = event.get("usage", {}) self._usage = event.get("usage", {})
return [] return []
# status events — no Anthropic equivalent elif etype == "status" and event.get("boundary"):
# Iteration-boundary marker (auto-continue reprompt). Close
# the open text block + reset _prev_text so the next content
# event diffs against zero. Non-boundary status events (UI
# badge clears) don't reach this branch.
return self._handle_boundary()
# Other status events have no Anthropic equivalent.
return [] return []
def _handle_boundary(self) -> list[str]:
# Close the current text block + reset the cumulative cursor.
# Do NOT pre-open a new text block here -- if the next event is
# a tool_start (not text), the eager-open would emit a zero-length
# text content block between intent text and the tool_use.
# _handle_content lazy-opens when real text arrives.
events = []
if self._text_block_open:
events.append(self._close_block())
self.block_index += 1
self._prev_text = ""
return events
def finish(self, stop_reason: str = "end_turn") -> list[str]: def finish(self, stop_reason: str = "end_turn") -> list[str]:
"""Close any open block and emit message_delta + message_stop.""" """Close any open block and emit message_delta + message_stop."""
events = [] events = []

View file

@ -10,6 +10,7 @@ through its OpenAI-compatible /v1/chat/completions endpoint.
import atexit import atexit
import contextlib import contextlib
import itertools as _itertools
import json import json
import os import os
import re import re
@ -56,6 +57,51 @@ _INTENT_SIGNAL = re.compile(
) )
_MAX_REPROMPTS = 3 _MAX_REPROMPTS = 3
# Mid-plan EOS detectors. Three shapes: trailing intent, list under a
# "Let me ...:" header, bare trailing colon. "let me know" is a closer,
# not a plan signal -- excluded via negative lookahead.
_TRAILING_PLAN_INTENT = re.compile(
r"(?i)("
r"(?:now\s+)?let me(?!\s+know\b)|i[']ll now|next,?\s*i[']ll|"
r"i[']m going to|i will now|let[']s now"
r")[^.!?\n]*[.!?]?\s*$"
)
_TRAILING_PLAN_LIST = re.compile(
# `\Z` (not `$`) so the regex only fires when the list is the last
# thing in the buffer, not when a closing paragraph follows.
# `[ \t]*` before each marker keeps it line-anchored so a mid-prose
# "42." cannot pose as a phantom list item.
r"(?i)"
r"(?:let me|i[']ll|i will|i[']m going to|i am going to|"
r"here[']?s (?:my |the |a )?(?:plan|approach|steps?)|"
r"as follows|the (?:plan|steps?) (?:is|are))"
r"[^:\n]{0,160}:\s*\n"
r"(?:[ \t]*(?:[-*•]|\d+\.)[ \t]+[^\n]+(?:\n|\Z))+"
r"\s*\Z"
)
_TRAILING_PLAN_COLON = re.compile(
r"(?i)(?:let me|i[']ll|i will|i[']m going to|i am going to|"
r"now i[']ll|now i will)"
r"[^\n:]{0,200}:\s*$"
)
_TRAILING_PLAN_WINDOW = 600
_MAX_CONTINUES = 3
def _trailing_plan_hit(stripped: str) -> bool:
"""True if the last `_TRAILING_PLAN_WINDOW` chars look mid-plan."""
if not stripped:
return False
tail = stripped[-_TRAILING_PLAN_WINDOW:]
if _TRAILING_PLAN_INTENT.search(tail) is not None:
return True
if _TRAILING_PLAN_LIST.search(tail) is not None:
return True
if _TRAILING_PLAN_COLON.search(tail) is not None:
return True
return False
# Without max_tokens, llama-server defaults to n_predict = n_ctx (up to # Without max_tokens, llama-server defaults to n_predict = n_ctx (up to
# 262144 for Qwen3.5), producing many-minute zombie decodes when cancel # 262144 for Qwen3.5), producing many-minute zombie decodes when cancel
# fails. t_max_predict_ms is a wall-clock backstop applied unconditionally, # fails. t_max_predict_ms is a wall-clock backstop applied unconditionally,
@ -4015,12 +4061,16 @@ class LlamaCppBackend:
# direct answer like "4" or "Hello!" will not match. # direct answer like "4" or "Hello!" will not match.
# Pattern is compiled once at module level (_INTENT_SIGNAL). # Pattern is compiled once at module level (_INTENT_SIGNAL).
_reprompt_count = 0 _reprompt_count = 0
# Separate counter so auto-continue doesn't steal reprompt budget.
_continue_count = 0
# Reserve extra iterations for re-prompts so they don't # Dynamic cap: caller's max_tool_iterations is honored exactly
# consume the caller's tool-call budget. Only add the # until a reprompt/continue actually fires; each consumed event
# extra slot when tool iterations are actually allowed. # earns its own slot back. itertools.count preserves loop-body
_extra = _MAX_REPROMPTS if max_tool_iterations > 0 else 0 # `continue` semantics.
for iteration in range(max_tool_iterations + _extra): for iteration in _itertools.count():
if iteration >= (max_tool_iterations + _reprompt_count + _continue_count):
break
if cancel_event is not None and cancel_event.is_set(): if cancel_event is not None and cancel_event.is_set():
return return
@ -4353,18 +4403,41 @@ class LlamaCppBackend:
_stripped = content_accum.strip() _stripped = content_accum.strip()
if not _stripped: if not _stripped:
_stripped = reasoning_accum.strip() _stripped = reasoning_accum.strip()
if (
# Tool-coercive reprompt: intent text without a tool call.
_tool_intent_hit = (
tools tools
and _reprompt_count < _MAX_REPROMPTS and _reprompt_count < _MAX_REPROMPTS
and 0 < len(_stripped) < _REPROMPT_MAX_CHARS and 0 < len(_stripped) < _REPROMPT_MAX_CHARS
and _INTENT_SIGNAL.search(_stripped) and _INTENT_SIGNAL.search(_stripped) is not None
): )
_reprompt_count += 1 # Neutral auto-continue on mid-plan EOS. Works without tools.
logger.info( _trailing_hit = (
f"Re-prompt {_reprompt_count}/{_MAX_REPROMPTS}: " _continue_count < _MAX_CONTINUES
f"model responded without calling tools " and _trailing_plan_hit(_stripped)
f"({len(_stripped)} chars)" )
)
if _tool_intent_hit or _trailing_hit:
if _tool_intent_hit:
_reprompt_count += 1
logger.info(
f"Re-prompt {_reprompt_count}/{_MAX_REPROMPTS}: "
f"model responded without calling tools "
f"({len(_stripped)} chars)"
)
_nudge = (
"STOP. Do NOT write code or explain. "
"You MUST call a tool NOW. "
"Call web_search or python immediately."
)
else:
_continue_count += 1
logger.info(
f"Auto-continue {_continue_count}/{_MAX_CONTINUES}: "
f"model ended turn mid-plan "
f"({len(_stripped)} chars)"
)
_nudge = "Continue."
conversation.append( conversation.append(
{ {
"role": "assistant", "role": "assistant",
@ -4374,11 +4447,7 @@ class LlamaCppBackend:
conversation.append( conversation.append(
{ {
"role": "user", "role": "user",
"content": ( "content": _nudge,
"STOP. Do NOT write code or explain. "
"You MUST call a tool NOW. "
"Call web_search or python immediately."
),
} }
) )
# Accumulate tokens and timing from this iteration # Accumulate tokens and timing from this iteration
@ -4389,7 +4458,9 @@ class LlamaCppBackend:
_it_r = _iter_timings or {} _it_r = _iter_timings or {}
_accumulated_predicted_ms += _it_r.get("predicted_ms", 0) _accumulated_predicted_ms += _it_r.get("predicted_ms", 0)
_accumulated_predicted_n += _it_r.get("predicted_n", 0) _accumulated_predicted_n += _it_r.get("predicted_n", 0)
yield {"type": "status", "text": ""} # boundary=True: next iter starts a fresh
# turn, so adapters must reset their cursor.
yield {"type": "status", "text": "", "boundary": True}
continue continue
# Content was already streamed. Yield metadata. # Content was already streamed. Yield metadata.
@ -4657,9 +4728,10 @@ class LlamaCppBackend:
tool_msg["tool_call_id"] = tool_call_id tool_msg["tool_call_id"] = tool_call_id
conversation.append(tool_msg) conversation.append(tool_msg)
# Clear tool status badge before next generation iteration # UI badge clear. NOT a boundary: tool_end already
# reset adapter cursors (would emit a spurious empty
# block if we flagged it).
yield {"type": "status", "text": ""} yield {"type": "status", "text": ""}
# Continue the loop to let model respond with context
continue continue
except httpx.ConnectError: except httpx.ConnectError:

View file

@ -2459,11 +2459,10 @@ async def openai_chat_completions(
break break
if event["type"] == "status": if event["type"] == "status":
# Empty status marks an iteration boundary # boundary=True: auto-continue reprompt.
# in the GGUF tool loop (e.g. after a # Reset cursor only then; plain empty-status
# re-prompt). Reset the cumulative cursor # events (badge clears) keep the cursor.
# so the next assistant turn streams cleanly. if event.get("boundary"):
if not event["text"]:
prev_text = "" prev_text = ""
# Emit tool status as a custom SSE event # Emit tool status as a custom SSE event
# (including empty ones to clear UI badges) # (including empty ones to clear UI badges)
@ -2477,8 +2476,10 @@ async def openai_chat_completions(
continue continue
if event["type"] in ("tool_start", "tool_end"): if event["type"] in ("tool_start", "tool_end"):
if event["type"] == "tool_start": # Both edges of a tool call restart cumulative
prev_text = "" # text: tool_start opens a new stream, tool_end
# is the cursor reset for the post-tool turn.
prev_text = ""
yield f"data: {json.dumps(event)}\n\n" yield f"data: {json.dumps(event)}\n\n"
continue continue
@ -4370,6 +4371,9 @@ async def _anthropic_tool_non_streaming(run_gen, message_id, model_name):
) )
elif etype == "tool_end": elif etype == "tool_end":
prev_text = "" prev_text = ""
elif etype == "status" and event.get("boundary"):
# Iteration-boundary marker: reset like tool_end does.
prev_text = ""
elif etype == "metadata": elif etype == "metadata":
usage = event.get("usage", {}) usage = event.get("usage", {})

View file

@ -629,6 +629,90 @@ class TestAnthropicStreamEmitter:
parsed = json.loads(events[0].split("data: ")[1]) parsed = json.loads(events[0].split("data: ")[1])
assert parsed["delta"]["text"] == "After tool" assert parsed["delta"]["text"] == "After tool"
def test_boundary_flag_closes_block_and_resets_cursor(self):
"""An iteration-boundary status (boundary=True) must close the
open text block, open a fresh one, and reset _prev_text so the
next content delta starts from zero."""
e = AnthropicStreamEmitter()
e.start("msg_1", "m")
e.feed({"type": "content", "text": "first turn"})
boundary = e.feed({"type": "status", "text": "", "boundary": True})
# Boundary must produce content_block_stop + content_block_start
# so the next text lives in a new block.
joined = "\n".join(boundary)
assert "content_block_stop" in joined
assert "content_block_start" in joined
# Next content delta must include the full "second turn", not a
# diff against the previous turn's length.
nxt = e.feed({"type": "content", "text": "second turn"})
parsed = json.loads(nxt[0].split("data: ")[1])
assert parsed["delta"]["text"] == "second turn"
def test_post_tool_empty_status_does_not_double_close(self):
"""After tool_end already opens a fresh text block, the post-tool
empty-status event emitted by llama_cpp.py (line 4766) must NOT
close that fresh block. Otherwise every tool call produces a
spurious empty content_block_stop + content_block_start pair
before the model's post-tool text arrives. Regression test for
PR 5549 codex 02:42Z."""
e = AnthropicStreamEmitter()
e.start("msg_1", "m")
e.feed({"type": "content", "text": "pre"})
e.feed(
{
"type": "tool_start",
"tool_name": "t",
"tool_call_id": "tc_1",
"arguments": {},
}
)
e.feed(
{
"type": "tool_end",
"tool_name": "t",
"tool_call_id": "tc_1",
"result": "ok",
}
)
block_after_tool = e.block_index
# Post-tool empty status (no boundary flag): should produce zero
# SSE events and leave block_index unchanged. The previous
# behaviour was to close+reopen, which produced a duplicate
# empty content block.
out = e.feed({"type": "status", "text": ""})
assert out == []
assert e.block_index == block_after_tool
# Next content delta lands in the same fresh text block that
# tool_end opened.
nxt = e.feed({"type": "content", "text": "post"})
parsed = json.loads(nxt[0].split("data: ")[1])
assert parsed["delta"]["text"] == "post"
def test_empty_status_without_boundary_does_not_close_block(self):
"""A non-boundary empty-status event (UI badge clear at normal
stream end, draining fallbacks, final status yields in
llama_cpp.py at lines 4501, 4584, 4794) must NOT close the
current text block or reset _prev_text - otherwise every normal
Anthropic response gets extra content_block_start/stop pairs
around its final text. Regression test for PR 5549 codex P2."""
e = AnthropicStreamEmitter()
e.start("msg_1", "m")
block_before = e.block_index
e.feed({"type": "content", "text": "hello "})
# Plain empty status (no boundary flag) -> no extra SSE events.
out = e.feed({"type": "status", "text": ""})
assert out == []
# block_index must not have advanced (no close+reopen happened).
assert e.block_index == block_before
# Next content delta is diffed against "hello ", so we only emit
# " world" (the new suffix).
nxt = e.feed({"type": "content", "text": "hello world"})
parsed = json.loads(nxt[0].split("data: ")[1])
assert parsed["delta"]["text"] == "world"
# ===================================================================== # =====================================================================
# Pass-through emitter tests (client-side tool execution path) # Pass-through emitter tests (client-side tool execution path)

View file

@ -0,0 +1,150 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for the mid-plan auto-continue regexes and ``_trailing_plan_hit``.
The trailing-plan detector lives in ``core.inference.llama_cpp`` and decides
whether the model just stopped mid-plan (and therefore deserves a neutral
``Continue.`` re-prompt). False positives cost real tool calls and latency,
so the patterns get explicit coverage here.
Bug history pinned by these tests:
* ``"If you need anything else, let me know."`` matched ``_TRAILING_PLAN_INTENT``
before the negative lookahead landed.
* ``"Here's my plan:\\n- a\\n- b\\n\\nDone, that should work."`` matched
``_TRAILING_PLAN_LIST`` before the regex was switched off the ``m`` flag
and re-anchored with ``\\Z``.
"""
from __future__ import annotations
import pytest
from core.inference.llama_cpp import (
_TRAILING_PLAN_COLON,
_TRAILING_PLAN_INTENT,
_TRAILING_PLAN_LIST,
_trailing_plan_hit,
)
# ----- _TRAILING_PLAN_INTENT -------------------------------------------------
@pytest.mark.parametrize(
"text,expected",
[
# Closing phrases must NOT match (regression: "let me know")
("If you need anything else, let me know.", False),
("Let me know if I can help further.", False),
("let me know!", False),
# Genuine mid-plan intent SHOULD match
("Let me clone the repo.", True),
("Let me check the file.", True),
("Now let me run the tests.", True),
("I'll now run the analyzer.", True),
("Ill now run the analyzer.", True), # curly apostrophe
("I will now begin.", True),
# Unrelated trailing text must NOT match
("Hello world.", False),
("The answer is 42.", False),
],
)
def test_trailing_plan_intent(text: str, expected: bool) -> None:
assert bool(_TRAILING_PLAN_INTENT.search(text)) is expected
# ----- _TRAILING_PLAN_LIST ---------------------------------------------------
@pytest.mark.parametrize(
"text,expected",
[
# List block at end of buffer SHOULD match
("Let me do this:\n- step one\n- step two\n", True),
("Here's my plan:\n1. one\n2. two\n", True),
("Here's my plan:\n1. one\n2. two\n \n", True), # trailing whitespace
# Unicode bullet at end of buffer SHOULD match
("Let me try:\n• first\n• second\n", True),
# List followed by a closing sentence MUST NOT match (regression:
# the `m` flag in `(?ims)` previously let `\s*$` match end-of-line)
(
"Here's my plan:\n- step one\n- step two\n\nDone, hope that helps.",
False,
),
(
"Let me walk through it:\n1. first\n2. second\n\nThat's everything.",
False,
),
# Single-item numbered list followed by closing prose MUST NOT match
(
"Let me explain:\n1. The function returns 42.\n\nThat's the answer.",
False,
),
# List embedded mid-text (not trailing) MUST NOT match
(
"Here's my plan:\n- step one\n- step two\nNow the conclusion follows.",
False,
),
],
)
def test_trailing_plan_list(text: str, expected: bool) -> None:
assert bool(_TRAILING_PLAN_LIST.search(text)) is expected
# ----- _TRAILING_PLAN_COLON --------------------------------------------------
@pytest.mark.parametrize(
"text,expected",
[
# Bare trailing colon SHOULD match
("Let me check the repo:", True),
("I'll now look at this:", True),
# Colon mid-sentence MUST NOT match
("Let me check this: it should work fine.", False),
# Colon not in an intent-cue clause MUST NOT match
("The result is:", False),
],
)
def test_trailing_plan_colon(text: str, expected: bool) -> None:
assert bool(_TRAILING_PLAN_COLON.search(text)) is expected
# ----- _trailing_plan_hit composite -----------------------------------------
@pytest.mark.parametrize(
"text,expected",
[
# Any of the three sub-patterns triggers a hit
("Now let me run the tests.", True),
("Let me do this:\n- step one\n- step two\n", True),
("Let me check the repo:", True),
# Negative cases that previously misfired
("If you need anything else, let me know.", False),
(
"Here's my plan:\n- step one\n- step two\n\nDone, hope that helps.",
False,
),
# Short empty string is a no-op
("", False),
(" ", False),
],
)
def test_trailing_plan_hit(text: str, expected: bool) -> None:
assert _trailing_plan_hit(text) is expected
# ----- window slicing --------------------------------------------------------
def test_trailing_plan_hit_respects_window() -> None:
"""An intent cue further back than ``_TRAILING_PLAN_WINDOW`` must NOT
trigger a hit; only the tail of the response is inspected."""
# 800-char prefix of unrelated text, then a finalising sentence.
prefix = "lorem ipsum " * 80 # ~960 chars
text = f"Let me check the repo. {prefix}The result is 42."
assert _trailing_plan_hit(text) is False