fix(ui): stabilize workplace chat scrolling
This commit is contained in:
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user