fix(office-ui): stop progress-row flicker and surface native role replies end to end

Two user-visible defects in company mode, root-caused via project 6666/8888
DB forensics:

Progress-row flicker (thinking preview appearing/disappearing): progress
entries were broadcast to clients before reaching the persistence buffer,
so a tool_call-triggered session_detail snapshot rebuilt from the DB erased
freshly streamed entries from the live log. Buffer now fills before the
broadcast and session_detail flushes it before reading.

Native role transcripts incomplete (thinking only at start, no narration,
no final summary — external agents unaffected):
- thinking deltas shared one stream id per conversation turn while seq
  reset per iteration, collapsing all iterations into one entry and
  silently dropping live thinking from iteration 2 on; now keyed per
  iteration like assistant deltas
- assistant_delta events were mapped to None; company mode now surfaces
  them as streaming 'assistant' progress entries (rendered as Reply cards,
  merged like thinking, excluded from inline chat rows)
- thinking was persisted one row per token, flooding the 1000-entry cap
  and evicting interleaved tool history; append_progress now folds
  streaming deltas per (type, turn, stream) with seq dedup
- the terminal company turn was hidden at summary detail and, worse, its
  id-keyed backfill merge kept the first-inserted intermediate content, so
  the final reply never reached any channel; terminal turns are now
  flagged company_final_turn, visible at summary detail, and carry their
  own ui_message_id so they insert as fresh rows
- appendProgressEntry applied its seq guard against unrelated entries when
  the stream key was absent from the log, killing the first delta of any
  fresh stream; the guard now only applies within the same stream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LZH-YS1998
