fix(office-ui): native progress parity, ui_state lock hardening, approval-card idempotency

Native agent progress panel (company mode):
- ws_handler: filter runtime bookkeeping noise (turn/status/member_inbox_updated),
  keep tool_completed as tool_call, thinking summary previews content,
  preserve raw thinking_delta fragments (no strip; skip whitespace-only)
- frontend progressLog: summarize thinking by content preview; merge thinking
  by detail only so the 'Thinking' label never splices into text
- AgentProgressBlock: add bottom "Show more (N earlier steps)" toggle

ui_state.db "database is locked" hardening:
- ws_handler: isolate engine progress/kanban/runtime-event callbacks so UI
  persistence failures never crash work items
- chat_store: busy_timeout, _retry_locked backoff, idempotent insert_message
  (INSERT OR REPLACE), create_channel read-before-write to stop poll writes
- server: flock single-instance guard for `opc ui` per OPC home

Approval card duplicate-click bug:
- EscalationPanel: disable buttons on click with Submitting state and 30s
  reconnect fallback
- ws_handler: stale-escalation branch checks real card status (new
  chat_store.get_checkpoint_message); already-resolved cards get an accurate
  "already handled (decision: X)" reply without being re-marked stale;
  dedup identical helper messages within 120s to stop reply spam

