phase: 192_verbatim_inline_midturn
Phase 192 — final verification pass complete. - **Verified (all 4 tasks already complete):** additive `verbatim_block` SSE frame (`VerbatimBlockPiece` → `ChatVerbatimBlockEvent` → chat dispatch, after the round's `tool`/`tool_result`, before the next round); `hydrateVerbatimNow` matched-only mid-turn pass + app.js branch; `dropPlacedTwin` per-locator done/restore dedupe; the dedicated E2E suite. - **Root cause of the rejected attempt:** my own concurrent background `pytest` run deadlocked the harness validation run on the shared Postgres (`psycopg.errors.DeadlockDetected`). No code defect — re-ran every gate **exclusively**. - `uv run pytest -q --cov=app --cov-report=term-missing` → **exit 0**, `TOTAL 7181 16 99%` (>90%) - `uv run pytest tests/e2e/test_verbatim_inline_midturn.py --no-cov` → **6 passed** (isolated) - `uv run pytest tests/e2e/test_verbatim_show_text.py tests/e2e/test_chat_history.py --no-cov` → **18 passed** (`test_chat_history.py` byte-unedited; the phase-182 suite's "no eighth type" pin was tightened, not loosened, to seven + `verbatim_block` with frame-order asserts) - `uv run ruff check .` → All checks passed (exit 0) · `uv run pyright` → **0 errors** - **Completion criteria:** single frame per success + zero per refusal ✅ · `BOR_ANSWER_TEXT=0` wire byte-identical (integration spelled baseline + E2E App B) ✅ · fence hydrates in place / unfenced lands at the call moment, before `done` ✅ · append+fence → 1 panel, two ranges → 2 panels ✅ · restore 1 panel/locator, `done.answer_text` + record key + `verbatim_blocks=N` unchanged ✅ · suite green, coverage 99%, lint/types clean ✅ · no-show_text turn byte-identical ✅ · commit → left to the harness (no git index/history commands run). - **Docs:** README reviewed line-by-line (2399 lines) + AGENTS.md in full; mechanically verified all 86 `BOR_*` settings, every `.env.example` var, table defaults, internal anchors (0 broken), ports 8123/8901, tree depth 1–8, pagination 25/1–100, 17 palette colors, `0029`/`0030` migrations, and every cited test file. Fixed 1 inconsistency: the verbatim "Parity" bullet claimed the live turn/reload/shared page share "the same three globals" — now names the settle-path trio and states that `hydrateVerbatimNow` is the ONE chat-only addition (a shared page has no stream). - **Notable:** `run_agent`'s four repeated piece unions were collapsed into the ONE exported `AgentPiece` alias, so a future piece type is spelled in one place; `test_frontend_edit_message.py`'s `runTurn` byte-pin was regenerated, not weakened. - **Next pending phase:** `193_sync_remaining_count`.
This commit is contained in:
@@ -47,14 +47,14 @@ from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Document, FolderSummary, GitSource
|
||||
from app.rag import agent
|
||||
from app.rag.agent import AGENT_TOOLS, SHOW_TEXT_TOOL, AgentHolder, run_agent
|
||||
from app.rag.llm import (
|
||||
LLMClient,
|
||||
RetryPiece,
|
||||
StreamPiece,
|
||||
ToolCallPiece,
|
||||
ToolResultPiece,
|
||||
from app.rag.agent import (
|
||||
AGENT_TOOLS,
|
||||
SHOW_TEXT_TOOL,
|
||||
AgentHolder,
|
||||
AgentPiece,
|
||||
run_agent,
|
||||
)
|
||||
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.rag.scaffolding import ScaffoldingFilter
|
||||
@@ -286,8 +286,8 @@ async def _consume(
|
||||
llm: LLMClient,
|
||||
db_factory: Callable[[], Session],
|
||||
holder: AgentHolder,
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||||
) -> list[AgentPiece]:
|
||||
out: list[AgentPiece] = []
|
||||
async for piece in run_agent(
|
||||
llm,
|
||||
db_factory, # SEC-14-04: session factory (short-lived sessions)
|
||||
|
||||
@@ -28,8 +28,8 @@ from sqlalchemy.orm import Session
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Document, GitSource
|
||||
from app.rag.agent import AgentHolder, _budget_note, run_agent
|
||||
from app.rag.llm import LLMClient, RetryPiece, StreamPiece, ToolCallPiece, ToolResultPiece
|
||||
from app.rag.agent import AgentHolder, AgentPiece, _budget_note, run_agent
|
||||
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.rag.scaffolding import ScaffoldingFilter
|
||||
@@ -128,9 +128,9 @@ def _run_call(
|
||||
|
||||
async def _consume(
|
||||
llm: LLMClient, db: Session, holder: AgentHolder
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||||
) -> list[AgentPiece]:
|
||||
"""SEC-14-04: uses a short-lived session per tool call."""
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||||
out: list[AgentPiece] = []
|
||||
async for piece in run_agent(
|
||||
llm,
|
||||
lambda: SessionLocal(), # SEC-14-04: session factory (short-lived sessions)
|
||||
|
||||
@@ -41,7 +41,7 @@ from app.rag.importer import import_sources
|
||||
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece, ToolResultPiece
|
||||
from app.rag.prompts import SHOW_TEXT_CLAUSE, build_high_prompt
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from app.schemas import ChatDoneEvent, SourceRef
|
||||
from app.schemas import ChatDoneEvent, SourceRef, VerbatimBlockRef
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
#: The fixture documents' fixed creation date (phase 106, D5): the
|
||||
@@ -3276,6 +3276,9 @@ def test_grounded_turn_carries_show_text_clause_and_tool_per_the_flag(
|
||||
|
||||
#: A fixture doc with a stable, countable body (16 lines as imported).
|
||||
_K8S = "docs/homelab/kubernetes.md"
|
||||
#: The second countable fixture doc (phase 192: the two-call turn needs two
|
||||
#: different canonical locators — the ``shown_texts`` dedupe is per locator).
|
||||
_BACKUPS = "docs/homelab/backups.md"
|
||||
|
||||
|
||||
def _verbatim_settings(**overrides: Any) -> Settings:
|
||||
@@ -3299,6 +3302,20 @@ def _doc_lines(db, combined: str) -> list[str]:
|
||||
return content.splitlines()
|
||||
|
||||
|
||||
def _doc_title(db, combined: str) -> str:
|
||||
"""The indexed row's ``title`` — the same authority the resolver puts on
|
||||
the block, never a string retyped here (the phase-182 ``_doc_lines``
|
||||
convention)."""
|
||||
source, _, path = combined.partition("/")
|
||||
title = db.scalar(
|
||||
select(Document.title).where(
|
||||
Document.source == source, Document.path == path
|
||||
)
|
||||
)
|
||||
assert title, combined
|
||||
return str(title)
|
||||
|
||||
|
||||
def test_show_text_turn_done_frame_carries_the_server_block(
|
||||
client, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@@ -3608,3 +3625,239 @@ def test_resolution_failure_degrades_to_no_blocks(
|
||||
assert any("verbatim block resolution failed" in m for m in lines)
|
||||
turn = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||
assert turn and turn[-1].endswith("verbatim_blocks=0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 192 (task 01, A1) — the additive mid-turn ``verbatim_block`` frame.
|
||||
#
|
||||
# A successful ``show_text`` used to reach the client ONLY on the ``done``
|
||||
# frame's ``answer_text`` list (phase 182 task 05), so every panel popped in
|
||||
# after the model had finished talking about the bytes. The agent loop now
|
||||
# also yields the block the moment the call succeeds, and the endpoint turns
|
||||
# it into one additive SSE frame — after the round's ``tool`` /
|
||||
# ``tool_result`` frames, before the next model round. The ``done`` frame,
|
||||
# the saved record and the ``verbatim_blocks=N`` log field keep the phase-182
|
||||
# contract byte for byte; a ``BOR_ANSWER_TEXT=0`` turn streams NOTHING new.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verbatim_block_frame_lands_midturn_before_done(
|
||||
client, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A scripted grounded turn whose model calls
|
||||
``show_text(docs/homelab/kubernetes.md, offset=3, limit=3)`` → the stream
|
||||
carries exactly ONE ``type="verbatim_block"`` frame with the EXACT
|
||||
:class:`VerbatimBlockRef` field set (plus ``type``), positioned after the
|
||||
round's ``tool`` frame and before the answer's ``delta`` frames and the
|
||||
``done`` frame. Its bytes are the DB's, its extent is the server's, and
|
||||
the ``done`` frame still carries the SAME block in ``answer_text`` (the
|
||||
restore/shared-page contract is untouched) — counted ONCE by the per-turn
|
||||
``verbatim_blocks=N`` line, which never counts mid-turn frames."""
|
||||
scripted = FakeRagLLM(
|
||||
tool_script=[
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="show_text",
|
||||
arguments={"path": _K8S, "offset": 3, "limit": 3},
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
types = [f["type"] for f in frames]
|
||||
assert types.count("verbatim_block") == 1
|
||||
i_tool, i_block, i_done = types.index("tool"), types.index("verbatim_block"), len(frames) - 1
|
||||
assert types[i_done] == "done"
|
||||
# The frame sits inside its own round: after the ``tool`` frame, before
|
||||
# the next round's answer deltas and long before ``done``.
|
||||
assert i_tool < i_block < types.index("delta") < i_done
|
||||
|
||||
lines = _doc_lines(db, _K8S)
|
||||
frame = frames[i_block]
|
||||
assert frame == {
|
||||
"type": "verbatim_block",
|
||||
"source": "docs",
|
||||
"path": "homelab/kubernetes.md",
|
||||
"title": "Kubernetes Homelab Cluster",
|
||||
"start_line": 3,
|
||||
"end_line": 5,
|
||||
"total_lines": len(lines),
|
||||
"text": "\n".join(lines[2:5]),
|
||||
"truncated": False,
|
||||
"locator": f"{_K8S}#3-5",
|
||||
}
|
||||
# The key set is the done-frame item's set plus ``type`` — the client's
|
||||
# ``isVerbatimBlock`` guard accepts the event as-is.
|
||||
assert set(frame) == set(VerbatimBlockRef.model_fields) | {"type"}
|
||||
|
||||
# The done frame is UNCHANGED from phase 182: the same block, in the same
|
||||
# shape, at the same place (the mid-turn frame is additive, never a
|
||||
# replacement — the client's idempotence keeps it from painting twice).
|
||||
done = frames[i_done]
|
||||
assert done["answer_text"] == [{k: v for k, v in frame.items() if k != "type"}]
|
||||
turn = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||
assert turn and turn[-1].endswith("verbatim_blocks=1")
|
||||
|
||||
|
||||
def test_kill_switch_stream_is_byte_identical(
|
||||
client, db, seeded_kb: FakeRagLLM, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The wire pin for the kill switch: with ``BOR_ANSWER_TEXT=0`` the tool
|
||||
is never offered, so a scripted ``show_text`` attempt lands on the
|
||||
phase-182 refusal path and the captured frame sequence is EXACTLY the
|
||||
pre-phase baseline — one ``tool`` frame, the answer's ``delta`` frames,
|
||||
the pre-phase ``done`` frame — with NO ``verbatim_block`` frame anywhere
|
||||
and no ``answer_text`` key on any frame."""
|
||||
answer = "The flag is off, so nothing is displayed here."
|
||||
monkeypatch.setattr(
|
||||
chat_api, "get_settings", lambda: _verbatim_settings(answer_text=False)
|
||||
)
|
||||
scripted = FakeRagLLM(
|
||||
answer=answer,
|
||||
tool_script=[
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="show_text",
|
||||
arguments={"path": _K8S, "offset": 3, "limit": 3},
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
# The documented pre-phase baseline, spelled out: the refused call's
|
||||
# ``tool`` frame (the exact ChatToolEvent bytes — no range keys, they are
|
||||
# ``read``-only), then one ``delta`` frame per 12-char slice of the
|
||||
# canned answer, then ``done``. Nothing else, in that order.
|
||||
expected = [{"type": "tool", "name": "show_text", "argument": _K8S}] + [
|
||||
{"type": "delta", "text": answer[i : i + 12]} for i in range(0, len(answer), 12)
|
||||
]
|
||||
assert frames[:-1] == expected
|
||||
assert frames[-1]["type"] == "done"
|
||||
assert set(frames[-1]) == {"type", "deflected", "sources", "related", "suggestions"}
|
||||
assert "verbatim_block" not in json.dumps(frames)
|
||||
assert "answer_text" not in json.dumps(frames)
|
||||
# The refusal the model saw is the phase-182 one (the tool does not exist
|
||||
# while the flag is off), and no block was recorded anywhere.
|
||||
tool_message = scripted.seen_messages[1][3]
|
||||
assert tool_message["role"] == "tool"
|
||||
assert tool_message["content"].startswith("Unknown tool.")
|
||||
|
||||
|
||||
def test_done_frame_and_log_line_unchanged_by_midturn_frames(
|
||||
client, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Phase 192 (task 03, A4) — the frame the mid-turn pass adds is a COPY of
|
||||
a done-frame item, never a participant in the done-frame contract.
|
||||
|
||||
Two agent rounds, two successful ``show_text`` calls, no fence in the
|
||||
answer: the stream carries TWO ``verbatim_block`` frames (each in its own
|
||||
round, before ``done``) and the ``done`` frame still carries exactly the
|
||||
phase-182 resolution — both blocks in CALL order with the server's
|
||||
extents, byte-identical to what the resolver reads out of the row. That
|
||||
list is the saved record's ``answer_text`` and what the reload / shared
|
||||
page hydrates from, so the dedupe the client performs lands on data the
|
||||
server never changed. The per-turn line therefore reads
|
||||
``verbatim_blocks=2`` — the DONE-frame count — and never 4: the mid-turn
|
||||
frames are counted NOWHERE."""
|
||||
scripted = FakeRagLLM(
|
||||
tool_script=[
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="show_text",
|
||||
arguments={"path": _K8S, "offset": 3, "limit": 3},
|
||||
)
|
||||
],
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_2",
|
||||
name="show_text",
|
||||
arguments={"path": _BACKUPS, "offset": 1, "limit": 2},
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
types = [f["type"] for f in frames]
|
||||
done = frames[-1]
|
||||
assert types[-1] == "done"
|
||||
mid = [f for f in frames if f["type"] == "verbatim_block"]
|
||||
assert len(mid) == 2 and types.count("verbatim_block") == 2
|
||||
# Each frame rides its own round: after that round's ``tool`` frame and
|
||||
# long before ``done`` (the phase-192 emission order, twice).
|
||||
tool_positions = [i for i, t in enumerate(types) if t == "tool"]
|
||||
assert len(tool_positions) == 2
|
||||
for round_index, frame in enumerate(mid):
|
||||
assert tool_positions[round_index] < frames.index(frame) < len(frames) - 1, (
|
||||
"the frame lands inside its OWN round: after its tool line, before done"
|
||||
)
|
||||
|
||||
# The done frame is the phase-182 resolution, spelled out from the row —
|
||||
# the same two blocks, in call order, with the server's extent/locator.
|
||||
k8s_lines = _doc_lines(db, _K8S)
|
||||
backup_lines = _doc_lines(db, _BACKUPS)
|
||||
assert done["answer_text"] == [
|
||||
{
|
||||
"source": "docs",
|
||||
"path": "homelab/kubernetes.md",
|
||||
"title": _doc_title(db, _K8S),
|
||||
"start_line": 3,
|
||||
"end_line": 5,
|
||||
"total_lines": len(k8s_lines),
|
||||
"text": "\n".join(k8s_lines[2:5]),
|
||||
"truncated": False,
|
||||
"locator": f"{_K8S}#3-5",
|
||||
},
|
||||
{
|
||||
"source": "docs",
|
||||
"path": "homelab/backups.md",
|
||||
"title": _doc_title(db, _BACKUPS),
|
||||
"start_line": 1,
|
||||
"end_line": 2,
|
||||
"total_lines": len(backup_lines),
|
||||
"text": "\n".join(backup_lines[0:2]),
|
||||
"truncated": False,
|
||||
"locator": f"{_BACKUPS}#1-2",
|
||||
},
|
||||
]
|
||||
# Each mid-turn frame is EXACTLY its done-frame twin minus ``type``: the
|
||||
# client hydrates both from one shape, and the mid-turn copy resolved at
|
||||
# the call moment cannot disagree with the done-time resolution.
|
||||
assert done["answer_text"] == [
|
||||
{k: v for k, v in frame.items() if k != "type"} for frame in mid
|
||||
]
|
||||
# The done frame's key set is the phase-182 set plus the additive
|
||||
# ``answer_text`` — no new key, none renamed, none dropped.
|
||||
assert set(done) == {
|
||||
"type",
|
||||
"deflected",
|
||||
"sources",
|
||||
"related",
|
||||
"suggestions",
|
||||
"answer_text",
|
||||
}
|
||||
# The ONE log line counts the blocks SENT on ``done`` (2), never the sum
|
||||
# of the mid-turn frames and the done items (4) — AGENTS.md rule 10.
|
||||
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||
assert len(lines) == 1, lines
|
||||
assert lines[-1].endswith("verbatim_blocks=2"), lines[-1]
|
||||
assert "verbatim_blocks=4" not in json.dumps(lines)
|
||||
|
||||
@@ -71,16 +71,11 @@ from app.rag.agent import (
|
||||
REREAD_PARTIAL,
|
||||
SHOW_TEXT_TOOL,
|
||||
AgentHolder,
|
||||
AgentPiece,
|
||||
run_agent,
|
||||
)
|
||||
from app.rag.importer import import_sources
|
||||
from app.rag.llm import (
|
||||
LLMClient,
|
||||
RetryPiece,
|
||||
StreamPiece,
|
||||
ToolCallPiece,
|
||||
ToolResultPiece,
|
||||
)
|
||||
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece, ToolResultPiece
|
||||
from app.rag.pdf import extract_pdf_text
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from tests.fakes import FakeEmbedder
|
||||
@@ -278,11 +273,11 @@ async def _consume(
|
||||
llm: HandbookAgentLLM,
|
||||
holder: AgentHolder,
|
||||
settings: Settings,
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||||
) -> list[AgentPiece]:
|
||||
"""Consume one scripted ``run_agent`` turn (the owner's question,
|
||||
no seed docs). SEC-14-04: a short-lived session per tool call —
|
||||
the same pattern as production and the phase-70 integration suite."""
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||||
out: list[AgentPiece] = []
|
||||
async for piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
lambda: SessionLocal(),
|
||||
@@ -296,7 +291,7 @@ async def _consume(
|
||||
return out
|
||||
|
||||
|
||||
def _answer(pieces: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]) -> str:
|
||||
def _answer(pieces: list[AgentPiece]) -> str:
|
||||
"""The turn's final answer text (the content pieces)."""
|
||||
return "".join(
|
||||
piece.text for piece in pieces if isinstance(piece, StreamPiece) and piece.kind == "content"
|
||||
|
||||
@@ -79,15 +79,10 @@ from app.rag import agent
|
||||
from app.rag.agent import (
|
||||
READ_TRUNCATION_NOTICE,
|
||||
AgentHolder,
|
||||
AgentPiece,
|
||||
run_agent,
|
||||
)
|
||||
from app.rag.llm import (
|
||||
LLMClient,
|
||||
RetryPiece,
|
||||
StreamPiece,
|
||||
ToolCallPiece,
|
||||
ToolResultPiece,
|
||||
)
|
||||
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece, ToolResultPiece
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -291,11 +286,11 @@ async def _consume(
|
||||
llm: TwoRangeAgentLLM,
|
||||
holder: AgentHolder,
|
||||
settings: Settings,
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||||
) -> list[AgentPiece]:
|
||||
"""Consume one scripted ``run_agent`` turn. SEC-14-04: a
|
||||
short-lived session per tool call — the same pattern as
|
||||
production and the phase-70/131 integration suites."""
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||||
out: list[AgentPiece] = []
|
||||
async for piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
lambda: SessionLocal(),
|
||||
@@ -309,7 +304,7 @@ async def _consume(
|
||||
return out
|
||||
|
||||
|
||||
def _answer(pieces: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]) -> str:
|
||||
def _answer(pieces: list[AgentPiece]) -> str:
|
||||
"""The turn's final answer text (the content pieces)."""
|
||||
return "".join(
|
||||
piece.text for piece in pieces if isinstance(piece, StreamPiece) and piece.kind == "content"
|
||||
|
||||
Reference in New Issue
Block a user