c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Web UI / check (push) Has been cancelled
929 lines
30 KiB
Python
929 lines
30 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Unit tests for Msg.append_event – event-stream-to-Msg accumulation.
|
||
|
||
The test drives a single Msg object through a full, realistic streaming
|
||
sequence and asserts the complete model_dump() after every individual event.
|
||
|
||
Coverage
|
||
--------
|
||
* TextBlock : start / delta (×2) / end
|
||
* ThinkingBlock : start / delta (×2) / end
|
||
* DataBlock (base-64) : start / delta (×2) / end
|
||
* ToolCallBlock streaming : start / delta (×2) / end
|
||
* RequireUserConfirmEvent → ASKING state
|
||
* UserConfirmResultEvent (confirmed=True) → ALLOWED state
|
||
* UserConfirmResultEvent (confirmed=False) → FINISHED state
|
||
* ToolResultBlock text output : start / text-delta (×2) / end (SUCCESS)
|
||
* RequireExternalExecutionEvent → SUBMITTED state
|
||
* ExternalExecutionResultEvent → ToolResultBlock appended directly
|
||
* ToolResultBlock data output : base-64 delta + URL delta / end (ERROR)
|
||
* ModelCallEndEvent (×2) → usage initialized then accumulated
|
||
* ReplyEndEvent → finished_at stamped
|
||
* Wrong reply_id → event silently skipped
|
||
* Missing block → warning, no crash
|
||
"""
|
||
from typing import Any
|
||
from unittest.async_case import IsolatedAsyncioTestCase
|
||
|
||
from utils import AnyString
|
||
|
||
from agentscope.event import (
|
||
ConfirmResult,
|
||
DataBlockDeltaEvent,
|
||
DataBlockEndEvent,
|
||
DataBlockStartEvent,
|
||
ExternalExecutionResultEvent,
|
||
ModelCallEndEvent,
|
||
ReplyEndEvent,
|
||
ReplyEndReason,
|
||
RequireExternalExecutionEvent,
|
||
RequireUserConfirmEvent,
|
||
TextBlockDeltaEvent,
|
||
TextBlockEndEvent,
|
||
TextBlockStartEvent,
|
||
ThinkingBlockDeltaEvent,
|
||
ThinkingBlockEndEvent,
|
||
ThinkingBlockStartEvent,
|
||
ToolCallDeltaEvent,
|
||
ToolCallEndEvent,
|
||
ToolCallStartEvent,
|
||
ToolResultDataDeltaEvent,
|
||
ToolResultEndEvent,
|
||
ToolResultStartEvent,
|
||
ToolResultTextDeltaEvent,
|
||
UserConfirmResultEvent,
|
||
)
|
||
from agentscope.message import (
|
||
Msg,
|
||
ToolCallBlock,
|
||
ToolResultBlock,
|
||
ToolResultState,
|
||
)
|
||
from agentscope.model import FinishedReason
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixed IDs used throughout – hard-coded so ground-truth dicts are readable.
|
||
# ---------------------------------------------------------------------------
|
||
_REPLY_ID = "reply_001"
|
||
_SESSION_ID = "session_001"
|
||
|
||
_B_TEXT = "b_text_001" # TextBlock id
|
||
_B_THINK = "b_think_001" # ThinkingBlock id
|
||
_B_DATA = "b_data_001" # DataBlock id
|
||
|
||
_TC_ALLOW = "tc_allow_001" # tool call that gets confirmed → allowed
|
||
_TC_DENY = "tc_deny_001" # tool call that gets denied → finished
|
||
_TC_EXT = "tc_ext_001" # tool call for external execution
|
||
_TC_IMG = "tc_img_001" # tool call whose result has data blocks
|
||
|
||
_RES_DATA_B = "res_data_001" # DataBlock inside tool-result (base-64)
|
||
_RES_URL_B = "res_url_001" # DataBlock inside tool-result (URL)
|
||
|
||
_FIXED_END_TS = "2026-01-01T12:00:00" # deterministic finished_at
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Block-dict helpers – each call returns a fresh dict to avoid aliasing.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _tb(block_id: str, text: str) -> dict:
|
||
"""Text block dict."""
|
||
return {"type": "text", "id": block_id, "text": text}
|
||
|
||
|
||
def _thb(block_id: str, thinking: str) -> dict:
|
||
"""Thinking block dict."""
|
||
return {"type": "thinking", "id": block_id, "thinking": thinking}
|
||
|
||
|
||
def _db_b64(block_id: str, data: str, media_type: str) -> dict:
|
||
"""DataBlock (base-64 source) dict."""
|
||
return {
|
||
"type": "data",
|
||
"id": block_id,
|
||
"source": {"type": "base64", "data": data, "media_type": media_type},
|
||
"name": None,
|
||
}
|
||
|
||
|
||
def _db_url(block_id: str, url: str, media_type: str) -> dict:
|
||
"""DataBlock (URL source) dict."""
|
||
return {
|
||
"type": "data",
|
||
"id": block_id,
|
||
"source": {"type": "url", "url": url, "media_type": media_type},
|
||
"name": None,
|
||
}
|
||
|
||
|
||
def _tcb(tc_id: str, name: str, inp: str, state: str) -> dict:
|
||
"""ToolCallBlock dict."""
|
||
return {
|
||
"type": "tool_call",
|
||
"id": tc_id,
|
||
"name": name,
|
||
"input": inp,
|
||
"state": state,
|
||
"suggested_rules": [],
|
||
}
|
||
|
||
|
||
def _trb(tc_id: str, name: str, output: Any, state: str) -> dict:
|
||
"""ToolResultBlock dict."""
|
||
return {
|
||
"type": "tool_result",
|
||
"id": tc_id,
|
||
"name": name,
|
||
"output": output,
|
||
"state": state,
|
||
"metadata": {},
|
||
}
|
||
|
||
|
||
class EventToMessageTest(IsolatedAsyncioTestCase):
|
||
"""Test Msg.append_event across a full streaming event sequence."""
|
||
|
||
# ------------------------------------------------------------------
|
||
# asyncSetUp: build self.events and self.ground_truths in lock-step.
|
||
# ------------------------------------------------------------------
|
||
|
||
async def asyncSetUp(self) -> None:
|
||
"""Build the Msg, the event list, and the ground-truth list."""
|
||
self.msg = Msg(
|
||
id=_REPLY_ID,
|
||
name="TestAgent",
|
||
role="assistant",
|
||
content=[],
|
||
)
|
||
_created_at = self.msg.created_at
|
||
|
||
def _base(
|
||
content: list,
|
||
finished_at: str | None = None,
|
||
usage: dict | None = None,
|
||
) -> dict:
|
||
"""Return the expected model_dump() of self.msg."""
|
||
return {
|
||
"name": "TestAgent",
|
||
"role": "assistant",
|
||
"id": _REPLY_ID,
|
||
"metadata": {},
|
||
"created_at": _created_at,
|
||
"finished_at": finished_at,
|
||
"content": content,
|
||
"usage": usage,
|
||
}
|
||
|
||
# ================================================================
|
||
# Stage 1 – Text block streaming
|
||
# ================================================================
|
||
ev_text_start = TextBlockStartEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id=_B_TEXT,
|
||
)
|
||
gt_text_start = _base([_tb(_B_TEXT, "")])
|
||
|
||
ev_text_delta1 = TextBlockDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id=_B_TEXT,
|
||
delta="Hello",
|
||
)
|
||
gt_text_delta1 = _base([_tb(_B_TEXT, "Hello")])
|
||
|
||
ev_text_delta2 = TextBlockDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id=_B_TEXT,
|
||
delta=" World",
|
||
)
|
||
gt_text_delta2 = _base([_tb(_B_TEXT, "Hello World")])
|
||
|
||
ev_text_end = TextBlockEndEvent(reply_id=_REPLY_ID, block_id=_B_TEXT)
|
||
gt_text_end = _base([_tb(_B_TEXT, "Hello World")]) # unchanged
|
||
|
||
# ================================================================
|
||
# Stage 2 – Thinking block streaming
|
||
# ================================================================
|
||
ev_think_start = ThinkingBlockStartEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id=_B_THINK,
|
||
)
|
||
gt_think_start = _base(
|
||
[
|
||
_tb(_B_TEXT, "Hello World"),
|
||
_thb(_B_THINK, ""),
|
||
],
|
||
)
|
||
|
||
ev_think_delta1 = ThinkingBlockDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id=_B_THINK,
|
||
delta="Let me",
|
||
)
|
||
gt_think_delta1 = _base(
|
||
[
|
||
_tb(_B_TEXT, "Hello World"),
|
||
_thb(_B_THINK, "Let me"),
|
||
],
|
||
)
|
||
|
||
ev_think_delta2 = ThinkingBlockDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id=_B_THINK,
|
||
delta=" think",
|
||
)
|
||
gt_think_delta2 = _base(
|
||
[
|
||
_tb(_B_TEXT, "Hello World"),
|
||
_thb(_B_THINK, "Let me think"),
|
||
],
|
||
)
|
||
|
||
ev_think_end = ThinkingBlockEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id=_B_THINK,
|
||
)
|
||
gt_think_end = _base(
|
||
[ # unchanged
|
||
_tb(_B_TEXT, "Hello World"),
|
||
_thb(_B_THINK, "Let me think"),
|
||
],
|
||
)
|
||
|
||
# ================================================================
|
||
# Stage 3 – Data block streaming (base-64)
|
||
# ================================================================
|
||
ev_data_start = DataBlockStartEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id=_B_DATA,
|
||
media_type="image/png",
|
||
)
|
||
gt_data_start = _base(
|
||
[
|
||
_tb(_B_TEXT, "Hello World"),
|
||
_thb(_B_THINK, "Let me think"),
|
||
_db_b64(_B_DATA, "", "image/png"),
|
||
],
|
||
)
|
||
|
||
# Each delta carries an independently base64-encoded chunk (with its
|
||
# own padding); ``append_event`` decodes -> concats bytes -> re-encodes
|
||
# so the message reflects the concatenated underlying bytes, not a
|
||
# string-concat of the chunks. Here: b"abc" + b"def" -> "YWJjZGVm".
|
||
ev_data_delta1 = DataBlockDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id=_B_DATA,
|
||
data="YWJj", # base64(b"abc")
|
||
media_type="image/png",
|
||
)
|
||
gt_data_delta1 = _base(
|
||
[
|
||
_tb(_B_TEXT, "Hello World"),
|
||
_thb(_B_THINK, "Let me think"),
|
||
_db_b64(_B_DATA, "YWJj", "image/png"),
|
||
],
|
||
)
|
||
|
||
ev_data_delta2 = DataBlockDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id=_B_DATA,
|
||
data="ZGVm", # base64(b"def")
|
||
media_type="image/png",
|
||
)
|
||
gt_data_delta2 = _base(
|
||
[
|
||
_tb(_B_TEXT, "Hello World"),
|
||
_thb(_B_THINK, "Let me think"),
|
||
_db_b64(_B_DATA, "YWJjZGVm", "image/png"),
|
||
],
|
||
)
|
||
|
||
ev_data_end = DataBlockEndEvent(reply_id=_REPLY_ID, block_id=_B_DATA)
|
||
gt_data_end = _base(
|
||
[ # unchanged
|
||
_tb(_B_TEXT, "Hello World"),
|
||
_thb(_B_THINK, "Let me think"),
|
||
_db_b64(_B_DATA, "YWJjZGVm", "image/png"),
|
||
],
|
||
)
|
||
|
||
# ================================================================
|
||
# Stage 4 – ToolCall (TC_ALLOW): stream → confirm → allowed
|
||
# + text tool-result (SUCCESS)
|
||
# ================================================================
|
||
ev_tc_allow_start = ToolCallStartEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_ALLOW,
|
||
tool_call_name="search",
|
||
)
|
||
_s4_prefix = [
|
||
_tb(_B_TEXT, "Hello World"),
|
||
_thb(_B_THINK, "Let me think"),
|
||
_db_b64(_B_DATA, "YWJjZGVm", "image/png"),
|
||
]
|
||
gt_tc_allow_start = _base(
|
||
_s4_prefix + [_tcb(_TC_ALLOW, "search", "", "pending")],
|
||
)
|
||
|
||
ev_tc_allow_delta1 = ToolCallDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_ALLOW,
|
||
delta='{"q"',
|
||
)
|
||
gt_tc_allow_delta1 = _base(
|
||
_s4_prefix + [_tcb(_TC_ALLOW, "search", '{"q"', "pending")],
|
||
)
|
||
|
||
ev_tc_allow_delta2 = ToolCallDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_ALLOW,
|
||
delta=': "hi"}',
|
||
)
|
||
gt_tc_allow_delta2 = _base(
|
||
_s4_prefix + [_tcb(_TC_ALLOW, "search", '{"q": "hi"}', "pending")],
|
||
)
|
||
|
||
ev_tc_allow_end = ToolCallEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_ALLOW,
|
||
)
|
||
gt_tc_allow_end = _base( # unchanged
|
||
_s4_prefix + [_tcb(_TC_ALLOW, "search", '{"q": "hi"}', "pending")],
|
||
)
|
||
|
||
# RequireUserConfirmEvent → state: pending → asking
|
||
_tc_allow_block = ToolCallBlock(
|
||
id=_TC_ALLOW,
|
||
name="search",
|
||
input='{"q": "hi"}',
|
||
)
|
||
ev_require_confirm = RequireUserConfirmEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_calls=[_tc_allow_block],
|
||
)
|
||
gt_require_confirm = _base(
|
||
_s4_prefix + [_tcb(_TC_ALLOW, "search", '{"q": "hi"}', "asking")],
|
||
)
|
||
|
||
# UserConfirmResultEvent (confirmed=True) → state: asking → allowed
|
||
ev_user_confirmed = UserConfirmResultEvent(
|
||
reply_id=_REPLY_ID,
|
||
confirm_results=[
|
||
ConfirmResult(confirmed=True, tool_call=_tc_allow_block),
|
||
],
|
||
)
|
||
gt_user_confirmed = _base(
|
||
_s4_prefix + [_tcb(_TC_ALLOW, "search", '{"q": "hi"}', "allowed")],
|
||
)
|
||
|
||
# ToolResult for _TC_ALLOW – text output
|
||
ev_result_start = ToolResultStartEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_ALLOW,
|
||
tool_call_name="search",
|
||
)
|
||
_s4b_prefix = _s4_prefix + [
|
||
_tcb(_TC_ALLOW, "search", '{"q": "hi"}', "allowed"),
|
||
]
|
||
gt_result_start = _base(
|
||
_s4b_prefix + [_trb(_TC_ALLOW, "search", [], "running")],
|
||
)
|
||
|
||
# First text-delta creates a new TextBlock with auto-generated ID.
|
||
ev_result_text1 = ToolResultTextDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_ALLOW,
|
||
delta="Found:",
|
||
)
|
||
gt_result_text1 = _base(
|
||
_s4b_prefix
|
||
+ [
|
||
_trb(
|
||
_TC_ALLOW,
|
||
"search",
|
||
[{"type": "text", "id": AnyString(), "text": "Found:"}],
|
||
"running",
|
||
),
|
||
],
|
||
)
|
||
|
||
# Second text-delta appends to the SAME TextBlock.
|
||
ev_result_text2 = ToolResultTextDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_ALLOW,
|
||
delta=" 3 items",
|
||
)
|
||
gt_result_text2 = _base(
|
||
_s4b_prefix
|
||
+ [
|
||
_trb(
|
||
_TC_ALLOW,
|
||
"search",
|
||
[
|
||
{
|
||
"type": "text",
|
||
"id": AnyString(),
|
||
"text": "Found: 3 items",
|
||
},
|
||
],
|
||
"running",
|
||
),
|
||
],
|
||
)
|
||
|
||
ev_result_end_ok = ToolResultEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_ALLOW,
|
||
state=ToolResultState.SUCCESS,
|
||
)
|
||
# TOOL_RESULT_END flips the paired ToolCallBlock to FINISHED, so the
|
||
# tool_call state in the prefix changes from "allowed" to "finished"
|
||
# from this point onward.
|
||
_s4b_done_prefix = _s4_prefix + [
|
||
_tcb(_TC_ALLOW, "search", '{"q": "hi"}', "finished"),
|
||
]
|
||
gt_result_end_ok = _base(
|
||
_s4b_done_prefix
|
||
+ [
|
||
_trb(
|
||
_TC_ALLOW,
|
||
"search",
|
||
[
|
||
{
|
||
"type": "text",
|
||
"id": AnyString(),
|
||
"text": "Found: 3 items",
|
||
},
|
||
],
|
||
"success",
|
||
),
|
||
],
|
||
)
|
||
|
||
# ================================================================
|
||
# Stage 5 – ToolCall (TC_DENY): stream → confirm → denied (finished)
|
||
# ================================================================
|
||
_s5_prefix = _s4b_done_prefix + [
|
||
_trb(
|
||
_TC_ALLOW,
|
||
"search",
|
||
[
|
||
{
|
||
"type": "text",
|
||
"id": AnyString(),
|
||
"text": "Found: 3 items",
|
||
},
|
||
],
|
||
"success",
|
||
),
|
||
]
|
||
|
||
ev_tc_deny_start = ToolCallStartEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_DENY,
|
||
tool_call_name="delete",
|
||
)
|
||
gt_tc_deny_start = _base(
|
||
_s5_prefix + [_tcb(_TC_DENY, "delete", "", "pending")],
|
||
)
|
||
|
||
ev_tc_deny_end = ToolCallEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_DENY,
|
||
)
|
||
gt_tc_deny_end = _base( # unchanged
|
||
_s5_prefix + [_tcb(_TC_DENY, "delete", "", "pending")],
|
||
)
|
||
|
||
_tc_deny_block = ToolCallBlock(id=_TC_DENY, name="delete", input="")
|
||
ev_require_confirm_deny = RequireUserConfirmEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_calls=[_tc_deny_block],
|
||
)
|
||
gt_require_confirm_deny = _base(
|
||
_s5_prefix + [_tcb(_TC_DENY, "delete", "", "asking")],
|
||
)
|
||
|
||
# UserConfirmResultEvent (confirmed=False) → state: asking → finished
|
||
ev_user_denied = UserConfirmResultEvent(
|
||
reply_id=_REPLY_ID,
|
||
confirm_results=[
|
||
ConfirmResult(confirmed=False, tool_call=_tc_deny_block),
|
||
],
|
||
)
|
||
gt_user_denied = _base(
|
||
_s5_prefix + [_tcb(_TC_DENY, "delete", "", "finished")],
|
||
)
|
||
|
||
# ================================================================
|
||
# Stage 6 – ToolCall (TC_EXT): external execution flow
|
||
# ================================================================
|
||
_s6_prefix = _s5_prefix + [_tcb(_TC_DENY, "delete", "", "finished")]
|
||
|
||
ev_tc_ext_start = ToolCallStartEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_EXT,
|
||
tool_call_name="run_code",
|
||
)
|
||
gt_tc_ext_start = _base(
|
||
_s6_prefix + [_tcb(_TC_EXT, "run_code", "", "pending")],
|
||
)
|
||
|
||
ev_tc_ext_end = ToolCallEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_EXT,
|
||
)
|
||
gt_tc_ext_end = _base( # unchanged
|
||
_s6_prefix + [_tcb(_TC_EXT, "run_code", "", "pending")],
|
||
)
|
||
|
||
_tc_ext_block = ToolCallBlock(id=_TC_EXT, name="run_code", input="")
|
||
ev_require_ext = RequireExternalExecutionEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_calls=[_tc_ext_block],
|
||
)
|
||
gt_require_ext = _base(
|
||
_s6_prefix + [_tcb(_TC_EXT, "run_code", "", "submitted")],
|
||
)
|
||
|
||
# ExternalExecutionResultEvent – appends a ToolResultBlock directly.
|
||
_ext_result_block = ToolResultBlock(
|
||
id=_TC_EXT,
|
||
name="run_code",
|
||
output="output: hello",
|
||
state=ToolResultState.SUCCESS,
|
||
)
|
||
ev_ext_result = ExternalExecutionResultEvent(
|
||
reply_id=_REPLY_ID,
|
||
execution_results=[_ext_result_block],
|
||
)
|
||
_s6b_prefix = _s6_prefix + [_tcb(_TC_EXT, "run_code", "", "submitted")]
|
||
gt_ext_result = _base(
|
||
_s6b_prefix
|
||
+ [_trb(_TC_EXT, "run_code", "output: hello", "success")],
|
||
)
|
||
|
||
# ================================================================
|
||
# Stage 7 – ToolResult with data output: base-64 + URL blocks
|
||
# ================================================================
|
||
_s7_prefix = _s6b_prefix + [
|
||
_trb(_TC_EXT, "run_code", "output: hello", "success"),
|
||
]
|
||
|
||
ev_tc_img_start = ToolCallStartEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_IMG,
|
||
tool_call_name="screenshot",
|
||
)
|
||
gt_tc_img_start = _base(
|
||
_s7_prefix + [_tcb(_TC_IMG, "screenshot", "", "pending")],
|
||
)
|
||
|
||
ev_tc_img_end = ToolCallEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_IMG,
|
||
)
|
||
gt_tc_img_end = _base( # unchanged
|
||
_s7_prefix + [_tcb(_TC_IMG, "screenshot", "", "pending")],
|
||
)
|
||
|
||
ev_res_img_start = ToolResultStartEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_IMG,
|
||
tool_call_name="screenshot",
|
||
)
|
||
_s7b_prefix = _s7_prefix + [_tcb(_TC_IMG, "screenshot", "", "pending")]
|
||
gt_res_img_start = _base(
|
||
_s7b_prefix + [_trb(_TC_IMG, "screenshot", [], "running")],
|
||
)
|
||
|
||
# Base-64 data delta → DataBlock(base64) appended to output
|
||
ev_res_img_b64 = ToolResultDataDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_IMG,
|
||
block_id=_RES_DATA_B,
|
||
media_type="image/png",
|
||
data="iVBOR==",
|
||
)
|
||
gt_res_img_b64 = _base(
|
||
_s7b_prefix
|
||
+ [
|
||
_trb(
|
||
_TC_IMG,
|
||
"screenshot",
|
||
[_db_b64(_RES_DATA_B, "iVBOR==", "image/png")],
|
||
"running",
|
||
),
|
||
],
|
||
)
|
||
|
||
# URL data delta → DataBlock(url) appended to output
|
||
ev_res_img_url = ToolResultDataDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_IMG,
|
||
block_id=_RES_URL_B,
|
||
media_type="image/jpeg",
|
||
url="https://example.com/img.jpg",
|
||
)
|
||
gt_res_img_url = _base(
|
||
_s7b_prefix
|
||
+ [
|
||
_trb(
|
||
_TC_IMG,
|
||
"screenshot",
|
||
[
|
||
_db_b64(_RES_DATA_B, "iVBOR==", "image/png"),
|
||
_db_url(
|
||
_RES_URL_B,
|
||
"https://example.com/img.jpg",
|
||
"image/jpeg",
|
||
),
|
||
],
|
||
"running",
|
||
),
|
||
],
|
||
)
|
||
|
||
ev_res_img_end = ToolResultEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id=_TC_IMG,
|
||
state=ToolResultState.ERROR,
|
||
)
|
||
# TOOL_RESULT_END flips the paired ToolCallBlock to FINISHED.
|
||
_s7b_done_prefix = _s7_prefix + [
|
||
_tcb(_TC_IMG, "screenshot", "", "finished"),
|
||
]
|
||
gt_res_img_end = _base(
|
||
_s7b_done_prefix
|
||
+ [
|
||
_trb(
|
||
_TC_IMG,
|
||
"screenshot",
|
||
[
|
||
_db_b64(_RES_DATA_B, "iVBOR==", "image/png"),
|
||
_db_url(
|
||
_RES_URL_B,
|
||
"https://example.com/img.jpg",
|
||
"image/jpeg",
|
||
),
|
||
],
|
||
"error",
|
||
),
|
||
],
|
||
)
|
||
|
||
# ================================================================
|
||
# Stage 8 – ModelCallEndEvent (first call: usage initialized;
|
||
# second call: usage accumulated)
|
||
# ================================================================
|
||
_final_content = _s7b_done_prefix + [
|
||
_trb(
|
||
_TC_IMG,
|
||
"screenshot",
|
||
[
|
||
_db_b64(_RES_DATA_B, "iVBOR==", "image/png"),
|
||
_db_url(
|
||
_RES_URL_B,
|
||
"https://example.com/img.jpg",
|
||
"image/jpeg",
|
||
),
|
||
],
|
||
"error",
|
||
),
|
||
]
|
||
ev_model_call_end_1 = ModelCallEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
input_tokens=10,
|
||
output_tokens=20,
|
||
finished_reason=FinishedReason.COMPLETED,
|
||
)
|
||
gt_model_call_end_1 = _base(
|
||
_final_content,
|
||
usage={"input_tokens": 10, "output_tokens": 20},
|
||
)
|
||
|
||
ev_model_call_end_2 = ModelCallEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
input_tokens=5,
|
||
output_tokens=8,
|
||
finished_reason=FinishedReason.COMPLETED,
|
||
)
|
||
gt_model_call_end_2 = _base(
|
||
_final_content,
|
||
usage={"input_tokens": 15, "output_tokens": 28},
|
||
)
|
||
|
||
# ================================================================
|
||
# Stage 9 – ReplyEndEvent
|
||
# ================================================================
|
||
ev_reply_end = ReplyEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
session_id=_SESSION_ID,
|
||
created_at=_FIXED_END_TS,
|
||
finished_reason=ReplyEndReason.COMPLETED,
|
||
)
|
||
gt_reply_end = _base(
|
||
_final_content,
|
||
finished_at=_FIXED_END_TS,
|
||
usage={"input_tokens": 15, "output_tokens": 28},
|
||
)
|
||
|
||
# ================================================================
|
||
# Assemble the two parallel lists
|
||
# ================================================================
|
||
self.events = [
|
||
# Stage 1: Text
|
||
ev_text_start,
|
||
ev_text_delta1,
|
||
ev_text_delta2,
|
||
ev_text_end,
|
||
# Stage 2: Thinking
|
||
ev_think_start,
|
||
ev_think_delta1,
|
||
ev_think_delta2,
|
||
ev_think_end,
|
||
# Stage 3: Data (base-64)
|
||
ev_data_start,
|
||
ev_data_delta1,
|
||
ev_data_delta2,
|
||
ev_data_end,
|
||
# Stage 4: ToolCall → confirm (allowed) + text result (success)
|
||
ev_tc_allow_start,
|
||
ev_tc_allow_delta1,
|
||
ev_tc_allow_delta2,
|
||
ev_tc_allow_end,
|
||
ev_require_confirm,
|
||
ev_user_confirmed,
|
||
ev_result_start,
|
||
ev_result_text1,
|
||
ev_result_text2,
|
||
ev_result_end_ok,
|
||
# Stage 5: ToolCall → confirm (denied)
|
||
ev_tc_deny_start,
|
||
ev_tc_deny_end,
|
||
ev_require_confirm_deny,
|
||
ev_user_denied,
|
||
# Stage 6: ToolCall → external execution
|
||
ev_tc_ext_start,
|
||
ev_tc_ext_end,
|
||
ev_require_ext,
|
||
ev_ext_result,
|
||
# Stage 7: ToolResult with data output (base-64 + URL)
|
||
ev_tc_img_start,
|
||
ev_tc_img_end,
|
||
ev_res_img_start,
|
||
ev_res_img_b64,
|
||
ev_res_img_url,
|
||
ev_res_img_end,
|
||
# Stage 8: MODEL_CALL_END (init + accumulate)
|
||
ev_model_call_end_1,
|
||
ev_model_call_end_2,
|
||
# Stage 9: REPLY_END
|
||
ev_reply_end,
|
||
]
|
||
self.ground_truths = [
|
||
# Stage 1
|
||
gt_text_start,
|
||
gt_text_delta1,
|
||
gt_text_delta2,
|
||
gt_text_end,
|
||
# Stage 2
|
||
gt_think_start,
|
||
gt_think_delta1,
|
||
gt_think_delta2,
|
||
gt_think_end,
|
||
# Stage 3
|
||
gt_data_start,
|
||
gt_data_delta1,
|
||
gt_data_delta2,
|
||
gt_data_end,
|
||
# Stage 4
|
||
gt_tc_allow_start,
|
||
gt_tc_allow_delta1,
|
||
gt_tc_allow_delta2,
|
||
gt_tc_allow_end,
|
||
gt_require_confirm,
|
||
gt_user_confirmed,
|
||
gt_result_start,
|
||
gt_result_text1,
|
||
gt_result_text2,
|
||
gt_result_end_ok,
|
||
# Stage 5
|
||
gt_tc_deny_start,
|
||
gt_tc_deny_end,
|
||
gt_require_confirm_deny,
|
||
gt_user_denied,
|
||
# Stage 6
|
||
gt_tc_ext_start,
|
||
gt_tc_ext_end,
|
||
gt_require_ext,
|
||
gt_ext_result,
|
||
# Stage 7
|
||
gt_tc_img_start,
|
||
gt_tc_img_end,
|
||
gt_res_img_start,
|
||
gt_res_img_b64,
|
||
gt_res_img_url,
|
||
gt_res_img_end,
|
||
# Stage 8
|
||
gt_model_call_end_1,
|
||
gt_model_call_end_2,
|
||
# Stage 9
|
||
gt_reply_end,
|
||
]
|
||
|
||
# ------------------------------------------------------------------
|
||
# Tests
|
||
# ------------------------------------------------------------------
|
||
|
||
async def test_append_event_stream(self) -> None:
|
||
"""Apply every event in order and assert the full Msg state after each.
|
||
|
||
Uses a zip loop over self.events / self.ground_truths so that the
|
||
event index is clear from the assertion failure message.
|
||
"""
|
||
self.assertEqual(
|
||
len(self.events),
|
||
len(self.ground_truths),
|
||
"events and ground_truths must have equal length",
|
||
)
|
||
for idx, (event, expected) in enumerate(
|
||
zip(self.events, self.ground_truths),
|
||
):
|
||
self.msg.append_event(event)
|
||
self.assertDictEqual(
|
||
self.msg.model_dump(),
|
||
expected,
|
||
msg=f"Mismatch after event[{idx}] ({event.type})",
|
||
)
|
||
|
||
async def test_wrong_reply_id_is_skipped(self) -> None:
|
||
"""An event whose reply_id does not match msg.id must be ignored."""
|
||
original_dump = self.msg.model_dump()
|
||
wrong_event = TextBlockStartEvent(
|
||
reply_id="totally_wrong_id",
|
||
block_id="should_not_appear",
|
||
)
|
||
self.msg.append_event(wrong_event)
|
||
self.assertDictEqual(
|
||
self.msg.model_dump(),
|
||
original_dump,
|
||
msg="Msg must not change when event.reply_id does not match",
|
||
)
|
||
|
||
async def test_missing_block_does_not_crash(self) -> None:
|
||
"""Sending a delta for a non-existent block must log a warning only."""
|
||
original_dump = self.msg.model_dump()
|
||
|
||
# Delta events for blocks that were never started
|
||
ghost_events = [
|
||
TextBlockDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id="ghost_text",
|
||
delta="x",
|
||
),
|
||
ThinkingBlockDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id="ghost_think",
|
||
delta="x",
|
||
),
|
||
DataBlockDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
block_id="ghost_data",
|
||
data="x",
|
||
media_type="image/png",
|
||
),
|
||
ToolCallDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id="ghost_tc",
|
||
delta="x",
|
||
),
|
||
ToolResultTextDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id="ghost_tr",
|
||
delta="x",
|
||
),
|
||
ToolResultDataDeltaEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id="ghost_tr",
|
||
media_type="image/png",
|
||
data="x",
|
||
),
|
||
ToolResultEndEvent(
|
||
reply_id=_REPLY_ID,
|
||
tool_call_id="ghost_tr",
|
||
state=ToolResultState.SUCCESS,
|
||
),
|
||
]
|
||
for ev in ghost_events:
|
||
self.msg.append_event(ev) # must not raise
|
||
|
||
self.assertDictEqual(
|
||
self.msg.model_dump(),
|
||
original_dump,
|
||
msg="Msg must not change when delta targets a missing block",
|
||
)
|
||
|
||
async def asyncTearDown(self) -> None:
|
||
"""No teardown needed."""
|