2026-07-09 20:46:14 +08:00
parent 35fa717cf6
commit a0402522da
13 changed files with 461 additions and 274 deletions
+21 -3
View File
@@ -435,8 +435,13 @@ class NativeRuntimeV2:
"canonical_turn_id": conversation_turn_id, "canonical_turn_id": conversation_turn_id,
"conversation_turn_id": conversation_turn_id, "conversation_turn_id": conversation_turn_id,
"execution_turn_id": execution_turn_id, "execution_turn_id": execution_turn_id,
"item_id": f"{turn_id}:thinking", # Keyed per iteration (like assistant_delta):
"stream_id": f"{turn_id}:thinking", # thinking_delta_seq resets every iteration, so a
# turn-scoped stream id makes downstream seq guards
# drop iteration>=2 deltas and collapses all
# iterations into one entry (no tool interleaving).
"item_id": f"{execution_turn_id}:thinking",
"stream_id": f"{execution_turn_id}:thinking",
"seq": thinking_delta_seq, "seq": thinking_delta_seq,
"text": thinking_text, "text": thinking_text,
}, },
@@ -2797,6 +2802,11 @@ class NativeRuntimeV2:
if is_company_mode: if is_company_mode:
metadata["execution_mode"] = "company_mode" metadata["execution_mode"] = "company_mode"
metadata["company_runtime_raw_turn"] = True metadata["company_runtime_raw_turn"] = True
if not tool_calls:
# Terminal iteration of the company turn — this is the role's
# final reply. Marked so the UI can show it at summary detail
# even though the kind is otherwise full-detail-only.
metadata["company_final_turn"] = True
if task.assigned_to: if task.assigned_to:
metadata["role_id"] = str(task.assigned_to) metadata["role_id"] = str(task.assigned_to)
else: else:
@@ -2808,7 +2818,15 @@ class NativeRuntimeV2:
if message_turn_id and message_turn_id != canonical_turn_id: if message_turn_id and message_turn_id != canonical_turn_id:
metadata["execution_turn_id"] = message_turn_id metadata["execution_turn_id"] = message_turn_id
if is_company_mode: if is_company_mode:
metadata["ui_message_id"] = f"runtime-v2-company-assistant:{canonical_turn_id}" # Tool-calling iterations of a company conversation turn share
# one UI row; the terminal reply gets its own id. The id-keyed
# backfill merge keeps the first-inserted content for same-kind
# candidates, so reusing the shared id freezes the row at
# iteration 1 and swallows the final reply entirely.
if metadata.get("company_final_turn"):
metadata["ui_message_id"] = f"runtime-v2-company-assistant-final:{canonical_turn_id}"
else:
metadata["ui_message_id"] = f"runtime-v2-company-assistant:{canonical_turn_id}"
elif is_intermediate_tool_turn: elif is_intermediate_tool_turn:
metadata["ui_message_id"] = f"runtime-v2-intermediate-assistant:{message_turn_id or canonical_turn_id}" metadata["ui_message_id"] = f"runtime-v2-intermediate-assistant:{message_turn_id or canonical_turn_id}"
else: else:
+53 -1
View File
@@ -1599,6 +1599,58 @@ class ChatStore:
# concern, swap this in-row JSON blob for a proper rolling table. # concern, swap this in-row JSON blob for a proper rolling table.
_PROGRESS_MAX_ENTRIES = 1000 _PROGRESS_MAX_ENTRIES = 1000
# Streaming text types arrive as one entry per token-sized delta. Without
# folding, thinking floods the entry cap (forensics: 929/1000 entries were
# single-token thinking rows) and evicts the interleaved tool history.
_PROGRESS_STREAM_MERGE_TYPES = frozenset({"thinking", "assistant"})
@staticmethod
def _progress_stream_key(entry: dict[str, Any]) -> tuple[str, str, str] | None:
entry_type = str(entry.get("type", "") or "")
if entry_type not in ChatStore._PROGRESS_STREAM_MERGE_TYPES:
return None
item_id = str(entry.get("item_id") or entry.get("stream_id") or "").strip()
if not item_id:
return None
return (entry_type, str(entry.get("turn_id", "") or ""), item_id)
@classmethod
def _fold_progress_entries(
cls,
existing: list[dict[str, Any]],
new_entries: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Fold streaming deltas into their stream's entry (mirrors the
frontend ``appendProgressEntry`` merge so persisted state equals what
the live client built)."""
merged = list(existing)
index_by_key: dict[tuple[str, str, str], int] = {}
for i, entry in enumerate(merged):
key = cls._progress_stream_key(entry)
if key is not None:
index_by_key[key] = i
for entry in new_entries:
key = cls._progress_stream_key(entry)
if key is None or key not in index_by_key:
if key is not None:
index_by_key[key] = len(merged)
merged.append(entry)
continue
target = merged[index_by_key[key]]
last_seq = target.get("seq")
new_seq = entry.get("seq")
if isinstance(last_seq, (int, float)) and isinstance(new_seq, (int, float)) and new_seq <= last_seq:
continue
# Deltas are disjoint token fragments — concatenate raw, no strip.
detail = f"{target.get('detail') or ''}{entry.get('detail') or ''}"
preview = " ".join(detail.split())
folded = dict(target)
folded.update(entry)
folded["detail"] = detail
folded["summary"] = preview[:120].rstrip() + ("..." if len(preview) > 120 else "")
merged[index_by_key[key]] = folded
return merged
async def append_progress( async def append_progress(
self, self,
task_id: str, task_id: str,
@@ -1611,7 +1663,7 @@ class ChatStore:
the first call creates the row and subsequent calls update it. the first call creates the row and subsequent calls update it.
""" """
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 = self._fold_progress_entries(existing, new_entries)[-self._PROGRESS_MAX_ENTRIES:]
async def _write() -> None: async def _write() -> None:
await self._db.execute( await self._db.execute(
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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-cOg8E2rt.js"></script> <script type="module" crossorigin src="./assets/index-D7a_jL_7.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>
@@ -18,6 +18,7 @@ export const INLINE_PROGRESS_ENTRY_TYPES = new Set<ProgressEntryType>(['thinking
const ENTRY_CONFIG: Record<ProgressEntryType, { icon: React.ReactNode; color: string; label: string }> = { const ENTRY_CONFIG: Record<ProgressEntryType, { icon: React.ReactNode; color: string; label: string }> = {
thinking: { icon: <IconBrain />, color: 'var(--accent)', label: 'Thinking' }, thinking: { icon: <IconBrain />, color: 'var(--accent)', label: 'Thinking' },
assistant: { icon: <IconSparkle />, color: 'var(--accent)', label: 'Reply' },
tool_call: { icon: <IconTool />, color: 'var(--green)', label: 'Tool' }, tool_call: { icon: <IconTool />, color: 'var(--green)', label: 'Tool' },
autonomy: { icon: <IconShield />, color: 'var(--yellow)', label: 'Autonomy' }, autonomy: { icon: <IconShield />, color: 'var(--yellow)', label: 'Autonomy' },
handoff: { icon: <IconArrowRight />, color: 'var(--accent)', label: 'Handoff' }, handoff: { icon: <IconArrowRight />, color: 'var(--accent)', label: 'Handoff' },
@@ -257,7 +258,7 @@ export const AgentProgressEntryCard = React.memo(function AgentProgressEntryCard
) )
} }
if (entry.type === 'thinking') { if (entry.type === 'thinking' || entry.type === 'assistant') {
return ( return (
<div className="ptl-tool-card"> <div className="ptl-tool-card">
<button <button
@@ -114,3 +114,38 @@ permissionLog = appendProgressEntry(permissionLog, {
assert.equal(permissionLog.length, 1) assert.equal(permissionLog.length, 1)
assert.equal(permissionLog[0]?.summary, 'shell_exec: allow') assert.equal(permissionLog[0]?.summary, 'shell_exec: allow')
// Native company assistant replies stream like thinking: same-stream deltas
// accumulate into one entry instead of replacing each other, and separate
// iterations (distinct item_id) stay separate entries.
let assistantLog = appendProgressEntry([], {
timestamp: 30,
type: 'assistant',
summary: '文件已成功写入',
detail: '文件已成功写入',
turnId: 'rt-1:4',
itemId: 'rt-1:4:iter:2:assistant',
seq: 1,
})
assistantLog = appendProgressEntry(assistantLog, {
timestamp: 31,
type: 'assistant',
summary: '(278 行)。',
detail: '(278 行)。',
turnId: 'rt-1:4',
itemId: 'rt-1:4:iter:2:assistant',
seq: 2,
})
assistantLog = appendProgressEntry(assistantLog, {
timestamp: 32,
type: 'assistant',
summary: '采集完成报告',
detail: '采集完成报告',
turnId: 'rt-1:4',
itemId: 'rt-1:4:iter:3:assistant',
seq: 1,
})
assert.equal(assistantLog.length, 2)
assert.equal(assistantLog[0]?.detail, '文件已成功写入(278 行)。')
assert.equal(assistantLog[0]?.summary, '文件已成功写入(278 行)。')
assert.equal(assistantLog[1]?.detail, '采集完成报告')
@@ -68,7 +68,7 @@ function canMergeProgress(left: ProgressEntry, right: ProgressEntry): boolean {
if (leftKey && rightKey) return leftKey === rightKey if (leftKey && rightKey) return leftKey === rightKey
if (right.timestamp - left.timestamp > STREAM_MERGE_WINDOW_MS) return false if (right.timestamp - left.timestamp > STREAM_MERGE_WINDOW_MS) return false
if (left.type !== right.type) return false if (left.type !== right.type) return false
if (left.type === 'thinking') return true if (left.type === 'thinking' || left.type === 'assistant') return true
if (left.type === 'tool_call') return left.summary === right.summary if (left.type === 'tool_call') return left.summary === right.summary
return false return false
} }
@@ -83,14 +83,15 @@ 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' || left.type === 'assistant') {
// Merge detail text only: summary is a label/preview ("Thinking", // Merge detail text only: summary is a label/preview ("Thinking",
// truncated excerpt), so falling back to it would splice label text // truncated excerpt), so falling back to it would splice label text
// into the middle of the merged thinking stream. // into the middle of the merged stream. Assistant reply streams merge
// the same way as thinking streams.
const detail = mergeText(left.detail ?? '', right.detail ?? '', 'thinking') const detail = mergeText(left.detail ?? '', right.detail ?? '', 'thinking')
return { return {
timestamp: right.timestamp, timestamp: right.timestamp,
type: 'thinking', type: left.type,
summary: summarizeThinking(detail, right.summary || left.summary), summary: summarizeThinking(detail, right.summary || left.summary),
detail: detail || undefined, detail: detail || undefined,
turnId: right.turnId ?? left.turnId, turnId: right.turnId ?? left.turnId,
@@ -136,8 +137,12 @@ export function appendProgressEntry(
const actualIndex = targetIndex >= 0 ? log.length - 1 - targetIndex : log.length - 1 const actualIndex = targetIndex >= 0 ? log.length - 1 - targetIndex : log.length - 1
const last = log[actualIndex] const last = log[actualIndex]
if (!last) return [normalized] if (!last) return [normalized]
// The seq guard only applies within the SAME stream: when the key was not
// found, `last` is an unrelated entry and a fresh stream legitimately
// restarts at seq 1 (per-iteration thinking/assistant streams).
if ( if (
normalizedKey normalizedKey
&& targetIndex >= 0
&& typeof last.seq === 'number' && typeof last.seq === 'number'
&& typeof normalized.seq === 'number' && typeof normalized.seq === 'number'
&& normalized.seq <= last.seq && normalized.seq <= last.seq
@@ -139,13 +139,13 @@ export interface KanbanTask {
createdAt: number createdAt: number
updatedAt: number updatedAt: number
// Agent runtime info (populated from agent_runtime_update WS events) // Agent runtime info (populated from agent_runtime_update WS events)
agentStatus?: AgentAnimStatus agentStatus?: AgentAnimStatus
currentTool?: string currentTool?: string
displayTool?: string displayTool?: string
iterationCount?: number iterationCount?: number
toolElapsedMs?: number toolElapsedMs?: number
lastToolSummary?: string lastToolSummary?: string
contextTokens?: number contextTokens?: number
contextWindow?: number contextWindow?: number
contextRemainingPct?: number contextRemainingPct?: number
@@ -163,11 +163,11 @@ export interface KanbanTask {
latestNotification?: WorkerNotification latestNotification?: WorkerNotification
// Work-item runtime identity (populated in Company Mode). // Work-item runtime identity (populated in Company Mode).
workItemProjectionId?: string workItemProjectionId?: string
workItemTurnType?: string workItemTurnType?: string
companyProfile?: string companyProfile?: string
orgId?: string orgId?: string
workItemRoleId?: string workItemRoleId?: string
workItemRoleName?: string workItemRoleName?: string
workItemGate?: WorkItemGate workItemGate?: WorkItemGate
runtimeSessionId?: string runtimeSessionId?: string
@@ -214,25 +214,25 @@ export interface KanbanTask {
// ── Progress Entry (per-task activity log) ────────────────────────────────── // ── Progress Entry (per-task activity log) ──────────────────────────────────
export type ProgressEntryType = export type ProgressEntryType =
| 'thinking' | 'tool_call' | 'autonomy' | 'handoff' | 'gate_result' | 'status_change' | 'thinking' | 'assistant' | 'tool_call' | 'autonomy' | 'handoff' | 'gate_result' | 'status_change'
| 'work_item_started' | 'gate_approved' | 'gate_rejected' | 'work_item_started' | 'gate_approved' | 'gate_rejected'
| 'awaiting_manager_review' | 'awaiting_human' | 'awaiting_review' | 'awaiting_peer' | 'awaiting_manager_review' | 'awaiting_human' | 'awaiting_review' | 'awaiting_peer'
| 'work_item_failed' | 'deadlock' | 'needs_input' | 'verification' | 'work_item_failed' | 'deadlock' | 'needs_input' | 'verification'
export interface ProgressEntry { export interface ProgressEntry {
timestamp: number timestamp: number
type: ProgressEntryType type: ProgressEntryType
summary: string // e.g. "file_read" or "Gate: approved" summary: string // e.g. "file_read" or "Gate: approved"
detail?: string // e.g. tool arguments preview detail?: string // e.g. tool arguments preview
turnId?: string turnId?: string
itemId?: string itemId?: string
streamId?: string streamId?: string
toolCallId?: string toolCallId?: string
permissionGroupKey?: string permissionGroupKey?: string
seq?: number seq?: number
executionMode?: string executionMode?: string
} }
// ── Work-Item Progress Entry (primary session timeline) ───────────────────── // ── Work-Item Progress Entry (primary session timeline) ─────────────────────
@@ -263,9 +263,9 @@ export interface WorkerNotification {
export type SessionMode = 'primary' | 'child' export type SessionMode = 'primary' | 'child'
export type TaskPreferredAgent = 'native' | 'codex' | 'claude_code' | 'cursor' | 'opencode' export type TaskPreferredAgent = 'native' | 'codex' | 'claude_code' | 'cursor' | 'opencode'
export interface Session { export interface Session {
projectId: string projectId: string
taskId: string taskId: string
/** Runtime Task id. Mirrors taskId for session rows, but gives UI code a semantic name. */ /** Runtime Task id. Mirrors taskId for session rows, but gives UI code a semantic name. */
runtimeTaskId?: string runtimeTaskId?: string
/** User-facing alias for the runtime Task backing this execution turn. */ /** User-facing alias for the runtime Task backing this execution turn. */
@@ -274,34 +274,34 @@ export interface Session {
sessionId?: string sessionId?: string
parentSessionId?: string parentSessionId?: string
mode?: SessionMode mode?: SessionMode
execMode?: string execMode?: string
companyProfile?: string companyProfile?: string
orgId?: string orgId?: string
preferredAgent?: TaskPreferredAgent preferredAgent?: TaskPreferredAgent
title: string title: string
status: string status: string
columnId: string columnId: string
assigneeIds: string[] assigneeIds: string[]
priority: string | null priority: string | null
tags: string[] tags: string[]
agentStatus?: string agentStatus?: string
currentTool?: string currentTool?: string
displayTool?: string displayTool?: string
progressLog: ProgressEntry[] progressLog: ProgressEntry[]
createdAt: number createdAt: number
updatedAt: number updatedAt: number
messageCount: number messageCount: number
latestPreview?: string latestPreview?: string
latestSender?: string latestSender?: string
latestMessageId?: string latestMessageId?: string
indexLoaded?: boolean indexLoaded?: boolean
detailLoaded?: boolean detailLoaded?: boolean
fullLoaded?: boolean fullLoaded?: boolean
hasMore?: boolean hasMore?: boolean
detailLoading?: boolean detailLoading?: boolean
detailError?: string detailError?: string
viewGeneration?: number viewGeneration?: number
// Company Mode work-item metadata. // Company Mode work-item metadata.
workItemProjectionId?: string workItemProjectionId?: string
workItemTurnType?: string workItemTurnType?: string
workItemRoleId?: string workItemRoleId?: string
@@ -327,17 +327,17 @@ export interface Session {
// Work-item runtime state (Company Mode primary sessions) // Work-item runtime state (Company Mode primary sessions)
isCompanyRuntime?: boolean isCompanyRuntime?: boolean
workItemLog?: WorkItemProgressEntry[] workItemLog?: WorkItemProgressEntry[]
/** /**
* Per-role DelegationWorkItem rollup grouped by current owner. Present on * Per-role DelegationWorkItem rollup grouped by current owner. Present on
* primary company-mode sessions only. * primary company-mode sessions only.
* Builder: ``snapshot_builder._build_role_work_items_for_session``. * Builder: ``snapshot_builder._build_role_work_items_for_session``.
*/ */
roleWorkItems?: Record<string, RoleWorkItemSummary> roleWorkItems?: Record<string, RoleWorkItemSummary>
/** /**
* Display-only DelegationWorkItem rollup grouped by original executor role. * Display-only DelegationWorkItem rollup grouped by original executor role.
* Execution Progress prefers this so worker chips stay visible during review. * Execution Progress prefers this so worker chips stay visible during review.
*/ */
executorRoleWorkItems?: Record<string, RoleWorkItemSummary> executorRoleWorkItems?: Record<string, RoleWorkItemSummary>
// Native Runtime V2 state // Native Runtime V2 state
runtimeSessionId?: string runtimeSessionId?: string
resumeCursor?: number resumeCursor?: number
@@ -348,7 +348,7 @@ export interface Session {
draftAssistantText?: string draftAssistantText?: string
draftUpdatedAt?: number draftUpdatedAt?: number
draftIteration?: number draftIteration?: number
draftTurnId?: string draftTurnId?: string
toolElapsedMs?: number toolElapsedMs?: number
lastToolSummary?: string lastToolSummary?: string
contextTokens?: number contextTokens?: number
@@ -383,22 +383,22 @@ export type ExecutionTurn = Session
// The mapping is locked by ``test_snapshot_builder_company_kanban.RoleWorkItemsRollupTests`` // The mapping is locked by ``test_snapshot_builder_company_kanban.RoleWorkItemsRollupTests``
// and ``frontend_src/lib/roleWorkItems.test.ts``. // and ``frontend_src/lib/roleWorkItems.test.ts``.
export type RoleAggregatedStatus = export type RoleAggregatedStatus =
| 'active' // tracker is reflecting/tool_active OR a phase is in_progress → orange | 'active' // tracker is reflecting/tool_active OR a phase is in_progress → orange
| 'waiting' // queued / awaiting review / awaiting human → yellow | 'waiting' // queued / awaiting review / awaiting human → yellow
| 'pending' // no work items yet → gray | 'pending' // no work items yet → gray
| 'done' // all approved → green | 'done' // all approved → green
| 'failed' // any failed/cancelled (and others terminal) → red | 'failed' // any failed/cancelled (and others terminal) → red
export interface RoleWorkItemActivitySection { export interface RoleWorkItemActivitySection {
kind: string kind: string
title: string title: string
roleName?: string roleName?: string
runtimeTaskId?: string runtimeTaskId?: string
entries: ProgressEntry[] entries: ProgressEntry[]
} }
export interface RoleWorkItemRow { export interface RoleWorkItemRow {
workItemId: string workItemId: string
workItemProjectionId?: string workItemProjectionId?: string
/** 14-state phase value (matches backend ``Phase``). */ /** 14-state phase value (matches backend ``Phase``). */
@@ -421,20 +421,20 @@ export interface RoleWorkItemRow {
/** Linked runtime Task id, if any. ``undefined`` for queued/never-dispatched /** Linked runtime Task id, if any. ``undefined`` for queued/never-dispatched
* work items, in which case the row is not yet click-through-able. */ * work items, in which case the row is not yet click-through-able. */
executionTurnId?: string executionTurnId?: string
/** Activity log already filtered by ``workItemProjectionId`` server-side. */ /** Activity log already filtered by ``workItemProjectionId`` server-side. */
progressLog: ProgressEntry[] progressLog: ProgressEntry[]
/** Detailed runtime activity grouped by visible work item + hidden /** Detailed runtime activity grouped by visible work item + hidden
* report/review helper work items that belong to this row. */ * report/review helper work items that belong to this row. */
activitySections?: RoleWorkItemActivitySection[] activitySections?: RoleWorkItemActivitySection[]
} }
export interface RoleWorkItemSummary { export interface RoleWorkItemSummary {
/** Stable key for React lists; equals the role_id within a single run. */ /** Stable key for React lists; equals the role_id within a single run. */
roleKey: string roleKey: string
roleId: string roleId: string
roleName: string roleName: string
roleSessionId?: string roleSessionId?: string
teamInstanceId?: string teamInstanceId?: string
/** Live agent runtime state from the per-role tracker. */ /** Live agent runtime state from the per-role tracker. */
runtimeStatus: AgentAnimStatus runtimeStatus: AgentAnimStatus
/** Single-source aggregated status; UI maps this directly to colour. */ /** Single-source aggregated status; UI maps this directly to colour. */
+10 -1
View File
@@ -230,6 +230,11 @@ def _transcript_message_hidden_from_ui(
) -> bool: ) -> bool:
metadata = dict(getattr(message, "metadata", {}) or {}) metadata = dict(getattr(message, "metadata", {}) or {})
kind = str(metadata.get("kind", "") or "").strip() kind = str(metadata.get("kind", "") or "").strip()
if metadata.get("company_final_turn"):
# The role's final reply of a company turn is the user-visible result
# (intake/aggregate turns have no engine-recorded result surface, so
# hiding this would drop the reply from the chat entirely).
return False
return detail_level != "full" and kind in _FULL_DETAIL_ONLY_TRANSCRIPT_KINDS return detail_level != "full" and kind in _FULL_DETAIL_ONLY_TRANSCRIPT_KINDS
@@ -1122,7 +1127,11 @@ def _transcript_item_to_ui_message(
"role": role, "role": role,
"task_id": task_id, "task_id": task_id,
"transcript_kind": kind, "transcript_kind": kind,
"detail_visibility": _transcript_message_visibility(kind), "detail_visibility": (
"summary"
if message_metadata.get("company_final_turn")
else _transcript_message_visibility(kind)
),
**({"type": "system"} if kind == "runtime_v2_user_turn" else {}), **({"type": "system"} if kind == "runtime_v2_user_turn" else {}),
**({"verification_verdict": verification_footer} if verification_footer else {}), **({"verification_verdict": verification_footer} if verification_footer else {}),
**({"runtime_thinking": runtime_thinking} if runtime_thinking else {}), **({"runtime_thinking": runtime_thinking} if runtime_thinking else {}),
+32 -10
View File
@@ -1691,6 +1691,12 @@ class WSHandler:
return return
entry["timestamp"] = time.time() entry["timestamp"] = time.time()
_add_execution_turn_aliases(entry, raw_task_id) _add_execution_turn_aliases(entry, raw_task_id)
# Buffer BEFORE broadcasting: broadcast awaits can interleave a
# session_detail read, and any entry a client has already seen must be
# visible in bufferDB or the snapshot will erase it from the live log.
buf = self._progress_buffer.setdefault(task_id, [])
buf.append(entry)
self._progress_project_ids[task_id] = pid
await self.broadcast({ await self.broadcast({
"type": "session_progress", "type": "session_progress",
"payload": { "payload": {
@@ -1711,10 +1717,9 @@ class WSHandler:
"entry": entry, "entry": entry,
}, },
}) })
buf = self._progress_buffer.setdefault(task_id, []) # Re-read the buffer: a concurrent flush during the broadcast awaits
buf.append(entry) # may have popped it, leaving `buf` as a stale detached list.
self._progress_project_ids[task_id] = pid if len(self._progress_buffer.get(task_id, [])) >= self._PROGRESS_FLUSH_THRESHOLD:
if len(buf) >= self._PROGRESS_FLUSH_THRESHOLD:
await self._flush_progress(task_id, project_id=pid) await self._flush_progress(task_id, project_id=pid)
if runtime_type in {"turn_completed", "turn_failed", "checkpoint_saved"}: if runtime_type in {"turn_completed", "turn_failed", "checkpoint_saved"}:
await self._sync_task_transcript_messages(task_id, engine=runtime_engine) await self._sync_task_transcript_messages(task_id, engine=runtime_engine)
@@ -2213,6 +2218,11 @@ class WSHandler:
if not entry.get("work_item_projection_title"): if not entry.get("work_item_projection_title"):
entry["work_item_projection_title"] = _role_label or None entry["work_item_projection_title"] = _role_label or None
_add_execution_turn_aliases(entry, raw_task_id or task_id) _add_execution_turn_aliases(entry, raw_task_id or task_id)
# Buffer BEFORE broadcasting: broadcast awaits can interleave a
# session_detail read, and any entry a client has already seen
# must be visible in bufferDB or the snapshot will erase it.
self._progress_buffer.setdefault(task_id, []).append(entry)
self._progress_project_ids[task_id] = pid
await self.broadcast({"type": "session_progress", "payload": { await self.broadcast({"type": "session_progress", "payload": {
"project_id": pid, "project_id": pid,
"task_id": task_id, "task_id": task_id,
@@ -2229,11 +2239,9 @@ class WSHandler:
"entry": entry, "entry": entry,
}}) }})
# ── Accumulate for persistence (flushed at threshold / task end) ── # ── Persist at threshold (re-read: a concurrent flush during the
buf = self._progress_buffer.setdefault(task_id, []) # broadcast awaits may have popped the buffer) ──
buf.append(entry) if len(self._progress_buffer.get(task_id, [])) >= self._PROGRESS_FLUSH_THRESHOLD:
self._progress_project_ids[task_id] = pid
if len(buf) >= self._PROGRESS_FLUSH_THRESHOLD:
await self._flush_progress(task_id, project_id=pid) await self._flush_progress(task_id, project_id=pid)
is_work_item_event = text.startswith("[Company:") is_work_item_event = text.startswith("[Company:")
@@ -2651,7 +2659,16 @@ class WSHandler:
entry_type = "work_item_started" entry_type = "work_item_started"
summary = f"Turn {payload.get('iteration', '?')} started" summary = f"Turn {payload.get('iteration', '?')} started"
elif runtime_type == "assistant_delta": elif runtime_type == "assistant_delta":
return None # Company mode only (task mode is filtered out above and already
# streams assistant text as the draft reply): surface the role's
# narration and final reply in its progress transcript, matching
# what external agents get via [External:*:result] parsing.
entry_type = "assistant"
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_idle": elif runtime_type == "member_idle":
return None return None
elif runtime_type == "thinking_delta": elif runtime_type == "thinking_delta":
@@ -5613,6 +5630,11 @@ class WSHandler:
} }
if "progress" in include_set or "work_items" in include_set or detail_level == "full": if "progress" in include_set or "work_items" in include_set or detail_level == "full":
try: try:
# Flush the in-memory progress buffer first: entries are
# broadcast to clients before they reach the DB, so a snapshot
# built from the DB alone would erase freshly streamed entries
# from the client's live log (visible as flickering rows).
await self._flush_progress(task_id, project_id=project_id)
progress_log = await self.chat_store.get_progress(task_id, project_id=project_id) progress_log = await self.chat_store.get_progress(task_id, project_id=project_id)
except Exception: except Exception:
progress_log = [] progress_log = []
+23 -1
View File
@@ -409,9 +409,31 @@ class NativeRuntimeV2Tests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(metadata["execution_mode"], "company_mode") self.assertEqual(metadata["execution_mode"], "company_mode")
self.assertTrue(metadata["company_runtime_raw_turn"]) self.assertTrue(metadata["company_runtime_raw_turn"])
self.assertEqual(metadata["role_id"], "chao") self.assertEqual(metadata["role_id"], "chao")
self.assertEqual(metadata["ui_message_id"], "runtime-v2-company-assistant:ui-turn:company") # No tool calls ⇒ terminal iteration: flagged as the role's final
# reply and given its own UI row id so the id-keyed backfill merge
# cannot swallow it into the frozen shared-iteration row.
self.assertTrue(metadata["company_final_turn"])
self.assertEqual(metadata["ui_message_id"], "runtime-v2-company-assistant-final:ui-turn:company")
self.assertNotIn("visible_speaker", metadata) self.assertNotIn("visible_speaker", metadata)
await runtime._persist_assistant_turn(
task,
"中间轮叙述",
[{"id": "call-1", "function": "file_read", "arguments": {}}],
runtime_session_id="rt_company",
turn_id="ui-turn:company:iter:1",
conversation_turn_id="ui-turn:company",
iteration=1,
)
intermediate_metadata = memory.appended_messages[-1]["kwargs"]["metadata"]
self.assertEqual(intermediate_metadata["kind"], "runtime_v2_company_assistant")
self.assertNotIn("company_final_turn", intermediate_metadata)
self.assertEqual(
intermediate_metadata["ui_message_id"],
"runtime-v2-company-assistant:ui-turn:company",
)
async def test_task_mode_assistant_turn_with_company_defaults_stays_opc_task_reply(self) -> None: async def test_task_mode_assistant_turn_with_company_defaults_stays_opc_task_reply(self) -> None:
memory = _StubMemoryManager(_StubStore()) memory = _StubMemoryManager(_StubStore())
runtime = NativeRuntimeV2( runtime = NativeRuntimeV2(
+25 -2
View File
@@ -80,9 +80,32 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
self.assertIsNone(entry) self.assertIsNone(entry)
def test_runtime_assistant_delta_is_not_recorded_as_progress_entry(self) -> None: def test_company_assistant_delta_becomes_assistant_progress_entry(self) -> None:
# Company mode surfaces the role's narration/final reply in its
# progress transcript (parity with external agents' [External:*:result]).
entry = WSHandler._runtime_event_to_progress_entry( entry = WSHandler._runtime_event_to_progress_entry(
{"type": "assistant_delta", "text": "final answer token"}, {
"type": "assistant_delta",
"text": "final answer token",
"execution_mode": "company_mode",
},
)
self.assertIsNotNone(entry)
assert entry is not None
self.assertEqual(entry["type"], "assistant")
self.assertEqual(entry["summary"], "final answer token")
self.assertEqual(entry["detail"], "final answer token")
def test_task_mode_assistant_delta_is_not_recorded_as_progress_entry(self) -> None:
# Task mode already streams the reply as the draft; a progress entry
# would duplicate it.
entry = WSHandler._runtime_event_to_progress_entry(
{
"type": "assistant_delta",
"text": "final answer token",
"execution_mode": "task_mode",
},
) )
self.assertIsNone(entry) self.assertIsNone(entry)