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:
@@ -1599,6 +1599,58 @@ class ChatStore:
|
||||
# concern, swap this in-row JSON blob for a proper rolling table.
|
||||
_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(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -1611,7 +1663,7 @@ class ChatStore:
|
||||
the first call creates the row and subsequent calls update it.
|
||||
"""
|
||||
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:
|
||||
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" />
|
||||
<link rel="icon" href="data:," />
|
||||
<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="stylesheet" crossorigin href="./assets/index-CMqG6mW8.css">
|
||||
</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 }> = {
|
||||
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. */
|
||||
|
||||
@@ -230,6 +230,11 @@ def _transcript_message_hidden_from_ui(
|
||||
) -> bool:
|
||||
metadata = dict(getattr(message, "metadata", {}) or {})
|
||||
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
|
||||
|
||||
|
||||
@@ -1122,7 +1127,11 @@ def _transcript_item_to_ui_message(
|
||||
"role": role,
|
||||
"task_id": task_id,
|
||||
"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 {}),
|
||||
**({"verification_verdict": verification_footer} if verification_footer else {}),
|
||||
**({"runtime_thinking": runtime_thinking} if runtime_thinking else {}),
|
||||
|
||||
@@ -1691,6 +1691,12 @@ class WSHandler:
|
||||
return
|
||||
entry["timestamp"] = time.time()
|
||||
_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 buffer∪DB 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({
|
||||
"type": "session_progress",
|
||||
"payload": {
|
||||
@@ -1711,10 +1717,9 @@ class WSHandler:
|
||||
"entry": entry,
|
||||
},
|
||||
})
|
||||
buf = self._progress_buffer.setdefault(task_id, [])
|
||||
buf.append(entry)
|
||||
self._progress_project_ids[task_id] = pid
|
||||
if len(buf) >= self._PROGRESS_FLUSH_THRESHOLD:
|
||||
# Re-read the buffer: a concurrent flush during the broadcast awaits
|
||||
# may have popped it, leaving `buf` as a stale detached list.
|
||||
if len(self._progress_buffer.get(task_id, [])) >= self._PROGRESS_FLUSH_THRESHOLD:
|
||||
await self._flush_progress(task_id, project_id=pid)
|
||||
if runtime_type in {"turn_completed", "turn_failed", "checkpoint_saved"}:
|
||||
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"):
|
||||
entry["work_item_projection_title"] = _role_label or None
|
||||
_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 buffer∪DB 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": {
|
||||
"project_id": pid,
|
||||
"task_id": task_id,
|
||||
@@ -2229,11 +2239,9 @@ class WSHandler:
|
||||
"entry": entry,
|
||||
}})
|
||||
|
||||
# ── Accumulate for persistence (flushed at threshold / task end) ──
|
||||
buf = self._progress_buffer.setdefault(task_id, [])
|
||||
buf.append(entry)
|
||||
self._progress_project_ids[task_id] = pid
|
||||
if len(buf) >= self._PROGRESS_FLUSH_THRESHOLD:
|
||||
# ── Persist at threshold (re-read: a concurrent flush during the
|
||||
# broadcast awaits may have popped the buffer) ──
|
||||
if len(self._progress_buffer.get(task_id, [])) >= self._PROGRESS_FLUSH_THRESHOLD:
|
||||
await self._flush_progress(task_id, project_id=pid)
|
||||
|
||||
is_work_item_event = text.startswith("[Company:")
|
||||
@@ -2651,7 +2659,16 @@ class WSHandler:
|
||||
entry_type = "work_item_started"
|
||||
summary = f"Turn {payload.get('iteration', '?')} started"
|
||||
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":
|
||||
return None
|
||||
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":
|
||||
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)
|
||||
except Exception:
|
||||
progress_log = []
|
||||
|
||||
Reference in New Issue
Block a user