Company mode prompt:
- add soft guidance that the runtime monitors state and re-activates roles,
  so leaders need not poll work items after delegation/review

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LZH-YS1998
2026-07-07 16:18:01 +08:00
parent ee79331d48
commit 12817a4e60
12 changed files with 516 additions and 157 deletions
@@ -40,6 +40,7 @@ _MULTI_TEAM_ORG_GUIDELINES = """
- The WorkItem is the collaboration source of truth. Use WorkItem IDs for collaboration; never use runtime Task IDs as WorkItem IDs.
- `workspace_root` and `comms_workspace_root` are guaranteed. `output_root` may be blank; if needed, choose a suitable subfolder under `workspace_root` and communicate it in handoffs or delegation briefs.
- Kanban state is advanced by the runtime from completion reports and review verdicts. Do not manually flip board states.
- There is no need to poll work items for progress: the runtime watches state changes and re-activates whichever roles need to act once delegated or dependent work completes. So after you have confirmed that your delegation succeeded or that your review verdict advanced the work, end your current run if nothing else requires your own work — the system monitors on your behalf.
- Use mailbox tools only for coordination, questions, blockers, or handoffs.
- Cross-team collaboration is request-based; only direct managers delegate executable work.
- If you are the root final decider, only your finished turn is the authoritative owner-facing result.
+127 -29
View File
@@ -6,16 +6,27 @@ Channel/message format uses snake_case to match what collabSync.ts expects.
from __future__ import annotations
import asyncio
import json
import re
import sqlite3
import time
import uuid
from typing import Any
from typing import Any, Awaitable, Callable
import aiosqlite
from opc.layer3_agent.adapters.codex_adapter import CodexAdapter
_LOCKED_ERROR_MARKERS = ("database is locked", "database table is locked")
_WRITE_RETRY_ATTEMPTS = 3
_WRITE_RETRY_BASE_DELAY_SECONDS = 0.25
def _is_locked_error(exc: BaseException) -> bool:
return isinstance(exc, sqlite3.OperationalError) and any(
marker in str(exc).lower() for marker in _LOCKED_ERROR_MARKERS
)
class ChatStore:
"""Chat channels + messages in ui_state.db.
@@ -319,6 +330,28 @@ class ChatStore:
"metadata": metadata,
}
async def _retry_locked(self, operation: Callable[[], Awaitable[Any]]) -> Any:
"""Run a write operation, retrying briefly on transient sqlite lock errors.
Another process sharing ui_state.db (a second server, the CLI) can hold
the write lock past busy_timeout; a short backoff usually clears it.
"""
last_error: BaseException | None = None
for attempt in range(_WRITE_RETRY_ATTEMPTS):
try:
return await operation()
except sqlite3.OperationalError as exc:
if not _is_locked_error(exc):
raise
last_error = exc
try:
await self._db.rollback()
except Exception:
pass
await asyncio.sleep(_WRITE_RETRY_BASE_DELAY_SECONDS * (2 ** attempt))
assert last_error is not None
raise last_error
async def initialize(self) -> None:
"""Create tables if not exist."""
await self._db.execute("""
@@ -488,18 +521,13 @@ class ChatStore:
now = time.time()
parts = participants or []
cursor = await self._db.execute(
"SELECT created_at FROM channels WHERE channel_id = ? AND project_id = ?",
"SELECT type, name, office_id, participants, created_at FROM channels "
"WHERE channel_id = ? AND project_id = ?",
(cid, project_id),
)
existing = await cursor.fetchone()
created_at = float(existing[0]) if existing and existing[0] is not None else now
await self._db.execute(
"INSERT OR REPLACE INTO channels (channel_id, type, name, office_id, participants, created_at, project_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(cid, channel_type, name, office_id, json.dumps(parts), created_at, project_id),
)
await self._db.commit()
return {
created_at = float(existing[4]) if existing and existing[4] is not None else now
channel = {
"channel_id": cid,
"type": channel_type,
"name": name,
@@ -508,6 +536,33 @@ class ChatStore:
"created_at": created_at,
"project_id": project_id,
}
if existing is not None:
# Callers (e.g. session_detail polling) invoke this on every
# request; skip the write when nothing changed so a read-only
# view does not generate a constant write load on ui_state.db.
try:
existing_parts = json.loads(existing[3]) if existing[3] else []
except (json.JSONDecodeError, TypeError):
existing_parts = None
unchanged = (
str(existing[0] or "") == channel_type
and str(existing[1] or "") == name
and (existing[2] or None) == (office_id or None)
and existing_parts == parts
)
if unchanged:
return channel
async def _write() -> None:
await self._db.execute(
"INSERT OR REPLACE INTO channels (channel_id, type, name, office_id, participants, created_at, project_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(cid, channel_type, name, office_id, json.dumps(parts), created_at, project_id),
)
await self._db.commit()
await self._retry_locked(_write)
return channel
async def insert_message(
self,
@@ -525,19 +580,23 @@ class ChatStore:
"""Insert a message. Returns message dict in backend format (snake_case)."""
mid = message_id or str(uuid.uuid4())
now = float(created_at) if created_at is not None else time.time()
await self._db.execute(
"INSERT INTO messages "
"(message_id, channel_id, sender, sender_name, content, timestamp, "
"reply_to_id, mentions, metadata, project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
mid, channel_id, sender, sender_name, content, now,
reply_to_id,
json.dumps(mentions or []),
json.dumps(metadata or {}),
project_id,
),
)
await self._db.commit()
async def _write() -> None:
await self._db.execute(
"INSERT OR REPLACE INTO messages "
"(message_id, channel_id, sender, sender_name, content, timestamp, "
"reply_to_id, mentions, metadata, project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
mid, channel_id, sender, sender_name, content, now,
reply_to_id,
json.dumps(mentions or []),
json.dumps(metadata or {}),
project_id,
),
)
await self._db.commit()
await self._retry_locked(_write)
return {
"message_id": mid,
"channel_id": channel_id,
@@ -1220,6 +1279,41 @@ class ChatStore:
project_id=project_id,
)
async def get_checkpoint_message(
self,
checkpoint_id: str,
*,
channel_id: str | None = None,
checkpoint_type: str | None = None,
project_id: str = "default",
) -> dict[str, Any] | None:
"""Read-only lookup of a checkpoint card message by checkpoint id."""
normalized_checkpoint_id = str(checkpoint_id or "").strip()
if not normalized_checkpoint_id:
return None
normalized_checkpoint_type = str(checkpoint_type or "").strip()
normalized_channel_id = str(channel_id or "").strip()
params: list[Any] = [project_id]
query = (
"SELECT message_id, channel_id, sender, sender_name, content, "
"timestamp, reply_to_id, mentions, metadata "
"FROM messages WHERE project_id = ?"
)
if normalized_channel_id:
query += " AND channel_id = ?"
params.append(normalized_channel_id)
query += " ORDER BY timestamp DESC"
cursor = await self._db.execute(query, tuple(params))
rows = await cursor.fetchall()
for row in rows:
metadata = json.loads(row[8]) if row[8] else {}
if str(metadata.get("checkpoint_id", "")).strip() != normalized_checkpoint_id:
continue
if normalized_checkpoint_type and str(metadata.get("checkpoint_type", "")).strip() != normalized_checkpoint_type:
continue
return self._row_to_message_dict(row)
return None
async def update_checkpoint_status(
self,
checkpoint_id: str,
@@ -1434,12 +1528,16 @@ class ChatStore:
"""
existing = await self.get_progress(task_id, project_id=project_id)
merged = (existing + new_entries)[-self._PROGRESS_MAX_ENTRIES:]
await self._db.execute(
"INSERT OR REPLACE INTO task_progress (task_id, entries, updated_at, project_id) "
"VALUES (?, ?, ?, ?)",
(task_id, json.dumps(merged, ensure_ascii=False, default=str), time.time(), project_id),
)
await self._db.commit()
async def _write() -> None:
await self._db.execute(
"INSERT OR REPLACE INTO task_progress (task_id, entries, updated_at, project_id) "
"VALUES (?, ?, ?, ?)",
(task_id, json.dumps(merged, ensure_ascii=False, default=str), time.time(), project_id),
)
await self._db.commit()
await self._retry_locked(_write)
async def get_progress(self, task_id: str, project_id: str = "default") -> list[dict[str, Any]]:
"""Read persisted progress entries for a task."""
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-DoyFIcGP.js"></script>
<script type="module" crossorigin src="./assets/index-CYE7dSgZ.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-CMqG6mW8.css">
</head>
@@ -155,7 +155,7 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
return (
<div key={progressEntryKey(entry, i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
<div key={progressEntryKey(entry, hiddenCount + i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
<div className="ptl-connector">
<div className="ptl-dot" style={{ color: cfg.color }}>
{cfg.icon}
@@ -183,13 +183,19 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
</div>
)}
{/* ── Collapse button (when expanded) ────────────── */}
{/* ── Expand/collapse toggle (below timeline) ────── */}
{expanded && filteredEntries.length > COLLAPSED_COUNT && (
<button className="ptl-expand" onClick={() => setExpanded(false)}>
<IconChevron down />
<span>Show less</span>
</button>
)}
{!expanded && hiddenCount > 0 && (
<button className="ptl-expand" onClick={() => setExpanded(true)}>
<IconChevron />
<span>Show more ({hiddenCount} earlier step{hiddenCount > 1 ? 's' : ''})</span>
</button>
)}
</div>
)
}
@@ -1,7 +1,11 @@
import React, { useCallback, useMemo } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import type { ChatMessageMeta, HumanEscalationOption } from '../types/chat'
import { MarkdownBody } from './MarkdownBody'
// If the server never confirms (click lost on a dropped connection), re-enable
// the buttons after this long so the user can retry.
const SUBMIT_CONFIRM_TIMEOUT_MS = 30000
interface EscalationPanelProps {
meta: ChatMessageMeta
onReply: (text: string) => void
@@ -57,10 +61,20 @@ export const EscalationPanel = React.memo(function EscalationPanel({
const worktreePath = String(meta.worktree_path ?? '').trim()
const hasRuntimeState = activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath
const [submittedOptionId, setSubmittedOptionId] = useState('')
const isSubmitting = !!submittedOptionId && !isResponded
useEffect(() => {
if (!isSubmitting) return
const timer = window.setTimeout(() => setSubmittedOptionId(''), SUBMIT_CONFIRM_TIMEOUT_MS)
return () => window.clearTimeout(timer)
}, [isSubmitting, submittedOptionId])
const handleReply = useCallback((option: HumanEscalationOption) => {
if (isResponded) return
if (isResponded || isSubmitting) return
setSubmittedOptionId(option.id)
onReply(option.label || option.id)
}, [isResponded, onReply])
}, [isResponded, isSubmitting, onReply])
return (
<div className="ckpt-panel ckpt-escalation">
@@ -109,15 +123,22 @@ export const EscalationPanel = React.memo(function EscalationPanel({
{options.map((option) => (
<button
key={option.id}
className={`ckpt-btn ${option.id.includes('deny') ? 'ckpt-btn-deny' : 'ckpt-btn-approve'}`}
className={`ckpt-btn ${option.id.includes('deny') ? 'ckpt-btn-deny' : 'ckpt-btn-approve'}${isSubmitting ? ' ckpt-btn-submitting' : ''}`}
onClick={() => handleReply(option)}
disabled={isSubmitting}
>
{option.label || option.id}
{isSubmitting && submittedOptionId === option.id ? 'Submitting…' : (option.label || option.id)}
</button>
))}
</div>
)}
{isSubmitting && (
<div className="ckpt-escalation-hint">
Decision sent waiting for server confirmation
</div>
)}
{!isResponded && meta.default_action && (
<div className="ckpt-escalation-hint">
Default on timeout: <code>{meta.default_action}</code>
@@ -28,9 +28,41 @@ for (const [seq, detail] of [
}
assert.equal(log.length, 1)
assert.equal(log[0]?.summary, 'Thinking')
assert.equal(log[0]?.summary, '我先联网抓取')
assert.equal(log[0]?.detail, '我先联网抓取')
// Token-sized streaming fragments keep their whitespace when merged, and
// entries without detail never splice their summary label into the text.
let spacedLog = appendProgressEntry([], {
timestamp: 100,
type: 'thinking',
summary: 'The user',
detail: 'The user',
turnId: 'rt-2:1',
itemId: 'rt-2:1:thinking',
seq: 1,
})
spacedLog = appendProgressEntry(spacedLog, {
timestamp: 101,
type: 'thinking',
summary: 'wants to',
detail: ' wants to',
turnId: 'rt-2:1',
itemId: 'rt-2:1:thinking',
seq: 2,
})
spacedLog = appendProgressEntry(spacedLog, {
timestamp: 102,
type: 'thinking',
summary: 'Thinking',
turnId: 'rt-2:1',
itemId: 'rt-2:1:thinking',
seq: 3,
})
assert.equal(spacedLog.length, 1)
assert.equal(spacedLog[0]?.detail, 'The user wants to')
assert.equal(spacedLog[0]?.summary, 'The user wants to')
const unchanged = appendProgressEntry(log, {
timestamp: 5,
type: 'thinking',
@@ -27,9 +27,9 @@ function mergeText(left: string, right: string, kind: 'thinking' | 'tool_call'):
}
function summarizeThinking(detail: string, fallback: string): string {
void detail
void fallback
return 'Thinking'
const text = detail.trim().replace(/\s+/g, ' ')
if (!text) return fallback || 'Thinking'
return text.length > 120 ? `${text.slice(0, 120).trimEnd()}...` : text
}
function normalizeProgressEntry(entry: ProgressEntry): ProgressEntry {
@@ -84,7 +84,10 @@ function isDuplicateProgress(left: ProgressEntry, right: ProgressEntry): boolean
function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry {
if (left.type === 'thinking') {
const detail = mergeText(left.detail ?? left.summary, right.detail ?? right.summary, 'thinking')
// Merge detail text only: summary is a label/preview ("Thinking",
// truncated excerpt), so falling back to it would splice label text
// into the middle of the merged thinking stream.
const detail = mergeText(left.detail ?? '', right.detail ?? '', 'thinking')
return {
timestamp: right.timestamp,
type: 'thinking',
+52
View File
@@ -7,6 +7,7 @@ sets up the event adapter pipeline, and serves static files + WebSocket.
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import Any
@@ -47,6 +48,41 @@ def _is_under_path(path: Path, base: Path) -> bool:
return False
def _acquire_single_instance_lock(opc_home: Path) -> Any | None:
"""Prevent two office-UI servers from sharing one OPC home.
Two server processes writing the same ui_state.db contend for the sqlite
write lock and surface as 'database is locked' failures mid-run. The lock
is advisory (flock), scoped to this OPC home, and released automatically
when the process exits — including on crash/SIGKILL, so a stale lock file
can never block a fresh start.
"""
try:
import fcntl
except ImportError:
return None # Non-POSIX platform: no flock available, skip the guard.
lock_path = opc_home / "office_ui.lock"
lock_file = open(lock_path, "a+", encoding="utf-8")
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
lock_file.seek(0)
holder_pid = lock_file.read().strip() or "unknown"
lock_file.close()
raise SystemExit(
f"Another office-UI server (pid {holder_pid}) is already running against "
f"{opc_home}. Two instances sharing one ui_state.db cause 'database is "
"locked' failures that can crash in-flight agent runs. Stop the other "
"instance first, or point this one at a different OPC home."
)
lock_file.seek(0)
lock_file.truncate()
lock_file.write(str(os.getpid()))
lock_file.flush()
return lock_file
# ── Application factory ──────────────────────────────────────────────────
async def create_app(
@@ -72,8 +108,12 @@ async def create_app(
# ── UI-state database (agents + chat) ─────────────────────────────
opc_home = engine.opc_home
instance_lock = _acquire_single_instance_lock(opc_home)
db_path = opc_home / "ui_state.db"
db = await aiosqlite.connect(str(db_path))
# Wait for a concurrent writer (CLI, tooling) instead of failing after
# sqlite's 5s default with 'database is locked'.
await db.execute("PRAGMA busy_timeout=30000")
agent_store = AgentStore(db)
await agent_store.initialize()
@@ -129,6 +169,7 @@ async def create_app(
app["engine"] = engine
app["db"] = db
app["ws_handler"] = ws_handler
app["instance_lock"] = instance_lock
# ── Routes ────────────────────────────────────────────────────────
app.router.add_get("/ws", ws_handler.handle_ws)
@@ -230,6 +271,12 @@ async def _on_shutdown(app: aiohttp.web.Application) -> None:
await engine.shutdown()
if db:
await db.close()
instance_lock = app.get("instance_lock")
if instance_lock is not None:
try:
instance_lock.close()
except Exception:
pass
logger.info("Office-UI server shut down")
@@ -263,3 +310,8 @@ def run_server(
asyncio.run(_start())
except KeyboardInterrupt:
terminal_status("Shutting down Office UI", kind="warning")
except SystemExit as exc:
if exc.code and not isinstance(exc.code, int):
terminal_status(str(exc.code), kind="error")
raise SystemExit(1) from None
raise
@@ -50,6 +50,9 @@ class OfficeServiceFactory:
on_escalation=self.on_escalation,
)
self.db = await aiosqlite.connect(str(self.engine.opc_home / "ui_state.db"))
# Wait for a concurrent writer (e.g. a running office-UI server)
# instead of failing after sqlite's 5s default with 'database is locked'.
await self.db.execute("PRAGMA busy_timeout=30000")
agent_store = AgentStore(self.db)
await agent_store.initialize()
chat_store = ChatStore(self.db)
+138 -32
View File
@@ -195,6 +195,14 @@ _TASK_MODE_DEBUG_ONLY_PROGRESS_TYPES: frozenset[str] = frozenset({
})
# Company mode shares the task-mode noise list; runtime bookkeeping events
# (turns, status snapshots, cost ticks) carry no reviewable content and drown
# out thinking/tool entries in the per-role activity feed.
_COMPANY_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES: frozenset[str] = (
_TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES | frozenset({"member_inbox_updated"})
)
_TASK_MODE_VISIBLE_RUNTIME_PROGRESS_TYPES: frozenset[str] = frozenset({
"thinking_delta",
"tool_started",
@@ -618,22 +626,39 @@ class WSHandler:
def _progress_callback_for_engine(self, engine: Any) -> Any:
async def _progress(text: str, **kw: Any) -> None:
await self.on_progress(
text,
_runtime_engine=engine,
_project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
**kw,
)
# UI progress is a best-effort display copy: a failure here (e.g.
# a locked ui_state.db) must never crash the agent execution that
# emitted the progress line.
try:
await self.on_progress(
text,
_runtime_engine=engine,
_project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
**kw,
)
except asyncio.CancelledError:
raise
except Exception:
logger.opt(exception=True).warning(
"UI progress handling failed; agent execution continues"
)
return _progress
def _runtime_event_callback_for_engine(self, engine: Any) -> Any:
async def _runtime_event(event: Any) -> None:
await self.on_opc_event(
event,
runtime_engine=engine,
project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
)
try:
await self.on_opc_event(
event,
runtime_engine=engine,
project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
)
except asyncio.CancelledError:
raise
except Exception:
logger.opt(exception=True).warning(
"UI runtime-event handling failed; agent execution continues"
)
setattr(_runtime_event, "_opc_ui_handler_id", id(self))
setattr(_runtime_event, "_opc_ui_project_id", self._normalize_project_id(getattr(engine, "project_id", None)))
@@ -653,7 +678,14 @@ class WSHandler:
def _kanban_callback_for_engine(self, engine: Any) -> Any:
async def _kanban_changed() -> None:
await self.on_kanban_changed(engine=engine)
try:
await self.on_kanban_changed(engine=engine)
except asyncio.CancelledError:
raise
except Exception:
logger.opt(exception=True).warning(
"UI kanban refresh failed; agent execution continues"
)
return _kanban_changed
@@ -2552,9 +2584,12 @@ class WSHandler:
if not runtime_type:
return None
is_task_mode = WSHandler._runtime_payload_is_task_mode(payload)
if is_task_mode and runtime_type in _TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES:
return None
if is_task_mode and runtime_type not in _TASK_MODE_VISIBLE_RUNTIME_PROGRESS_TYPES:
if is_task_mode:
if runtime_type in _TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES:
return None
if runtime_type not in _TASK_MODE_VISIBLE_RUNTIME_PROGRESS_TYPES:
return None
elif runtime_type in _COMPANY_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES:
return None
summary = runtime_type.replace("_", " ").title()
@@ -2570,8 +2605,14 @@ class WSHandler:
return None
elif runtime_type == "thinking_delta":
entry_type = "thinking"
detail = str(payload.get("text", "") or "").strip()
summary = "Thinking"
# Keep the raw fragment: streaming deltas are token-sized, so
# stripping them destroys the whitespace between tokens once the
# fragments are merged back into one entry.
detail = str(payload.get("text", "") or "")
if not detail.strip():
return None
preview = " ".join(detail.split())
summary = preview[:120].rstrip() + ("..." if len(preview) > 120 else "")
elif runtime_type == "member_claimed_work_item":
entry_type = "work_item_started"
priority = str(payload.get("message_priority", "") or "").strip().lower()
@@ -2595,10 +2636,8 @@ class WSHandler:
summary = str(payload.get("tool_name", "") or "tool")
detail = str(payload.get("text", "") or payload.get("message", "") or "").strip()
elif runtime_type == "tool_completed":
entry_type = "tool_call" if is_task_mode else "status_change"
entry_type = "tool_call"
summary = str(payload.get("tool_name", "") or "tool")
if not is_task_mode:
summary = f"{summary} completed"
detail = str(payload.get("result_summary", "") or payload.get("result_preview", "") or "").strip()
elif runtime_type == "status_snapshot":
entry_type = "status_change"
@@ -2726,7 +2765,7 @@ class WSHandler:
"detail": detail[:4000] if detail else None,
}
tool_call_id = str(payload.get("tool_call_id", "") or "").strip()
if is_task_mode and tool_call_id and entry_type in {"tool_call", "autonomy"}:
if tool_call_id and entry_type in {"tool_call", "autonomy"}:
turn_id = str(payload.get("turn_id", "") or "").strip()
prefix = "permission" if entry_type == "autonomy" else "tool"
entry.setdefault("item_id", f"{turn_id}:{prefix}:{tool_call_id}" if turn_id else f"{prefix}:{tool_call_id}")
@@ -4818,6 +4857,40 @@ class WSHandler:
)
await self.broadcast({"type": "session_message", "payload": msg})
async def _recent_identical_helper_exists(
self,
channel_id: str,
content: str,
*,
project_id: str,
window_seconds: float = 120.0,
scan_limit: int = 10,
) -> bool:
"""True when an identical assistant helper was posted very recently.
Used to collapse rapid duplicate user clicks into a single helper
reply instead of one warning per click.
"""
try:
recent = await self.chat_store.get_channel_messages(
channel_id, limit=scan_limit, project_id=project_id,
)
except Exception:
return False
now = time.time()
for item in reversed(recent):
if str(item.get("sender", "")) != "assistant":
continue
if str(item.get("content", "")) != content:
continue
try:
created_at = float(item.get("created_at", 0) or 0)
except (TypeError, ValueError):
continue
if now - created_at <= window_seconds:
return True
return False
async def _mark_human_escalation_checkpoint_status(
self,
escalation_id: str,
@@ -5503,21 +5576,54 @@ class WSHandler:
if stale_human_escalation:
if _looks_like_escalation_reply(content):
stale_checkpoint_id = explicit_escalation_id or explicit_checkpoint_id
await self._mark_human_escalation_checkpoint_status(
stale_checkpoint_id,
status="stale",
project_id=pid,
channel_id=channel_id,
reason="reply_to_inactive_escalation",
)
# Duplicate clicks on an approval card that was JUST resolved
# (e.g. the user's own first click) are a normal occurrence
# when the server is slow: answer idempotently instead of
# flipping the card to "stale" and spamming inactive warnings.
card = None
try:
card = await self.chat_store.get_checkpoint_message(
stale_checkpoint_id,
channel_id=channel_id,
checkpoint_type="human_escalation",
project_id=pid,
)
except Exception:
logger.opt(exception=True).debug(
"Failed to load checkpoint card for stale escalation reply"
)
card_meta = dict((card or {}).get("metadata", {}) or {})
card_status = str(card_meta.get("checkpoint_status", "") or "").strip().lower()
if card_status in {"resolved", "responded"}:
resolution_reply = str(
card_meta.get("checkpoint_resolution_reply", "") or ""
).strip()
helper_text = (
"This approval was already handled"
+ (f" (decision: {resolution_reply})" if resolution_reply else "")
+ ". No further action is needed."
)
else:
await self._mark_human_escalation_checkpoint_status(
stale_checkpoint_id,
status="stale",
project_id=pid,
channel_id=channel_id,
reason="reply_to_inactive_escalation",
)
helper_text = (
"That approval request is no longer active. "
"The approval card has been marked inactive in the session history."
)
if await self._recent_identical_helper_exists(
channel_id, helper_text, project_id=pid
):
return
helper = await self.chat_store.insert_message(
channel_id=channel_id,
sender="assistant",
sender_name="OPC",
content=(
"That approval request is no longer active. "
"The approval card has been marked inactive in the session history."
),
content=helper_text,
project_id=pid,
metadata={"type": "system"},
)
+82 -45
View File
@@ -87,22 +87,23 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
self.assertIsNone(entry)
def test_runtime_status_snapshot_maps_to_status_change_entry(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry(
{
"type": "status_snapshot",
"current_tool": "shell_exec",
"context_remaining_pct": 64,
"turn_cost_usd": 0.0123,
},
)
self.assertIsNotNone(entry)
assert entry is not None
self.assertEqual(entry["type"], "status_change")
self.assertEqual(entry["summary"], "Runtime status")
self.assertIn("tool=shell_exec", entry["detail"])
self.assertIn("context=36% used", entry["detail"])
def test_company_mode_hides_runtime_bookkeeping_events(self) -> None:
for runtime_type in (
"turn_started",
"turn_completed",
"status_snapshot",
"context_usage",
"cost_update",
"member_inbox_updated",
):
entry = WSHandler._runtime_event_to_progress_entry(
{
"type": runtime_type,
"current_tool": "shell_exec",
"turn_cost_usd": 0.0123,
},
)
self.assertIsNone(entry, runtime_type)
def test_runtime_member_claimed_work_item_maps_to_started_entry(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry(
@@ -124,10 +125,10 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
self.assertNotIn("legacy_title", entry)
self.assertTrue(entry["is_company_runtime"])
def test_runtime_context_usage_prefers_token_count_over_remaining_pct(self) -> None:
def test_runtime_context_warning_prefers_token_count_over_remaining_pct(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry(
{
"type": "context_usage",
"type": "context_warning",
"context_tokens": 3200,
"context_window": 8000,
"context_remaining_pct": 70,
@@ -137,23 +138,77 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
self.assertIsNotNone(entry)
assert entry is not None
self.assertEqual(entry["type"], "status_change")
self.assertEqual(entry["summary"], "Context usage")
self.assertEqual(entry["summary"], "Context usage high")
self.assertEqual(entry["detail"], "3200/8000 tokens | 40% used")
def test_runtime_cost_update_maps_to_status_change_entry(self) -> None:
def test_company_mode_tool_completed_stays_tool_call_with_stable_item_id(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry(
{
"type": "cost_update",
"turn_cost_usd": 0.0123,
"session_cost_usd": 0.0456,
"type": "tool_completed",
"turn_id": "rt-1:2",
"tool_call_id": "call-1",
"tool_name": "web_search",
"result_summary": "3 results",
"work_item_projection_id": "engineering_execution",
},
)
self.assertIsNotNone(entry)
assert entry is not None
self.assertEqual(entry["type"], "status_change")
self.assertEqual(entry["summary"], "Cost update")
self.assertIn("turn=$0.0123", entry["detail"])
self.assertEqual(entry["type"], "tool_call")
self.assertEqual(entry["summary"], "web_search")
self.assertEqual(entry["detail"], "3 results")
self.assertEqual(entry["item_id"], "rt-1:2:tool:call-1")
self.assertEqual(entry["stream_id"], "rt-1:2:tool:call-1")
self.assertEqual(entry["tool_call_id"], "call-1")
self.assertTrue(entry["is_company_runtime"])
def test_thinking_delta_preserves_fragment_whitespace(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry(
{
"type": "thinking_delta",
"turn_id": "rt-1:1",
"item_id": "rt-1:1:thinking",
"seq": 2,
"text": " wants to analyze",
},
)
self.assertIsNotNone(entry)
assert entry is not None
self.assertEqual(entry["detail"], " wants to analyze")
self.assertEqual(entry["summary"], "wants to analyze")
def test_thinking_delta_whitespace_only_fragment_is_skipped(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry(
{
"type": "thinking_delta",
"turn_id": "rt-1:1",
"item_id": "rt-1:1:thinking",
"seq": 3,
"text": " \n ",
},
)
self.assertIsNone(entry)
def test_company_mode_thinking_summary_previews_content(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry(
{
"type": "thinking_delta",
"turn_id": "rt-1:1",
"item_id": "rt-1:1:thinking",
"seq": 1,
"text": "先梳理竞品清单,再对比功能矩阵。",
"work_item_projection_id": "engineering_execution",
},
)
self.assertIsNotNone(entry)
assert entry is not None
self.assertEqual(entry["type"], "thinking")
self.assertEqual(entry["summary"], "先梳理竞品清单,再对比功能矩阵。")
self.assertEqual(entry["detail"], "先梳理竞品清单,再对比功能矩阵。")
def test_task_mode_low_value_runtime_events_are_hidden(self) -> None:
for runtime_type in (
@@ -195,7 +250,7 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
self.assertIsNotNone(entry)
assert entry is not None
self.assertEqual(entry["type"], "thinking")
self.assertEqual(entry["summary"], "Thinking")
self.assertEqual(entry["summary"], "我先检查。")
self.assertEqual(entry["detail"], "我先检查。")
self.assertEqual(entry["turn_id"], "rt-1:1")
self.assertEqual(entry["item_id"], "rt-1:1:thinking")
@@ -256,24 +311,6 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
self.assertEqual(entry["summary"], "Needs input")
self.assertEqual(entry["detail"], "task_user_input")
def test_runtime_member_inbox_updated_maps_to_status_change_entry(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry(
{
"type": "member_inbox_updated",
"actionable_inbox_count": 2,
"protocol_backlog_count": 1,
"notification_backlog_count": 3,
"resident_status": "idle",
},
)
self.assertIsNotNone(entry)
assert entry is not None
self.assertEqual(entry["type"], "status_change")
self.assertEqual(entry["summary"], "Resident inbox updated")
self.assertIn("chat=2", entry["detail"])
self.assertIn("protocol=1", entry["detail"])
def test_runtime_worker_notification_maps_error_to_work_item_failed_entry(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry(
{