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. - 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. - `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. - 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. - Use mailbox tools only for coordination, questions, blockers, or handoffs.
- Cross-team collaboration is request-based; only direct managers delegate executable work. - 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. - If you are the root final decider, only your finished turn is the authoritative owner-facing result.
+109 -11
View File
@@ -6,16 +6,27 @@ Channel/message format uses snake_case to match what collabSync.ts expects.
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import re import re
import sqlite3 import sqlite3
import time import time
import uuid import uuid
from typing import Any from typing import Any, Awaitable, Callable
import aiosqlite import aiosqlite
from opc.layer3_agent.adapters.codex_adapter import CodexAdapter 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: class ChatStore:
"""Chat channels + messages in ui_state.db. """Chat channels + messages in ui_state.db.
@@ -319,6 +330,28 @@ class ChatStore:
"metadata": metadata, "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: async def initialize(self) -> None:
"""Create tables if not exist.""" """Create tables if not exist."""
await self._db.execute(""" await self._db.execute("""
@@ -488,18 +521,13 @@ class ChatStore:
now = time.time() now = time.time()
parts = participants or [] parts = participants or []
cursor = await self._db.execute( 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), (cid, project_id),
) )
existing = await cursor.fetchone() existing = await cursor.fetchone()
created_at = float(existing[0]) if existing and existing[0] is not None else now created_at = float(existing[4]) if existing and existing[4] is not None else now
await self._db.execute( channel = {
"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 {
"channel_id": cid, "channel_id": cid,
"type": channel_type, "type": channel_type,
"name": name, "name": name,
@@ -508,6 +536,33 @@ class ChatStore:
"created_at": created_at, "created_at": created_at,
"project_id": project_id, "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( async def insert_message(
self, self,
@@ -525,8 +580,10 @@ class ChatStore:
"""Insert a message. Returns message dict in backend format (snake_case).""" """Insert a message. Returns message dict in backend format (snake_case)."""
mid = message_id or str(uuid.uuid4()) mid = message_id or str(uuid.uuid4())
now = float(created_at) if created_at is not None else time.time() now = float(created_at) if created_at is not None else time.time()
async def _write() -> None:
await self._db.execute( await self._db.execute(
"INSERT INTO messages " "INSERT OR REPLACE INTO messages "
"(message_id, channel_id, sender, sender_name, content, timestamp, " "(message_id, channel_id, sender, sender_name, content, timestamp, "
"reply_to_id, mentions, metadata, project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "reply_to_id, mentions, metadata, project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
( (
@@ -538,6 +595,8 @@ class ChatStore:
), ),
) )
await self._db.commit() await self._db.commit()
await self._retry_locked(_write)
return { return {
"message_id": mid, "message_id": mid,
"channel_id": channel_id, "channel_id": channel_id,
@@ -1220,6 +1279,41 @@ class ChatStore:
project_id=project_id, 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( async def update_checkpoint_status(
self, self,
checkpoint_id: str, checkpoint_id: str,
@@ -1434,6 +1528,8 @@ class ChatStore:
""" """
existing = await self.get_progress(task_id, project_id=project_id) existing = await self.get_progress(task_id, project_id=project_id)
merged = (existing + new_entries)[-self._PROGRESS_MAX_ENTRIES:] merged = (existing + new_entries)[-self._PROGRESS_MAX_ENTRIES:]
async def _write() -> None:
await self._db.execute( await self._db.execute(
"INSERT OR REPLACE INTO task_progress (task_id, entries, updated_at, project_id) " "INSERT OR REPLACE INTO task_progress (task_id, entries, updated_at, project_id) "
"VALUES (?, ?, ?, ?)", "VALUES (?, ?, ?, ?)",
@@ -1441,6 +1537,8 @@ class ChatStore:
) )
await self._db.commit() 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]]: async def get_progress(self, task_id: str, project_id: str = "default") -> list[dict[str, Any]]:
"""Read persisted progress entries for a task.""" """Read persisted progress entries for a task."""
cursor = await self._db.execute( cursor = await self._db.execute(
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," /> <link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title> <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="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-CMqG6mW8.css"> <link rel="stylesheet" crossorigin href="./assets/index-CMqG6mW8.css">
</head> </head>
@@ -155,7 +155,7 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
return ( 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-connector">
<div className="ptl-dot" style={{ color: cfg.color }}> <div className="ptl-dot" style={{ color: cfg.color }}>
{cfg.icon} {cfg.icon}
@@ -183,13 +183,19 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
</div> </div>
)} )}
{/* ── Collapse button (when expanded) ────────────── */} {/* ── Expand/collapse toggle (below timeline) ────── */}
{expanded && filteredEntries.length > COLLAPSED_COUNT && ( {expanded && filteredEntries.length > COLLAPSED_COUNT && (
<button className="ptl-expand" onClick={() => setExpanded(false)}> <button className="ptl-expand" onClick={() => setExpanded(false)}>
<IconChevron down /> <IconChevron down />
<span>Show less</span> <span>Show less</span>
</button> </button>
)} )}
{!expanded && hiddenCount > 0 && (
<button className="ptl-expand" onClick={() => setExpanded(true)}>
<IconChevron />
<span>Show more ({hiddenCount} earlier step{hiddenCount > 1 ? 's' : ''})</span>
</button>
)}
</div> </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 type { ChatMessageMeta, HumanEscalationOption } from '../types/chat'
import { MarkdownBody } from './MarkdownBody' 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 { interface EscalationPanelProps {
meta: ChatMessageMeta meta: ChatMessageMeta
onReply: (text: string) => void onReply: (text: string) => void
@@ -57,10 +61,20 @@ export const EscalationPanel = React.memo(function EscalationPanel({
const worktreePath = String(meta.worktree_path ?? '').trim() const worktreePath = String(meta.worktree_path ?? '').trim()
const hasRuntimeState = activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath 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) => { const handleReply = useCallback((option: HumanEscalationOption) => {
if (isResponded) return if (isResponded || isSubmitting) return
setSubmittedOptionId(option.id)
onReply(option.label || option.id) onReply(option.label || option.id)
}, [isResponded, onReply]) }, [isResponded, isSubmitting, onReply])
return ( return (
<div className="ckpt-panel ckpt-escalation"> <div className="ckpt-panel ckpt-escalation">
@@ -109,15 +123,22 @@ export const EscalationPanel = React.memo(function EscalationPanel({
{options.map((option) => ( {options.map((option) => (
<button <button
key={option.id} 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)} onClick={() => handleReply(option)}
disabled={isSubmitting}
> >
{option.label || option.id} {isSubmitting && submittedOptionId === option.id ? 'Submitting…' : (option.label || option.id)}
</button> </button>
))} ))}
</div> </div>
)} )}
{isSubmitting && (
<div className="ckpt-escalation-hint">
Decision sent waiting for server confirmation
</div>
)}
{!isResponded && meta.default_action && ( {!isResponded && meta.default_action && (
<div className="ckpt-escalation-hint"> <div className="ckpt-escalation-hint">
Default on timeout: <code>{meta.default_action}</code> 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.length, 1)
assert.equal(log[0]?.summary, 'Thinking') assert.equal(log[0]?.summary, '我先联网抓取')
assert.equal(log[0]?.detail, '我先联网抓取') 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, { const unchanged = appendProgressEntry(log, {
timestamp: 5, timestamp: 5,
type: 'thinking', type: 'thinking',
@@ -27,9 +27,9 @@ function mergeText(left: string, right: string, kind: 'thinking' | 'tool_call'):
} }
function summarizeThinking(detail: string, fallback: string): string { function summarizeThinking(detail: string, fallback: string): string {
void detail const text = detail.trim().replace(/\s+/g, ' ')
void fallback if (!text) return fallback || 'Thinking'
return 'Thinking' return text.length > 120 ? `${text.slice(0, 120).trimEnd()}...` : text
} }
function normalizeProgressEntry(entry: ProgressEntry): ProgressEntry { function normalizeProgressEntry(entry: ProgressEntry): ProgressEntry {
@@ -84,7 +84,10 @@ function isDuplicateProgress(left: ProgressEntry, right: ProgressEntry): boolean
function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry { function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry {
if (left.type === 'thinking') { 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 { return {
timestamp: right.timestamp, timestamp: right.timestamp,
type: 'thinking', type: 'thinking',
+52
View File
@@ -7,6 +7,7 @@ sets up the event adapter pipeline, and serves static files + WebSocket.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -47,6 +48,41 @@ def _is_under_path(path: Path, base: Path) -> bool:
return False 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 ────────────────────────────────────────────────── # ── Application factory ──────────────────────────────────────────────────
async def create_app( async def create_app(
@@ -72,8 +108,12 @@ async def create_app(
# ── UI-state database (agents + chat) ───────────────────────────── # ── UI-state database (agents + chat) ─────────────────────────────
opc_home = engine.opc_home opc_home = engine.opc_home
instance_lock = _acquire_single_instance_lock(opc_home)
db_path = opc_home / "ui_state.db" db_path = opc_home / "ui_state.db"
db = await aiosqlite.connect(str(db_path)) 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) agent_store = AgentStore(db)
await agent_store.initialize() await agent_store.initialize()
@@ -129,6 +169,7 @@ async def create_app(
app["engine"] = engine app["engine"] = engine
app["db"] = db app["db"] = db
app["ws_handler"] = ws_handler app["ws_handler"] = ws_handler
app["instance_lock"] = instance_lock
# ── Routes ──────────────────────────────────────────────────────── # ── Routes ────────────────────────────────────────────────────────
app.router.add_get("/ws", ws_handler.handle_ws) 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() await engine.shutdown()
if db: if db:
await db.close() 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") logger.info("Office-UI server shut down")
@@ -263,3 +310,8 @@ def run_server(
asyncio.run(_start()) asyncio.run(_start())
except KeyboardInterrupt: except KeyboardInterrupt:
terminal_status("Shutting down Office UI", kind="warning") 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, on_escalation=self.on_escalation,
) )
self.db = await aiosqlite.connect(str(self.engine.opc_home / "ui_state.db")) 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) agent_store = AgentStore(self.db)
await agent_store.initialize() await agent_store.initialize()
chat_store = ChatStore(self.db) chat_store = ChatStore(self.db)
+118 -12
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({ _TASK_MODE_VISIBLE_RUNTIME_PROGRESS_TYPES: frozenset[str] = frozenset({
"thinking_delta", "thinking_delta",
"tool_started", "tool_started",
@@ -618,22 +626,39 @@ class WSHandler:
def _progress_callback_for_engine(self, engine: Any) -> Any: def _progress_callback_for_engine(self, engine: Any) -> Any:
async def _progress(text: str, **kw: Any) -> None: async def _progress(text: str, **kw: Any) -> None:
# 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( await self.on_progress(
text, text,
_runtime_engine=engine, _runtime_engine=engine,
_project_id=self._normalize_project_id(getattr(engine, "project_id", None)), _project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
**kw, **kw,
) )
except asyncio.CancelledError:
raise
except Exception:
logger.opt(exception=True).warning(
"UI progress handling failed; agent execution continues"
)
return _progress return _progress
def _runtime_event_callback_for_engine(self, engine: Any) -> Any: def _runtime_event_callback_for_engine(self, engine: Any) -> Any:
async def _runtime_event(event: Any) -> None: async def _runtime_event(event: Any) -> None:
try:
await self.on_opc_event( await self.on_opc_event(
event, event,
runtime_engine=engine, runtime_engine=engine,
project_id=self._normalize_project_id(getattr(engine, "project_id", None)), 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_handler_id", id(self))
setattr(_runtime_event, "_opc_ui_project_id", self._normalize_project_id(getattr(engine, "project_id", None))) 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: def _kanban_callback_for_engine(self, engine: Any) -> Any:
async def _kanban_changed() -> None: async def _kanban_changed() -> None:
try:
await self.on_kanban_changed(engine=engine) 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 return _kanban_changed
@@ -2552,9 +2584,12 @@ class WSHandler:
if not runtime_type: if not runtime_type:
return None return None
is_task_mode = WSHandler._runtime_payload_is_task_mode(payload) is_task_mode = WSHandler._runtime_payload_is_task_mode(payload)
if is_task_mode and runtime_type in _TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES: if is_task_mode:
if runtime_type in _TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES:
return None return None
if is_task_mode and runtime_type not in _TASK_MODE_VISIBLE_RUNTIME_PROGRESS_TYPES: 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 return None
summary = runtime_type.replace("_", " ").title() summary = runtime_type.replace("_", " ").title()
@@ -2570,8 +2605,14 @@ class WSHandler:
return None return None
elif runtime_type == "thinking_delta": elif runtime_type == "thinking_delta":
entry_type = "thinking" entry_type = "thinking"
detail = str(payload.get("text", "") or "").strip() # Keep the raw fragment: streaming deltas are token-sized, so
summary = "Thinking" # 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": elif runtime_type == "member_claimed_work_item":
entry_type = "work_item_started" entry_type = "work_item_started"
priority = str(payload.get("message_priority", "") or "").strip().lower() priority = str(payload.get("message_priority", "") or "").strip().lower()
@@ -2595,10 +2636,8 @@ class WSHandler:
summary = str(payload.get("tool_name", "") or "tool") summary = str(payload.get("tool_name", "") or "tool")
detail = str(payload.get("text", "") or payload.get("message", "") or "").strip() detail = str(payload.get("text", "") or payload.get("message", "") or "").strip()
elif runtime_type == "tool_completed": 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") 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() detail = str(payload.get("result_summary", "") or payload.get("result_preview", "") or "").strip()
elif runtime_type == "status_snapshot": elif runtime_type == "status_snapshot":
entry_type = "status_change" entry_type = "status_change"
@@ -2726,7 +2765,7 @@ class WSHandler:
"detail": detail[:4000] if detail else None, "detail": detail[:4000] if detail else None,
} }
tool_call_id = str(payload.get("tool_call_id", "") or "").strip() 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() turn_id = str(payload.get("turn_id", "") or "").strip()
prefix = "permission" if entry_type == "autonomy" else "tool" 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}") 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}) 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( async def _mark_human_escalation_checkpoint_status(
self, self,
escalation_id: str, escalation_id: str,
@@ -5503,6 +5576,34 @@ class WSHandler:
if stale_human_escalation: if stale_human_escalation:
if _looks_like_escalation_reply(content): if _looks_like_escalation_reply(content):
stale_checkpoint_id = explicit_escalation_id or explicit_checkpoint_id stale_checkpoint_id = explicit_escalation_id or explicit_checkpoint_id
# 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( await self._mark_human_escalation_checkpoint_status(
stale_checkpoint_id, stale_checkpoint_id,
status="stale", status="stale",
@@ -5510,14 +5611,19 @@ class WSHandler:
channel_id=channel_id, channel_id=channel_id,
reason="reply_to_inactive_escalation", 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( helper = await self.chat_store.insert_message(
channel_id=channel_id, channel_id=channel_id,
sender="assistant", sender="assistant",
sender_name="OPC", sender_name="OPC",
content=( content=helper_text,
"That approval request is no longer active. "
"The approval card has been marked inactive in the session history."
),
project_id=pid, project_id=pid,
metadata={"type": "system"}, metadata={"type": "system"},
) )
+76 -39
View File
@@ -87,22 +87,23 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
self.assertIsNone(entry) self.assertIsNone(entry)
def test_runtime_status_snapshot_maps_to_status_change_entry(self) -> None: 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( entry = WSHandler._runtime_event_to_progress_entry(
{ {
"type": "status_snapshot", "type": runtime_type,
"current_tool": "shell_exec", "current_tool": "shell_exec",
"context_remaining_pct": 64,
"turn_cost_usd": 0.0123, "turn_cost_usd": 0.0123,
}, },
) )
self.assertIsNone(entry, runtime_type)
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_runtime_member_claimed_work_item_maps_to_started_entry(self) -> None: def test_runtime_member_claimed_work_item_maps_to_started_entry(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry( entry = WSHandler._runtime_event_to_progress_entry(
@@ -124,10 +125,10 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
self.assertNotIn("legacy_title", entry) self.assertNotIn("legacy_title", entry)
self.assertTrue(entry["is_company_runtime"]) 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( entry = WSHandler._runtime_event_to_progress_entry(
{ {
"type": "context_usage", "type": "context_warning",
"context_tokens": 3200, "context_tokens": 3200,
"context_window": 8000, "context_window": 8000,
"context_remaining_pct": 70, "context_remaining_pct": 70,
@@ -137,23 +138,77 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
self.assertIsNotNone(entry) self.assertIsNotNone(entry)
assert entry is not None assert entry is not None
self.assertEqual(entry["type"], "status_change") 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") 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( entry = WSHandler._runtime_event_to_progress_entry(
{ {
"type": "cost_update", "type": "tool_completed",
"turn_cost_usd": 0.0123, "turn_id": "rt-1:2",
"session_cost_usd": 0.0456, "tool_call_id": "call-1",
"tool_name": "web_search",
"result_summary": "3 results",
"work_item_projection_id": "engineering_execution",
}, },
) )
self.assertIsNotNone(entry) self.assertIsNotNone(entry)
assert entry is not None assert entry is not None
self.assertEqual(entry["type"], "status_change") self.assertEqual(entry["type"], "tool_call")
self.assertEqual(entry["summary"], "Cost update") self.assertEqual(entry["summary"], "web_search")
self.assertIn("turn=$0.0123", entry["detail"]) 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: def test_task_mode_low_value_runtime_events_are_hidden(self) -> None:
for runtime_type in ( for runtime_type in (
@@ -195,7 +250,7 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
self.assertIsNotNone(entry) self.assertIsNotNone(entry)
assert entry is not None assert entry is not None
self.assertEqual(entry["type"], "thinking") self.assertEqual(entry["type"], "thinking")
self.assertEqual(entry["summary"], "Thinking") self.assertEqual(entry["summary"], "我先检查。")
self.assertEqual(entry["detail"], "我先检查。") self.assertEqual(entry["detail"], "我先检查。")
self.assertEqual(entry["turn_id"], "rt-1:1") self.assertEqual(entry["turn_id"], "rt-1:1")
self.assertEqual(entry["item_id"], "rt-1:1:thinking") 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["summary"], "Needs input")
self.assertEqual(entry["detail"], "task_user_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: def test_runtime_worker_notification_maps_error_to_work_item_failed_entry(self) -> None:
entry = WSHandler._runtime_event_to_progress_entry( entry = WSHandler._runtime_event_to_progress_entry(
{ {