Improve replayed run status handling

This commit is contained in:
武阳
2026-05-07 17:19:28 +08:00
parent 5b4f7c2c8d
commit 7837430a63
3 changed files with 130 additions and 21 deletions
+56 -18
View File
@@ -830,6 +830,13 @@ def create_app(state: AgentState) -> FastAPI:
agent = state.agent_for(request.account_id, requested_session_id) agent = state.agent_for(request.account_id, requested_session_id)
config = state.config_for(request.account_id) config = state.config_for(request.account_id)
session_directory = state.account_paths(request.account_id)['sessions'] session_directory = state.account_paths(request.account_id)['sessions']
def emit_agent_event(event: dict[str, object]) -> None:
stage = _runtime_event_stage(event)
if stage:
state.run_manager.update(run_record.run_id, stage=stage)
_emit_runtime_event(event_sink, event)
if request.resume_session_id is None: if request.resume_session_id is None:
_save_in_progress_session( _save_in_progress_session(
directory=session_directory, directory=session_directory,
@@ -898,14 +905,14 @@ def create_app(state: AgentState) -> FastAPI:
request.prompt.strip(), request.prompt.strip(),
stored, stored,
runtime_context=request.runtime_context, runtime_context=request.runtime_context,
event_sink=event_sink, event_sink=emit_agent_event,
) )
else: else:
result = agent.run( result = agent.run(
request.prompt.strip(), request.prompt.strip(),
session_id=requested_session_id, session_id=requested_session_id,
runtime_context=request.runtime_context, runtime_context=request.runtime_context,
event_sink=event_sink, event_sink=emit_agent_event,
) )
except Exception as exc: except Exception as exc:
_mark_session_interrupted( _mark_session_interrupted(
@@ -1164,23 +1171,11 @@ def _sanitize_stored_session_for_resume(
stored: StoredAgentSession, stored: StoredAgentSession,
) -> StoredAgentSession: ) -> StoredAgentSession:
trimmed, changed = _trim_incomplete_message_tail(stored.messages) trimmed, changed = _trim_incomplete_message_tail(stored.messages)
messages = _without_run_status_messages(trimmed)
if len(messages) != len(trimmed):
changed = True
if not changed: if not changed:
return stored return stored
messages = list(trimmed)
if not _last_message_is_run_status(messages, 'interrupted'):
messages.append(
{
'role': 'assistant',
'content': _interrupted_session_message('interrupted'),
'state': 'final',
'stop_reason': 'interrupted',
'metadata': {
'kind': 'run_status',
'status': 'interrupted',
'created_at_ms': int(time.time() * 1000),
},
}
)
budget_state = ( budget_state = (
dict(stored.budget_state) dict(stored.budget_state)
if isinstance(stored.budget_state, dict) if isinstance(stored.budget_state, dict)
@@ -1189,7 +1184,7 @@ def _sanitize_stored_session_for_resume(
budget_state['status'] = 'interrupted' budget_state['status'] = 'interrupted'
return replace( return replace(
stored, stored,
messages=tuple(messages), messages=messages,
budget_state=budget_state, budget_state=budget_state,
) )
@@ -1232,6 +1227,21 @@ def _assistant_has_trailing_tool_call(message: dict[str, Any]) -> bool:
return isinstance(tool_calls, list) and bool(tool_calls) return isinstance(tool_calls, list) and bool(tool_calls)
def _without_run_status_messages(
messages: tuple[dict[str, Any], ...],
) -> tuple[dict[str, Any], ...]:
return tuple(
message
for message in messages
if not _is_run_status_message(message)
)
def _is_run_status_message(message: dict[str, Any]) -> bool:
metadata = message.get('metadata')
return isinstance(metadata, dict) and metadata.get('kind') == 'run_status'
def _last_message_is_run_status(messages: list[dict[str, Any]], status: str) -> bool: def _last_message_is_run_status(messages: list[dict[str, Any]], status: str) -> bool:
if not messages: if not messages:
return False return False
@@ -1251,6 +1261,34 @@ def _interrupted_session_message(status: str) -> str:
return '上一次任务已中断,后台没有正在执行的进程。你可以继续回复,或重新发起任务。' return '上一次任务已中断,后台没有正在执行的进程。你可以继续回复,或重新发起任务。'
def _runtime_event_stage(event: dict[str, object]) -> str:
event_type = event.get('type')
if event_type == 'tool_start':
tool_name = str(event.get('tool_name') or '').strip()
return f'调用工具 {tool_name}' if tool_name else '正在调用工具'
if event_type == 'tool_result':
tool_name = str(event.get('tool_name') or '').strip()
return f'工具完成 {tool_name}' if tool_name else '工具调用完成'
if event_type == 'final_text_start':
return '正在整理回复'
if event_type == 'final_text_end':
return '回复整理完成'
if event_type == 'user_review_required':
return '等待用户 review'
if event_type == 'continuation_request':
return '继续补全回复'
if event_type in {
'prompt_length_check',
'prompt_length_recovery',
'auto_compact_summary',
'auto_compact_circuit_breaker',
}:
return '整理上下文'
if event_type == 'task_budget_exceeded':
return '达到任务预算限制'
return ''
def _emit_runtime_event( def _emit_runtime_event(
event_sink: Any | None, event_sink: Any | None,
event: dict[str, object], event: dict[str, object],
@@ -39,6 +39,7 @@ type ClawStoredMessage = {
stop_reason?: string; stop_reason?: string;
metadata?: { metadata?: {
elapsed_ms?: unknown; elapsed_ms?: unknown;
kind?: unknown;
placeholder?: unknown; placeholder?: unknown;
phase?: unknown; phase?: unknown;
status?: unknown; status?: unknown;
@@ -523,8 +524,11 @@ function getReplayMessages(
messages: readonly ClawStoredMessage[], messages: readonly ClawStoredMessage[],
runStatus?: ClawRunStatus | null, runStatus?: ClawRunStatus | null,
) { ) {
if (isActiveRunStatus(runStatus)) return [...messages]; const visibleMessages = isActiveRunStatus(runStatus)
return trimIncompleteReplayTail(messages); ? messages.filter((message) => !isRunStatusMessage(message))
: [...messages];
if (isActiveRunStatus(runStatus)) return visibleMessages;
return trimIncompleteReplayTail(visibleMessages);
} }
function shouldShowInterruptedNotice( function shouldShowInterruptedNotice(
@@ -532,6 +536,7 @@ function shouldShowInterruptedNotice(
runStatus?: ClawRunStatus | null, runStatus?: ClawRunStatus | null,
) { ) {
if (isActiveRunStatus(runStatus)) return false; if (isActiveRunStatus(runStatus)) return false;
if (hasRunStatusMessage(messages, runStatus?.status)) return false;
return ( return (
runStatus?.status === "interrupted" || runStatus?.status === "interrupted" ||
runStatus?.status === "cancelled" || runStatus?.status === "cancelled" ||
@@ -574,6 +579,21 @@ function hasTrailingAssistantToolCall(message?: ClawStoredMessage) {
return message?.role === "assistant" && Boolean(message.tool_calls?.length); return message?.role === "assistant" && Boolean(message.tool_calls?.length);
} }
function hasRunStatusMessage(
messages: readonly ClawStoredMessage[],
status?: string,
) {
return messages.some(
(message) =>
isRunStatusMessage(message) &&
(!status || message.metadata?.status === status),
);
}
function isRunStatusMessage(message?: ClawStoredMessage) {
return message?.metadata?.kind === "run_status";
}
function normalizeRunTimestampMs(value: unknown) { function normalizeRunTimestampMs(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined; if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return value < 10_000_000_000 ? Math.round(value * 1000) : Math.round(value); return value < 10_000_000_000 ? Math.round(value * 1000) : Math.round(value);
+52 -1
View File
@@ -16,7 +16,12 @@ from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from backend.api.server import AgentState, create_app from backend.api.server import (
AgentState,
create_app,
_runtime_event_stage,
_sanitize_stored_session_for_resume,
)
from src.agent_types import AgentRunResult from src.agent_types import AgentRunResult
from src.session_store import StoredAgentSession, load_agent_session, save_agent_session from src.session_store import StoredAgentSession, load_agent_session, save_agent_session
@@ -360,6 +365,52 @@ class GuiServerTests(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['status'], 'interrupted') self.assertEqual(response.json()['status'], 'interrupted')
def test_resume_sanitizer_removes_ui_run_status_messages(self) -> None:
stored = StoredAgentSession(
session_id='thread-1',
model_config={'model': 'test-model'},
runtime_config={},
system_prompt_parts=('system prompt',),
user_context={},
system_context={},
messages=(
{'role': 'user', 'content': 'hello'},
{
'role': 'assistant',
'content': '上一次任务已中断,后台没有正在执行的进程。',
'state': 'final',
'stop_reason': 'interrupted',
'metadata': {'kind': 'run_status', 'status': 'interrupted'},
},
{'role': 'user', 'content': 'continue'},
),
turns=1,
tool_calls=0,
usage={},
total_cost_usd=0.0,
file_history=(),
budget_state={'status': 'interrupted'},
plugin_state={},
)
sanitized = _sanitize_stored_session_for_resume(stored)
self.assertEqual(
[message['content'] for message in sanitized.messages],
['hello', 'continue'],
)
def test_runtime_event_stage_names_long_running_work(self) -> None:
self.assertEqual(
_runtime_event_stage({'type': 'tool_start', 'tool_name': 'python_exec'}),
'调用工具 python_exec',
)
self.assertEqual(
_runtime_event_stage({'type': 'final_text_start'}),
'正在整理回复',
)
self.assertEqual(_runtime_event_stage({'type': 'server_heartbeat'}), '')
def test_chat_uses_account_scoped_model_config(self) -> None: def test_chat_uses_account_scoped_model_config(self) -> None:
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
client, _ = _build_client(Path(d)) client, _ = _build_client(Path(d))