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
@@ -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 }> = {
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' },
autonomy: { icon: <IconShield />, color: 'var(--yellow)', label: 'Autonomy' },
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 (
<div className="ptl-tool-card">
<button
@@ -114,3 +114,38 @@ permissionLog = appendProgressEntry(permissionLog, {
assert.equal(permissionLog.length, 1)
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 (right.timestamp - left.timestamp > STREAM_MERGE_WINDOW_MS) 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
return false
}
@@ -83,14 +83,15 @@ function isDuplicateProgress(left: ProgressEntry, right: ProgressEntry): boolean
}
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",
// 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')
return {
timestamp: right.timestamp,
type: 'thinking',
type: left.type,
summary: summarizeThinking(detail, right.summary || left.summary),
detail: detail || undefined,
turnId: right.turnId ?? left.turnId,
@@ -136,8 +137,12 @@ export function appendProgressEntry(
const actualIndex = targetIndex >= 0 ? log.length - 1 - targetIndex : log.length - 1
const last = log[actualIndex]
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 (
normalizedKey
&& targetIndex >= 0
&& typeof last.seq === 'number'
&& typeof normalized.seq === 'number'
&& normalized.seq <= last.seq
@@ -139,13 +139,13 @@ export interface KanbanTask {
createdAt: number
updatedAt: number
// Agent runtime info (populated from agent_runtime_update WS events)
agentStatus?: AgentAnimStatus
currentTool?: string
displayTool?: string
iterationCount?: number
toolElapsedMs?: number
lastToolSummary?: string
// Agent runtime info (populated from agent_runtime_update WS events)
agentStatus?: AgentAnimStatus
currentTool?: string
displayTool?: string
iterationCount?: number
toolElapsedMs?: number
lastToolSummary?: string
contextTokens?: number
contextWindow?: number
contextRemainingPct?: number
@@ -163,11 +163,11 @@ export interface KanbanTask {
latestNotification?: WorkerNotification
// Work-item runtime identity (populated in Company Mode).
workItemProjectionId?: string
workItemTurnType?: string
companyProfile?: string
orgId?: string
workItemRoleId?: string
workItemProjectionId?: string
workItemTurnType?: string
companyProfile?: string
orgId?: string
workItemRoleId?: string
workItemRoleName?: string
workItemGate?: WorkItemGate
runtimeSessionId?: string
@@ -214,25 +214,25 @@ export interface KanbanTask {
// ── Progress Entry (per-task activity log) ──────────────────────────────────
export type ProgressEntryType =
| 'thinking' | 'tool_call' | 'autonomy' | 'handoff' | 'gate_result' | 'status_change'
| 'work_item_started' | 'gate_approved' | 'gate_rejected'
| 'awaiting_manager_review' | 'awaiting_human' | 'awaiting_review' | 'awaiting_peer'
| 'work_item_failed' | 'deadlock' | 'needs_input' | 'verification'
export interface ProgressEntry {
timestamp: number
type: ProgressEntryType
summary: string // e.g. "file_read" or "Gate: approved"
detail?: string // e.g. tool arguments preview
turnId?: string
itemId?: string
streamId?: string
toolCallId?: string
permissionGroupKey?: string
seq?: number
executionMode?: string
}
export type ProgressEntryType =
| 'thinking' | 'assistant' | 'tool_call' | 'autonomy' | 'handoff' | 'gate_result' | 'status_change'
| 'work_item_started' | 'gate_approved' | 'gate_rejected'
| 'awaiting_manager_review' | 'awaiting_human' | 'awaiting_review' | 'awaiting_peer'
| 'work_item_failed' | 'deadlock' | 'needs_input' | 'verification'
export interface ProgressEntry {
timestamp: number
type: ProgressEntryType
summary: string // e.g. "file_read" or "Gate: approved"
detail?: string // e.g. tool arguments preview
turnId?: string
itemId?: string
streamId?: string
toolCallId?: string
permissionGroupKey?: string
seq?: number
executionMode?: string
}
// ── Work-Item Progress Entry (primary session timeline) ─────────────────────
@@ -263,9 +263,9 @@ export interface WorkerNotification {
export type SessionMode = 'primary' | 'child'
export type TaskPreferredAgent = 'native' | 'codex' | 'claude_code' | 'cursor' | 'opencode'
export interface Session {
projectId: string
taskId: string
export interface Session {
projectId: string
taskId: string
/** Runtime Task id. Mirrors taskId for session rows, but gives UI code a semantic name. */
runtimeTaskId?: string
/** User-facing alias for the runtime Task backing this execution turn. */
@@ -274,34 +274,34 @@ export interface Session {
sessionId?: string
parentSessionId?: string
mode?: SessionMode
execMode?: string
companyProfile?: string
orgId?: string
preferredAgent?: TaskPreferredAgent
execMode?: string
companyProfile?: string
orgId?: string
preferredAgent?: TaskPreferredAgent
title: string
status: string
columnId: string
assigneeIds: string[]
priority: string | null
tags: string[]
agentStatus?: string
currentTool?: string
displayTool?: string
progressLog: ProgressEntry[]
createdAt: number
updatedAt: number
messageCount: number
latestPreview?: string
latestSender?: string
latestMessageId?: string
indexLoaded?: boolean
detailLoaded?: boolean
fullLoaded?: boolean
hasMore?: boolean
detailLoading?: boolean
detailError?: string
viewGeneration?: number
// Company Mode work-item metadata.
tags: string[]
agentStatus?: string
currentTool?: string
displayTool?: string
progressLog: ProgressEntry[]
createdAt: number
updatedAt: number
messageCount: number
latestPreview?: string
latestSender?: string
latestMessageId?: string
indexLoaded?: boolean
detailLoaded?: boolean
fullLoaded?: boolean
hasMore?: boolean
detailLoading?: boolean
detailError?: string
viewGeneration?: number
// Company Mode work-item metadata.
workItemProjectionId?: string
workItemTurnType?: string
workItemRoleId?: string
@@ -327,17 +327,17 @@ export interface Session {
// Work-item runtime state (Company Mode primary sessions)
isCompanyRuntime?: boolean
workItemLog?: WorkItemProgressEntry[]
/**
* Per-role DelegationWorkItem rollup grouped by current owner. Present on
* primary company-mode sessions only.
* Builder: ``snapshot_builder._build_role_work_items_for_session``.
*/
roleWorkItems?: Record<string, RoleWorkItemSummary>
/**
* Display-only DelegationWorkItem rollup grouped by original executor role.
* Execution Progress prefers this so worker chips stay visible during review.
*/
executorRoleWorkItems?: Record<string, RoleWorkItemSummary>
/**
* Per-role DelegationWorkItem rollup grouped by current owner. Present on
* primary company-mode sessions only.
* Builder: ``snapshot_builder._build_role_work_items_for_session``.
*/
roleWorkItems?: Record<string, RoleWorkItemSummary>
/**
* Display-only DelegationWorkItem rollup grouped by original executor role.
* Execution Progress prefers this so worker chips stay visible during review.
*/
executorRoleWorkItems?: Record<string, RoleWorkItemSummary>
// Native Runtime V2 state
runtimeSessionId?: string
resumeCursor?: number
@@ -348,7 +348,7 @@ export interface Session {
draftAssistantText?: string
draftUpdatedAt?: number
draftIteration?: number
draftTurnId?: string
draftTurnId?: string
toolElapsedMs?: number
lastToolSummary?: string
contextTokens?: number
@@ -383,22 +383,22 @@ export type ExecutionTurn = Session
// The mapping is locked by ``test_snapshot_builder_company_kanban.RoleWorkItemsRollupTests``
// and ``frontend_src/lib/roleWorkItems.test.ts``.
export type RoleAggregatedStatus =
| 'active' // tracker is reflecting/tool_active OR a phase is in_progress → orange
| 'waiting' // queued / awaiting review / awaiting human → yellow
| 'pending' // no work items yet → gray
| 'done' // all approved → green
| 'failed' // any failed/cancelled (and others terminal) → red
export interface RoleWorkItemActivitySection {
kind: string
title: string
roleName?: string
runtimeTaskId?: string
entries: ProgressEntry[]
}
export interface RoleWorkItemRow {
export type RoleAggregatedStatus =
| 'active' // tracker is reflecting/tool_active OR a phase is in_progress → orange
| 'waiting' // queued / awaiting review / awaiting human → yellow
| 'pending' // no work items yet → gray
| 'done' // all approved → green
| 'failed' // any failed/cancelled (and others terminal) → red
export interface RoleWorkItemActivitySection {
kind: string
title: string
roleName?: string
runtimeTaskId?: string
entries: ProgressEntry[]
}
export interface RoleWorkItemRow {
workItemId: string
workItemProjectionId?: string
/** 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
* work items, in which case the row is not yet click-through-able. */
executionTurnId?: string
/** Activity log already filtered by ``workItemProjectionId`` server-side. */
progressLog: ProgressEntry[]
/** Detailed runtime activity grouped by visible work item + hidden
* report/review helper work items that belong to this row. */
activitySections?: RoleWorkItemActivitySection[]
}
/** Activity log already filtered by ``workItemProjectionId`` server-side. */
progressLog: ProgressEntry[]
/** Detailed runtime activity grouped by visible work item + hidden
* report/review helper work items that belong to this row. */
activitySections?: RoleWorkItemActivitySection[]
}
export interface RoleWorkItemSummary {
/** Stable key for React lists; equals the role_id within a single run. */
roleKey: string
roleId: string
roleName: string
roleSessionId?: string
teamInstanceId?: string
roleId: string
roleName: string
roleSessionId?: string
teamInstanceId?: string
/** Live agent runtime state from the per-role tracker. */
runtimeStatus: AgentAnimStatus
/** Single-source aggregated status; UI maps this directly to colour. */