Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,715 @@
/**
* collabSync — translates backend collaboration data format to frontend store types.
*
* Backend uses snake_case; frontend uses camelCase. This module bridges the two.
*/
import type { ChatChannel, ChatMessage, ChannelType } from '../types/chat'
import type { KanbanBoard, KanbanColumn, KanbanPhase, KanbanTask, TaskPriority, AgentAnimStatus, ProgressEntry, Session, SessionMode, Project, EmployeeAssignment, RoleAggregatedStatus, RoleWorkItemActivitySection, RoleWorkItemRow, RoleWorkItemSummary, WorkItemGate, WorkItemProgressEntry } from '../types/kanban'
import { normalizeProgressLog, normalizeWorkItemLog } from './progressLog'
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
/* eslint-disable @typescript-eslint/no-explicit-any */
const UNIX_MS_THRESHOLD = 1_000_000_000_000
const WORK_ITEM_EVENT_RE = /^\[Company:([^\]]+)\]\s*(.*)$/
const COMPANY_RUNTIME_EVENT_RE = /^\[Company\]\s*(.*)$/
function normalizeAgentRuntimeStatus(rawStatus: unknown, rawAgentStatus: unknown): AgentAnimStatus | undefined {
if (rawAgentStatus === 'idle' || rawAgentStatus === 'reflecting' || rawAgentStatus === 'tool_active') {
return rawAgentStatus
}
return rawStatus === 'running' ? 'reflecting' : undefined
}
function normalizeEpochMs(raw: unknown): number {
const value = typeof raw === 'string' ? Number(raw) : raw
if (typeof value !== 'number' || !Number.isFinite(value)) return Date.now()
return value < UNIX_MS_THRESHOLD ? value * 1000 : value
}
function mapBackendProgressLog(raw: any): ProgressEntry[] {
if (!Array.isArray(raw)) return []
return normalizeProgressLog(raw
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry) => ({
timestamp: normalizeEpochMs(entry.timestamp),
type: (entry.type ?? 'status_change') as ProgressEntry['type'],
summary: typeof entry.summary === 'string' ? entry.summary : '',
detail: typeof entry.detail === 'string' ? entry.detail : undefined,
turnId: typeof entry.turn_id === 'string'
? entry.turn_id
: typeof entry.turnId === 'string'
? entry.turnId
: undefined,
itemId: typeof entry.item_id === 'string'
? entry.item_id
: typeof entry.itemId === 'string'
? entry.itemId
: undefined,
streamId: typeof entry.stream_id === 'string'
? entry.stream_id
: typeof entry.streamId === 'string'
? entry.streamId
: undefined,
seq: typeof entry.seq === 'number' && Number.isFinite(entry.seq) ? entry.seq : undefined,
executionMode: typeof entry.execution_mode === 'string'
? entry.execution_mode
: typeof entry.executionMode === 'string'
? entry.executionMode
: undefined,
})))
}
function mapBackendWorkItemLog(raw: any): WorkItemProgressEntry[] {
if (!Array.isArray(raw)) return []
return normalizeWorkItemLog(raw
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry) => {
const runtimeTaskId = typeof entry.runtime_task_id === 'string'
? entry.runtime_task_id
: typeof entry.runtimeTaskId === 'string'
? entry.runtimeTaskId
: typeof entry.execution_turn_id === 'string'
? entry.execution_turn_id
: typeof entry.executionTurnId === 'string'
? entry.executionTurnId
: undefined
const executionTurnId = typeof entry.execution_turn_id === 'string'
? entry.execution_turn_id
: typeof entry.executionTurnId === 'string'
? entry.executionTurnId
: runtimeTaskId
return {
timestamp: normalizeEpochMs(entry.timestamp),
type: (entry.type ?? 'gate_result') as WorkItemProgressEntry['type'],
workItemProjectionId: typeof entry.work_item_projection_id === 'string'
? entry.work_item_projection_id
: typeof entry.workItemProjectionId === 'string'
? entry.workItemProjectionId
: undefined,
workItemTurnType: typeof entry.work_item_turn_type === 'string'
? entry.work_item_turn_type
: typeof entry.workItemTurnType === 'string'
? entry.workItemTurnType
: undefined,
workItemProjectionTitle: typeof entry.work_item_projection_title === 'string'
? entry.work_item_projection_title
: typeof entry.workItemProjectionTitle === 'string'
? entry.workItemProjectionTitle
: undefined,
runtimeTaskId,
executionTurnId,
roleName: typeof entry.role_name === 'string'
? entry.role_name
: typeof entry.roleName === 'string'
? entry.roleName
: undefined,
detail: typeof entry.detail === 'string' ? entry.detail : undefined,
}
}))
}
function workItemProjectionTitle(projectionId: string): string {
return projectionId
.replace(/[_-]/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function workItemEntryType(action: string): WorkItemProgressEntry['type'] {
const actionLower = action.toLowerCase()
if (actionLower.includes('starting') || actionLower.includes('started')) return 'work_item_started'
if (actionLower.includes('gate passed') || actionLower.includes('approved') || actionLower.includes('completed')) return 'gate_approved'
if (actionLower.includes('rejected') || actionLower.includes('reworking')) return 'gate_rejected'
if (actionLower.includes('awaiting peer')) return 'awaiting_peer'
if (actionLower.includes('awaiting manager review')) return 'awaiting_manager_review'
if (
actionLower.includes('awaiting user')
|| actionLower.includes('awaiting human review')
|| actionLower.includes('awaiting review')
|| actionLower.includes('awaiting confirmation')
|| actionLower.includes('awaiting feedback')
) {
return 'awaiting_human'
}
if (actionLower.includes('failed')) return 'work_item_failed'
if (actionLower.includes('deadlock')) return 'deadlock'
return 'gate_result'
}
function workItemEntryFromMessage(message: ChatMessage): WorkItemProgressEntry | null {
const content = message.content.trim()
const meta = (message.metadata ?? {}) as Record<string, unknown>
const roleName = message.sender !== 'system' && message.sender !== 'assistant'
? message.senderName
: undefined
const executionTurnId = typeof meta.forwarded_from === 'string'
? meta.forwarded_from
: typeof meta.task_id === 'string'
? meta.task_id
: typeof meta.taskId === 'string'
? meta.taskId
: undefined
const projectionMatch = WORK_ITEM_EVENT_RE.exec(content)
if (projectionMatch) {
const [, projectionId, actionRaw] = projectionMatch
const action = actionRaw.trim()
return {
timestamp: message.timestamp,
type: workItemEntryType(action),
workItemProjectionId: projectionId,
workItemProjectionTitle: workItemProjectionTitle(projectionId),
roleName,
detail: action || undefined,
runtimeTaskId: executionTurnId,
executionTurnId,
}
}
const runtimeMatch = COMPANY_RUNTIME_EVENT_RE.exec(content)
if (runtimeMatch) {
const action = runtimeMatch[1].trim()
return {
timestamp: message.timestamp,
type: workItemEntryType(action),
workItemProjectionId: 'company_runtime',
workItemProjectionTitle: 'Company Runtime',
roleName,
detail: action || undefined,
runtimeTaskId: executionTurnId,
executionTurnId,
}
}
return null
}
function deriveWorkItemLog(messages: ChatMessage[]): WorkItemProgressEntry[] {
return messages
.map(workItemEntryFromMessage)
.filter((entry): entry is WorkItemProgressEntry => entry != null)
.sort((a, b) => a.timestamp - b.timestamp)
}
function hydrateCompanyRuntimeSessions(sessions: Session[], messages: ChatMessage[]): Session[] {
if (sessions.length === 0 || messages.length === 0) return sessions
const messagesByChannel = new Map<string, ChatMessage[]>()
for (const message of messages) {
const bucket = messagesByChannel.get(message.channelId)
if (bucket) bucket.push(message)
else messagesByChannel.set(message.channelId, [message])
}
return sessions.map((session) => {
const derivedWorkItemLog = deriveWorkItemLog(messagesByChannel.get(session.channelId) ?? [])
const workItemLog = session.workItemLog && session.workItemLog.length > 0
? session.workItemLog
: derivedWorkItemLog
const latestConcreteWorkItem = [...workItemLog]
.reverse()
.find((entry) => {
const projectionId = entry.workItemProjectionId
return projectionId && projectionId !== 'company_runtime'
})
const workItemProjectionId = session.mode === 'primary'
? (latestConcreteWorkItem?.workItemProjectionId ?? session.workItemProjectionId)
: (session.workItemProjectionId ?? latestConcreteWorkItem?.workItemProjectionId)
const isCompanyRuntime = session.isCompanyRuntime || (session.mode === 'primary' && workItemLog.length > 0)
if (
workItemLog.length === 0
&& workItemProjectionId === session.workItemProjectionId
&& isCompanyRuntime === session.isCompanyRuntime
) {
return session
}
return {
...session,
isCompanyRuntime,
workItemProjectionId,
...(workItemLog.length > 0 ? { workItemLog } : {}),
}
})
}
function hydrateCompanyRuntimeTasks(tasks: KanbanTask[], sessions: Session[]): KanbanTask[] {
if (tasks.length === 0 || sessions.length === 0) return tasks
const sessionsByTaskId = new Map(sessions.map((session) => [session.taskId, session]))
return tasks.map((task) => {
const session = sessionsByTaskId.get(task.id)
if (!session) return task
const workItemProjectionId = session.workItemProjectionId ?? task.workItemProjectionId
const companyProfile = session.companyProfile ?? task.companyProfile
const workItemRoleId = session.workItemRoleId ?? task.workItemRoleId
const workItemRoleName = session.workItemRoleName ?? task.workItemRoleName
if (
workItemProjectionId === task.workItemProjectionId
&& companyProfile === task.companyProfile
&& workItemRoleId === task.workItemRoleId
&& workItemRoleName === task.workItemRoleName
) {
return task
}
return {
...task,
workItemProjectionId,
companyProfile,
workItemRoleId,
workItemRoleName,
}
})
}
export function mapBackendChannel(raw: any): ChatChannel {
return {
id: raw.channel_id ?? raw.id ?? '',
type: (raw.channel_type ?? raw.type ?? 'session') as ChannelType,
name: raw.name ?? '',
officeId: raw.office_id ?? raw.officeId,
participants: raw.participants ?? [],
pinned: !!raw.pinned,
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
}
}
export function mapBackendMessage(raw: any): ChatMessage {
const rawSenderName = raw.sender_name ?? raw.senderName ?? ''
const senderName = String(rawSenderName).trim().toLowerCase() === 'task generalist'
? 'OPC'
: rawSenderName
return {
id: raw.message_id ?? raw.id ?? '',
channelId: raw.channel_id ?? raw.channelId ?? '',
sender: raw.sender ?? '',
senderName,
content: raw.content ?? '',
timestamp: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.timestamp ?? Date.now()),
replyToId: raw.reply_to_id ?? raw.replyToId,
mentions: raw.mentions ?? [],
metadata: raw.metadata,
senderDeleted: !!(raw.sender_deleted ?? raw.senderDeleted),
}
}
export function mapBackendBoard(raw: any): KanbanBoard {
return {
id: raw.board_id ?? raw.id ?? '',
name: raw.name ?? '',
description: raw.description,
color: raw.color ?? '#3b82f6',
officeId: raw.office_id ?? raw.officeId,
prefix: raw.prefix ?? 'T',
nextTaskNum: raw.next_task_num ?? raw.nextTaskNum ?? 1,
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
updatedAt: typeof raw.updated_at === 'number' ? raw.updated_at * 1000 : (raw.updatedAt ?? Date.now()),
}
}
export function mapBackendColumn(raw: any): KanbanColumn {
return {
id: raw.column_id ?? raw.id ?? '',
boardId: raw.board_id ?? raw.boardId ?? '',
name: raw.name ?? '',
color: raw.color ?? '#888',
sortOrder: raw.sort_order ?? raw.sortOrder ?? 0,
isTerminal: !!(raw.is_terminal ?? raw.isTerminal),
// Phase 2: work-item column metadata
roleLabel: raw.role_label ?? raw.roleLabel,
gateType: raw.gate_type ?? raw.gateType,
isParallel: raw.is_parallel ?? raw.isParallel,
}
}
export function mapBackendTask(raw: any): KanbanTask {
const agentStatus = normalizeAgentRuntimeStatus(raw.status, raw.agent_status ?? raw.agentStatus)
// Backend now emits the work-item phase plus a `kanban_column` projection.
// The legacy `status: <column>` alias on the kanban-card payload was removed
// when DelegationWorkItem.status was retired, so we accept either shape and
// fall back to the explicit `column_id` for the few payloads that still use
// the older field name.
const columnId = raw.kanban_column ?? raw.column_id ?? raw.columnId ?? ''
const cardId = raw.work_item_id ?? raw.workItemId ?? raw.task_id ?? raw.id ?? ''
const runtimeTaskId = raw.runtime_task_id ?? raw.runtimeTaskId
?? raw.execution_turn_id ?? raw.executionTurnId
?? ((raw.work_item_id ?? raw.workItemId) ? undefined : (raw.task_id ?? raw.id))
const executionTurnId = raw.execution_turn_id ?? raw.executionTurnId ?? runtimeTaskId
const executionMode = raw.execution_mode ?? raw.executionMode
const rawProjectionId = raw.work_item_projection_id ?? raw.workItemProjectionId
const isTaskModeRuntime = executionMode === 'task_mode' || rawProjectionId === 'task_mode_execution'
return {
id: cardId,
displayId: raw.display_id ?? raw.displayId ?? '',
boardId: raw.board_id ?? raw.boardId ?? '',
columnId,
title: raw.title ?? '',
description: raw.description,
priority: (raw.priority ?? null) as TaskPriority | null,
assigneeIds: raw.assignee_ids ?? raw.assigneeIds ?? [],
tags: raw.tags ?? [],
sortOrder: raw.sort_order ?? raw.sortOrder ?? 0,
sessionId: raw.session_id ?? raw.sessionId,
workItemId: raw.work_item_id ?? raw.workItemId,
runtimeTaskId,
executionTurnId,
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
updatedAt: typeof raw.updated_at === 'number' ? raw.updated_at * 1000 : (raw.updatedAt ?? Date.now()),
// Phase 2: agent runtime state
agentStatus,
currentTool: raw.current_tool ?? raw.currentTool,
displayTool: raw.display_tool ?? raw.displayTool ?? raw.current_tool ?? raw.currentTool,
toolElapsedMs: raw.tool_elapsed_ms ?? raw.toolElapsedMs,
lastToolSummary: raw.last_tool_summary ?? raw.lastToolSummary,
contextTokens: raw.context_tokens ?? raw.contextTokens,
contextWindow: raw.context_window ?? raw.contextWindow,
contextRemainingPct: raw.context_remaining_pct ?? raw.contextRemainingPct,
inputTokens: raw.input_tokens ?? raw.inputTokens,
outputTokens: raw.output_tokens ?? raw.outputTokens,
totalTokens: raw.total_tokens ?? raw.totalTokens,
turnCostUsd: raw.turn_cost_usd ?? raw.turnCostUsd,
sessionCostUsd: raw.session_cost_usd ?? raw.sessionCostUsd,
pendingPermissionCount: raw.pending_permission_count ?? raw.pendingPermissionCount,
drainMode: raw.drain_mode ?? raw.drainMode,
residentStatus: raw.resident_status ?? raw.residentStatus,
actionableInboxCount: raw.actionable_inbox_count ?? raw.actionableInboxCount,
protocolBacklogCount: raw.protocol_backlog_count ?? raw.protocolBacklogCount,
notificationBacklogCount: raw.notification_backlog_count ?? raw.notificationBacklogCount,
latestNotification: raw.latest_notification ?? raw.latestNotification,
// Phase 2: work-item runtime & dependencies
workItemProjectionId: isTaskModeRuntime ? undefined : rawProjectionId,
workItemTurnType: isTaskModeRuntime ? undefined : (raw.work_item_turn_type ?? raw.workItemTurnType),
companyProfile: isTaskModeRuntime ? undefined : (raw.company_profile ?? raw.companyProfile),
orgId: isTaskModeRuntime ? undefined : (raw.org_id ?? raw.organization_id ?? raw.orgId ?? raw.organizationId),
workItemRoleId: isTaskModeRuntime ? undefined : (raw.work_item_role_id ?? raw.workItemRoleId),
workItemRoleName: isTaskModeRuntime ? undefined : (raw.work_item_role_name ?? raw.workItemRoleName),
workItemGate: isTaskModeRuntime ? undefined : _mapWorkItemGate(raw.work_item_gate ?? raw.workItemGate),
employeeAssignment: _mapEmployeeAssignment(raw.employee_assignment ?? raw.employeeAssignment),
selectedExecutionAgent: raw.selected_execution_agent ?? raw.selectedExecutionAgent,
originChannel: raw.origin_channel ?? raw.originChannel,
dependencies: raw.dependencies,
progressLog: mapBackendProgressLog(raw.progress_log ?? raw.progressLog),
handoffContext: raw.handoff_context ?? raw.handoffContext,
phase: raw.phase,
runtimeSessionId: raw.runtime_session_id ?? raw.runtimeSessionId,
resumeCursor: raw.resume_cursor ?? raw.resumeCursor,
worktreePath: raw.worktree_path ?? raw.worktreePath,
blockedReason: raw.blocked_reason ?? raw.blockedReason,
reviewVerdict: raw.review_verdict ?? raw.reviewVerdict,
reviewSummary: raw.review_summary ?? raw.reviewSummary,
reviewOwnerRoleId: raw.review_owner_role_id ?? raw.reviewOwnerRoleId,
reviewOwnerSeatId: raw.review_owner_seat_id ?? raw.reviewOwnerSeatId,
managerRoleId: raw.manager_role_id ?? raw.managerRoleId,
managerSeatId: raw.manager_seat_id ?? raw.managerSeatId,
scopeKey: raw.scope_key ?? raw.scopeKey,
completionReport: raw.completion_report ?? raw.completionReport,
reworkFeedback: raw.rework_feedback ?? raw.reworkFeedback,
planningContext: raw.planning_context ?? raw.planningContext,
deliverables: raw.deliverables,
acceptanceCriteria: raw.acceptance_criteria ?? raw.acceptanceCriteria,
delegationRationale: raw.delegation_rationale ?? raw.delegationRationale,
nonOverlapGuard: raw.non_overlap_guard ?? raw.nonOverlapGuard,
coordinationNotes: raw.coordination_notes ?? raw.coordinationNotes,
originalMessage: raw.original_message ?? raw.originalMessage,
residentAssignment: raw.resident_assignment ?? raw.residentAssignment,
memberSessionState: raw.member_session_state ?? raw.memberSessionState,
ownershipContract: raw.ownership_contract ?? raw.ownershipContract,
}
}
function _mapEmployeeAssignment(raw: any): EmployeeAssignment | undefined {
if (!raw || typeof raw !== 'object') return undefined
return {
name: raw.name,
employeeId: raw.employee_id ?? raw.employeeId,
category: raw.category,
experienceScore: raw.experience_score ?? raw.experienceScore,
domains: raw.domains,
preferredExternalAgent: raw.preferred_external_agent ?? raw.preferredExternalAgent,
promptContext: raw.prompt_context ?? raw.promptContext,
deltaContext: raw.delta_context ?? raw.deltaContext,
skillRefs: raw.skill_refs ?? raw.skillRefs,
}
}
function _mapWorkItemGate(raw: any): WorkItemGate | undefined {
if (!raw || typeof raw !== 'object') return undefined
return {
type: raw.type ?? raw.gate_type ?? raw.gateType,
reviewerRole: raw.reviewer_role ?? raw.reviewerRole,
autoApprove: raw.auto_approve ?? raw.autoApprove,
criteria: raw.criteria,
}
}
// Backend phase string is the source of truth — the frontend lives off
// that vocabulary too (see ``types/kanban.ts:KanbanPhase``). Validate at
// the deserialization boundary so a typo upstream surfaces here, not deep
// inside the rendering code.
const KNOWN_PHASES: ReadonlySet<KanbanPhase> = new Set<KanbanPhase>([
'queued', 'ready', 'ready_for_rework', 'waiting_dependencies',
'running', 'waiting_for_peer', 'waiting_for_children', 'paused', 'needs_attention',
'awaiting_manager_review', 'awaiting_human',
'approved', 'failed', 'cancelled',
])
const KNOWN_AGGREGATED_STATUS: ReadonlySet<RoleAggregatedStatus> = new Set<RoleAggregatedStatus>([
'active', 'waiting', 'pending', 'done', 'failed',
])
function coercePhase(value: unknown): KanbanPhase {
if (typeof value === 'string' && KNOWN_PHASES.has(value as KanbanPhase)) {
return value as KanbanPhase
}
// Falling back to ``queued`` matches the column id ``todo``, which is
// the safest "haven't done anything yet" placeholder.
return 'queued'
}
function coerceAggregatedStatus(value: unknown): RoleAggregatedStatus {
if (typeof value === 'string' && KNOWN_AGGREGATED_STATUS.has(value as RoleAggregatedStatus)) {
return value as RoleAggregatedStatus
}
return 'pending'
}
function coerceRuntimeStatus(value: unknown): AgentAnimStatus {
if (value === 'reflecting' || value === 'tool_active' || value === 'idle') return value
return 'idle'
}
function mapBackendRoleWorkItemActivitySection(raw: any): RoleWorkItemActivitySection {
const kind = typeof raw.kind === 'string' && raw.kind.trim() ? raw.kind : 'activity'
const title = typeof raw.title === 'string' && raw.title.trim()
? raw.title
: kind.replace(/_/g, ' ')
return {
kind,
title,
roleName: typeof raw.role_name === 'string'
? raw.role_name
: (typeof raw.roleName === 'string' ? raw.roleName : undefined),
runtimeTaskId: typeof raw.runtime_task_id === 'string'
? raw.runtime_task_id
: (typeof raw.runtimeTaskId === 'string' ? raw.runtimeTaskId : undefined),
entries: mapBackendProgressLog(raw.entries),
}
}
function mapBackendRoleWorkItemRow(raw: any): RoleWorkItemRow {
const rawActivitySections: unknown[] = Array.isArray(raw.activity_sections)
? raw.activity_sections
: Array.isArray(raw.activitySections)
? raw.activitySections
: []
return {
workItemId: typeof raw.work_item_id === 'string' ? raw.work_item_id : (raw.workItemId ?? ''),
workItemProjectionId: typeof raw.work_item_projection_id === 'string'
? raw.work_item_projection_id
: (raw.workItemProjectionId ?? undefined),
phase: coercePhase(raw.phase),
kanbanColumn: typeof raw.kanban_column === 'string'
? raw.kanban_column
: (raw.kanbanColumn ?? 'todo'),
title: typeof raw.title === 'string' ? raw.title : '',
kind: typeof raw.kind === 'string' ? raw.kind : (raw.kind ?? undefined),
isReviewTarget: !!(raw.is_review_target ?? raw.isReviewTarget),
executorRoleId: raw.executor_role_id ?? raw.executorRoleId ?? undefined,
executorRoleName: raw.executor_role_name ?? raw.executorRoleName ?? undefined,
reviewerRoleId: raw.reviewer_role_id ?? raw.reviewerRoleId ?? undefined,
createdAt: normalizeEpochMs(raw.created_at ?? raw.createdAt),
updatedAt: normalizeEpochMs(raw.updated_at ?? raw.updatedAt),
executionTurnId: raw.execution_turn_id ?? raw.executionTurnId ?? undefined,
activitySections: rawActivitySections
.filter((section): section is Record<string, unknown> => !!section && typeof section === 'object')
.map(mapBackendRoleWorkItemActivitySection),
progressLog: mapBackendProgressLog(raw.progress_log ?? raw.progressLog),
}
}
function mapBackendRoleWorkItems(raw: any): Record<string, RoleWorkItemSummary> | undefined {
if (!raw || typeof raw !== 'object') return undefined
const out: Record<string, RoleWorkItemSummary> = {}
for (const [key, value] of Object.entries(raw)) {
if (!value || typeof value !== 'object') continue
const summary = value as Record<string, unknown>
const workItemsRaw = Array.isArray(summary.work_items)
? summary.work_items
: Array.isArray(summary.workItems)
? summary.workItems
: []
const workItems = (workItemsRaw as any[])
.map(mapBackendRoleWorkItemRow)
// Defensive ASC sort by createdAt — backend already orders, but a
// belt-and-braces sort here means UI never sees a flapping row order
// if backend ordering ever drifts.
.sort((a, b) => a.createdAt - b.createdAt)
const roleKey = typeof summary.role_key === 'string'
? summary.role_key
: (typeof summary.roleKey === 'string' ? summary.roleKey : key)
out[key] = {
roleKey,
roleId: typeof summary.role_id === 'string'
? summary.role_id
: (typeof summary.roleId === 'string' ? summary.roleId : key),
roleName: typeof summary.role_name === 'string'
? summary.role_name
: (typeof summary.roleName === 'string' ? summary.roleName : key),
roleSessionId: typeof summary.role_session_id === 'string'
? summary.role_session_id
: (typeof summary.roleSessionId === 'string' ? summary.roleSessionId : undefined),
teamInstanceId: typeof summary.team_instance_id === 'string'
? summary.team_instance_id
: (typeof summary.teamInstanceId === 'string' ? summary.teamInstanceId : undefined),
runtimeStatus: coerceRuntimeStatus(summary.runtime_status ?? summary.runtimeStatus),
aggregatedStatus: coerceAggregatedStatus(summary.aggregated_status ?? summary.aggregatedStatus),
workItems,
}
}
return Object.keys(out).length > 0 ? out : undefined
}
export function mapBackendSession(raw: any): Session {
const agentStatus = normalizeAgentRuntimeStatus(raw.status, raw.agent_status ?? raw.agentStatus)
const taskId = raw.task_id ?? raw.taskId ?? ''
const runtimeTaskId = raw.runtime_task_id ?? raw.runtimeTaskId ?? raw.execution_turn_id ?? raw.executionTurnId ?? taskId
const executionTurnId = raw.execution_turn_id ?? raw.executionTurnId ?? runtimeTaskId
const executionMode = raw.execution_mode ?? raw.executionMode
const rawExecMode = String(raw.exec_mode ?? raw.execMode ?? '').trim().toLowerCase()
const rawProjectionId = raw.work_item_projection_id ?? raw.workItemProjectionId
const isTaskModeRuntime = (
rawExecMode === 'task'
|| rawExecMode === 'project'
|| rawExecMode === 'single'
|| executionMode === 'task_mode'
|| rawProjectionId === 'task_mode_execution'
)
const parentSessionId = isTaskModeRuntime
? undefined
: (raw.parent_session_id ?? raw.parentSessionId)
const mapped: Session = {
projectId: raw.project_id ?? raw.projectId ?? '',
taskId,
runtimeTaskId,
executionTurnId,
channelId: raw.channel_id ?? raw.channelId ?? '',
sessionId: raw.session_id ?? raw.sessionId,
parentSessionId,
mode: (isTaskModeRuntime ? 'primary' : (raw.mode ?? (parentSessionId ? 'child' : 'primary'))) as SessionMode,
title: raw.title ?? 'Untitled',
status: raw.status ?? 'pending',
columnId: raw.column_id ?? raw.columnId ?? 'todo',
assigneeIds: raw.assignee_ids ?? raw.assigneeIds ?? [],
priority: raw.priority ?? null,
tags: raw.tags ?? [],
agentStatus,
currentTool: raw.current_tool ?? raw.currentTool,
displayTool: raw.display_tool ?? raw.displayTool ?? raw.current_tool ?? raw.currentTool,
toolElapsedMs: raw.tool_elapsed_ms ?? raw.toolElapsedMs,
lastToolSummary: raw.last_tool_summary ?? raw.lastToolSummary,
contextTokens: raw.context_tokens ?? raw.contextTokens,
contextWindow: raw.context_window ?? raw.contextWindow,
contextRemainingPct: raw.context_remaining_pct ?? raw.contextRemainingPct,
inputTokens: raw.input_tokens ?? raw.inputTokens,
outputTokens: raw.output_tokens ?? raw.outputTokens,
totalTokens: raw.total_tokens ?? raw.totalTokens,
turnCostUsd: raw.turn_cost_usd ?? raw.turnCostUsd,
sessionCostUsd: raw.session_cost_usd ?? raw.sessionCostUsd,
pendingPermissionCount: raw.pending_permission_count ?? raw.pendingPermissionCount,
drainMode: raw.drain_mode ?? raw.drainMode,
progressLog: mapBackendProgressLog(raw.progress_log ?? raw.progressLog),
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
updatedAt: typeof raw.updated_at === 'number' ? raw.updated_at * 1000 : (raw.updatedAt ?? Date.now()),
messageCount: raw.message_count ?? raw.messageCount ?? 0,
latestPreview: raw.latest_preview ?? raw.latestPreview,
latestSender: raw.latest_sender ?? raw.latestSender,
latestMessageId: raw.latest_message_id ?? raw.latestMessageId,
indexLoaded: raw.index_loaded ?? raw.indexLoaded,
detailLoaded: raw.detail_loaded ?? raw.detailLoaded,
fullLoaded: raw.full_loaded ?? raw.fullLoaded,
hasMore: raw.has_more ?? raw.hasMore,
detailLoading: raw.detail_loading ?? raw.detailLoading,
detailError: raw.detail_error ?? raw.detailError,
viewGeneration: raw.view_generation ?? raw.viewGeneration,
execMode: raw.exec_mode ?? raw.execMode,
companyProfile: isTaskModeRuntime ? undefined : (raw.company_profile ?? raw.companyProfile),
orgId: isTaskModeRuntime ? undefined : (raw.org_id ?? raw.organization_id ?? raw.orgId ?? raw.organizationId),
preferredAgent: raw.preferred_agent ?? raw.preferredAgent,
// Company Mode metadata
workItemProjectionId: isTaskModeRuntime ? undefined : rawProjectionId,
workItemTurnType: isTaskModeRuntime ? undefined : (raw.work_item_turn_type ?? raw.workItemTurnType),
workItemRoleId: isTaskModeRuntime ? undefined : (raw.work_item_role_id ?? raw.workItemRoleId),
workItemRoleName: isTaskModeRuntime ? undefined : (raw.work_item_role_name ?? raw.workItemRoleName),
workItemGate: isTaskModeRuntime ? undefined : _mapWorkItemGate(raw.work_item_gate ?? raw.workItemGate),
employeeAssignment: _mapEmployeeAssignment(raw.employee_assignment ?? raw.employeeAssignment),
selectedExecutionAgent: raw.selected_execution_agent ?? raw.selectedExecutionAgent,
originChannel: raw.origin_channel ?? raw.originChannel,
originTaskId: raw.origin_task_id ?? raw.originTaskId ?? raw.task_id ?? raw.taskId,
runtimeControlState: raw.runtime_control_state ?? raw.runtimeControlState,
canStop: raw.can_stop ?? raw.canStop,
canResume: raw.can_resume ?? raw.canResume,
resumeParentTaskId: raw.resume_parent_task_id ?? raw.resumeParentTaskId,
resumeParentSessionId: raw.resume_parent_session_id ?? raw.resumeParentSessionId,
pendingRuntimeCheckpointId: raw.pending_runtime_checkpoint_id ?? raw.pendingRuntimeCheckpointId,
stopIntentId: raw.stop_intent_id ?? raw.stopIntentId,
handoffContext: raw.handoff_context ?? raw.handoffContext,
handoffTo: raw.handoff_to ?? raw.handoffTo,
artifacts: raw.artifacts,
isCompanyRuntime: isTaskModeRuntime ? false : (raw.is_company_runtime ?? raw.isCompanyRuntime),
workItemLog: isTaskModeRuntime ? [] : mapBackendWorkItemLog(raw.work_item_log ?? raw.workItemLog),
roleWorkItems: isTaskModeRuntime ? undefined : mapBackendRoleWorkItems(raw.role_work_items ?? raw.roleWorkItems),
executorRoleWorkItems: isTaskModeRuntime
? undefined
: mapBackendRoleWorkItems(raw.executor_role_work_items ?? raw.executorRoleWorkItems),
draftTurnId: raw.draft_turn_id ?? raw.draftTurnId,
runtimeSessionId: raw.runtime_session_id ?? raw.runtimeSessionId,
resumeCursor: raw.resume_cursor ?? raw.resumeCursor,
activeSubagents: raw.active_subagents ?? raw.activeSubagents,
permissionRequests: raw.permission_requests ?? raw.permissionRequests,
worktreePath: raw.worktree_path ?? raw.worktreePath,
residentStatus: raw.resident_status ?? raw.residentStatus,
actionableInboxCount: raw.actionable_inbox_count ?? raw.actionableInboxCount,
protocolBacklogCount: raw.protocol_backlog_count ?? raw.protocolBacklogCount,
notificationBacklogCount: raw.notification_backlog_count ?? raw.notificationBacklogCount,
latestNotification: raw.latest_notification ?? raw.latestNotification,
}
return canonicalizeSessionExecutionIdentity(mapped)
}
export interface CollabSyncData {
channels: ChatChannel[]
messages: ChatMessage[]
boards: KanbanBoard[]
columns: KanbanColumn[]
tasks: KanbanTask[]
sessions: Session[]
}
export function mapCollabSyncPayload(payload: any): CollabSyncData {
const channels = (payload.channels ?? []).map(mapBackendChannel)
const messages = (payload.messages ?? []).map(mapBackendMessage)
const boards = (payload.boards ?? []).map(mapBackendBoard)
const columns = (payload.columns ?? []).map(mapBackendColumn)
const sessions = hydrateCompanyRuntimeSessions(
(payload.sessions ?? []).map(mapBackendSession),
messages,
)
const tasks = hydrateCompanyRuntimeTasks(
(payload.tasks ?? []).map(mapBackendTask),
sessions,
)
return {
channels,
messages,
boards,
columns,
tasks,
sessions,
}
}
@@ -0,0 +1,81 @@
export interface ContextUsageLike {
contextTokens?: number | null
contextWindow?: number | null
contextRemainingPct?: number | null
}
export interface ContextUsageMetrics {
usedPct?: number
remainingPct?: number
usedTokens?: number
windowTokens?: number
}
function asFiniteNumber(value: number | null | undefined): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return value
}
function clampPct(value: number): number {
return Math.max(0, Math.min(Math.round(value), 100))
}
export function getContextUsageMetrics(
value: ContextUsageLike | null | undefined,
): ContextUsageMetrics {
const contextTokens = asFiniteNumber(value?.contextTokens)
const contextWindow = asFiniteNumber(value?.contextWindow)
const contextRemainingPct = asFiniteNumber(value?.contextRemainingPct)
const normalizedTokens = typeof contextTokens === 'number' ? Math.max(0, Math.round(contextTokens)) : undefined
const normalizedWindow = typeof contextWindow === 'number' && contextWindow > 0
? Math.max(1, Math.round(contextWindow))
: undefined
const normalizedRemainingPct = typeof contextRemainingPct === 'number'
? clampPct(contextRemainingPct)
: undefined
if (typeof normalizedTokens === 'number' && typeof normalizedWindow === 'number') {
const usedTokens = Math.min(normalizedTokens, normalizedWindow)
const usedPct = clampPct((usedTokens / normalizedWindow) * 100)
return {
usedPct,
remainingPct: 100 - usedPct,
usedTokens,
windowTokens: normalizedWindow,
}
}
if (typeof normalizedRemainingPct === 'number' && typeof normalizedWindow === 'number') {
const usedPct = 100 - normalizedRemainingPct
return {
usedPct,
remainingPct: normalizedRemainingPct,
usedTokens: Math.round((usedPct / 100) * normalizedWindow),
windowTokens: normalizedWindow,
}
}
if (typeof normalizedWindow === 'number') {
return {
usedPct: 0,
remainingPct: 100,
usedTokens: 0,
windowTokens: normalizedWindow,
}
}
if (typeof normalizedRemainingPct === 'number') {
// No usable window: the used/max ratio is undefined. Upstream reports an
// unknown window as remaining_pct=0, so deriving usedPct here would render
// a misleading 100% (or 0% at turn start). Surface remaining for any text
// display but do not drive the ring — the ring hides without a usedPct.
return { remainingPct: normalizedRemainingPct }
}
if (typeof normalizedTokens === 'number') {
return { usedTokens: normalizedTokens }
}
return {}
}
@@ -0,0 +1,64 @@
/**
* Locks in: frontend's PHASE_TO_COLUMN matches backend's
* ``opc/presentation/kanban.py:STATUS_TO_COLUMN`` and
* ``opc/layer2_organization/phase.py:_PHASE_TO_COLUMN``.
*
* If the backend ever adds, removes, or renames a phase / column, this
* test surfaces the drift immediately — preventing a class of silent
* UI bug where a card ends up in the wrong column because the frontend
* projection fell out of sync with the backend.
*/
import { describe, it, expect } from 'vitest'
import type { KanbanPhase } from '../types/kanban'
import { PHASE_TO_COLUMN, deriveColumnFromPhase } from './phaseHelpers'
const ALL_PHASES: KanbanPhase[] = [
'queued', 'ready', 'ready_for_rework', 'waiting_dependencies',
'running', 'waiting_for_peer', 'waiting_for_children', 'paused', 'needs_attention',
'awaiting_manager_review', 'awaiting_human',
'approved', 'failed', 'cancelled',
]
describe('PHASE_TO_COLUMN', () => {
it('covers every KanbanPhase exactly once', () => {
expect(Object.keys(PHASE_TO_COLUMN).sort()).toEqual([...ALL_PHASES].sort())
})
it('projects TODO-family phases to "todo"', () => {
for (const p of ['queued', 'ready', 'ready_for_rework', 'waiting_dependencies'] as KanbanPhase[]) {
expect(PHASE_TO_COLUMN[p]).toBe('todo')
}
})
it('projects IN-PROGRESS-family phases to "in-progress"', () => {
for (const p of ['running', 'waiting_for_peer', 'waiting_for_children', 'paused', 'needs_attention'] as KanbanPhase[]) {
expect(PHASE_TO_COLUMN[p]).toBe('in-progress')
}
})
it('projects IN-REVIEW-family phases to "in-review"', () => {
for (const p of ['awaiting_manager_review', 'awaiting_human'] as KanbanPhase[]) {
expect(PHASE_TO_COLUMN[p]).toBe('in-review')
}
})
it('projects terminal phases to "done"', () => {
for (const p of ['approved', 'failed', 'cancelled'] as KanbanPhase[]) {
expect(PHASE_TO_COLUMN[p]).toBe('done')
}
})
})
describe('deriveColumnFromPhase', () => {
it('returns "todo" for undefined / null phase', () => {
expect(deriveColumnFromPhase(undefined)).toBe('todo')
expect(deriveColumnFromPhase(null)).toBe('todo')
})
it('projects each phase using the same table', () => {
for (const p of ALL_PHASES) {
expect(deriveColumnFromPhase(p)).toBe(PHASE_TO_COLUMN[p])
}
})
})
@@ -0,0 +1,52 @@
/**
* Single-source-of-truth projection: KanbanPhase → kanban column id.
*
* This mirror MUST stay in sync with the backend projection in
* ``opc/presentation/kanban.py:STATUS_TO_COLUMN`` and
* ``opc/layer2_organization/phase.py:_PHASE_TO_COLUMN``. The test in
* ``phaseHelpers.test.ts`` locks the mapping by enumerating all 14
* phases; if the backend ever renames or reshapes the column set, that
* test catches the drift before the UI silently mis-groups cards.
*
* The UI previously grouped cards by the backend-supplied ``columnId``
* field, which is itself a projection of phase on the backend. Moving
* the projection into the frontend removes a layer of indirection and
* lets the UI stay internally consistent when future optimistic writes
* only know the phase intent, not the derived column.
*/
import type { KanbanPhase } from '../types/kanban'
export const PHASE_TO_COLUMN: Record<KanbanPhase, string> = {
// todo
queued: 'todo',
ready: 'todo',
ready_for_rework: 'todo',
waiting_dependencies: 'todo',
// in-progress
running: 'in-progress',
waiting_for_peer: 'in-progress',
waiting_for_children: 'in-progress',
paused: 'in-progress',
needs_attention: 'in-progress',
// in-review
awaiting_manager_review: 'in-review',
awaiting_human: 'in-review',
// done
approved: 'done',
failed: 'done',
cancelled: 'done',
}
/**
* Derive the kanban column for a task based on its phase.
* Falls back to ``'todo'`` when phase is missing or unknown; callers
* that already have a backend-supplied ``columnId`` should prefer that
* value during the transition window and only use this helper when the
* phase is trustworthy.
*/
export function deriveColumnFromPhase(phase: KanbanPhase | undefined | null): string {
if (!phase) return 'todo'
const mapped = PHASE_TO_COLUMN[phase]
return mapped ?? 'todo'
}
@@ -0,0 +1,36 @@
import type { ProgressEntry } from '../types/kanban'
function compact(value: unknown): string {
return String(value ?? '')
.trim()
.replace(/\s+/g, ' ')
.slice(0, 96)
}
export function progressEntryKey(entry: ProgressEntry, fallbackIndex = 0): 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 === 'tool_call' && entry.turnId) {
return `tool:${compact(entry.turnId)}:${compact(entry.summary) || 'tool'}:${fallbackIndex}`
}
if (entry.turnId && typeof entry.seq === 'number') {
return `${entry.type}:${compact(entry.turnId)}:seq:${entry.seq}`
}
return [
entry.type,
compact(entry.turnId),
Number.isFinite(entry.timestamp) ? entry.timestamp : '',
compact(entry.summary),
compact(entry.detail),
fallbackIndex,
].join(':')
}
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import { appendProgressEntry } from './progressLog'
let log = appendProgressEntry([], {
timestamp: 1,
type: 'thinking',
summary: 'Thinking',
detail: '我先',
turnId: 'rt-1:1',
itemId: 'rt-1:1:thinking',
seq: 1,
})
for (const [seq, detail] of [
[2, '先联网'],
[3, '联网抓'],
[4, '抓取'],
] as const) {
log = appendProgressEntry(log, {
timestamp: seq,
type: 'thinking',
summary: 'Thinking',
detail,
turnId: 'rt-1:1',
itemId: 'rt-1:1:thinking',
seq,
})
}
assert.equal(log.length, 1)
assert.equal(log[0]?.summary, 'Thinking')
assert.equal(log[0]?.detail, '我先联网抓取')
const unchanged = appendProgressEntry(log, {
timestamp: 5,
type: 'thinking',
summary: 'Thinking',
detail: '重复',
turnId: 'rt-1:1',
itemId: 'rt-1:1:thinking',
seq: 4,
})
assert.equal(unchanged[0]?.detail, '我先联网抓取')
let toolLog = appendProgressEntry([], {
timestamp: 10,
type: 'tool_call',
summary: 'web_search',
detail: '{"query":"weather"}',
turnId: 'rt-1:2',
toolCallId: 'call-1',
})
toolLog = appendProgressEntry(toolLog, {
timestamp: 11,
type: 'tool_call',
summary: 'web_search',
detail: 'completed',
turnId: 'rt-1:2',
toolCallId: 'call-1',
})
assert.equal(toolLog.length, 1)
assert.equal(toolLog[0]?.detail, '{"query":"weather"}\ncompleted')
let permissionLog = appendProgressEntry([], {
timestamp: 20,
type: 'autonomy',
summary: 'shell_exec: ask',
turnId: 'rt-1:3',
permissionGroupKey: 'tool:shell_exec/python:domain:example.com',
})
permissionLog = appendProgressEntry(permissionLog, {
timestamp: 21,
type: 'autonomy',
summary: 'shell_exec: allow',
turnId: 'rt-1:3',
permissionGroupKey: 'tool:shell_exec/python:domain:example.com',
})
assert.equal(permissionLog.length, 1)
assert.equal(permissionLog[0]?.summary, 'shell_exec: allow')
@@ -0,0 +1,237 @@
import type { ProgressEntry, WorkItemProgressEntry } from '../types/kanban'
const STREAM_MERGE_WINDOW_MS = 4000
function clampEntries<T>(entries: T[], maxEntries: number): T[] {
return entries.length > maxEntries ? entries.slice(-maxEntries) : entries
}
function mergeText(left: string, right: string, kind: 'thinking' | 'tool_call'): string {
if (!left) return right
if (!right) return left
if (left === right) return left
if (right.startsWith(left)) return right
if (left.startsWith(right)) return left
if (left.endsWith(right)) return left
if (right.endsWith(left)) return right
const maxOverlap = Math.min(left.length, right.length)
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
if (left.slice(-overlap) === right.slice(0, overlap)) {
return `${left}${right.slice(overlap)}`
}
}
if (kind === 'tool_call' && /[}\]"]$/.test(left) && !/^\s/.test(right)) {
return `${left}\n${right}`
}
return `${left}${right}`
}
function summarizeThinking(detail: string, fallback: string): string {
void detail
void fallback
return 'Thinking'
}
function normalizeProgressEntry(entry: ProgressEntry): ProgressEntry {
return {
timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
type: entry.type,
summary: typeof entry.summary === 'string' ? entry.summary : '',
detail: typeof entry.detail === 'string' && entry.detail ? entry.detail : undefined,
turnId: typeof entry.turnId === 'string' && entry.turnId ? entry.turnId : undefined,
itemId: typeof entry.itemId === 'string' && entry.itemId ? entry.itemId : undefined,
streamId: typeof entry.streamId === 'string' && entry.streamId ? entry.streamId : undefined,
toolCallId: typeof entry.toolCallId === 'string' && entry.toolCallId ? entry.toolCallId : undefined,
permissionGroupKey: typeof entry.permissionGroupKey === 'string' && entry.permissionGroupKey ? entry.permissionGroupKey : undefined,
seq: typeof entry.seq === 'number' && Number.isFinite(entry.seq) ? entry.seq : undefined,
executionMode: typeof entry.executionMode === 'string' && entry.executionMode ? entry.executionMode : undefined,
}
}
function streamKey(entry: ProgressEntry): string {
const itemKey = entry.itemId || entry.streamId
if (!itemKey) {
if (entry.toolCallId && (entry.type === 'tool_call' || entry.type === 'autonomy')) {
return `${entry.type}:${entry.turnId ?? ''}:${entry.toolCallId}`
}
if (entry.permissionGroupKey && entry.type === 'autonomy') {
return `${entry.type}:${entry.turnId ?? ''}:${entry.permissionGroupKey}`
}
return ''
}
return `${entry.type}:${entry.turnId ?? ''}:${itemKey}`
}
function canMergeProgress(left: ProgressEntry, right: ProgressEntry): boolean {
const leftKey = streamKey(left)
const rightKey = streamKey(right)
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 === 'tool_call') return left.summary === right.summary
return false
}
function isDuplicateProgress(left: ProgressEntry, right: ProgressEntry): boolean {
return (
right.timestamp - left.timestamp <= STREAM_MERGE_WINDOW_MS
&& left.type === right.type
&& left.summary === right.summary
&& (left.detail ?? '') === (right.detail ?? '')
)
}
function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry {
if (left.type === 'thinking') {
const detail = mergeText(left.detail ?? left.summary, right.detail ?? right.summary, 'thinking')
return {
timestamp: right.timestamp,
type: 'thinking',
summary: summarizeThinking(detail, right.summary || left.summary),
detail: detail || undefined,
turnId: right.turnId ?? left.turnId,
itemId: right.itemId ?? left.itemId,
streamId: right.streamId ?? left.streamId,
toolCallId: right.toolCallId ?? left.toolCallId,
permissionGroupKey: right.permissionGroupKey ?? left.permissionGroupKey,
seq: right.seq ?? left.seq,
executionMode: right.executionMode ?? left.executionMode,
}
}
if (left.type === 'tool_call') {
const mergedDetail = mergeText(left.detail ?? '', right.detail ?? '', 'tool_call')
return {
timestamp: right.timestamp,
type: 'tool_call',
summary: right.summary || left.summary,
detail: mergedDetail || undefined,
turnId: right.turnId ?? left.turnId,
itemId: right.itemId ?? left.itemId,
streamId: right.streamId ?? left.streamId,
toolCallId: right.toolCallId ?? left.toolCallId,
permissionGroupKey: right.permissionGroupKey ?? left.permissionGroupKey,
seq: right.seq ?? left.seq,
executionMode: right.executionMode ?? left.executionMode,
}
}
return right
}
export function appendProgressEntry(
log: ProgressEntry[],
entry: ProgressEntry,
maxEntries = 100,
): ProgressEntry[] {
const normalized = normalizeProgressEntry(entry)
const normalizedKey = streamKey(normalized)
const targetIndex = normalizedKey
? [...log].reverse().findIndex(existing => streamKey(existing) === normalizedKey)
: -1
const actualIndex = targetIndex >= 0 ? log.length - 1 - targetIndex : log.length - 1
const last = log[actualIndex]
if (!last) return [normalized]
if (
normalizedKey
&& typeof last.seq === 'number'
&& typeof normalized.seq === 'number'
&& normalized.seq <= last.seq
) {
return log
}
if (isDuplicateProgress(last, normalized)) {
return clampEntries([
...log.slice(0, actualIndex),
{ ...last, timestamp: normalized.timestamp },
...log.slice(actualIndex + 1),
], maxEntries)
}
if (canMergeProgress(last, normalized)) {
return clampEntries([
...log.slice(0, actualIndex),
mergeProgress(last, normalized),
...log.slice(actualIndex + 1),
], maxEntries)
}
return clampEntries([...log, normalized], maxEntries)
}
export function normalizeProgressLog(log: ProgressEntry[], maxEntries = 100): ProgressEntry[] {
return (Array.isArray(log) ? log : []).reduce<ProgressEntry[]>(
(acc, entry) => appendProgressEntry(acc, entry, maxEntries),
[],
)
}
function normalizeWorkItemEntry(entry: WorkItemProgressEntry): WorkItemProgressEntry {
return {
timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
type: entry.type,
workItemProjectionId: typeof entry.workItemProjectionId === 'string' && entry.workItemProjectionId ? entry.workItemProjectionId : undefined,
workItemTurnType: typeof entry.workItemTurnType === 'string' && entry.workItemTurnType ? entry.workItemTurnType : undefined,
workItemProjectionTitle: typeof entry.workItemProjectionTitle === 'string' && entry.workItemProjectionTitle ? entry.workItemProjectionTitle : undefined,
runtimeTaskId: typeof entry.runtimeTaskId === 'string' && entry.runtimeTaskId ? entry.runtimeTaskId : undefined,
executionTurnId: typeof entry.executionTurnId === 'string' && entry.executionTurnId ? entry.executionTurnId : undefined,
roleName: typeof entry.roleName === 'string' && entry.roleName ? entry.roleName : undefined,
detail: typeof entry.detail === 'string' && entry.detail ? entry.detail : undefined,
}
}
function sameWorkItemScope(left: WorkItemProgressEntry, right: WorkItemProgressEntry): boolean {
return (
left.type === right.type
&& (left.workItemProjectionId ?? '') === (right.workItemProjectionId ?? '')
&& (left.workItemTurnType ?? '') === (right.workItemTurnType ?? '')
&& (left.workItemProjectionTitle ?? '') === (right.workItemProjectionTitle ?? '')
&& (left.executionTurnId ?? left.runtimeTaskId ?? '') === (right.executionTurnId ?? right.runtimeTaskId ?? '')
&& (left.roleName ?? '') === (right.roleName ?? '')
)
}
function canMergeWorkItem(left: WorkItemProgressEntry, right: WorkItemProgressEntry): boolean {
return (
right.timestamp - left.timestamp <= STREAM_MERGE_WINDOW_MS
&& sameWorkItemScope(left, right)
&& (left.type === 'thinking' || left.type === 'tool_call')
)
}
function isDuplicateWorkItem(left: WorkItemProgressEntry, right: WorkItemProgressEntry): boolean {
return sameWorkItemScope(left, right) && (left.detail ?? '') === (right.detail ?? '') && right.timestamp - left.timestamp <= STREAM_MERGE_WINDOW_MS
}
function mergeWorkItem(left: WorkItemProgressEntry, right: WorkItemProgressEntry): WorkItemProgressEntry {
const kind = right.type === 'tool_call' ? 'tool_call' : 'thinking'
return {
...left,
...right,
timestamp: right.timestamp,
detail: mergeText(left.detail ?? '', right.detail ?? '', kind) || undefined,
}
}
export function appendWorkItemProgressEntry(
log: WorkItemProgressEntry[],
entry: WorkItemProgressEntry,
maxEntries = 100,
): WorkItemProgressEntry[] {
const normalized = normalizeWorkItemEntry(entry)
const last = log[log.length - 1]
if (!last) return [normalized]
if (isDuplicateWorkItem(last, normalized)) {
return clampEntries([...log.slice(0, -1), { ...last, timestamp: normalized.timestamp }], maxEntries)
}
if (canMergeWorkItem(last, normalized)) {
return clampEntries([...log.slice(0, -1), mergeWorkItem(last, normalized)], maxEntries)
}
return clampEntries([...log, normalized], maxEntries)
}
export function normalizeWorkItemLog(log: WorkItemProgressEntry[], maxEntries = 100): WorkItemProgressEntry[] {
return (Array.isArray(log) ? log : []).reduce<WorkItemProgressEntry[]>(
(acc, entry) => appendWorkItemProgressEntry(acc, entry, maxEntries),
[],
)
}
@@ -0,0 +1,207 @@
import assert from 'node:assert/strict'
import { mapBackendSession } from './collabSync'
/* ──────────────────────────────────────────────────────────────────────
* roleWorkItems deserialization tests
*
* Locks the snake_case → camelCase conversion at the collabSync boundary
* so a backend rename or a missing field surfaces here, not as a silent
* UI regression in WorkItemProgressCard. The schema this matches is
* produced by ``snapshot_builder._build_role_work_items_for_session`` —
* keep these two in sync.
* ────────────────────────────────────────────────────────────────────── */
const session = mapBackendSession({
task_id: 'task-1',
channel_id: 'session:task-1',
status: 'running',
exec_mode: 'company',
is_company_runtime: true,
role_work_items: {
engineer: {
role_key: 'engineer',
role_id: 'engineer',
role_name: 'Engineer',
runtime_status: 'tool_active',
aggregated_status: 'active',
work_items: [
{
work_item_id: 'wi-1',
work_item_projection_id: 'proj-engineer-1',
phase: 'running',
kanban_column: 'in-progress',
title: 'Implement summary',
kind: 'execute',
is_review_target: false,
executor_role_id: 'engineer',
executor_role_name: 'Engineer',
reviewer_role_id: 'cto',
created_at: 100.0,
updated_at: 200.0,
execution_turn_id: 'runtime-task-1',
activity_sections: [
{
kind: 'execute',
title: 'Execute',
role_name: 'Engineer',
runtime_task_id: 'runtime-task-1',
entries: [
{ timestamp: 151.0, type: 'thinking', summary: 'plan' },
],
},
{
kind: 'report',
title: 'Report',
role_name: 'Engineer',
runtime_task_id: 'runtime-report-1',
entries: [
{ timestamp: 175.0, type: 'tool_call', summary: 'write_report' },
],
},
],
progress_log: [
{ timestamp: 150.0, type: 'tool_call', summary: 'edit_file' },
],
},
],
},
cto: {
role_key: 'cto',
role_id: 'cto',
role_name: 'CTO',
runtime_status: 'idle',
aggregated_status: 'waiting',
work_items: [
{
work_item_id: 'wi-2',
work_item_projection_id: 'proj-engineer-1',
phase: 'awaiting_manager_review',
kanban_column: 'in-review',
title: 'Implement summary',
kind: 'execute',
is_review_target: true,
executor_role_id: 'engineer',
reviewer_role_id: 'cto',
created_at: 50.0,
updated_at: 250.0,
execution_turn_id: 'runtime-task-1',
progress_log: [],
},
],
},
},
executor_role_work_items: {
engineer: {
role_key: 'engineer',
role_id: 'engineer',
role_name: 'Engineer',
runtime_status: 'idle',
aggregated_status: 'waiting',
work_items: [
{
work_item_id: 'wi-2',
work_item_projection_id: 'proj-engineer-1',
phase: 'awaiting_manager_review',
kanban_column: 'in-review',
title: 'Implement summary',
kind: 'execute',
is_review_target: true,
executor_role_id: 'engineer',
executor_role_name: 'Engineer',
reviewer_role_id: 'cto',
created_at: 50.0,
updated_at: 250.0,
execution_turn_id: 'runtime-task-1',
progress_log: [],
},
],
},
},
})
const roleWorkItems = session.roleWorkItems
assert.ok(roleWorkItems, 'roleWorkItems should be parsed')
assert.deepEqual(Object.keys(roleWorkItems!).sort(), ['cto', 'engineer'])
const engineer = roleWorkItems!.engineer
assert.equal(engineer.aggregatedStatus, 'active')
assert.equal(engineer.runtimeStatus, 'tool_active')
assert.equal(engineer.workItems.length, 1)
assert.equal(engineer.workItems[0].phase, 'running')
assert.equal(engineer.workItems[0].kanbanColumn, 'in-progress')
assert.equal(engineer.workItems[0].executionTurnId, 'runtime-task-1')
assert.equal(engineer.workItems[0].progressLog.length, 1)
assert.equal(engineer.workItems[0].progressLog[0].summary, 'edit_file')
assert.equal(engineer.workItems[0].activitySections?.length, 2)
assert.equal(engineer.workItems[0].activitySections?.[0].roleName, 'Engineer')
assert.equal(engineer.workItems[0].activitySections?.[1].runtimeTaskId, 'runtime-report-1')
assert.equal(engineer.workItems[0].activitySections?.[1].entries[0].summary, 'write_report')
const cto = roleWorkItems!.cto
assert.equal(cto.aggregatedStatus, 'waiting')
assert.equal(cto.runtimeStatus, 'idle')
assert.equal(cto.workItems.length, 1)
assert.equal(cto.workItems[0].isReviewTarget, true)
assert.equal(cto.workItems[0].kanbanColumn, 'in-review')
const executorRoleWorkItems = session.executorRoleWorkItems
assert.ok(executorRoleWorkItems, 'executorRoleWorkItems should be parsed')
assert.deepEqual(Object.keys(executorRoleWorkItems!).sort(), ['engineer'])
assert.equal(executorRoleWorkItems!.engineer.workItems.length, 1)
assert.equal(executorRoleWorkItems!.engineer.workItems[0].isReviewTarget, true)
assert.equal(executorRoleWorkItems!.engineer.workItems[0].reviewerRoleId, 'cto')
assert.equal(executorRoleWorkItems!.engineer.aggregatedStatus, 'waiting')
// Sanity: an unknown phase falls back to ``queued`` (safest "not started"
// label) rather than throwing — a backend with a temporarily stale enum
// shouldn't crash the panel.
const sessionWithBadPhase = mapBackendSession({
task_id: 'task-2',
channel_id: 'session:task-2',
status: 'running',
role_work_items: {
engineer: {
role_key: 'engineer',
role_id: 'engineer',
role_name: 'Engineer',
runtime_status: 'idle',
aggregated_status: 'pending',
work_items: [
{
work_item_id: 'wi-bad',
work_item_projection_id: 'proj-x',
phase: 'totally_made_up',
kanban_column: 'todo',
title: 'Mystery item',
created_at: 10.0,
updated_at: 10.0,
progress_log: [],
},
],
},
},
})
const fallbackRow = sessionWithBadPhase.roleWorkItems!.engineer.workItems[0]
assert.equal(fallbackRow.phase, 'queued')
// Empty / missing payload normalizes to ``undefined`` so the consumer can
// branch on ``.roleWorkItems != null`` without falsy-checking against {}.
const sessionWithoutPayload = mapBackendSession({
task_id: 'task-3',
channel_id: 'session:task-3',
status: 'running',
})
assert.equal(sessionWithoutPayload.roleWorkItems, undefined)
assert.equal(sessionWithoutPayload.executorRoleWorkItems, undefined)
const sessionWithEmptyPayload = mapBackendSession({
task_id: 'task-4',
channel_id: 'session:task-4',
status: 'running',
role_work_items: {},
executor_role_work_items: {},
})
assert.equal(sessionWithEmptyPayload.roleWorkItems, undefined)
assert.equal(sessionWithEmptyPayload.executorRoleWorkItems, undefined)
console.log('roleWorkItems deserialization checks passed')
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict'
import { getRuntimeOrgView, normalizeOrgInfoPayload } from './runtimeOrg'
const payload = normalizeOrgInfoPayload({
roles: [],
employees: [],
channels: [],
connectors: [],
company_profile: 'corporate',
organization_id: 'quantum_harbor',
organization_name: 'Quantum Harbor Research Studio',
organization_config_file: 'company_orgs/org_quantum_harbor_config.yaml',
org_version: 2,
runtime_topology_version: 2,
runtime_teams: [
{
cell_id: 'cell-2',
manager_role_id: 'mgr-2',
member_role_ids: ['role-b'],
status: 'idle',
},
],
runtime_seats: [
{
role_session_id: 'seat-2',
role_id: 'role-b',
employee_id: 'emp-2',
status: 'cold',
},
],
work_items: [
{
work_item_id: 'wi-2',
role_id: 'role-b',
cell_id: 'cell-2',
title: 'Runtime work',
kind: 'execute',
phase: 'ready',
metadata: {
adaptive: {
normalized_state: 'waiting_for_gate',
blocked_reason: 'Waiting for required signals: implementation_ready',
},
},
},
],
frontier: {
status: 'paused',
total_work_items: 1,
},
project_run: {
run_id: 'run-modern',
lifecycle_status: 'deliverable',
current_revision: 2,
},
project_dossier: {
latest_deliverable_summary: 'Ship candidate ready',
open_issues: ['Need QA sign-off'],
},
seat_digests: [
{
seat_id: 'seat-2',
team_id: 'cell-2',
manager_digest: {
pending_decisions: [{ subject: 'Approve release' }],
},
},
],
revision_links: [
{
link_id: 'link-1',
session_id: 'session-new',
linked_session_id: 'session-old',
link_type: 'revision_of',
},
],
})
assert.equal(payload.runtime_teams?.[0]?.cell_id, 'cell-2')
assert.equal(payload.runtime_seats?.[0]?.role_session_id, 'seat-2')
assert.equal(payload.work_items?.[0]?.work_item_id, 'wi-2')
assert.equal(payload.frontier?.status, 'paused')
assert.equal(payload.organization_id, 'quantum_harbor')
assert.equal(payload.organization_name, 'Quantum Harbor Research Studio')
assert.equal(payload.organization_config_file, 'company_orgs/org_quantum_harbor_config.yaml')
assert.equal(payload.project_run?.run_id, 'run-modern')
assert.equal(payload.project_dossier?.open_issues?.[0], 'Need QA sign-off')
assert.equal(payload.seat_digests?.[0]?.seat_id, 'seat-2')
assert.equal(payload.revision_links?.[0]?.link_type, 'revision_of')
const view = getRuntimeOrgView(payload)
assert.equal(view.runtimeTeams[0]?.cell_id, 'cell-2')
assert.equal(view.runtimeSeats[0]?.role_session_id, 'seat-2')
assert.equal(view.workItems[0]?.work_item_id, 'wi-2')
assert.equal(view.workItems[0]?.adaptive?.normalized_state, 'waiting_for_gate')
assert.equal(view.frontier.status, 'paused')
assert.equal(view.projectRun?.current_revision, 2)
assert.equal(view.projectDossier?.latest_deliverable_summary, 'Ship candidate ready')
assert.equal(view.seatDigests[0]?.seat_id, 'seat-2')
assert.equal(view.revisionLinks[0]?.link_type, 'revision_of')
console.log('runtimeOrg contract checks passed')
@@ -0,0 +1,227 @@
import type {
OrgInfoPayload,
ProjectDossierInfo,
ProjectRunInfo,
RuntimeFrontierSummary,
RuntimeSeatInfo,
RuntimeTeamInfo,
RuntimeWorkItemInfo,
SeatDigestInfo,
SessionLinkInfo,
} from '../types/visual'
type RecordLike = Record<string, unknown>
function isRecord(value: unknown): value is RecordLike {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function asRecordArray<T extends RecordLike = RecordLike>(value: unknown): T[] {
return Array.isArray(value) ? (value.filter(isRecord) as T[]) : []
}
function asStringList(value: unknown): string[] {
return Array.isArray(value)
? value.map((item) => String(item ?? '').trim()).filter(Boolean)
: []
}
function asRuntimeFrontierSummary(value: unknown): RuntimeFrontierSummary {
if (!isRecord(value)) return {}
return {
run_id: typeof value.run_id === 'string' ? value.run_id : undefined,
status: typeof value.status === 'string' ? value.status : undefined,
total_cells: typeof value.total_cells === 'number' ? value.total_cells : undefined,
total_role_sessions: typeof value.total_role_sessions === 'number' ? value.total_role_sessions : undefined,
total_work_items: typeof value.total_work_items === 'number' ? value.total_work_items : undefined,
ready_count: typeof value.ready_count === 'number' ? value.ready_count : undefined,
running_count: typeof value.running_count === 'number' ? value.running_count : undefined,
blocked_count: typeof value.blocked_count === 'number' ? value.blocked_count : undefined,
waiting_count: typeof value.waiting_count === 'number' ? value.waiting_count : undefined,
done_count: typeof value.done_count === 'number' ? value.done_count : undefined,
failed_count: typeof value.failed_count === 'number' ? value.failed_count : undefined,
}
}
function mapRuntimeTeam(raw: RecordLike): RuntimeTeamInfo {
return {
cell_id: String(raw.cell_id ?? raw.team_id ?? raw.id ?? '').trim(),
team_instance_id: typeof raw.team_instance_id === 'string' ? raw.team_instance_id : undefined,
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
manager_role_id: String(raw.manager_role_id ?? '').trim(),
member_role_ids: asStringList(raw.member_role_ids),
seat_ids: asStringList(raw.seat_ids),
parent_team_id: typeof raw.parent_team_id === 'string' ? raw.parent_team_id : undefined,
status: String(raw.status ?? 'idle'),
is_final_decider_cell: typeof raw.is_final_decider_cell === 'boolean' ? raw.is_final_decider_cell : undefined,
}
}
function mapRuntimeSeat(raw: RecordLike): RuntimeSeatInfo {
return {
role_session_id: String(raw.role_session_id ?? raw.id ?? '').trim(),
role_id: String(raw.role_id ?? '').trim(),
employee_id: String(raw.employee_id ?? '').trim(),
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
team_instance_id: typeof raw.team_instance_id === 'string' ? raw.team_instance_id : undefined,
seat_id: typeof raw.seat_id === 'string' ? raw.seat_id : undefined,
focused_work_item_id: typeof raw.focused_work_item_id === 'string' ? raw.focused_work_item_id : undefined,
current_work_item_id: typeof raw.current_work_item_id === 'string' ? raw.current_work_item_id : undefined,
background_work_item_ids: asStringList(raw.background_work_item_ids),
manager_role_ids: asStringList(raw.manager_role_ids),
manager_seat_id: typeof raw.manager_seat_id === 'string' ? raw.manager_seat_id : undefined,
resident_status: typeof raw.resident_status === 'string' ? raw.resident_status : undefined,
latest_notification: isRecord(raw.latest_notification) ? raw.latest_notification : undefined,
manager_digest: isRecord(raw.manager_digest) ? raw.manager_digest : undefined,
status: String(raw.status ?? 'cold'),
}
}
function mapRuntimeWorkItem(raw: RecordLike): RuntimeWorkItemInfo {
const metadata = isRecord(raw.metadata) ? raw.metadata : undefined
const adaptive = isRecord(raw.adaptive)
? raw.adaptive
: metadata && isRecord(metadata.adaptive)
? metadata.adaptive
: undefined
return {
work_item_id: String(raw.work_item_id ?? raw.id ?? '').trim(),
role_id: String(raw.role_id ?? '').trim(),
cell_id: String(raw.cell_id ?? '').trim(),
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
team_instance_id: typeof raw.team_instance_id === 'string' ? raw.team_instance_id : undefined,
seat_id: typeof raw.seat_id === 'string' ? raw.seat_id : undefined,
title: String(raw.title ?? ''),
kind: String(raw.kind ?? 'execute'),
phase: String(raw.phase ?? 'ready'),
kanban_column: String(raw.kanban_column ?? 'todo'),
batch_id: typeof raw.batch_id === 'string' ? raw.batch_id : undefined,
batch_index: typeof raw.batch_index === 'number' ? raw.batch_index : undefined,
deliverable_summary: typeof raw.deliverable_summary === 'string' ? raw.deliverable_summary : undefined,
blocked_reason: typeof raw.blocked_reason === 'string' ? raw.blocked_reason : undefined,
handoff_status: typeof raw.handoff_status === 'string' ? raw.handoff_status : undefined,
parent_work_item_id: typeof raw.parent_work_item_id === 'string' ? raw.parent_work_item_id : undefined,
work_item_projection_id: typeof raw.work_item_projection_id === 'string' ? raw.work_item_projection_id : undefined,
metadata,
adaptive,
}
}
function asProjectRunInfo(value: unknown): ProjectRunInfo | undefined {
if (!isRecord(value)) return undefined
return {
run_id: typeof value.run_id === 'string' ? value.run_id : undefined,
project_id: typeof value.project_id === 'string' ? value.project_id : undefined,
session_id: typeof value.session_id === 'string' ? value.session_id : undefined,
status: typeof value.status === 'string' ? value.status : undefined,
lifecycle_status: typeof value.lifecycle_status === 'string' ? value.lifecycle_status : undefined,
company_profile: typeof value.company_profile === 'string' ? value.company_profile : undefined,
execution_model: typeof value.execution_model === 'string' ? value.execution_model : undefined,
current_revision: typeof value.current_revision === 'number' ? value.current_revision : undefined,
latest_deliverable_summary: typeof value.latest_deliverable_summary === 'string' ? value.latest_deliverable_summary : undefined,
recovery_pointer: isRecord(value.recovery_pointer) ? value.recovery_pointer : undefined,
}
}
function asProjectDossierInfo(value: unknown): ProjectDossierInfo | undefined {
if (!isRecord(value)) return undefined
return {
project_id: typeof value.project_id === 'string' ? value.project_id : undefined,
run_id: typeof value.run_id === 'string' ? value.run_id : undefined,
latest_deliverable_summary: typeof value.latest_deliverable_summary === 'string' ? value.latest_deliverable_summary : undefined,
architecture_decisions: asRecordArray(value.architecture_decisions),
completed_work_items: asRecordArray(value.completed_work_items),
open_issues: asStringList(value.open_issues),
verification_summary: typeof value.verification_summary === 'string' ? value.verification_summary : undefined,
artifact_index: asRecordArray(value.artifact_index),
handoff_summaries: asRecordArray(value.handoff_summaries),
last_failure_summary: typeof value.last_failure_summary === 'string' ? value.last_failure_summary : undefined,
project_memory_excerpt: typeof value.project_memory_excerpt === 'string' ? value.project_memory_excerpt : undefined,
session_memory_excerpt: typeof value.session_memory_excerpt === 'string' ? value.session_memory_excerpt : undefined,
}
}
function mapSeatDigest(raw: RecordLike): SeatDigestInfo {
return {
seat_id: String(raw.seat_id ?? raw.id ?? '').trim(),
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
role_id: typeof raw.role_id === 'string' ? raw.role_id : undefined,
employee_id: typeof raw.employee_id === 'string' ? raw.employee_id : undefined,
role_session_id: typeof raw.role_session_id === 'string' ? raw.role_session_id : undefined,
resident_status: typeof raw.resident_status === 'string' ? raw.resident_status : undefined,
current_work_item: isRecord(raw.current_work_item) ? raw.current_work_item : undefined,
latest_notification: isRecord(raw.latest_notification) ? raw.latest_notification : undefined,
manager_digest: isRecord(raw.manager_digest) ? raw.manager_digest : undefined,
}
}
function mapSessionLink(raw: RecordLike): SessionLinkInfo {
return {
link_id: typeof raw.link_id === 'string' ? raw.link_id : undefined,
session_id: typeof raw.session_id === 'string' ? raw.session_id : undefined,
linked_session_id: typeof raw.linked_session_id === 'string' ? raw.linked_session_id : undefined,
link_type: typeof raw.link_type === 'string' ? raw.link_type : undefined,
metadata: isRecord(raw.metadata) ? raw.metadata : undefined,
created_at: typeof raw.created_at === 'string' ? raw.created_at : undefined,
}
}
export function normalizeOrgInfoPayload(raw: OrgInfoPayload | RecordLike | null | undefined): OrgInfoPayload {
const source = isRecord(raw) ? raw : {}
const runtimeTeams = asRecordArray(source.runtime_teams).map(mapRuntimeTeam)
const runtimeSeats = asRecordArray(source.runtime_seats).map(mapRuntimeSeat)
const workItems = asRecordArray(source.work_items).map(mapRuntimeWorkItem)
return {
roles: Array.isArray(source.roles) ? source.roles as OrgInfoPayload['roles'] : [],
employees: Array.isArray(source.employees) ? source.employees as OrgInfoPayload['employees'] : [],
company_profile: typeof source.company_profile === 'string' ? source.company_profile : '',
organization_id: typeof source.organization_id === 'string' ? source.organization_id : undefined,
organization_name: typeof source.organization_name === 'string' ? source.organization_name : undefined,
organization_config_file: typeof source.organization_config_file === 'string' ? source.organization_config_file : undefined,
final_decider_role_id: typeof source.final_decider_role_id === 'string' ? source.final_decider_role_id : null,
top_level_role_ids: asStringList(source.top_level_role_ids),
runtime_teams: runtimeTeams,
runtime_seats: runtimeSeats,
work_items: workItems,
frontier: asRuntimeFrontierSummary(source.frontier),
project_run: asProjectRunInfo(source.project_run),
project_dossier: asProjectDossierInfo(source.project_dossier),
seat_digests: asRecordArray(source.seat_digests).map(mapSeatDigest),
revision_links: asRecordArray(source.revision_links).map(mapSessionLink),
project_recovery: isRecord(source.project_recovery) ? source.project_recovery : undefined,
channels: Array.isArray(source.channels) ? source.channels as OrgInfoPayload['channels'] : [],
connectors: Array.isArray(source.connectors) ? source.connectors as OrgInfoPayload['connectors'] : [],
org_version: typeof source.org_version === 'number' ? source.org_version : 0,
runtime_topology_version: typeof source.runtime_topology_version === 'number' ? source.runtime_topology_version : 0,
installed_packages: Array.isArray(source.installed_packages) ? source.installed_packages as NonNullable<OrgInfoPayload['installed_packages']> : [],
runtime_policy: isRecord(source.runtime_policy) ? source.runtime_policy as NonNullable<OrgInfoPayload['runtime_policy']> : undefined,
}
}
export interface RuntimeOrgView {
runtimeTeams: RuntimeTeamInfo[]
runtimeSeats: RuntimeSeatInfo[]
workItems: RuntimeWorkItemInfo[]
frontier: RuntimeFrontierSummary
projectRun?: ProjectRunInfo
projectDossier?: ProjectDossierInfo
seatDigests: SeatDigestInfo[]
revisionLinks: SessionLinkInfo[]
projectRecovery?: Record<string, unknown>
}
export function getRuntimeOrgView(payload: OrgInfoPayload | null | undefined): RuntimeOrgView {
const normalized = normalizeOrgInfoPayload(payload)
return {
runtimeTeams: normalized.runtime_teams ?? [],
runtimeSeats: normalized.runtime_seats ?? [],
workItems: normalized.work_items ?? [],
frontier: normalized.frontier ?? {},
projectRun: normalized.project_run,
projectDossier: normalized.project_dossier,
seatDigests: normalized.seat_digests ?? [],
revisionLinks: normalized.revision_links ?? [],
projectRecovery: normalized.project_recovery,
}
}
@@ -0,0 +1,51 @@
import type { Session } from '../types/kanban'
export type CanonicalSessionExecMode = 'task' | 'company' | 'org'
export type CanonicalCompanyProfile = 'corporate' | 'custom'
export function normalizeSessionExecMode(value?: string | null): CanonicalSessionExecMode {
const normalized = String(value ?? '').trim().toLowerCase()
if (normalized === 'company') return 'company'
if (normalized === 'org' || normalized === 'custom') return 'org'
return 'task'
}
export function normalizeSessionCompanyProfile(value?: string | null): CanonicalCompanyProfile {
return String(value ?? '').trim().toLowerCase() === 'custom' ? 'custom' : 'corporate'
}
export function canonicalizeSessionExecutionIdentity<T extends Partial<Session>>(session: T): T {
const rawMode = String(session.execMode ?? '').trim().toLowerCase()
const rawProfile = String(session.companyProfile ?? '').trim().toLowerCase()
const rawOrgId = String(session.orgId ?? '').trim()
const hasExplicitMode = rawMode.length > 0
const execMode: CanonicalSessionExecMode = hasExplicitMode
? normalizeSessionExecMode(rawMode)
: (rawProfile === 'custom' || rawOrgId ? 'org' : normalizeSessionExecMode(rawMode))
if (execMode === 'org') {
return {
...session,
execMode: 'org',
companyProfile: 'custom',
orgId: rawOrgId || undefined,
}
}
if (execMode === 'company') {
return {
...session,
execMode: 'company',
companyProfile: 'corporate',
orgId: undefined,
}
}
return {
...session,
execMode: 'task',
companyProfile: undefined,
orgId: undefined,
}
}
@@ -0,0 +1,67 @@
import type { ChatMessage } from '../types/chat'
/** Channel id that buckets a session's chat messages. */
export function sessionChannelId(taskId: string): string {
return `session:${taskId}`
}
const RECRUITMENT_CHECKPOINT_TYPES = new Set([
'company_recruitment_confirmation',
'company_staffing_selection',
])
/**
* Map `role_id -> recruited person/template display names` for a single chat
* session, derived from that session's latest recruitment checkpoint message.
*
* This is display-only plumbing: org_info / `data.employees` is global (the
* project's active run / accumulated org config), so it cannot represent the
* recruitment of an arbitrary *selected* session. The recruitment a user
* confirmed in a session is persisted as a chat checkpoint message, which is
* the only per-session source the frontend already has.
*
* Returns `null` when the session has no recruitment checkpoint loaded, so the
* caller can fall back to the global employee list (previous behaviour).
*/
export function extractSessionRecruitmentByRole(
messages: ChatMessage[],
): Record<string, string[]> | null {
let latest: ChatMessage | null = null
for (const m of messages) {
const ct = m.metadata?.checkpoint_type
if (!ct || !RECRUITMENT_CHECKPOINT_TYPES.has(ct)) continue
if (!latest || (m.timestamp ?? 0) >= (latest.timestamp ?? 0)) latest = m
}
if (!latest?.metadata) return null
const meta = latest.metadata
const map: Record<string, string[]> = {}
const push = (roleId: unknown, name: unknown) => {
const r = String(roleId ?? '').trim()
const n = String(name ?? '').trim()
if (!r || !n) return
const arr = map[r] ?? (map[r] = [])
if (!arr.includes(n)) arr.push(n)
}
// Preferred: recruitment_rationales is already a flat per-role display label
// (this is exactly the "(role_id, role, recruited name, reason)" list the
// user sees in the recruitment confirmation panel).
for (const r of meta.recruitment_rationales ?? []) {
if (r?.selection_label) push(r.role_id, r.selection_label)
}
// Fallback: derive a name from the structured proposals when rationales are
// absent (older payloads / staffing-only checkpoints).
if (Object.keys(map).length === 0) {
for (const p of meta.proposals ?? []) {
const name =
p?.existing_employee?.employee_name ||
p?.candidate?.proposed_name ||
p?.candidate?.template_name ||
''
push(p?.role_id, name)
}
}
return Object.keys(map).length ? map : null
}
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import type { Session } from '../types/kanban'
import {
companyRuntimeControlPatchForBoardStatus,
isCompanyRuntimeSession,
} from './sessionRuntime'
function makeSession(overrides: Partial<Session>): Partial<Session> {
return {
taskId: 'task-a',
status: 'idle',
execMode: 'task',
...overrides,
}
}
assert.equal(
isCompanyRuntimeSession(makeSession({ execMode: 'org' })),
true,
'org sessions are company runtime sessions',
)
assert.equal(
isCompanyRuntimeSession(makeSession({ execMode: 'company' })),
true,
'company sessions are company runtime sessions',
)
assert.equal(
isCompanyRuntimeSession(makeSession({ parentSessionId: 'root-session' })),
true,
'child runtime sessions are company runtime sessions',
)
assert.equal(
isCompanyRuntimeSession(makeSession({ execMode: 'task', companyProfile: undefined })),
false,
'plain task sessions are not company runtime sessions',
)
assert.equal(
isCompanyRuntimeSession(makeSession({ execMode: 'task', companyProfile: 'corporate' })),
false,
'explicit task sessions with legacy default companyProfile stay plain task sessions',
)
assert.deepEqual(
companyRuntimeControlPatchForBoardStatus(makeSession({ execMode: 'org' }), 'running'),
{ runtimeControlState: 'running', canStop: true, canResume: false },
'company running board events must show Stop immediately',
)
assert.deepEqual(
companyRuntimeControlPatchForBoardStatus(
makeSession({ execMode: 'org', runtimeControlState: 'running', canStop: true }),
'done',
),
{ runtimeControlState: 'idle', canStop: false, canResume: false },
'company terminal board events must clear Stop after completion',
)
assert.deepEqual(
companyRuntimeControlPatchForBoardStatus(
makeSession({ execMode: 'org', runtimeControlState: 'suspended', canResume: true }),
'cancelled',
),
{},
'terminal child events must not erase an explicit suspended/continue state',
)
assert.deepEqual(
companyRuntimeControlPatchForBoardStatus(makeSession({ execMode: 'task' }), 'running'),
{},
'plain task board events must not opt into company runtime control',
)
console.log('sessionRuntime.test.ts: OK (company board status drives Stop without breaking Continue)')
@@ -0,0 +1,74 @@
import type { AgentAnimStatus, Session } from '../types/kanban'
export const TERMINAL_SESSION_STATUSES = new Set(['done', 'failed', 'cancelled'])
const COMPANY_EXEC_MODES = new Set(['company', 'org', 'custom'])
const BOARD_STATUS_IDLE = new Set(['idle', 'done', 'failed', 'cancelled'])
export function getSessionRuntimeStatus(
session: Pick<Session, 'status' | 'agentStatus' | 'runtimeControlState'>,
): AgentAnimStatus {
if (TERMINAL_SESSION_STATUSES.has(session.status)) return 'idle'
if (session.agentStatus === 'reflecting' || session.agentStatus === 'tool_active') {
return session.agentStatus
}
if (
session.runtimeControlState
&& session.runtimeControlState !== 'running'
&& session.runtimeControlState !== 'suspending'
&& session.runtimeControlState !== 'resuming'
) {
return 'idle'
}
if (session.status === 'running') return 'reflecting'
return 'idle'
}
export function isSessionWorking(
session: Pick<Session, 'status' | 'agentStatus' | 'runtimeControlState'>,
): boolean {
return getSessionRuntimeStatus(session) !== 'idle'
}
export function isCompanyRuntimeSession(session?: Partial<Session> | null): boolean {
if (!session) return false
const mode = String(session.execMode ?? '').trim().toLowerCase()
const hasExplicitTaskMode = mode === 'task'
return COMPANY_EXEC_MODES.has(mode)
|| !!session.isCompanyRuntime
|| !!session.parentSessionId
|| !!session.orgId
|| !!session.workItemProjectionId
|| !!session.roleWorkItems
|| !!session.executorRoleWorkItems
|| (!!session.companyProfile && !hasExplicitTaskMode)
}
export function companyRuntimeControlPatchForBoardStatus(
session: Partial<Session> | undefined | null,
status: string,
): Partial<Pick<Session, 'runtimeControlState' | 'canStop' | 'canResume'>> {
if (!isCompanyRuntimeSession(session)) return {}
const normalizedStatus = String(status ?? '').trim().toLowerCase()
if (normalizedStatus === 'running') {
return {
runtimeControlState: 'running',
canStop: true,
canResume: false,
}
}
if (BOARD_STATUS_IDLE.has(normalizedStatus)) {
const existingState = session?.runtimeControlState
if (existingState === 'suspending' || existingState === 'suspended') {
return {}
}
return {
runtimeControlState: 'idle',
canStop: false,
canResume: false,
}
}
return {}
}
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
import { compactSessionTitle } from './sessionTitle'
assert.equal(
compactSessionTitle('one two three four five six seven eight nine ten eleven'),
'one two three four five six seven eight nine ten...',
)
assert.equal(
compactSessionTitle('请你帮我设计实现一个后端管理系统'),
'请你帮我设计实现一个...',
)
assert.equal(
compactSessionTitle('Build API 请实现登录流程 and tests now'),
'Build API 请实现登录流程 and...',
)
assert.equal(compactSessionTitle('Short title'), 'Short title')
assert.equal(compactSessionTitle(' '), 'New Chat')
console.log('sessionTitle.test.ts: OK (session titles compact to 10 mixed units)')
@@ -0,0 +1,66 @@
function isCjkCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x3400 && codePoint <= 0x4dbf)
|| (codePoint >= 0x4e00 && codePoint <= 0x9fff)
|| (codePoint >= 0xf900 && codePoint <= 0xfaff)
|| (codePoint >= 0x3040 && codePoint <= 0x30ff)
|| (codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
function isCjkChar(ch: string): boolean {
const codePoint = ch.codePointAt(0)
return typeof codePoint === 'number' && isCjkCodePoint(codePoint)
}
function isWordChar(ch: string): boolean {
return !isCjkChar(ch) && /^[\p{L}\p{N}_]$/u.test(ch)
}
export function compactSessionTitle(input: string, maxUnits = 10, fallback = 'New Chat'): string {
const text = String(input || '').replace(/\s+/g, ' ').trim()
if (!text || maxUnits <= 0) return fallback
let units = 0
let index = 0
let cutIndex = text.length
while (index < text.length) {
const codePoint = text.codePointAt(index)
if (typeof codePoint !== 'number') break
const ch = String.fromCodePoint(codePoint)
if (/\s/.test(ch)) {
index += ch.length
continue
}
if (isCjkChar(ch)) {
units += 1
index += ch.length
if (units === maxUnits) {
cutIndex = index
break
}
continue
}
if (isWordChar(ch)) {
while (index < text.length) {
const innerCodePoint = text.codePointAt(index)
if (typeof innerCodePoint !== 'number') break
const innerChar = String.fromCodePoint(innerCodePoint)
if (!isWordChar(innerChar)) break
index += innerChar.length
}
units += 1
if (units === maxUnits) {
cutIndex = index
break
}
continue
}
index += ch.length
}
const compact = text.slice(0, cutIndex).trim() || fallback
const hasMoreUnits = Array.from(text.slice(cutIndex)).some(ch => isCjkChar(ch) || isWordChar(ch))
return hasMoreUnits ? `${compact}...` : compact
}
@@ -0,0 +1,21 @@
import type { ChatStoreState } from '../chat/ChatStore'
import type { KanbanTask } from '../types/kanban'
export function notifyTaskAssigned(
chatStore: ChatStoreState,
task: KanbanTask,
agentNames: string[],
officeChannelId?: string,
) {
const names = agentNames.join(', ')
const channelId = officeChannelId ?? `session:${task.id}`
const targetCh = chatStore.channels.find(ch => ch.id === channelId) ?? chatStore.channels.find(ch => ch.type === 'activity')
if (!targetCh) return
chatStore.sendMessage({
channelId: targetCh.id,
sender: 'system',
senderName: 'System',
content: `Task **${task.displayId}** assigned to ${names}`,
metadata: { type: 'task_assigned', taskId: task.id, boardId: task.boardId },
})
}
@@ -0,0 +1,32 @@
import type { EmployeeAssignment } from '../types/kanban'
type WorkItemIdentity = {
workItemRoleId?: string
workItemRoleName?: string
employeeAssignment?: EmployeeAssignment
}
export function humanizeWorkItemRoleId(value?: string): string {
const normalized = (value ?? '').trim()
if (!normalized) return ''
return normalized
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
export function getWorkItemRoleLabel(identity: WorkItemIdentity): string {
const explicit = (identity.workItemRoleName ?? '').trim()
if (explicit) return explicit
return humanizeWorkItemRoleId(identity.workItemRoleId)
}
export function getWorkItemEmployeeLabel(identity: WorkItemIdentity): string {
return (identity.employeeAssignment?.name ?? '').trim()
}
export function getWorkItemAssignmentLabel(identity: WorkItemIdentity): string {
const role = getWorkItemRoleLabel(identity)
const employee = getWorkItemEmployeeLabel(identity)
if (role && employee) return `${role} · ${employee}`
return role || employee
}
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import { mapBackendSession, mapBackendTask } from './collabSync'
import { getExecutionTurnId, getLinkedRuntimeTaskId, getWorkItemCardId } from './workItemRuntimeIds'
const workItemCard = mapBackendTask({
task_id: 'wi-1',
work_item_id: 'wi-1',
title: 'Prepare launch plan',
kanban_column: 'in-progress',
runtime_task_id: 'runtime-task-1',
execution_turn_id: 'runtime-task-1',
})
assert.equal(getWorkItemCardId(workItemCard), 'wi-1')
assert.equal(getLinkedRuntimeTaskId(workItemCard), 'runtime-task-1')
assert.equal(workItemCard.runtimeTaskId, 'runtime-task-1')
assert.equal(workItemCard.executionTurnId, 'runtime-task-1')
const canonicalCard = mapBackendTask({
work_item_id: 'wi-2',
title: 'Review launch plan',
kanban_column: 'in-review',
runtime_task_id: 'runtime-task-2',
execution_turn_id: 'runtime-task-2',
})
assert.equal(getWorkItemCardId(canonicalCard), 'wi-2')
assert.equal(getLinkedRuntimeTaskId(canonicalCard), 'runtime-task-2')
const plainTaskCard = mapBackendTask({
task_id: 'plain-task-1',
title: 'Plain task',
kanban_column: 'todo',
runtime_task_id: 'plain-task-1',
})
assert.equal(plainTaskCard.runtimeTaskId, 'plain-task-1')
assert.equal(getLinkedRuntimeTaskId(plainTaskCard), '')
const runtimeSession = mapBackendSession({
task_id: 'legacy-task-id',
runtime_task_id: 'runtime-task-3',
execution_turn_id: 'turn-3',
channel_id: 'session:runtime-task-3',
title: 'CTO Execution Turn',
})
assert.equal(runtimeSession.taskId, 'legacy-task-id')
assert.equal(runtimeSession.runtimeTaskId, 'runtime-task-3')
assert.equal(runtimeSession.executionTurnId, 'turn-3')
assert.equal(getExecutionTurnId(runtimeSession), 'turn-3')
const taskSessionWithRuntimeParentMetadata = mapBackendSession({
project_id: 'default',
task_id: 'task-mode-session',
session_id: 'task-session-id',
parent_session_id: 'task-session-id',
channel_id: 'session:task-mode-session',
title: 'Task mode session detail',
exec_mode: 'task',
})
assert.equal(taskSessionWithRuntimeParentMetadata.execMode, 'task')
assert.equal(taskSessionWithRuntimeParentMetadata.mode, 'primary')
assert.equal(taskSessionWithRuntimeParentMetadata.parentSessionId, undefined)
console.log('workItemRuntimeIds alias checks passed')
@@ -0,0 +1,34 @@
import type { KanbanTask, Session, WorkItemProgressEntry } from '../types/kanban'
type WorkItemCardLike = Pick<KanbanTask, 'id' | 'workItemId'>
type ExecutionTurnLike =
| Pick<Session, 'taskId' | 'runtimeTaskId' | 'executionTurnId'>
| Pick<WorkItemProgressEntry, 'runtimeTaskId' | 'executionTurnId'>
type LinkedRuntimeLike = Pick<KanbanTask, 'runtimeTaskId' | 'executionTurnId'>
& Pick<KanbanTask, 'workItemId'>
function clean(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
export function getWorkItemCardId(card: WorkItemCardLike | null | undefined): string {
return clean(card?.workItemId) || clean(card?.id)
}
export function getExecutionTurnId(turn: ExecutionTurnLike | null | undefined): string {
if (!turn) return ''
const raw = turn as Partial<Session & WorkItemProgressEntry>
return (
clean(raw.executionTurnId)
|| clean(raw.runtimeTaskId)
|| clean(raw.taskId)
)
}
export function getLinkedRuntimeTaskId(card: LinkedRuntimeLike | null | undefined): string {
return clean(card?.workItemId)
? clean(card?.executionTurnId) || clean(card?.runtimeTaskId)
: ''
}
@@ -0,0 +1,372 @@
import assert from 'node:assert/strict'
import type { ChatMessage } from '../types/chat'
import type { Session } from '../types/kanban'
import { mapBackendSession } from './collabSync'
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation } from './workItemSessions'
function makeSession(overrides: Partial<Session> & Pick<Session, 'taskId' | 'channelId' | 'title' | 'status' | 'columnId' | 'assigneeIds' | 'priority' | 'tags' | 'progressLog' | 'createdAt' | 'updatedAt' | 'messageCount'>): Session {
return {
projectId: 'test-project',
...overrides,
}
}
const parent = makeSession({
taskId: 'root-task',
channelId: 'session:root-task',
title: 'Root',
status: 'running',
columnId: 'in-progress',
assigneeIds: [],
priority: null,
tags: [],
progressLog: [],
createdAt: 1,
updatedAt: 10,
messageCount: 1,
mode: 'primary',
originTaskId: 'root-task',
})
const child = makeSession({
taskId: 'child-task',
channelId: 'session:child-task',
title: 'CEO Intake',
status: 'done',
columnId: 'done',
assigneeIds: ['ceo'],
priority: null,
tags: [],
progressLog: [],
createdAt: 2,
updatedAt: 11,
messageCount: 2,
mode: 'child',
parentSessionId: 'parent-session-id-that-has-not-arrived-yet',
originTaskId: 'root-task',
})
const matches = getWorkItemChildSessions(parent, [parent, child])
assert.equal(matches.length, 1)
assert.equal(matches[0]?.taskId, 'child-task')
const companyParent = makeSession({
taskId: 'company-root',
channelId: 'session:company-root',
sessionId: 'company-root-session',
title: 'Work-Item Runtime Root',
status: 'running',
columnId: 'in-progress',
assigneeIds: [],
priority: null,
tags: [],
progressLog: [],
createdAt: 1,
updatedAt: 10,
messageCount: 1,
mode: 'primary',
execMode: 'company',
originTaskId: 'company-root',
})
const companyChild = makeSession({
taskId: 'company-child',
channelId: 'session:company-child',
title: 'CTO Delegation',
status: 'running',
columnId: 'in-progress',
assigneeIds: ['cto'],
priority: null,
tags: [],
progressLog: [],
createdAt: 2,
updatedAt: 25,
messageCount: 4,
mode: 'child',
parentSessionId: 'company-root-session',
originTaskId: 'company-root',
})
const companyProjection = projectSessionConversation(companyParent, [companyChild])
assert.deepEqual(
companyProjection.timelineSessions.map((session) => session.taskId),
['company-root', 'company-child'],
)
assert.equal(companyProjection.displaySession?.taskId, 'company-root')
assert.equal(companyProjection.runtimeSession?.taskId, 'company-child')
assert.equal(companyProjection.projectedFromChild, false)
const idleChildWithNewerInboxTimestamp = makeSession({
...companyChild,
taskId: 'company-idle-child',
channelId: 'session:company-idle-child',
title: 'Idle Child Inbox Update',
updatedAt: 1_000_000,
messageCount: 0,
progressLog: [],
})
const stableCompanyProjection = projectSessionConversation(companyParent, [idleChildWithNewerInboxTimestamp])
assert.equal(stableCompanyProjection.runtimeSession?.taskId, 'company-root')
const companyHeaderView = getConversationHeaderSession(
{
...companyParent,
workItemRoleName: 'CEO',
employeeAssignment: {
name: 'Root Employee',
category: 'leadership',
},
},
{
...companyChild,
workItemRoleName: 'CTO',
contextTokens: 0,
contextWindow: 128000,
inputTokens: 11,
outputTokens: 22,
totalTokens: 33,
turnCostUsd: 0.001,
sessionCostUsd: 0.002,
selectedExecutionAgent: 'codex',
employeeAssignment: {
name: 'Child Employee',
category: 'engineering',
},
},
[companyParent, companyChild],
)
assert.equal(companyHeaderView?.taskId, 'company-root')
assert.equal(companyHeaderView?.workItemRoleName, 'CEO')
assert.equal(companyHeaderView?.employeeAssignment?.name, 'Root Employee')
const resultMessage = (id: string, channelId: string, content: string, metadata: ChatMessage['metadata'], sender = 'chao'): ChatMessage => ({
id,
channelId,
sender,
senderName: sender === 'system' ? 'Company Member' : 'Chao',
content,
timestamp: 1000 + id.length,
mentions: [],
metadata,
})
const finalBody = 'Final delivery is ready with a long enough body to be considered the same user-visible result across transcript mirrors and worker notifications.'
const mergedDeliveryMessages = mergeConversationMessages([
[
resultMessage(
'opc-top-level',
'session:company-root',
finalBody,
{ source: 'engine', transcript_kind: 'top_level_reply' },
'assistant',
),
],
[
resultMessage(
'parent-mirror',
'session:company-root',
`**Deliver final result to user: Chao Intake**: ${finalBody}`,
{ source: 'engine', transcript_kind: 'child_result' },
),
],
[
resultMessage(
'child-direct',
'session:company-child',
finalBody,
{ source: 'engine', transcript_kind: 'child_task_result' },
),
resultMessage(
'worker-note',
'session:company-child',
finalBody,
{ source: 'runtime_event', kind: 'worker_notification', notification_kind: 'task_complete' },
'system',
),
],
])
assert.equal(mergedDeliveryMessages.length, 1)
assert.equal(mergedDeliveryMessages[0]?.id, 'child-direct')
assert.equal(companyHeaderView?.status, 'running')
assert.equal(companyHeaderView?.contextTokens, 0)
assert.equal(companyHeaderView?.contextWindow, 128000)
assert.equal(companyHeaderView?.inputTokens, 11)
assert.equal(companyHeaderView?.outputTokens, 22)
assert.equal(companyHeaderView?.totalTokens, 33)
assert.equal(companyHeaderView?.turnCostUsd, 0.001)
assert.equal(companyHeaderView?.sessionCostUsd, 0.002)
assert.equal(companyHeaderView?.selectedExecutionAgent, 'codex')
const customOrgRoot = makeSession({
taskId: 'custom-root',
channelId: 'session:custom-root',
sessionId: 'custom-root-session',
title: 'Custom Work-Item Runtime Root',
status: 'running',
columnId: 'in-progress',
assigneeIds: ['chief_architect'],
priority: null,
tags: [],
progressLog: [],
createdAt: 1,
updatedAt: 30,
messageCount: 1,
mode: 'primary',
execMode: 'custom',
isCompanyRuntime: true,
originTaskId: 'custom-root',
workItemRoleId: 'chief_architect',
workItemRoleName: 'Chief Architect',
})
const customOrgChild = makeSession({
taskId: 'custom-child',
channelId: 'session:custom-child',
title: 'Research Lead Turn',
status: 'running',
columnId: 'in-progress',
assigneeIds: ['research_lead'],
priority: null,
tags: [],
progressLog: [],
createdAt: 2,
updatedAt: 31,
messageCount: 2,
mode: 'child',
parentSessionId: 'custom-root-session',
originTaskId: 'custom-root',
workItemRoleId: 'research_lead',
workItemRoleName: 'Research Lead',
})
assert.deepEqual(
getWorkItemChildSessions(customOrgRoot, [customOrgRoot, customOrgChild]).map((session) => session.taskId),
['custom-child'],
)
assert.deepEqual(
getWorkItemRoleSessions(customOrgRoot, [customOrgRoot, customOrgChild]).map((session) => session.workItemRoleName),
['Chief Architect', 'Research Lead'],
)
const mergedCustomView = getConversationSessionView(
{
...customOrgRoot,
status: 'failed',
roleWorkItems: {
chief_architect: {
roleKey: 'chief_architect',
roleId: 'chief_architect',
roleName: 'Chief Architect',
runtimeStatus: 'idle',
aggregatedStatus: 'active',
workItems: [],
},
},
},
{
...customOrgChild,
runtimeControlState: 'running',
canStop: true,
},
[customOrgRoot, customOrgChild],
)
assert.equal(mergedCustomView?.runtimeControlState, 'running')
assert.equal(mergedCustomView?.canStop, true)
assert.equal(mergedCustomView?.roleWorkItems?.chief_architect.roleName, 'Chief Architect')
assert.equal(mergedCustomView?.status, 'running')
assert.equal(deriveCompanyRuntimeDisplayStatus(mergedCustomView), 'running')
const failedCustomView = getConversationSessionView(
{
...customOrgRoot,
status: 'failed',
roleWorkItems: {
chief_architect: {
roleKey: 'chief_architect',
roleId: 'chief_architect',
roleName: 'Chief Architect',
runtimeStatus: 'idle',
aggregatedStatus: 'failed',
workItems: [],
},
},
},
null,
[],
)
assert.equal(failedCustomView?.status, 'failed')
const runtimeRollupOverridesStaleActiveView = getConversationSessionView(
{
...customOrgRoot,
status: 'failed',
roleWorkItems: {
chief_architect: {
roleKey: 'chief_architect',
roleId: 'chief_architect',
roleName: 'Chief Architect',
runtimeStatus: 'idle',
aggregatedStatus: 'failed',
workItems: [],
},
},
},
{
...customOrgRoot,
status: 'failed',
roleWorkItems: {
chief_architect: {
roleKey: 'chief_architect',
roleId: 'chief_architect',
roleName: 'Chief Architect',
runtimeStatus: 'tool_active',
aggregatedStatus: 'active',
workItems: [],
},
},
},
[],
)
assert.equal(runtimeRollupOverridesStaleActiveView?.status, 'running')
const companyIdentity = canonicalizeSessionExecutionIdentity({
taskId: 'company-stale-org',
execMode: 'company',
companyProfile: 'custom',
orgId: 'quantum_harbor',
})
assert.equal(companyIdentity.execMode, 'company')
assert.equal(companyIdentity.companyProfile, 'corporate')
assert.equal(companyIdentity.orgId, undefined)
const customIdentity = canonicalizeSessionExecutionIdentity({
taskId: 'custom-org',
execMode: 'org',
companyProfile: 'corporate',
orgId: 'quantum_harbor',
})
assert.equal(customIdentity.execMode, 'org')
assert.equal(customIdentity.companyProfile, 'custom')
assert.equal(customIdentity.orgId, 'quantum_harbor')
const mappedCompanySession = mapBackendSession({
task_id: 'mapped-company',
channel_id: 'session:mapped-company',
title: 'Mapped Company',
status: 'running',
column_id: 'in-progress',
assignee_ids: [],
tags: [],
created_at: 1,
updated_at: 2,
exec_mode: 'company',
company_profile: 'custom',
org_id: 'quantum_harbor',
})
assert.equal(mappedCompanySession.execMode, 'company')
assert.equal(mappedCompanySession.companyProfile, 'corporate')
assert.equal(mappedCompanySession.orgId, undefined)
console.log('workItemSessions origin-task linking checks passed')
@@ -0,0 +1,507 @@
import type { ChatMessage } from '../types/chat'
import type { ProgressEntry, Session } from '../types/kanban'
import { getContextUsageMetrics } from './contextUsage'
import { isSessionWorking } from './sessionRuntime'
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
function compactWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
function stripNarrativeTitlePrefix(content: string): string {
const trimmed = String(content || '').trim()
const markdownTitle = trimmed.match(/^\*\*(.{8,160}?)\*\*:\s+([\s\S]+)$/)
if (markdownTitle) {
const body = markdownTitle[2].trim()
if (body.length >= 80) return body
}
const colonIndex = trimmed.indexOf(': ')
if (colonIndex < 8 || colonIndex > 160) return trimmed
const prefix = trimmed.slice(0, colonIndex).replace(/\*/g, '').trim()
const body = trimmed.slice(colonIndex + 2).trim()
if (body.length < 80) return trimmed
if (!/[A-Za-z\u4e00-\u9fff]/.test(prefix)) return trimmed
if (/^(https?|file)$/i.test(prefix)) return trimmed
return body
}
function resultSurfacePriority(message: ChatMessage): number {
const meta = (message.metadata ?? {}) as Record<string, unknown>
const transcriptKind = String(meta.transcript_kind ?? meta.kind ?? '').trim()
switch (transcriptKind) {
case 'child_task_result':
return 80
case 'child_task_result_retry':
return 79
case 'company_role_result':
return 75
case 'company_role_result_retry':
return 74
case 'child_result':
return 70
case 'runtime_v2_assistant':
return 60
case 'runtime_v2_company_assistant':
return 20
case 'top_level_reply':
return 40
default:
break
}
if (String(meta.kind ?? '').trim() === 'worker_notification') {
return 20
}
return 0
}
function resultSurfaceDedupeKey(message: ChatMessage): string {
if (resultSurfacePriority(message) <= 0) return ''
const content = compactWhitespace(stripNarrativeTitlePrefix(message.content)).slice(0, 2000)
return content ? `result:${content}` : ''
}
function parseProgressNumber(value: string | undefined): number | undefined {
if (!value) return undefined
const normalized = value.replace(/,/g, '').trim()
if (!normalized) return undefined
const parsed = Number(normalized)
return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : undefined
}
function clampPct(value: number | undefined): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.min(Math.round(value), 100))
}
export function deriveCompanyRuntimeDisplayStatus(session: Session | null | undefined): string | undefined {
if (!session) return undefined
const execMode = String(session.execMode ?? '').trim()
const isCompanyLike = (
execMode === 'company'
|| execMode === 'org'
|| execMode === 'custom'
|| !!session.isCompanyRuntime
|| !!session.roleWorkItems
|| !!session.executorRoleWorkItems
)
if (!isCompanyLike) return undefined
const summaries = Object.values(session.executorRoleWorkItems ?? session.roleWorkItems ?? {})
if (summaries.length === 0) return undefined
const statuses = summaries.map(summary => summary.aggregatedStatus)
if (statuses.some(status => status === 'active')) return 'running'
if (statuses.some(status => status === 'waiting')) return 'pending'
if (statuses.some(status => status === 'pending')) return 'pending'
if (statuses.every(status => status === 'failed')) return 'failed'
if (statuses.every(status => status === 'done')) return 'done'
if (statuses.some(status => status === 'done')) return 'done'
return undefined
}
function deriveContextFromProgressLog(progressLog: ProgressEntry[] | undefined): {
contextTokens?: number
contextWindow?: number
contextRemainingPct?: number
} {
const derived: {
contextTokens?: number
contextWindow?: number
contextRemainingPct?: number
} = {}
for (const entry of progressLog ?? []) {
const text = `${entry.summary ?? ''} ${entry.detail ?? ''}`.trim()
if (!text) continue
const tokenMatch = CONTEXT_TOKENS_RE.exec(text)
if (tokenMatch) {
const usedTokens = parseProgressNumber(tokenMatch[1])
const windowTokens = parseProgressNumber(tokenMatch[2])
if (typeof usedTokens === 'number') derived.contextTokens = usedTokens
if (typeof windowTokens === 'number' && windowTokens > 0) derived.contextWindow = windowTokens
}
const remainingMatch = REMAINING_PCT_RE.exec(text)
if (remainingMatch) {
derived.contextRemainingPct = clampPct(parseProgressNumber(remainingMatch[1]))
continue
}
const usedMatch = USED_PCT_RE.exec(text)
if (usedMatch) {
const usedPct = clampPct(parseProgressNumber(usedMatch[1]))
if (typeof usedPct === 'number') derived.contextRemainingPct = 100 - usedPct
}
}
return derived
}
function withDerivedSessionRuntime(session: Session): Session {
const derivedContext = deriveContextFromProgressLog(session.progressLog)
const contextTokens = session.contextTokens ?? derivedContext.contextTokens
const contextWindow = session.contextWindow ?? derivedContext.contextWindow
const contextRemainingPct = session.contextRemainingPct ?? derivedContext.contextRemainingPct
if (
contextTokens === session.contextTokens
&& contextWindow === session.contextWindow
&& contextRemainingPct === session.contextRemainingPct
) {
return session
}
return {
...session,
contextTokens,
contextWindow,
contextRemainingPct,
}
}
function uniqueSessionsByTaskId(sessions: Session[]): Session[] {
const seen = new Set<string>()
const unique: Session[] = []
for (const session of sessions) {
if (seen.has(session.taskId)) continue
seen.add(session.taskId)
unique.push(session)
}
return unique
}
function hasWorkItemRoleIdentity(session: Session | null | undefined): boolean {
return !!(
String(session?.workItemRoleId ?? '').trim()
|| String(session?.workItemRoleName ?? '').trim()
)
}
export function getWorkItemChildSessions(activeSession: Session | null, sessions: Session[]): Session[] {
if (!activeSession || activeSession.mode === 'child') return []
const parentKeys = new Set<string>()
if (activeSession.sessionId) parentKeys.add(activeSession.sessionId)
parentKeys.add(activeSession.taskId)
const activeOriginTaskId = String(activeSession.originTaskId ?? activeSession.taskId ?? '').trim()
const executionTurnOrder = new Map<string, number>()
for (const entry of activeSession.workItemLog ?? []) {
const taskId = entry.executionTurnId || entry.runtimeTaskId
if (taskId && !executionTurnOrder.has(taskId)) {
executionTurnOrder.set(taskId, executionTurnOrder.size)
}
}
const seen = new Set<string>()
const matches = sessions.filter((session) => {
if (session.taskId === activeSession.taskId) return false
if (seen.has(session.taskId)) return false
const linkedByParent = !!session.parentSessionId && parentKeys.has(session.parentSessionId)
const linkedByWorkItem = executionTurnOrder.has(session.taskId)
const linkedByOrigin = !!activeOriginTaskId && String(session.originTaskId ?? '').trim() === activeOriginTaskId
if (!linkedByParent && !linkedByWorkItem && !linkedByOrigin) return false
seen.add(session.taskId)
return true
})
return matches.sort((a, b) => {
const aOrder = executionTurnOrder.get(a.taskId)
const bOrder = executionTurnOrder.get(b.taskId)
if (aOrder != null && bOrder != null && aOrder !== bOrder) return aOrder - bOrder
if (aOrder != null && bOrder == null) return -1
if (aOrder == null && bOrder != null) return 1
return b.updatedAt - a.updatedAt
})
}
export function getWorkItemRoleSessions(activeSession: Session | null, sessions: Session[]): Session[] {
const childSessions = getWorkItemChildSessions(activeSession, sessions)
if (!activeSession || activeSession.mode === 'child') return childSessions
if (!hasWorkItemRoleIdentity(activeSession)) return childSessions
return uniqueSessionsByTaskId([activeSession, ...childSessions])
}
export function getConversationPeerSessions(activeSession: Session | null, sessions: Session[]): Session[] {
if (!activeSession || activeSession.mode === 'child') return []
const activeSessionId = String(activeSession.sessionId ?? '').trim()
if (!activeSessionId) return []
const seen = new Set<string>()
return sessions.filter((session) => {
if (session.taskId === activeSession.taskId) return false
if (seen.has(session.taskId)) return false
if (String(session.sessionId ?? '').trim() !== activeSessionId) return false
seen.add(session.taskId)
return true
}).sort((a, b) => b.updatedAt - a.updatedAt)
}
function isSummaryConversationSession(session: Session | null | undefined): boolean {
const execMode = String(session?.execMode ?? '').trim()
return execMode === 'company' || execMode === 'org' || execMode === 'custom'
}
function projectedDisplayScore(session: Session): number {
const workingBonus = isSessionWorking(session) ? 1_000_000_000 : 0
const draftBonus = String(session.draftAssistantText ?? '').trim() ? 10_000_000_000 : 0
const messageBonus = Math.max(0, session.messageCount ?? 0) * 10_000
const progressBonus = (session.progressLog?.length ?? 0) * 100
return draftBonus + workingBonus + messageBonus + progressBonus + session.updatedAt
}
function runtimeProjectionScore(session: Session): number {
const contextUsage = getContextUsageMetrics(session)
const workingBonus = isSessionWorking(session) ? 100_000_000_000 : 0
const draftBonus = String(session.draftAssistantText ?? '').trim() ? 10_000_000_000 : 0
const contextBonus = (
typeof contextUsage.usedPct === 'number'
|| typeof contextUsage.usedTokens === 'number'
|| typeof contextUsage.windowTokens === 'number'
) ? 10_000_000_000 : 0
const tokenBonus = (
typeof session.inputTokens === 'number'
|| typeof session.outputTokens === 'number'
|| typeof session.totalTokens === 'number'
) ? 1_000_000_000 : 0
const messageBonus = Math.max(0, session.messageCount ?? 0) * 10_000
const progressBonus = (session.progressLog?.length ?? 0) * 100
return workingBonus + draftBonus + contextBonus + tokenBonus + messageBonus + progressBonus
}
export interface SessionConversationProjection {
timelineSessions: Session[]
displaySession: Session | null
runtimeSession: Session | null
projectedFromChild: boolean
}
export function projectSessionConversation(
activeSession: Session | null,
relatedSessions: Session[],
): SessionConversationProjection {
if (!activeSession) {
return {
timelineSessions: [],
displaySession: null,
runtimeSession: null,
projectedFromChild: false,
}
}
const normalizedActiveSession = withDerivedSessionRuntime(activeSession)
if (activeSession.mode === 'child' || relatedSessions.length === 0) {
return {
timelineSessions: [normalizedActiveSession],
displaySession: normalizedActiveSession,
runtimeSession: normalizedActiveSession,
projectedFromChild: false,
}
}
const normalizedRelatedSessions = relatedSessions.map(withDerivedSessionRuntime)
const visibleRelatedSessions = normalizedRelatedSessions.filter((session) => session.status !== 'cancelled')
const projectedSessions = visibleRelatedSessions.length > 0 ? visibleRelatedSessions : normalizedRelatedSessions
const timelineSessions = uniqueSessionsByTaskId([normalizedActiveSession, ...projectedSessions])
const summaryConversation = isSummaryConversationSession(activeSession)
const projectedDisplaySession = [...timelineSessions].sort(
(a, b) => projectedDisplayScore(b) - projectedDisplayScore(a),
)[0] ?? normalizedActiveSession
const displaySession = summaryConversation
? normalizedActiveSession
: projectedDisplaySession
const runtimeSession = [...timelineSessions].sort(
(a, b) => runtimeProjectionScore(b) - runtimeProjectionScore(a),
)[0] ?? displaySession
return {
timelineSessions,
displaySession,
runtimeSession,
projectedFromChild: !summaryConversation && displaySession.taskId !== activeSession.taskId,
}
}
export function getConversationSessionView(
activeSession: Session | null,
runtimeSession: Session | null,
timelineSessions: Session[],
): Session | null {
if (!activeSession) return null
const normalizedActiveSession = withDerivedSessionRuntime(activeSession)
const runtimeSource = runtimeSession ?? normalizedActiveSession
const companyDisplayStatus = deriveCompanyRuntimeDisplayStatus(runtimeSource)
?? deriveCompanyRuntimeDisplayStatus(normalizedActiveSession)
const mergedMessageCount = getConversationMessageCount(
timelineSessions.length > 0 ? timelineSessions : [normalizedActiveSession],
)
return {
...normalizedActiveSession,
status: companyDisplayStatus ?? (runtimeSource.status || normalizedActiveSession.status),
assigneeIds: runtimeSource.assigneeIds.length > 0
? runtimeSource.assigneeIds
: normalizedActiveSession.assigneeIds,
agentStatus: runtimeSource.agentStatus ?? normalizedActiveSession.agentStatus,
currentTool: runtimeSource.currentTool ?? normalizedActiveSession.currentTool,
displayTool: runtimeSource.displayTool ?? runtimeSource.currentTool ?? normalizedActiveSession.displayTool ?? normalizedActiveSession.currentTool,
toolElapsedMs: runtimeSource.toolElapsedMs ?? normalizedActiveSession.toolElapsedMs,
lastToolSummary: runtimeSource.lastToolSummary ?? normalizedActiveSession.lastToolSummary,
contextTokens: runtimeSource.contextTokens ?? normalizedActiveSession.contextTokens,
contextWindow: runtimeSource.contextWindow ?? normalizedActiveSession.contextWindow,
contextRemainingPct: runtimeSource.contextRemainingPct ?? normalizedActiveSession.contextRemainingPct,
inputTokens: runtimeSource.inputTokens ?? normalizedActiveSession.inputTokens,
outputTokens: runtimeSource.outputTokens ?? normalizedActiveSession.outputTokens,
totalTokens: runtimeSource.totalTokens ?? normalizedActiveSession.totalTokens,
turnCostUsd: runtimeSource.turnCostUsd ?? normalizedActiveSession.turnCostUsd,
sessionCostUsd: runtimeSource.sessionCostUsd ?? normalizedActiveSession.sessionCostUsd,
pendingPermissionCount: runtimeSource.pendingPermissionCount ?? normalizedActiveSession.pendingPermissionCount,
drainMode: runtimeSource.drainMode ?? normalizedActiveSession.drainMode,
workItemProjectionId: runtimeSource.workItemProjectionId ?? normalizedActiveSession.workItemProjectionId,
workItemTurnType: runtimeSource.workItemTurnType ?? normalizedActiveSession.workItemTurnType,
companyProfile: normalizedActiveSession.companyProfile ?? runtimeSource.companyProfile,
workItemRoleId: normalizedActiveSession.workItemRoleId ?? runtimeSource.workItemRoleId,
workItemRoleName: normalizedActiveSession.workItemRoleName ?? runtimeSource.workItemRoleName,
workItemGate: normalizedActiveSession.workItemGate ?? runtimeSource.workItemGate,
employeeAssignment: normalizedActiveSession.employeeAssignment ?? runtimeSource.employeeAssignment,
selectedExecutionAgent: runtimeSource.selectedExecutionAgent ?? normalizedActiveSession.selectedExecutionAgent,
draftAssistantText: runtimeSource.draftAssistantText ?? normalizedActiveSession.draftAssistantText,
draftUpdatedAt: runtimeSource.draftUpdatedAt ?? normalizedActiveSession.draftUpdatedAt,
draftIteration: runtimeSource.draftIteration ?? normalizedActiveSession.draftIteration,
draftTurnId: runtimeSource.draftTurnId ?? normalizedActiveSession.draftTurnId,
runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState,
canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop,
canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume,
resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId,
resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId,
pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId,
stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId,
updatedAt: Math.max(
normalizedActiveSession.updatedAt,
runtimeSource.updatedAt,
),
messageCount: Math.max(
normalizedActiveSession.messageCount ?? 0,
runtimeSource.messageCount ?? 0,
mergedMessageCount,
),
isCompanyRuntime: !!(normalizedActiveSession.isCompanyRuntime || runtimeSource.isCompanyRuntime),
workItemLog: (normalizedActiveSession.workItemLog?.length ?? 0) > 0
? normalizedActiveSession.workItemLog
: runtimeSource.workItemLog,
roleWorkItems: normalizedActiveSession.roleWorkItems ?? runtimeSource.roleWorkItems,
executorRoleWorkItems: normalizedActiveSession.executorRoleWorkItems ?? runtimeSource.executorRoleWorkItems,
activeSubagents: normalizedActiveSession.activeSubagents ?? runtimeSource.activeSubagents,
permissionRequests: normalizedActiveSession.permissionRequests ?? runtimeSource.permissionRequests,
}
}
export function getConversationHeaderSession(
activeSession: Session | null,
runtimeSession: Session | null,
timelineSessions: Session[],
): Session | null {
if (!activeSession) return null
const normalizedActiveSession = withDerivedSessionRuntime(activeSession)
const runtimeSource = runtimeSession ?? normalizedActiveSession
const companyDisplayStatus = deriveCompanyRuntimeDisplayStatus(runtimeSource)
?? deriveCompanyRuntimeDisplayStatus(normalizedActiveSession)
const mergedMessageCount = getConversationMessageCount(
timelineSessions.length > 0 ? timelineSessions : [normalizedActiveSession],
)
return {
...normalizedActiveSession,
status: companyDisplayStatus ?? (runtimeSource.status || normalizedActiveSession.status),
agentStatus: runtimeSource.agentStatus ?? normalizedActiveSession.agentStatus,
currentTool: runtimeSource.currentTool ?? normalizedActiveSession.currentTool,
displayTool: runtimeSource.displayTool ?? runtimeSource.currentTool ?? normalizedActiveSession.displayTool ?? normalizedActiveSession.currentTool,
toolElapsedMs: runtimeSource.toolElapsedMs ?? normalizedActiveSession.toolElapsedMs,
lastToolSummary: runtimeSource.lastToolSummary ?? normalizedActiveSession.lastToolSummary,
contextTokens: runtimeSource.contextTokens ?? normalizedActiveSession.contextTokens,
contextWindow: runtimeSource.contextWindow ?? normalizedActiveSession.contextWindow,
contextRemainingPct: runtimeSource.contextRemainingPct ?? normalizedActiveSession.contextRemainingPct,
inputTokens: runtimeSource.inputTokens ?? normalizedActiveSession.inputTokens,
outputTokens: runtimeSource.outputTokens ?? normalizedActiveSession.outputTokens,
totalTokens: runtimeSource.totalTokens ?? normalizedActiveSession.totalTokens,
turnCostUsd: runtimeSource.turnCostUsd ?? normalizedActiveSession.turnCostUsd,
sessionCostUsd: runtimeSource.sessionCostUsd ?? normalizedActiveSession.sessionCostUsd,
pendingPermissionCount: runtimeSource.pendingPermissionCount ?? normalizedActiveSession.pendingPermissionCount,
drainMode: runtimeSource.drainMode ?? normalizedActiveSession.drainMode,
selectedExecutionAgent: runtimeSource.selectedExecutionAgent ?? normalizedActiveSession.selectedExecutionAgent,
runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState,
canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop,
canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume,
resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId,
resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId,
pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId,
stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId,
updatedAt: Math.max(normalizedActiveSession.updatedAt, runtimeSource.updatedAt),
messageCount: Math.max(
normalizedActiveSession.messageCount ?? 0,
runtimeSource.messageCount ?? 0,
mergedMessageCount,
),
}
}
export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatMessage[] {
const seen = new Set<string>()
const resultKeyIndex = 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 uiMessageId = typeof metadata.ui_message_id === 'string'
? metadata.ui_message_id.trim()
: ''
const resultKey = resultSurfaceDedupeKey(message)
if (resultKey) {
const existingIndex = resultKeyIndex.get(resultKey)
if (existingIndex !== undefined) {
if (resultSurfacePriority(message) > resultSurfacePriority(merged[existingIndex])) {
merged[existingIndex] = message
}
continue
}
resultKeyIndex.set(resultKey, merged.length)
}
const dedupeKey = resultKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
merged.push(message)
}
}
return merged.sort((a, b) => (
a.timestamp === b.timestamp
? a.id.localeCompare(b.id)
: a.timestamp - b.timestamp
))
}
export function mergeConversationProgressLog(timelineSessions: Session[]): ProgressEntry[] {
const seen = new Set<string>()
const merged: ProgressEntry[] = []
for (const session of timelineSessions) {
for (const entry of session.progressLog ?? []) {
const dedupeKey = `${entry.timestamp}:${entry.type}:${entry.summary}:${entry.detail ?? ''}`
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
merged.push(entry)
}
}
return merged.sort((a, b) => a.timestamp - b.timestamp)
}
export function getConversationMessageCount(timelineSessions: Session[]): number {
return timelineSessions.reduce(
(total, session) => total + Math.max(0, session.messageCount ?? 0),
0,
)
}
@@ -0,0 +1,856 @@
import type {
AgentRuntimePayload,
EmployeeDetailPayload,
KanbanViewDataPayload,
OrgInfoPayload,
OrgCreateMemberInput,
OrgSavedCreatePayload,
ReorgListPayload,
SavedOrgSummary,
SessionProgressPayload,
SocketEnvelope,
SocketStatus,
TalentListPayload,
VisualEvent,
VisualSnapshot,
WorkerNotificationPayload,
WorkItemProgressPayload,
} from '../types/visual'
import type { CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
import type { TaskPreferredAgent } from '../types/kanban'
interface SocketHandlers {
onSnapshot?: (snapshot: VisualSnapshot) => void
onEvent?: (event: VisualEvent) => void
onAck?: (payload: Record<string, unknown>) => void
onStatus?: (status: SocketStatus, detail?: string) => void
onChannelCreated?: (payload: { channel_id: string; name: string; channel_type: string; participants: string[] }) => void
onBoardEvent?: (payload: Record<string, unknown>) => void
onCrossOfficeCollab?: (payload: { agent_ids: string[]; task_id: string; action: string }) => void
onCollabMessage?: (type: string, payload: Record<string, unknown>) => void
onAgentRuntimeUpdate?: (payload: AgentRuntimePayload) => void
onWorkerNotification?: (payload: WorkerNotificationPayload) => void
onKanbanViewData?: (payload: KanbanViewDataPayload) => void
onSessionCreated?: (payload: { project_id: string; task_id: string; channel_id: string; session_id?: string; parent_session_id?: string; origin_task_id?: string; title: string; status: string; created_at: number; assignee_ids?: string[]; exec_mode?: string; company_profile?: string; org_id?: string; organization_id?: string; preferred_agent?: TaskPreferredAgent; selected_execution_agent?: TaskPreferredAgent }) => void
onSessionUpdated?: (payload: { project_id: string; task_id: string; exec_mode?: string; company_profile?: string; org_id?: string; organization_id?: string; preferred_agent?: TaskPreferredAgent; selected_execution_agent?: TaskPreferredAgent }) => void
onSessionMessage?: (payload: Record<string, unknown>) => void
onSessionTitleUpdated?: (payload: { project_id: string; task_id: string; title: string }) => void
onSessionDeleted?: (payload: { project_id: string; task_id: string }) => void
onSessionProgress?: (payload: SessionProgressPayload) => void
onChildSessionCreated?: (payload: { project_id: string; session_id: string; parent_session_id: string; task_id: string; origin_task_id?: string; title: string; agent_id?: string; org_id?: string; organization_id?: string; selected_execution_agent?: TaskPreferredAgent }) => void
onProjectSwitched?: (payload: { project_id: string; switch_seq?: string }) => void
onProjectDeleted?: (payload: { project_id: string }) => void
onOrgInfo?: (payload: OrgInfoPayload) => void
onRecoveryStatus?: (payload: any) => void
onRecoveryResult?: (payload: any) => void
onTalentList?: (payload: TalentListPayload) => void
onTalentScanLocal?: (payload: { templates: Array<{ template_id: string; name: string; description: string; category: string; domains: string[]; tags: string[] }> }) => void
onEmployeeDetail?: (payload: EmployeeDetailPayload) => void
onReorgList?: (payload: ReorgListPayload) => void
onWorkItemProgress?: (payload: WorkItemProgressPayload) => void
onMarketListInstalled?: (payload: { packages: Array<Record<string, unknown>> }) => void
onMarketBrowse?: (payload: { presets: Array<Record<string, unknown>> }) => void
onMarketPreview?: (payload: Record<string, unknown>) => void
onOrgConfigExport?: (payload: { yaml: string }) => void
onOrgConfigImport?: (payload: { ok: boolean; dry_run?: boolean; preview?: { roles_added: number; roles_removed: number; employees_changed: number }; error?: string; validation_errors?: string[] }) => void
onOrgSavedList?: (payload: { orgs: SavedOrgSummary[]; active_name?: string | null }) => void
onOrgSavedSaveAs?: (payload: { ok: boolean; name: string; error?: string }) => void
onOrgSavedCreate?: (payload: OrgSavedCreatePayload) => void
onOrgSavedLoad?: (payload: { ok: boolean; name: string; error?: string }) => void
onOrgSavedDelete?: (payload: { ok: boolean; name: string; error?: string }) => void
onCommsState?: (payload: CommsStatePayload) => void
onCommsMessage?: (payload: CommsMessagePayload) => void
}
export interface CommsMessageItem {
message_id: string
from: string
to?: string
subject: string
sent_at: string
blocking: boolean
path: string
bucket?: 'new' | 'seen' | 'sent'
}
/** @deprecated Use CommsMessageItem instead */
export type CommsRecentUnread = CommsMessageItem
export interface CommsRolePayload {
role_id: string
unread_count: number
has_blocking: boolean
seen_count: number
outbox_count: number
recent_unread: CommsMessageItem[]
recent_seen?: CommsMessageItem[]
recent_outbox?: CommsMessageItem[]
}
export interface CommsMeetingPayload {
meeting_id: string
topic: string
status: string
organizer: string
participants: string[]
entry_count: number
opened_at: string
closed_at?: string | null
decision?: string | null
transcript_path: string
}
export interface CommsFailurePayload {
operation: string
from_role: string
to_role: string
reason: string
attempted_path?: string
attempted_command?: string
recorded_at?: string
attempt_count?: number
can_retry?: boolean
}
export interface CommsStatePayload {
available: boolean
reason?: string
empty?: boolean
project_id?: string
session_id?: string
workspace_root?: string
output_root?: string
comms_root?: string
projection_status?: string
recent_failures?: CommsFailurePayload[]
roles?: CommsRolePayload[]
meetings?: CommsMeetingPayload[]
}
export interface CommsMessagePayload {
project_id: string
path: string
header: {
from?: string
to?: string
sent_at?: string
blocking?: boolean
[key: string]: unknown
}
body: string
}
const RECONNECT_BASE_MS = 2000
const RECONNECT_MAX_MS = 30000
const RECONNECT_MAX_ATTEMPTS = 20
const PENDING_QUEUE_MAX = 100
const HEARTBEAT_INTERVAL_MS = 30_000
const HEARTBEAT_TIMEOUT_MS = 10_000
const PROJECT_SCOPED_MESSAGE_TYPES = new Set([
'collab_sync',
'kanban_create_board',
'kanban_create_task',
'kanban_update_task',
'kanban_move_task',
'kanban_delete_board',
'kanban_delete_task',
'kanban_assign',
'kanban_status',
'kanban_switch_view',
'run_task',
'create_session',
'session_send',
'session_update_config',
'session_delete',
'session_detail',
'session_stop',
'session_resume',
'session_complete',
'session_update_title',
'secretary_send',
'project_index',
'recovery_action',
'comms_state',
'comms_read_message',
])
export class VisualSocketClient {
private ws: WebSocket | null = null
private reconnectTimer: number | null = null
private closedByUser = false
private reconnectAttempt = 0
private pendingQueue: string[] = []
private heartbeatTimer: number | null = null
private pongTimer: number | null = null
constructor(
private url: string,
private handlers: SocketHandlers,
) {}
updateUrl(url: string): void {
this.url = url
}
connect(): void {
this.closedByUser = false
this.handlers.onStatus?.('connecting')
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
this.reconnectAttempt = 0
this.handlers.onStatus?.('connected')
this.flushPendingQueue()
this.startHeartbeat()
}
this.ws.onmessage = (evt) => {
this.handleMessage(evt.data)
}
this.ws.onerror = () => {
this.handlers.onStatus?.('error', 'WebSocket error')
}
this.ws.onclose = () => {
this.stopHeartbeat()
this.handlers.onStatus?.('disconnected')
this.ws = null
if (!this.closedByUser) {
this.scheduleReconnect()
}
}
}
disconnect(): void {
this.closedByUser = true
this.stopHeartbeat()
if (this.reconnectTimer !== null) {
window.clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.ws?.close()
this.ws = null
}
send(payload: Record<string, unknown>): void {
if (!this.ensureProjectScope(payload)) {
return
}
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
}
this.ws.send(data)
}
// ── Agent management ───────────────────────────────────────────────────
createAgent(role: Record<string, unknown>): void {
this.send({ type: 'create_agent', role })
}
deleteAgent(agentId: string): void {
this.send({ type: 'delete_agent', agent_id: agentId })
}
moveAgent(agentId: string, officeId: string, seatZone?: string): void {
this.send({ type: 'move_agent', agent_id: agentId, office_id: officeId, seat_zone: seatZone })
}
listAgents(): void {
this.send({ type: 'list_agents' })
}
createFromTemplate(templateId: string, name?: string): void {
this.send({ type: 'create_agent', role: { id: templateId, template: templateId, name: name ?? templateId } })
}
// ── Execution mode ─────────────────────────────────────────────────────
setExecutionMode(mode: string, profile?: string, preferredAgent?: TaskPreferredAgent, orgId?: string): void {
this.send({ type: 'set_execution_mode', mode, profile: profile ?? 'corporate', preferred_agent: preferredAgent, org_id: orgId })
}
// ── Kanban integration ─────────────────────────────────────────────────
assignTaskToAgent(projectId: string, taskId: string, agentId: string, taskTitle: string): void {
const pid = this.requireProjectId(projectId, 'kanban_assign')
this.send({ type: 'kanban_assign', task_id: taskId, agent_id: agentId, task_title: taskTitle, project_id: pid })
}
updateTaskStatus(projectId: string, taskId: string, status: string): void {
const pid = this.requireProjectId(projectId, 'kanban_status')
this.send({ type: 'kanban_status', task_id: taskId, status, project_id: pid })
}
kanbanCreateTask(opts: {
board_id: string; column_id: string; title: string;
description?: string; priority?: string;
assignee_ids?: string[]; tags?: string[];
task_id?: string;
project_id: string;
}): void {
const pid = this.requireProjectId(opts.project_id, 'kanban_create_task')
this.send({ type: 'kanban_create_task', ...opts, project_id: pid })
}
kanbanUpdateTask(projectId: string, taskId: string, updates: Record<string, unknown>): void {
const pid = this.requireProjectId(projectId, 'kanban_update_task')
this.send({ type: 'kanban_update_task', task_id: taskId, updates, project_id: pid })
}
kanbanMoveTask(projectId: string, taskId: string, columnId: string, sortOrder = 0): void {
const pid = this.requireProjectId(projectId, 'kanban_move_task')
this.send({ type: 'kanban_move_task', task_id: taskId, column_id: columnId, sort_order: sortOrder, project_id: pid })
}
kanbanDeleteBoard(projectId: string, boardId: string): void {
const pid = this.requireProjectId(projectId, 'kanban_delete_board')
this.send({ type: 'kanban_delete_board', project_id: pid, board_id: boardId })
}
kanbanDeleteTask(projectId: string, taskId: string): void {
const pid = this.requireProjectId(projectId, 'kanban_delete_task')
this.send({ type: 'kanban_delete_task', task_id: taskId, project_id: pid })
}
kanbanSwitchView(projectId: string, level: 'global' | 'office' | 'agent', targetId?: string): void {
const pid = this.requireProjectId(projectId, 'kanban_switch_view')
this.send({ type: 'kanban_switch_view', level, target_id: targetId, project_id: pid })
}
getAgentDetail(agentId: string): void {
this.send({ type: 'get_agent_detail', agent_id: agentId })
}
// ── Collaboration protocol ─────────────────────────────────────────────
collabSync(projectId: string, switchSeq?: string, viewGeneration?: number): void {
const pid = this.requireProjectId(projectId, 'collab_sync')
this.send({ type: 'collab_sync', project_id: pid, switch_seq: switchSeq, view_generation: viewGeneration })
}
projectIndex(projectId: string, switchSeq?: string, viewGeneration?: number): void {
const pid = this.requireProjectId(projectId, 'project_index')
this.send({ type: 'project_index', project_id: pid, switch_seq: switchSeq, view_generation: viewGeneration })
}
// ── Session protocol ───────────────────────────────────────────────────
createSession(projectId: string, title?: string, execMode?: string, companyProfile?: string, preferredAgent?: TaskPreferredAgent, orgId?: string): void {
const pid = this.requireProjectId(projectId, 'create_session')
this.send({
type: 'create_session',
project_id: pid,
title: title ?? 'New Chat',
exec_mode: execMode,
company_profile: companyProfile,
preferred_agent: preferredAgent,
org_id: orgId,
})
}
sessionSend(
projectId: string,
taskId: string,
content: string,
attachments?: OutgoingAttachmentPayload[],
metadata?: CheckpointReplyMetadata,
): void {
const pid = this.requireProjectId(projectId, 'session_send')
this.send({
type: 'session_send',
project_id: pid,
task_id: taskId,
content,
attachments: attachments ?? [],
metadata,
})
}
deleteSession(projectId: string, taskId: string): void {
const pid = this.requireProjectId(projectId, 'session_delete')
this.send({ type: 'session_delete', project_id: pid, task_id: taskId })
}
sessionUpdateTitle(projectId: string, taskId: string, title: string): void {
const pid = this.requireProjectId(projectId, 'session_update_title')
this.send({ type: 'session_update_title', project_id: pid, task_id: taskId, title })
}
sessionUpdateConfig(projectId: string, taskId: string, execMode: string, companyProfile?: string, preferredAgent?: TaskPreferredAgent, orgId?: string): void {
const pid = this.requireProjectId(projectId, 'session_update_config')
this.send({
type: 'session_update_config',
project_id: pid,
task_id: taskId,
exec_mode: execMode,
company_profile: companyProfile,
preferred_agent: preferredAgent,
org_id: orgId,
})
}
sessionStop(projectId: string, taskId: string): void {
const pid = this.requireProjectId(projectId, 'session_stop')
this.send({ type: 'session_stop', project_id: pid, task_id: taskId })
}
sessionResume(projectId: string, taskId: string, content?: string): void {
const pid = this.requireProjectId(projectId, 'session_resume')
this.send({ type: 'session_resume', project_id: pid, task_id: taskId, content })
}
sessionComplete(projectId: string, taskId: string): void {
const pid = this.requireProjectId(projectId, 'session_complete')
this.send({ type: 'session_complete', project_id: pid, task_id: taskId })
}
sessionDetail(
projectId: string,
taskId: string,
opts?: { limit?: number; beforeCreatedAt?: number; beforeMessageId?: string; detailLevel?: 'summary' | 'full'; include?: string[]; viewGeneration?: number },
): void {
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,
})
}
secretarySend(projectId: string, content: string): void {
const pid = this.requireProjectId(projectId, 'secretary_send')
this.send({ type: 'secretary_send', project_id: pid, content })
}
// ── Project management ──────────────────────────────────────────────
listProjects(): void {
this.send({ type: 'list_projects' })
}
createProject(projectId: string): void {
this.send({ type: 'create_project', project_id: this.normalizeProjectId(projectId) })
}
deleteProject(projectId: string): void {
this.send({ type: 'delete_project', project_id: this.normalizeProjectId(projectId) })
}
switchProject(projectId: string, switchSeq?: string): void {
this.send({ type: 'switch_project', project_id: this.normalizeProjectId(projectId), switch_seq: switchSeq })
}
// ── Org info ──────────────────────────────────────────────────────────
orgInfo(): void {
this.send({ type: 'org_info' })
}
// ── Phase 4: Talent Market, Employee Detail, Reorg ───────────────────
talentImport(repoPath: string): void {
this.send({ type: 'talent_import', repo_path: repoPath })
}
talentList(): void {
this.send({ type: 'talent_list' })
}
talentScanLocal(): void {
this.send({ type: 'talent_scan_local' })
}
talentImportSelected(templateIds: string[]): void {
this.send({ type: 'talent_import_selected', template_ids: templateIds })
}
talentHire(templateId: string, roleId: string, employeeName?: string, orgId?: string): void {
this.send({ type: 'talent_hire', template_id: templateId, role_id: roleId, employee_name: employeeName, org_id: orgId })
}
employeeDetail(employeeId: string): void {
this.send({ type: 'employee_detail', employee_id: employeeId })
}
reorgList(): void {
this.send({ type: 'reorg_list' })
}
reorgDecide(proposalId: string, approved: boolean, notes?: string): void {
this.send({ type: 'reorg_decide', proposal_id: proposalId, approved, notes })
}
importEmployeeAsAgent(employeeId: string, officeId?: string): void {
this.send({ type: 'import_employee_as_agent', employee_id: employeeId, office_id: officeId })
}
// ── OPC Market ─────────────────────────────────────────────────────────
marketBrowse(): void {
this.send({ type: 'market_browse' })
}
marketPreview(presetId: string): void {
this.send({ type: 'market_preview', preset_id: presetId })
}
marketApplyPreset(presetId: string, strategy: string = 'namespace'): void {
this.send({ type: 'market_apply_preset', preset_id: presetId, strategy })
}
marketListInstalled(): void {
this.send({ type: 'market_list_installed' })
}
marketExport(data: { package_id: string; name: string; description: string; version: string }): void {
this.send({ type: 'market_export', ...data })
}
marketInstall(path: string, strategy: string = 'namespace'): void {
this.send({ type: 'market_install', path, strategy })
}
marketUninstall(packageId: string): void {
this.send({ type: 'market_uninstall', package_id: packageId })
}
// ── Org Editing ───────────────────────────────────────────────────────
addRole(roleId: string, name: string, responsibility: string, reportsTo: string = 'owner', icon?: string | null): void {
this.send({ type: 'add_role', role_id: roleId, name, responsibility, reports_to: reportsTo, icon: icon || null })
}
bulkAddRoles(roles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string; icon?: string | null }>): void {
this.send({ type: 'bulk_add_roles', roles })
}
updateRole(roleId: string, updates: {
name?: string
responsibility?: string
reports_to?: string
can_spawn?: string[]
icon?: string | null
execution_strategy?: string
preferred_external_agent?: string | null
prompt_refs?: string[]
tools?: string[]
}): void {
this.send({ type: 'update_role', role_id: roleId, ...updates })
}
deleteRole(roleId: string): void {
this.send({ type: 'delete_role', role_id: roleId })
}
updateRuntimePolicy(policy: Record<string, any>): void {
this.send({ type: 'update_runtime_policy', policy })
}
updateOrgStrategy(data: { final_decider_role_id?: string | null }): void {
this.send({ type: 'update_org_strategy', ...data })
}
resetArchitecture(): void {
this.send({ type: 'reset_architecture' })
}
orgConfigExport(): void {
this.send({ type: 'org_config_export' })
}
orgConfigImport(yaml: string, dryRun: boolean): void {
this.send({ type: 'org_config_import', yaml, dry_run: dryRun })
}
orgSavedList(): void {
this.send({ type: 'org_saved_list' })
}
orgSavedSaveAs(name: string, overwrite: boolean): void {
this.send({ type: 'org_saved_save_as', name, overwrite })
}
orgSavedCreate(organizationName: string, members: OrgCreateMemberInput[]): void {
this.send({ type: 'org_saved_create', organization_name: organizationName, members })
}
orgSavedLoad(name: string): void {
this.send({ type: 'org_saved_load', name })
}
orgSavedDelete(name: string): void {
this.send({ type: 'org_saved_delete', name })
}
recoveryAction(projectId: string, action: 'resume' | 'cancel' | 'scan', parentTaskId?: string): void {
const pid = this.requireProjectId(projectId, 'recovery_action')
this.send({ type: 'recovery_action', project_id: pid, action, parent_task_id: parentTaskId })
}
commsState(projectId: string, opts?: { task_id?: string; session_id?: string }): void {
const pid = this.requireProjectId(projectId, 'comms_state')
this.send({ type: 'comms_state', project_id: pid, ...(opts || {}) })
}
commsReadMessage(projectId: string, path: string): void {
const pid = this.requireProjectId(projectId, 'comms_read_message')
this.send({ type: 'comms_read_message', project_id: pid, path })
}
// ── Internal ───────────────────────────────────────────────────────────
private normalizeProjectId(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
private requireProjectId(projectId: unknown, action: string): string {
const pid = this.normalizeProjectId(projectId)
if (!pid) {
throw new Error(`${action} requires non-empty project_id`)
}
return pid
}
private ensureProjectScope(payload: Record<string, unknown>): boolean {
const messageType = typeof payload.type === 'string' ? payload.type : ''
if (!PROJECT_SCOPED_MESSAGE_TYPES.has(messageType)) {
return true
}
const pid = this.normalizeProjectId(payload.project_id ?? payload.projectId)
if (!pid) {
const error = `${messageType} requires non-empty project_id`
console.error(`[wsClient] ${error}`, payload)
this.handlers.onAck?.({ ok: false, error, action: messageType })
return false
}
payload.project_id = pid
return true
}
private handleMessage(raw: unknown): void {
if (typeof raw !== 'string') {
return
}
let parsed: SocketEnvelope | null = null
try {
parsed = JSON.parse(raw) as SocketEnvelope
} catch {
return
}
if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) {
return
}
try { switch (parsed.type) {
case 'snapshot':
this.handlers.onSnapshot?.(parsed.payload)
break
case 'event':
this.handlers.onEvent?.(parsed.payload)
break
case 'ack':
this.handlers.onAck?.(parsed.payload)
break
case 'channel_created':
this.handlers.onChannelCreated?.(parsed.payload)
break
case 'board_task_created':
case 'board_task_moved':
this.handlers.onBoardEvent?.(parsed.payload as unknown as Record<string, unknown>)
break
case 'board_task_status_changed':
case 'execution_mode_resolved':
case 'project_run_updated':
case 'seat_digest_updated':
case 'work_item_batch_updated':
case 'session_runtime_control':
this.handlers.onCollabMessage?.(parsed.type, parsed.payload as Record<string, unknown>)
break
case 'cross_office_collab':
this.handlers.onCrossOfficeCollab?.(parsed.payload)
break
case 'chat_new_message':
case 'chat_channel_created':
case 'kanban_updated':
case 'kanban_board_created':
case 'collab_sync_push':
case 'project_index_push':
this.handlers.onCollabMessage?.(parsed.type, parsed.payload as Record<string, unknown>)
break
case 'agent_runtime_update':
this.handlers.onAgentRuntimeUpdate?.(parsed.payload)
break
case 'worker_notification':
this.handlers.onWorkerNotification?.(parsed.payload as WorkerNotificationPayload)
break
case 'session_progress':
this.handlers.onSessionProgress?.(parsed.payload)
break
case 'work_item_progress':
this.handlers.onWorkItemProgress?.(parsed.payload as unknown as WorkItemProgressPayload)
break
case 'kanban_view_data':
this.handlers.onKanbanViewData?.(parsed.payload)
break
case 'session_created':
this.handlers.onSessionCreated?.(parsed.payload)
break
case 'session_updated':
this.handlers.onSessionUpdated?.(parsed.payload)
break
case 'session_message':
this.handlers.onSessionMessage?.(parsed.payload as Record<string, unknown>)
break
case 'session_title_updated':
this.handlers.onSessionTitleUpdated?.(parsed.payload)
break
case 'session_deleted':
this.handlers.onSessionDeleted?.(parsed.payload)
break
case 'child_session_created':
this.handlers.onChildSessionCreated?.(parsed.payload)
break
case 'project_switched':
this.handlers.onProjectSwitched?.(parsed.payload)
break
case 'project_deleted':
this.handlers.onProjectDeleted?.(parsed.payload)
break
case 'org_info':
this.handlers.onOrgInfo?.(parsed.payload)
break
case 'comms_state':
this.handlers.onCommsState?.(parsed.payload as unknown as CommsStatePayload)
break
case 'comms_message':
this.handlers.onCommsMessage?.(parsed.payload as unknown as CommsMessagePayload)
break
case 'comms_state_dirty':
// Server pushed a "something changed" hint after a comms message
// was sent. Re-issue the snapshot request so the panel updates
// immediately instead of waiting for its polling tick.
try {
const projectId = typeof parsed.payload?.project_id === 'string' ? parsed.payload.project_id : ''
if (projectId) this.commsState(projectId)
} catch { /* ignore */ }
break
case 'recovery_status':
this.handlers.onRecoveryStatus?.(parsed.payload)
break
case 'recovery_result':
this.handlers.onRecoveryResult?.(parsed.payload)
break
case 'talent_list':
this.handlers.onTalentList?.(parsed.payload)
break
case 'talent_scan_local':
this.handlers.onTalentScanLocal?.(parsed.payload)
break
case 'employee_detail':
this.handlers.onEmployeeDetail?.(parsed.payload)
break
case 'reorg_list':
this.handlers.onReorgList?.(parsed.payload)
break
case 'market_list_installed':
this.handlers.onMarketListInstalled?.(parsed.payload as unknown as { packages: Array<Record<string, unknown>> })
break
case 'market_browse':
this.handlers.onMarketBrowse?.(parsed.payload as unknown as { presets: Array<Record<string, unknown>> })
break
case 'market_preview':
this.handlers.onMarketPreview?.(parsed.payload as Record<string, unknown>)
break
case 'org_config_export':
this.handlers.onOrgConfigExport?.(parsed.payload as { yaml: string })
break
case 'org_config_import':
this.handlers.onOrgConfigImport?.(parsed.payload as any)
break
case 'org_saved_list':
this.handlers.onOrgSavedList?.(parsed.payload as { orgs: SavedOrgSummary[]; active_name?: string | null })
break
case 'org_saved_save_as':
this.handlers.onOrgSavedSaveAs?.(parsed.payload as { ok: boolean; name: string; error?: string })
break
case 'org_saved_create':
this.handlers.onOrgSavedCreate?.(parsed.payload as OrgSavedCreatePayload)
break
case 'org_saved_load':
this.handlers.onOrgSavedLoad?.(parsed.payload as { ok: boolean; name: string; error?: string })
break
case 'org_saved_delete':
this.handlers.onOrgSavedDelete?.(parsed.payload as { ok: boolean; name: string; error?: string })
break
case 'pong':
this.handlePong()
break
default:
break
}
} catch (e) { console.error('[wsClient] Error handling message:', parsed.type, e) }
}
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)
}
}
private startHeartbeat(): void {
this.stopHeartbeat()
this.heartbeatTimer = window.setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(JSON.stringify({ type: 'ping' }))
this.pongTimer = window.setTimeout(() => {
this.pongTimer = null
this.ws?.close()
}, HEARTBEAT_TIMEOUT_MS)
}, HEARTBEAT_INTERVAL_MS)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer !== null) {
window.clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.pongTimer !== null) {
window.clearTimeout(this.pongTimer)
this.pongTimer = null
}
}
private handlePong(): void {
if (this.pongTimer !== null) {
window.clearTimeout(this.pongTimer)
this.pongTimer = null
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer !== null) return
if (this.reconnectAttempt >= RECONNECT_MAX_ATTEMPTS) {
this.handlers.onStatus?.('error', 'max reconnect attempts reached')
return
}
const delay = Math.min(
RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempt),
RECONNECT_MAX_MS,
)
this.reconnectAttempt++
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null
this.connect()
}, delay)
}
}