fix(ui): stabilize workplace chat scrolling

This commit is contained in:
LZH-YS1998
2026-07-13 23:02:39 +08:00
parent 4e7aa75ba5
commit 4bc18dcd27
46 changed files with 5937 additions and 1258 deletions
@@ -15,6 +15,17 @@ const UNIX_MS_THRESHOLD = 1_000_000_000_000
const WORK_ITEM_EVENT_RE = /^\[Company:([^\]]+)\]\s*(.*)$/
const COMPANY_RUNTIME_EVENT_RE = /^\[Company\]\s*(.*)$/
export function mergeSessionDetailHasMore(
previous: boolean | undefined,
incoming: boolean,
isHistoryPage: boolean,
): boolean {
// A latest-page refresh only describes that 200-row response. It must not
// reopen an older-history cursor that the user already exhausted.
if (!isHistoryPage && previous === false) return false
return incoming
}
function normalizeAgentRuntimeStatus(rawStatus: unknown, rawAgentStatus: unknown): AgentAnimStatus | undefined {
if (rawAgentStatus === 'idle' || rawAgentStatus === 'reflecting' || rawAgentStatus === 'tool_active') {
return rawAgentStatus
@@ -52,6 +63,16 @@ function mapBackendProgressLog(raw: any): ProgressEntry[] {
: typeof entry.streamId === 'string'
? entry.streamId
: undefined,
toolCallId: typeof entry.tool_call_id === 'string'
? entry.tool_call_id
: typeof entry.toolCallId === 'string'
? entry.toolCallId
: undefined,
permissionGroupKey: typeof entry.permission_group_key === 'string'
? entry.permission_group_key
: typeof entry.permissionGroupKey === 'string'
? entry.permissionGroupKey
: undefined,
seq: typeof entry.seq === 'number' && Number.isFinite(entry.seq) ? entry.seq : undefined,
executionMode: typeof entry.execution_mode === 'string'
? entry.execution_mode
@@ -633,6 +654,8 @@ export function mapBackendSession(raw: any): Session {
detailLoaded: raw.detail_loaded ?? raw.detailLoaded,
fullLoaded: raw.full_loaded ?? raw.fullLoaded,
hasMore: raw.has_more ?? raw.hasMore,
summaryHasMore: raw.summary_has_more ?? raw.summaryHasMore,
fullHasMore: raw.full_has_more ?? raw.fullHasMore,
detailLoading: raw.detail_loading ?? raw.detailLoading,
detailError: raw.detail_error ?? raw.detailError,
viewGeneration: raw.view_generation ?? raw.viewGeneration,
@@ -0,0 +1,30 @@
import type { ChatMessage } from '../types/chat'
export function stableMessageTimelineKey(message: ChatMessage): string {
const metadata = message.metadata ?? {}
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
if (checkpointId) return `checkpoint:${checkpointId}`
const uiMessageId = String(metadata.ui_message_id ?? '').trim()
const transcriptKind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
const metadataRole = String((metadata as Record<string, unknown>).role ?? '').trim().toLowerCase()
const isUserTurn = message.sender === 'user'
|| metadataRole === 'user'
|| transcriptKind === 'runtime_v2_user_turn'
|| transcriptKind === 'top_level_user_turn'
// The optimistic and persisted user surfaces share one client identity.
if (isUserTurn && uiMessageId) return `ui:${uiMessageId}`
// ChatStore attaches this only when one semantic result surface replaces
// another. It preserves the already-mounted row without entering protocol
// or persistence data.
const retainedTimelineId = String(metadata.ui_timeline_id ?? '').trim()
if (retainedTimelineId) return retainedTimelineId
const turnId = String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
if (!isUserTurn && turnId && transcriptKind === 'runtime_v2_assistant') {
return `turn:assistant:${turnId}`
}
return `message:${message.id}`
}
@@ -7,30 +7,39 @@ function compact(value: unknown): string {
.slice(0, 96)
}
export function progressEntryKey(entry: ProgressEntry, fallbackIndex = 0): string {
export function progressEntryKey(entry: ProgressEntry): string {
const stableId = entry.itemId || entry.streamId || entry.toolCallId || entry.permissionGroupKey
if (stableId) {
return `${entry.type}:${compact(entry.turnId)}:${compact(stableId)}`
}
if (entry.type === 'thinking') {
return `thinking:${compact(entry.turnId) || compact(entry.executionMode) || compact(entry.summary) || 'stream'}:${fallbackIndex}`
if (entry.type === 'thinking' || entry.type === 'assistant') {
return `${entry.type}:${compact(entry.turnId) || compact(entry.executionMode) || 'stream'}:${
Number.isFinite(entry.timestamp) ? entry.timestamp : ''
}`
}
if (entry.type === 'tool_call' && entry.turnId) {
return `tool:${compact(entry.turnId)}:${compact(entry.summary) || 'tool'}:${fallbackIndex}`
if (entry.type === 'tool_call') {
return `tool:${compact(entry.turnId) || 'turnless'}:${compact(entry.summary) || 'tool'}:${
Number.isFinite(entry.timestamp) ? entry.timestamp : ''
}`
}
if (entry.turnId && typeof entry.seq === 'number') {
return `${entry.type}:${compact(entry.turnId)}:seq:${entry.seq}`
}
return [
const fallbackParts: Array<string | number> = [
entry.type,
compact(entry.turnId),
]
if (typeof entry.seq === 'number' && Number.isFinite(entry.seq)) {
fallbackParts.push(`seq-${entry.seq}`)
}
fallbackParts.push(
Number.isFinite(entry.timestamp) ? entry.timestamp : '',
compact(entry.summary),
compact(entry.detail),
fallbackIndex,
].join(':')
)
return fallbackParts.join(':')
}
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict'
import { appendProgressEntry } from './progressLog'
import type { ProgressEntry } from '../types/kanban'
import { mapBackendSession } from './collabSync'
import { progressEntryKey } from './progressEntryKey'
import { appendProgressEntry, normalizeProgressLog } from './progressLog'
let log = appendProgressEntry([], {
timestamp: 1,
@@ -149,3 +152,144 @@ assert.equal(assistantLog.length, 2)
assert.equal(assistantLog[0]?.detail, '文件已成功写入(278 行)。')
assert.equal(assistantLog[0]?.summary, '文件已成功写入(278 行)。')
assert.equal(assistantLog[1]?.detail, '采集完成报告')
// A live client receives these as individual deltas. A reconnect receives the
// same rows as a full snake_case snapshot. Both paths must produce identical
// row identities: otherwise React remounts progress rows and browser anchoring
// sees a false remove/insert pair during every full sync.
const snapshotSeconds = 1_700_000_000
const snapshotRows = [
{
timestamp: snapshotSeconds,
type: 'status_change',
summary: 'Running',
detail: 'phase=running',
},
{
timestamp: snapshotSeconds + 0.1,
type: 'status_change',
summary: 'Running',
detail: 'phase=running',
},
{
timestamp: snapshotSeconds + 1,
type: 'thinking',
summary: 'Thinking',
detail: 'Need ',
},
{
timestamp: snapshotSeconds + 1.1,
type: 'thinking',
summary: 'Thinking',
detail: 'context',
},
{
timestamp: snapshotSeconds + 2,
type: 'assistant',
summary: 'Answer',
detail: 'Answer ',
},
{
timestamp: snapshotSeconds + 2.1,
type: 'assistant',
summary: 'ready',
detail: 'ready',
},
{
timestamp: snapshotSeconds + 3,
type: 'tool_call',
summary: 'file_read',
detail: '{"path":',
},
{
timestamp: snapshotSeconds + 3.1,
type: 'tool_call',
summary: 'file_read',
detail: '"README.md"}',
},
] as const
const liveDeltas: ProgressEntry[] = snapshotRows.map(entry => ({
timestamp: entry.timestamp * 1000,
type: entry.type,
summary: entry.summary,
detail: entry.detail,
}))
const liveSnapshot = liveDeltas.reduce<ProgressEntry[]>(
(entries, entry) => appendProgressEntry(entries, entry),
[],
)
const normalizedSnapshot = normalizeProgressLog(liveDeltas)
const mappedSnapshot = mapBackendSession({
task_id: 'progress-snapshot',
channel_id: 'session:progress-snapshot',
progress_log: snapshotRows,
}).progressLog
assert.equal(liveSnapshot.length, 4)
assert.deepEqual(
liveSnapshot.map(entry => entry.type),
['status_change', 'thinking', 'assistant', 'tool_call'],
)
assert.deepEqual(
normalizedSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
liveSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
)
assert.deepEqual(
mappedSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
liveSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
)
assert.deepEqual(
liveSnapshot.map(entry => entry.timestamp),
[
snapshotSeconds * 1000,
(snapshotSeconds + 1) * 1000,
(snapshotSeconds + 2) * 1000,
(snapshotSeconds + 3) * 1000,
],
)
assert.deepEqual(
liveSnapshot.map(progressEntryKey),
[
'status_change::1700000000000:Running:phase=running',
'thinking:stream:1700000001000',
'assistant:stream:1700000002000',
'tool:turnless:file_read:1700000003000',
],
)
assert.ok(liveSnapshot.every(entry => (
!entry.itemId
&& !entry.streamId
&& !entry.toolCallId
&& !entry.permissionGroupKey
)))
// Persisted snake_case identifiers must survive the full-sync bridge. These
// identifiers take precedence over mutable summaries and timestamps when the
// UI derives a row key.
const mappedStableIds = mapBackendSession({
task_id: 'progress-stable-ids',
channel_id: 'session:progress-stable-ids',
progress_log: [
{
timestamp: snapshotSeconds + 10,
type: 'tool_call',
summary: 'shell_exec',
tool_call_id: 'call-42',
},
{
timestamp: snapshotSeconds + 11,
type: 'autonomy',
summary: 'shell_exec: ask',
permission_group_key: 'tool:shell_exec/python:domain:example.com',
},
],
}).progressLog
assert.equal(mappedStableIds[0]?.toolCallId, 'call-42')
assert.equal(mappedStableIds[1]?.permissionGroupKey, 'tool:shell_exec/python:domain:example.com')
assert.equal(progressEntryKey(mappedStableIds[0]!), 'tool_call::call-42')
assert.equal(
progressEntryKey(mappedStableIds[1]!),
'autonomy::tool:shell_exec/python:domain:example.com',
)
@@ -90,7 +90,9 @@ function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry
// the same way as thinking streams.
const detail = mergeText(left.detail ?? '', right.detail ?? '', 'thinking')
return {
timestamp: right.timestamp,
// A stream occupies the timeline slot where it began. Deltas update the
// row in place instead of repeatedly re-sorting it around tool events.
timestamp: left.timestamp,
type: left.type,
summary: summarizeThinking(detail, right.summary || left.summary),
detail: detail || undefined,
@@ -107,7 +109,7 @@ function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry
if (left.type === 'tool_call') {
const mergedDetail = mergeText(left.detail ?? '', right.detail ?? '', 'tool_call')
return {
timestamp: right.timestamp,
timestamp: left.timestamp,
type: 'tool_call',
summary: right.summary || left.summary,
detail: mergedDetail || undefined,
@@ -152,7 +154,7 @@ export function appendProgressEntry(
if (isDuplicateProgress(last, normalized)) {
return clampEntries([
...log.slice(0, actualIndex),
{ ...last, timestamp: normalized.timestamp },
last,
...log.slice(actualIndex + 1),
], maxEntries)
}
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict'
import type { ChatMessage } from '../types/chat'
import type { Session } from '../types/kanban'
import { mapBackendSession } from './collabSync'
import { mapBackendSession, mergeSessionDetailHasMore } from './collabSync'
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation } from './workItemSessions'
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation, selectCompanySummaryMessages } from './workItemSessions'
function makeSession(overrides: Partial<Session> & Pick<Session, 'taskId' | 'channelId' | 'title' | 'status' | 'columnId' | 'assigneeIds' | 'priority' | 'tags' | 'progressLog' | 'createdAt' | 'updatedAt' | 'messageCount'>): Session {
return {
@@ -189,6 +189,161 @@ const mergedDeliveryMessages = mergeConversationMessages([
assert.equal(mergedDeliveryMessages.length, 1)
assert.equal(mergedDeliveryMessages[0]?.id, 'child-direct')
const earlierResult = {
...resultMessage(
'earlier-parent-result',
'session:company-root',
finalBody,
{ source: 'engine', transcript_kind: 'child_result' },
),
timestamp: 900,
}
const authoritativeResult = {
...resultMessage(
'later-authoritative-result',
'session:company-child',
finalBody,
{ source: 'engine', transcript_kind: 'child_task_result' },
),
timestamp: 1_100,
}
for (const groups of [
[[earlierResult], [authoritativeResult]],
[[authoritativeResult], [earlierResult]],
]) {
const result = mergeConversationMessages(groups)
assert.equal(result.length, 1)
assert.equal(result[0]?.id, 'later-authoritative-result')
assert.equal(result[0]?.timestamp, 900, 'result chronology must not depend on channel traversal order')
}
const pendingCheckpoint = {
...resultMessage(
'pending-checkpoint-surface',
'session:company-root',
'Approval required.',
{ checkpoint_id: 'shared-checkpoint', checkpoint_type: 'human_escalation', status: 'pending' },
'system',
),
timestamp: 1_200,
}
const resolvedCheckpoint = {
...resultMessage(
'resolved-checkpoint-surface',
'session:company-child',
'Approval required.',
{ checkpoint_id: 'shared-checkpoint', checkpoint_type: 'human_escalation', status: 'resolved' },
'system',
),
timestamp: 1_300,
}
const mergedCheckpoint = mergeConversationMessages([[pendingCheckpoint], [resolvedCheckpoint]])
assert.equal(mergedCheckpoint.length, 1)
assert.equal(mergedCheckpoint[0]?.id, 'pending-checkpoint-surface')
assert.equal(mergedCheckpoint[0]?.timestamp, 1_200)
assert.equal(mergedCheckpoint[0]?.metadata?.status, 'resolved')
const companySummaryMessages = selectCompanySummaryMessages([
resultMessage(
'parent-user',
'session:company-root',
'Please investigate the issue.',
{ source: 'ui' },
'user',
),
resultMessage(
'child-transient',
'session:company-child',
'A child draft or internal assistant turn must stay out of the parent transcript.',
{ source: 'runtime_event', transcript_kind: 'runtime_v2_assistant' },
'assistant',
),
resultMessage(
'canonical-role-result',
'session:company-child',
'The canonical role delivery remains visible in the company summary.',
{ source: 'engine', transcript_kind: 'company_role_result' },
'assistant',
),
resultMessage(
'summary-company-final',
'session:company-child',
'A summary-visible company final remains when no canonical role mirror exists.',
{ source: 'engine', kind: 'runtime_v2_company_assistant', detail_visibility: 'summary' },
'assistant',
),
{
...resultMessage(
'parent-full-only-terminal',
'session:company-root',
'A full-only parent surface must neither render nor suppress the committed child summary.',
{
source: 'engine',
transcript_kind: 'runtime_v2_assistant',
detail_visibility: 'full',
canonical_turn_id: 'shared-terminal-turn',
},
'assistant',
),
timestamp: 1400,
},
{
...resultMessage(
'summary-terminal-a',
'session:company-child',
'First authoritative terminal for one shared canonical turn.',
{
source: 'engine',
transcript_kind: 'runtime_v2_assistant',
detail_visibility: 'summary',
canonical_turn_id: 'shared-terminal-turn',
},
'assistant',
),
timestamp: 1200,
},
{
...resultMessage(
'summary-terminal-b',
'session:company-sibling',
'A second terminal surface with different content must not duplicate the turn.',
{
source: 'engine',
transcript_kind: 'runtime_v2_assistant',
detail_visibility: 'summary',
canonical_turn_id: 'shared-terminal-turn',
},
'assistant',
),
timestamp: 1300,
},
resultMessage(
'child-checkpoint',
'session:company-child',
'Approval is required.',
{ checkpoint_id: 'checkpoint-child', checkpoint_type: 'company_work_item_gate' },
'assistant',
),
resultMessage(
'child-checkpoint-response',
'session:company-child',
'Approved.',
{ response_to_checkpoint_id: 'checkpoint-child', ui_message_id: 'ui-checkpoint-response' },
'user',
),
], 'session:company-root')
assert.deepEqual(
companySummaryMessages.map(message => message.id).sort(),
[
'parent-user',
'canonical-role-result',
'summary-company-final',
'summary-terminal-b',
'child-checkpoint',
'child-checkpoint-response',
].sort(),
)
assert.equal(companyHeaderView?.status, 'running')
assert.equal(companyHeaderView?.contextTokens, 0)
assert.equal(companyHeaderView?.contextWindow, 128000)
@@ -369,4 +524,15 @@ assert.equal(mappedCompanySession.execMode, 'company')
assert.equal(mappedCompanySession.companyProfile, 'corporate')
assert.equal(mappedCompanySession.orgId, undefined)
assert.equal(
mergeSessionDetailHasMore(false, true, false),
false,
'a cursorless live refresh must not reopen an exhausted history boundary',
)
assert.equal(
mergeSessionDetailHasMore(false, true, true),
true,
'a real cursor page may advance the scoped history boundary',
)
console.log('workItemSessions origin-task linking checks passed')
@@ -2,11 +2,20 @@ import type { ChatMessage } from '../types/chat'
import type { ProgressEntry, Session } from '../types/kanban'
import { getContextUsageMetrics } from './contextUsage'
import { isSessionWorking } from './sessionRuntime'
import { stableMessageTimelineKey } from './messageTimelineIdentity'
const CONTEXT_TOKENS_RE = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+tokens/i
const USED_PCT_RE = /(\d{1,3})%\s*used/i
const REMAINING_PCT_RE = /(\d{1,3})%\s*remaining/i
export function isMessageVisibleAtDetailLevel(
message: ChatMessage,
detailLevel: 'summary' | 'full',
): boolean {
if (detailLevel === 'full') return true
return String(message.metadata?.detail_visibility ?? 'summary').trim() !== 'full'
}
function compactWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
@@ -58,7 +67,7 @@ function resultSurfacePriority(message: ChatMessage): number {
return 0
}
function resultSurfaceDedupeKey(message: ChatMessage): string {
export function resultSurfaceDedupeKey(message: ChatMessage): string {
if (resultSurfacePriority(message) <= 0) return ''
const content = compactWhitespace(stripNarrativeTitlePrefix(message.content)).slice(0, 2000)
return content ? `result:${content}` : ''
@@ -454,10 +463,29 @@ export function getConversationHeaderSession(
export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatMessage[] {
const seen = new Set<string>()
const resultKeyIndex = new Map<string, number>()
const checkpointIndex = new Map<string, number>()
const merged: ChatMessage[] = []
for (const group of messageGroups) {
for (const message of group) {
const metadata = (message.metadata ?? {}) as Record<string, unknown>
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
if (checkpointId) {
const existingIndex = checkpointIndex.get(checkpointId)
if (existingIndex !== undefined) {
const existing = merged[existingIndex]
const latest = message.timestamp >= existing.timestamp ? message : existing
const earliest = message.timestamp < existing.timestamp ? message : existing
merged[existingIndex] = {
...existing,
...latest,
id: existing.id,
channelId: existing.channelId,
timestamp: earliest.timestamp,
metadata: { ...(existing.metadata ?? {}), ...(latest.metadata ?? {}) },
}
continue
}
}
const uiMessageId = typeof metadata.ui_message_id === 'string'
? metadata.ui_message_id.trim()
: ''
@@ -465,16 +493,32 @@ export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatM
if (resultKey) {
const existingIndex = resultKeyIndex.get(resultKey)
if (existingIndex !== undefined) {
if (resultSurfacePriority(message) > resultSurfacePriority(merged[existingIndex])) {
merged[existingIndex] = message
const existing = merged[existingIndex]
const candidateWins = resultSurfacePriority(message) > resultSurfacePriority(existing)
const preferred = candidateWins ? message : existing
const secondary = candidateWins ? existing : message
merged[existingIndex] = {
...secondary,
...preferred,
// A result surface keeps the chronology of the first underlying
// delivery, independent of which related channel happened to be
// traversed first for this render.
timestamp: Math.min(existing.timestamp, message.timestamp),
metadata: {
...(secondary.metadata ?? {}),
...(preferred.metadata ?? {}),
ui_timeline_id: stableMessageTimelineKey(existing),
},
}
continue
}
resultKeyIndex.set(resultKey, merged.length)
}
const dedupeKey = resultKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
const checkpointKey = checkpointId ? `checkpoint:${checkpointId}` : ''
const dedupeKey = resultKey || checkpointKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
if (checkpointId) checkpointIndex.set(checkpointId, merged.length)
merged.push(message)
}
}
@@ -485,6 +529,98 @@ export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatM
))
}
/**
* Build the durable transcript shown by a company/org parent session.
*
* A parent conversation may observe every related runtime channel so that
* canonical role deliveries can be surfaced in one place. Those channels
* also contain transient child turns, however, and rendering all of them in
* the parent transcript makes the visible timeline change whenever the
* runtime projection selects a different child. Keep the parent's committed
* messages and only admit the canonical cross-channel result surfaces.
*/
export function selectCompanySummaryMessages(
messages: ChatMessage[],
parentChannelId: string,
): ChatMessage[] {
const terminalAssistantTurn = (message: ChatMessage): string => {
const metadata = (message.metadata ?? {}) as Record<string, unknown>
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
if (kind !== 'runtime_v2_assistant' && kind !== 'runtime_v2_company_assistant') return ''
return String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
}
const parentTerminalTurns = new Set(
messages
.filter(message => (
message.channelId === parentChannelId
&& isMessageVisibleAtDetailLevel(message, 'summary')
))
.map(terminalAssistantTurn)
.filter(Boolean),
)
const childTerminalByTurn = new Map<string, ChatMessage>()
const durableMessages: ChatMessage[] = []
for (const message of messages) {
if (message.channelId === parentChannelId) {
if (isMessageVisibleAtDetailLevel(message, 'summary')) {
durableMessages.push(message)
}
continue
}
const metadata = (message.metadata ?? {}) as Record<string, unknown>
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
const checkpointType = String(metadata.checkpoint_type ?? '').trim()
const checkpointResponseId = String(metadata.response_to_checkpoint_id ?? '').trim()
const escalationResponseId = String(metadata.response_to_escalation_id ?? '').trim()
if ((checkpointId && checkpointType) || checkpointResponseId || escalationResponseId) {
durableMessages.push(message)
continue
}
if ([
'company_role_result',
'company_role_result_retry',
'child_task_result',
'child_task_result_retry',
'child_result',
'top_level_reply',
].includes(kind)) {
durableMessages.push(message)
continue
}
// Snapshot builder deliberately marks the final runtime surface as
// summary-visible. Preserve that durable contract instead of reusing the
// result-dedupe priority table as a visibility threshold.
const isSummaryTerminal = String(metadata.detail_visibility ?? '').trim() === 'summary'
&& (kind === 'runtime_v2_assistant' || kind === 'runtime_v2_company_assistant')
if (!isSummaryTerminal) continue
const turnId = terminalAssistantTurn(message)
if (!turnId) {
durableMessages.push(message)
continue
}
if (parentTerminalTurns.has(turnId)) continue
const existing = childTerminalByTurn.get(turnId)
if (!existing) {
childTerminalByTurn.set(turnId, message)
continue
}
const existingPriority = resultSurfacePriority(existing)
const candidatePriority = resultSurfacePriority(message)
if (candidatePriority > existingPriority) {
childTerminalByTurn.set(turnId, message)
} else if (
candidatePriority === existingPriority
&& (message.timestamp > existing.timestamp
|| (message.timestamp === existing.timestamp && message.id.localeCompare(existing.id) > 0))
) {
childTerminalByTurn.set(turnId, message)
}
}
durableMessages.push(...childTerminalByTurn.values())
return mergeConversationMessages([durableMessages])
}
export function mergeConversationProgressLog(timelineSessions: Session[]): ProgressEntry[] {
const seen = new Set<string>()
const merged: ProgressEntry[] = []
@@ -0,0 +1,217 @@
import assert from 'node:assert/strict'
import { VisualSocketClient } from './wsClient'
type TestSocketClient = {
handleMessage: (raw: unknown) => void
pendingQueue: string[]
pendingSessionDetailRequests: Array<{
queued: boolean
settled: boolean
timeout: ReturnType<typeof setTimeout> | null
}>
timeoutSessionDetailRequest: (index: number) => void
}
const deliverAck = (
client: VisualSocketClient,
payload: Record<string, unknown>,
) => {
;(client as unknown as TestSocketClient).handleMessage(JSON.stringify({
type: 'ack',
payload,
}))
}
const flushPromises = async () => {
await Promise.resolve()
await Promise.resolve()
}
const client = new VisualSocketClient('ws://unit.test', {})
// A summary and a full request for the same task are distinct correlations.
// Neither Promise may settle merely because the request was queued locally.
const summaryPromise = client.sessionDetail('project-a', 'task-1', { detailLevel: 'summary' })
const fullPromise = client.sessionDetail('project-a', 'task-1', { detailLevel: 'full' })
const summarySettlements: Array<Record<string, unknown>> = []
const fullSettlements: Array<Record<string, unknown>> = []
void summaryPromise.then(payload => { summarySettlements.push(payload) })
void fullPromise.then(payload => { fullSettlements.push(payload) })
await flushPromises()
assert.equal(summarySettlements.length, 0, 'summary Promise must remain pending before its ACK')
assert.equal(fullSettlements.length, 0, 'full Promise must remain pending before its ACK')
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-1',
detail_level: 'full',
marker: 'full-ack',
})
await flushPromises()
assert.equal(fullSettlements[0]?.marker, 'full-ack', 'the matching full ACK must settle the full request')
assert.equal(summarySettlements.length, 0, 'a full ACK must not settle the same task\'s summary request')
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-1',
detail_level: 'summary',
marker: 'summary-ack',
})
await flushPromises()
assert.equal(summarySettlements[0]?.marker, 'summary-ack', 'the matching summary ACK must settle the summary request')
assert.equal(summarySettlements[0]?.client_history_page, false, 'a request without a cursor is not a history page')
const historyPagePromise = client.sessionDetail('project-a', 'task-history', {
detailLevel: 'summary',
beforeCreatedAt: 123,
})
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-history',
detail_level: 'summary',
})
assert.equal((await historyPagePromise).client_history_page, true, 'a cursor request must be identified as a history page')
// Requests with the same correlation fields are settled in request order.
const firstPromise = client.sessionDetail('project-a', 'task-2', { detailLevel: 'summary' })
const secondPromise = client.sessionDetail('project-a', 'task-2', { detailLevel: 'summary' })
const firstSettlements: Array<Record<string, unknown>> = []
const secondSettlements: Array<Record<string, unknown>> = []
void firstPromise.then(payload => { firstSettlements.push(payload) })
void secondPromise.then(payload => { secondSettlements.push(payload) })
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-2',
detail_level: 'summary',
marker: 'first-ack',
})
await flushPromises()
assert.equal(firstSettlements[0]?.marker, 'first-ack', 'the first matching ACK must settle the oldest request')
assert.equal(secondSettlements.length, 0, 'the second same-scope request must remain pending after one ACK')
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-2',
detail_level: 'summary',
marker: 'second-ack',
})
await flushPromises()
assert.equal(secondSettlements[0]?.marker, 'second-ack', 'the next matching ACK must settle the next FIFO request')
// The backend's early store-not-ready path cannot echo request fields. The
// client must still correlate and normalize that error instead of leaving the
// history single-flight Promise pending forever.
const storeNotReadyPromise = client.sessionDetail('project-a', 'task-store', {
detailLevel: 'summary',
viewGeneration: 9,
})
const storeNotReadySettlements: Array<Record<string, unknown>> = []
void storeNotReadyPromise.then(payload => { storeNotReadySettlements.push(payload) })
deliverAck(client, {
ok: false,
action: 'create_session',
error: 'store_not_ready',
project_id: 'project-a',
view_generation: 9,
})
await flushPromises()
assert.equal(
storeNotReadySettlements.length,
0,
'store_not_ready for another explicit action must not settle a session_detail request',
)
deliverAck(client, {
ok: false,
error: 'store_not_ready',
project_id: 'project-a',
view_generation: 9,
})
const storeNotReady = await storeNotReadyPromise
assert.equal(storeNotReady.action, 'session_detail')
assert.equal(storeNotReady.task_id, 'task-store')
assert.equal(storeNotReady.detail_level, 'summary')
// A sent request timeout releases its caller, but leaves a settled FIFO
// tombstone so a late ACK cannot be mis-correlated to a newer request.
const timeoutClient = new VisualSocketClient('ws://unit.test', {})
const sentPromise = timeoutClient.sessionDetail('project-a', 'task-timeout', {
detailLevel: 'summary',
})
const sentSettlements: Array<Record<string, unknown>> = []
void sentPromise.then(payload => { sentSettlements.push(payload) })
const timeoutInternals = timeoutClient as unknown as TestSocketClient
timeoutInternals.pendingSessionDetailRequests[0].queued = false
timeoutInternals.pendingQueue = []
timeoutInternals.timeoutSessionDetailRequest(0)
await flushPromises()
assert.equal(sentSettlements[0]?.error, 'request_timeout', 'sent timeout must release the loading caller')
assert.equal(timeoutInternals.pendingSessionDetailRequests.length, 1, 'sent timeout must retain a FIFO tombstone')
assert.equal(timeoutInternals.pendingSessionDetailRequests[0].settled, true, 'the retained request must be a settled tombstone')
assert.equal(timeoutInternals.pendingSessionDetailRequests[0].timeout, null, 'sent request timer must be released')
const afterTimeoutPromise = timeoutClient.sessionDetail('project-a', 'task-timeout', {
detailLevel: 'summary',
})
const afterTimeoutSettlements: Array<Record<string, unknown>> = []
void afterTimeoutPromise.then(payload => { afterTimeoutSettlements.push(payload) })
deliverAck(timeoutClient, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-timeout',
detail_level: 'summary',
marker: 'old-request-ack',
})
await flushPromises()
assert.equal(sentSettlements.length, 1, 'a late ACK must be consumed by the older tombstone')
assert.equal(sentSettlements[0]?.error, 'request_timeout', 'a late ACK cannot resettle the timed-out Promise')
assert.equal(afterTimeoutSettlements.length, 0, 'the first ACK must not settle the newer same-scope request')
assert.equal(timeoutInternals.pendingSessionDetailRequests.length, 1, 'the newer request must remain pending')
deliverAck(timeoutClient, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-timeout',
detail_level: 'summary',
marker: 'current-request-ack',
})
await flushPromises()
assert.equal(afterTimeoutSettlements[0]?.marker, 'current-request-ack', 'the next ACK must settle the newer request')
const queuedTimeoutClient = new VisualSocketClient('ws://unit.test', {})
const queuedTimeout = queuedTimeoutClient.sessionDetail('project-a', 'task-queued-timeout', {
beforeMessageId: 'older-message',
})
const queuedTimeoutInternals = queuedTimeoutClient as unknown as TestSocketClient
queuedTimeoutInternals.timeoutSessionDetailRequest(0)
const queuedTimeoutFailure = await queuedTimeout
assert.equal(queuedTimeoutFailure.error, 'request_timeout', 'a request still queued locally may time out')
assert.equal(queuedTimeoutFailure.client_history_page, true, 'synthetic failures must preserve history-page correlation')
assert.equal(queuedTimeoutInternals.pendingSessionDetailRequests.length, 0)
assert.equal(queuedTimeoutInternals.pendingQueue.length, 0)
const disconnectClient = new VisualSocketClient('ws://unit.test', {})
const disconnected = disconnectClient.sessionDetail('project-a', 'task-disconnect')
disconnectClient.disconnect()
assert.equal((await disconnected).error, 'disconnected', 'disconnect must settle every pending detail request')
const saturatedClient = new VisualSocketClient('ws://unit.test', {})
;(saturatedClient as unknown as TestSocketClient).pendingQueue = Array.from({ length: 100 }, () => '{}')
const saturated = await saturatedClient.sessionDetail('project-a', 'task-saturated')
assert.equal(saturated.error, 'send_queue_full', 'a saturated transport queue must fail immediately')
console.log('wsClient.test.ts: OK (session_detail correlation and lifecycle cleanup)')
@@ -174,6 +174,9 @@ const PROJECT_SCOPED_MESSAGE_TYPES = new Set([
'comms_read_message',
])
const SESSION_DETAIL_REQUEST_TIMEOUT_MS = 30_000
type SendDisposition = 'sent' | 'queued' | 'queue-full' | 'send-failed'
export class VisualSocketClient {
private ws: WebSocket | null = null
private reconnectTimer: number | null = null
@@ -182,6 +185,18 @@ export class VisualSocketClient {
private pendingQueue: string[] = []
private heartbeatTimer: number | null = null
private pongTimer: number | null = null
private pendingSessionDetailRequests: Array<{
projectId: string
taskId: string
detailLevel: 'summary' | 'full'
viewGeneration?: number
historyPage: boolean
wireData: string
queued: boolean
settled: boolean
timeout: ReturnType<typeof setTimeout> | null
resolve: (payload: Record<string, unknown>) => void
}> = []
constructor(
private url: string,
@@ -211,6 +226,7 @@ export class VisualSocketClient {
}
this.ws.onclose = () => {
this.stopHeartbeat()
this.failPendingSessionDetailRequests('connection_closed')
this.handlers.onStatus?.('disconnected')
this.ws = null
if (!this.closedByUser) {
@@ -222,6 +238,7 @@ export class VisualSocketClient {
disconnect(): void {
this.closedByUser = true
this.stopHeartbeat()
this.failPendingSessionDetailRequests('disconnected')
if (this.reconnectTimer !== null) {
window.clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
@@ -230,18 +247,24 @@ export class VisualSocketClient {
this.ws = null
}
send(payload: Record<string, unknown>): void {
send(payload: Record<string, unknown>): SendDisposition {
if (!this.ensureProjectScope(payload)) {
return
return 'send-failed'
}
const data = JSON.stringify(payload)
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
if (this.pendingQueue.length < PENDING_QUEUE_MAX) {
this.pendingQueue.push(data)
return 'queued'
}
return
return 'queue-full'
}
try {
this.ws.send(data)
return 'sent'
} catch {
return 'send-failed'
}
this.ws.send(data)
}
// ── Agent management ───────────────────────────────────────────────────
@@ -411,18 +434,50 @@ export class VisualSocketClient {
projectId: string,
taskId: string,
opts?: { limit?: number; beforeCreatedAt?: number; beforeMessageId?: string; detailLevel?: 'summary' | 'full'; include?: string[]; viewGeneration?: number },
): void {
): Promise<Record<string, unknown>> {
const pid = this.requireProjectId(projectId, 'session_detail')
this.send({
type: 'session_detail',
project_id: pid,
task_id: taskId,
limit: opts?.limit,
before_created_at: opts?.beforeCreatedAt,
before_message_id: opts?.beforeMessageId,
detail_level: opts?.detailLevel,
include: opts?.include,
view_generation: opts?.viewGeneration,
const detailLevel = opts?.detailLevel ?? 'summary'
return new Promise((resolve) => {
const payload = {
type: 'session_detail',
project_id: pid,
task_id: taskId,
limit: opts?.limit,
before_created_at: opts?.beforeCreatedAt,
before_message_id: opts?.beforeMessageId,
detail_level: detailLevel,
include: opts?.include,
view_generation: opts?.viewGeneration,
}
const wireData = JSON.stringify(payload)
const request = {
projectId: pid,
taskId,
detailLevel,
viewGeneration: opts?.viewGeneration,
historyPage: opts?.beforeCreatedAt !== undefined || !!opts?.beforeMessageId,
wireData,
queued: false,
settled: false,
timeout: null as ReturnType<typeof setTimeout> | null,
resolve,
}
request.timeout = setTimeout(() => {
const index = this.pendingSessionDetailRequests.indexOf(request)
if (index >= 0) this.timeoutSessionDetailRequest(index)
}, SESSION_DETAIL_REQUEST_TIMEOUT_MS)
this.pendingSessionDetailRequests.push(request)
const disposition = this.send(payload)
request.queued = disposition === 'queued'
if (disposition === 'queue-full' || disposition === 'send-failed') {
const index = this.pendingSessionDetailRequests.indexOf(request)
if (index >= 0) {
this.failSessionDetailRequest(
index,
disposition === 'queue-full' ? 'send_queue_full' : 'send_failed',
)
}
}
})
}
@@ -656,9 +711,13 @@ export class VisualSocketClient {
case 'event':
this.handlers.onEvent?.(parsed.payload)
break
case 'ack':
this.handlers.onAck?.(parsed.payload)
case 'ack': {
const ackPayload = this.settleSessionDetailRequest(
parsed.payload as unknown as Record<string, unknown>,
)
this.handlers.onAck?.(ackPayload as typeof parsed.payload)
break
}
case 'channel_created':
this.handlers.onChannelCreated?.(parsed.payload)
break
@@ -799,11 +858,109 @@ export class VisualSocketClient {
} catch (e) { console.error('[wsClient] Error handling message:', parsed.type, e) }
}
private settleSessionDetailRequest(payload: Record<string, unknown>): Record<string, unknown> {
const action = typeof payload.action === 'string' ? payload.action.trim() : ''
const isSessionDetailAck = action === 'session_detail'
|| (!action && payload.error === 'store_not_ready')
if (!isSessionDetailAck) return payload
const projectId = this.normalizeProjectId(payload.project_id ?? payload.projectId)
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
const detailLevel = payload.detail_level === 'full' ? 'full' : payload.detail_level === 'summary' ? 'summary' : ''
const viewGeneration = typeof payload.view_generation === 'number' ? payload.view_generation : undefined
const index = this.pendingSessionDetailRequests.findIndex(request => (
(!projectId || request.projectId === projectId)
&& (!taskId || request.taskId === taskId)
&& (!detailLevel || request.detailLevel === detailLevel)
&& (viewGeneration === undefined || request.viewGeneration === viewGeneration)
))
if (index < 0) return payload
const [request] = this.pendingSessionDetailRequests.splice(index, 1)
if (request.timeout !== null) clearTimeout(request.timeout)
const normalizedPayload = {
...payload,
action: 'session_detail',
project_id: projectId || request.projectId,
task_id: taskId || request.taskId,
detail_level: detailLevel || request.detailLevel,
client_history_page: request.historyPage,
}
if (!request.settled) {
request.settled = true
request.resolve(normalizedPayload)
}
return normalizedPayload
}
private timeoutSessionDetailRequest(index: number): void {
const request = this.pendingSessionDetailRequests[index]
if (!request) return
if (request.queued) {
this.failSessionDetailRequest(index, 'request_timeout')
return
}
if (request.timeout !== null) clearTimeout(request.timeout)
request.timeout = null
if (!request.settled) {
request.settled = true
request.resolve(this.sessionDetailFailurePayload(request, 'request_timeout'))
}
// Keep a settled tombstone in FIFO order until its ACK or connection
// cleanup. Removing it would let a late ACK settle a newer request with
// identical correlation fields; closing the shared socket would interrupt
// unrelated runtime events.
}
private failSessionDetailRequest(index: number, error: string): void {
const [request] = this.pendingSessionDetailRequests.splice(index, 1)
if (!request) return
if (request.timeout !== null) clearTimeout(request.timeout)
if (request.queued) {
const queuedIndex = this.pendingQueue.indexOf(request.wireData)
if (queuedIndex >= 0) this.pendingQueue.splice(queuedIndex, 1)
}
if (!request.settled) request.resolve(this.sessionDetailFailurePayload(request, error))
}
private sessionDetailFailurePayload(
request: {
projectId: string
taskId: string
detailLevel: 'summary' | 'full'
historyPage: boolean
},
error: string,
): Record<string, unknown> {
return {
ok: false,
action: 'session_detail',
error,
project_id: request.projectId,
task_id: request.taskId,
detail_level: request.detailLevel,
client_history_page: request.historyPage,
}
}
private failPendingSessionDetailRequests(error: string): void {
while (this.pendingSessionDetailRequests.length > 0) {
this.failSessionDetailRequest(0, error)
}
}
private flushPendingQueue(): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
const queued = this.pendingQueue.splice(0)
for (const data of queued) {
this.ws.send(data)
const detailRequestIndex = this.pendingSessionDetailRequests.findIndex(
request => request.queued && request.wireData === data,
)
try {
this.ws.send(data)
if (detailRequestIndex >= 0) this.pendingSessionDetailRequests[detailRequestIndex].queued = false
} catch {
if (detailRequestIndex >= 0) this.failSessionDetailRequest(detailRequestIndex, 'send_failed')
}
}
}