fix(ui): stabilize company chat result topology
This commit is contained in:
@@ -575,23 +575,43 @@ class ChatStore:
|
||||
@staticmethod
|
||||
def _strip_narrative_title_prefix(content: str) -> str:
|
||||
trimmed = str(content or "").strip()
|
||||
markdown_title = re.match(r"^\*\*(.{8,160}?)\*\*:\s+([\s\S]+)$", trimmed)
|
||||
if markdown_title:
|
||||
# Only discard an explicit, anchored narrative wrapper. The former
|
||||
# fallback used the first ``": "`` anywhere in the first 160
|
||||
# characters, so ordinary Markdown such as ``**Work Item 1: ...**``
|
||||
# was progressively truncated every time a sync was merged. Peeling
|
||||
# explicit nested wrappers to a fixed point keeps this comparison
|
||||
# helper idempotent without interpreting body punctuation as structure.
|
||||
while True:
|
||||
markdown_title = re.match(r"^\*\*(.{8,160}?)\*\*:\s+([\s\S]+)$", trimmed)
|
||||
if not markdown_title:
|
||||
return trimmed
|
||||
body = markdown_title.group(2).strip()
|
||||
if len(body) >= 80:
|
||||
return body
|
||||
colon_index = trimmed.find(": ")
|
||||
if colon_index < 8 or colon_index > 160:
|
||||
return trimmed
|
||||
prefix = trimmed[:colon_index].replace("*", "").strip()
|
||||
body = trimmed[colon_index + 2 :].strip()
|
||||
if len(body) < 80:
|
||||
return trimmed
|
||||
if not re.search(r"[A-Za-z\u4e00-\u9fff]", prefix):
|
||||
return trimmed
|
||||
if re.match(r"^(https?|file)$", prefix, flags=re.IGNORECASE):
|
||||
return trimmed
|
||||
return body
|
||||
if len(body) < 80 or body == trimmed:
|
||||
return trimmed
|
||||
trimmed = body
|
||||
|
||||
@classmethod
|
||||
def _select_duplicate_display_content(
|
||||
cls,
|
||||
preferred: dict[str, Any],
|
||||
secondary: dict[str, Any],
|
||||
) -> str:
|
||||
"""Choose an exact input body; never persist the comparison key itself."""
|
||||
preferred_content = str(preferred.get("content", "") or "")
|
||||
secondary_content = str(secondary.get("content", "") or "")
|
||||
preferred_key = cls._normalize_duplicate_content(preferred_content)
|
||||
if not preferred_key or preferred_key != cls._normalize_duplicate_content(secondary_content):
|
||||
return preferred_content
|
||||
# If one real surface is already the comparison body while the other
|
||||
# carries an explicit narrative wrapper/footer, reuse that real body.
|
||||
# This preserves the historical visible answer without manufacturing
|
||||
# display text from a lossy canonicalization function.
|
||||
if (
|
||||
secondary_content.strip() == preferred_key
|
||||
and preferred_content.strip() != preferred_key
|
||||
):
|
||||
return secondary_content
|
||||
return preferred_content
|
||||
|
||||
@staticmethod
|
||||
def _message_timestamp(message: dict[str, Any]) -> float:
|
||||
@@ -663,6 +683,9 @@ class ChatStore:
|
||||
normalized = str(value or "").strip()
|
||||
if normalized:
|
||||
keys.add(normalized)
|
||||
result_delivery_id = str(metadata.get("result_delivery_id", "") or "").strip()
|
||||
if result_delivery_id:
|
||||
keys.add(f"result_delivery:{result_delivery_id}")
|
||||
return keys
|
||||
|
||||
@classmethod
|
||||
@@ -681,10 +704,15 @@ class ChatStore:
|
||||
cls,
|
||||
existing: dict[str, Any],
|
||||
candidate: dict[str, Any],
|
||||
*,
|
||||
prefer_candidate: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
preferred = existing
|
||||
secondary = candidate
|
||||
if cls._message_preference_score(candidate) > cls._message_preference_score(existing):
|
||||
if (
|
||||
prefer_candidate
|
||||
or cls._message_preference_score(candidate) > cls._message_preference_score(existing)
|
||||
):
|
||||
preferred = candidate
|
||||
secondary = existing
|
||||
|
||||
@@ -694,12 +722,7 @@ class ChatStore:
|
||||
secondary_meta = dict(secondary.get("metadata", {}) or {})
|
||||
preferred_meta = dict(preferred.get("metadata", {}) or {})
|
||||
merged["metadata"] = {**secondary_meta, **preferred_meta}
|
||||
normalized_content = cls._normalize_duplicate_content(preferred.get("content", ""))
|
||||
if (
|
||||
normalized_content
|
||||
and normalized_content == cls._normalize_duplicate_content(secondary.get("content", ""))
|
||||
):
|
||||
merged["content"] = normalized_content
|
||||
merged["content"] = cls._select_duplicate_display_content(preferred, secondary)
|
||||
|
||||
shared_ids = cls._message_identity_keys(existing) & cls._message_identity_keys(candidate)
|
||||
canonical_id = ""
|
||||
@@ -742,7 +765,10 @@ class ChatStore:
|
||||
return (
|
||||
str(existing.get("sender", "") or "") == str(candidate.get("sender", "") or "")
|
||||
and str(existing.get("sender_name", "") or "") == str(candidate.get("sender_name", "") or "")
|
||||
and cls._normalize_duplicate_content(existing.get("content", "")) == cls._normalize_duplicate_content(candidate.get("content", ""))
|
||||
# Display content is persisted data, not a duplicate-comparison
|
||||
# key. Exact comparison lets authoritative transcript backfill
|
||||
# repair a legacy row whose body was destructively normalized.
|
||||
and str(existing.get("content", "") or "") == str(candidate.get("content", "") or "")
|
||||
and cls._message_timestamp(existing) == cls._message_timestamp(candidate)
|
||||
and str(existing.get("reply_to_id", "") or "") == str(candidate.get("reply_to_id", "") or "")
|
||||
and list(existing.get("mentions", []) or []) == list(candidate.get("mentions", []) or [])
|
||||
@@ -791,6 +817,55 @@ class ChatStore:
|
||||
return None
|
||||
return await self._message_scope(str(message_id).strip())
|
||||
|
||||
async def _persist_merged_message(
|
||||
self,
|
||||
existing: dict[str, Any],
|
||||
merged: dict[str, Any],
|
||||
*,
|
||||
channel_id: str,
|
||||
project_id: str,
|
||||
preserve_timestamp: bool = False,
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""Persist a duplicate merge while retaining the mounted cache row.
|
||||
|
||||
Semantic transcript duplicates can have a different source message id.
|
||||
Keeping the existing cache identity (and, for semantic replacement, its
|
||||
timestamp) upgrades the row in place instead of moving it in the UI.
|
||||
"""
|
||||
persisted_id = str(existing.get("message_id", "") or existing.get("id", "") or "")
|
||||
existing_timestamp = self._message_timestamp(existing)
|
||||
merged_timestamp = (
|
||||
existing_timestamp
|
||||
if preserve_timestamp and existing_timestamp
|
||||
else self._message_timestamp(merged) or time.time()
|
||||
)
|
||||
persisted = {
|
||||
**merged,
|
||||
"message_id": persisted_id,
|
||||
"channel_id": channel_id,
|
||||
"timestamp": merged_timestamp,
|
||||
"created_at": merged_timestamp,
|
||||
}
|
||||
if self._message_persisted_equal(existing, persisted):
|
||||
return persisted, False
|
||||
await self._db.execute(
|
||||
"UPDATE messages SET sender = ?, sender_name = ?, content = ?, timestamp = ?, "
|
||||
"reply_to_id = ?, mentions = ?, metadata = ? WHERE message_id = ? AND channel_id = ? AND project_id = ?",
|
||||
(
|
||||
persisted["sender"],
|
||||
persisted["sender_name"],
|
||||
persisted["content"],
|
||||
merged_timestamp,
|
||||
persisted.get("reply_to_id"),
|
||||
json.dumps(persisted.get("mentions", [])),
|
||||
json.dumps(persisted.get("metadata", {})),
|
||||
persisted_id,
|
||||
channel_id,
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
return persisted, True
|
||||
|
||||
async def _merge_into_same_scope_row(
|
||||
self,
|
||||
message_id: str,
|
||||
@@ -798,13 +873,14 @@ class ChatStore:
|
||||
channel_id: str,
|
||||
project_id: str,
|
||||
candidate: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
prefer_candidate: bool = False,
|
||||
) -> tuple[dict[str, Any], bool] | None:
|
||||
"""Merge ``candidate`` into an already-persisted row with the same id/scope.
|
||||
|
||||
Returns the merged row when the update happened (or nothing changed), or
|
||||
None when the row could not be loaded. Backfill and the live insert path
|
||||
can race on the same message id; the duplicate must merge in place, never
|
||||
be re-inserted under a scoped alias id in the same channel.
|
||||
Returns ``(merged row, materially changed)`` or ``None`` when the row
|
||||
could not be loaded. Backfill and the live insert path can race on the
|
||||
same message id; the duplicate must merge in place, never be re-inserted
|
||||
under a scoped alias id in the same channel.
|
||||
"""
|
||||
cursor = await self._db.execute(
|
||||
"SELECT message_id, channel_id, sender, sender_name, content, "
|
||||
@@ -816,29 +892,17 @@ class ChatStore:
|
||||
if row is None:
|
||||
return None
|
||||
existing = self._row_to_message_dict(row)
|
||||
merged = self._merge_duplicate_messages(existing, candidate)
|
||||
if self._message_persisted_equal(existing, merged):
|
||||
return merged
|
||||
merged_timestamp = self._message_timestamp(merged) or time.time()
|
||||
await self._db.execute(
|
||||
"UPDATE messages SET sender = ?, sender_name = ?, content = ?, timestamp = ?, "
|
||||
"reply_to_id = ?, mentions = ?, metadata = ? WHERE message_id = ? AND channel_id = ? AND project_id = ?",
|
||||
(
|
||||
merged["sender"],
|
||||
merged["sender_name"],
|
||||
merged["content"],
|
||||
merged_timestamp,
|
||||
merged.get("reply_to_id"),
|
||||
json.dumps(merged.get("mentions", [])),
|
||||
json.dumps(merged.get("metadata", {})),
|
||||
message_id,
|
||||
channel_id,
|
||||
project_id,
|
||||
),
|
||||
merged = self._merge_duplicate_messages(
|
||||
existing,
|
||||
candidate,
|
||||
prefer_candidate=prefer_candidate,
|
||||
)
|
||||
return await self._persist_merged_message(
|
||||
existing,
|
||||
merged,
|
||||
channel_id=channel_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
merged["timestamp"] = merged_timestamp
|
||||
merged["created_at"] = merged_timestamp
|
||||
return merged
|
||||
|
||||
async def _allocate_scoped_message_id(
|
||||
self,
|
||||
@@ -1581,35 +1645,24 @@ class ChatStore:
|
||||
existing_index = existing_positions.get(mid)
|
||||
if existing_index is not None:
|
||||
existing_match = existing_messages[existing_index]
|
||||
merged_existing = self._merge_duplicate_messages(existing_match, normalized_message)
|
||||
if not self._message_persisted_equal(existing_match, merged_existing):
|
||||
merged_timestamp = self._message_timestamp(merged_existing) or time.time()
|
||||
await self._db.execute(
|
||||
"UPDATE messages SET sender = ?, sender_name = ?, content = ?, timestamp = ?, "
|
||||
"reply_to_id = ?, mentions = ?, metadata = ? WHERE message_id = ? AND channel_id = ? AND project_id = ?",
|
||||
(
|
||||
merged_existing["sender"],
|
||||
merged_existing["sender_name"],
|
||||
merged_existing["content"],
|
||||
merged_timestamp,
|
||||
merged_existing.get("reply_to_id"),
|
||||
json.dumps(merged_existing.get("mentions", [])),
|
||||
json.dumps(merged_existing.get("metadata", {})),
|
||||
mid,
|
||||
channel_id,
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
semantic_index.replace(existing_index, {
|
||||
**merged_existing,
|
||||
"created_at": merged_timestamp,
|
||||
})
|
||||
inserted_messages.append({
|
||||
**merged_existing,
|
||||
"channel_id": channel_id,
|
||||
"timestamp": merged_timestamp,
|
||||
"created_at": merged_timestamp,
|
||||
})
|
||||
# This candidate came directly from the durable transcript.
|
||||
# For the same identity its original display body is
|
||||
# authoritative, while metadata unique to the cache is
|
||||
# still retained by the merge.
|
||||
merged_existing = self._merge_duplicate_messages(
|
||||
existing_match,
|
||||
normalized_message,
|
||||
prefer_candidate=True,
|
||||
)
|
||||
persisted, did_change = await self._persist_merged_message(
|
||||
existing_match,
|
||||
merged_existing,
|
||||
channel_id=channel_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
if did_change:
|
||||
semantic_index.replace(existing_index, persisted)
|
||||
inserted_messages.append(persisted)
|
||||
changed_existing = True
|
||||
continue
|
||||
|
||||
@@ -1623,10 +1676,15 @@ class ChatStore:
|
||||
channel_id=channel_id,
|
||||
project_id=project_id,
|
||||
candidate=normalized_message,
|
||||
prefer_candidate=True,
|
||||
)
|
||||
if merged is not None:
|
||||
persisted, did_change = merged
|
||||
existing_ids.add(mid)
|
||||
existing_positions[mid] = semantic_index.append(merged)
|
||||
existing_positions[mid] = semantic_index.append(persisted)
|
||||
if did_change:
|
||||
inserted_messages.append(persisted)
|
||||
changed_existing = True
|
||||
continue
|
||||
if existing_scope and existing_scope != (channel_id, project_id):
|
||||
metadata = dict(normalized_message.get("metadata", {}) or {})
|
||||
@@ -1644,9 +1702,33 @@ class ChatStore:
|
||||
excluded_message_ids=consumed_existing_ids,
|
||||
)
|
||||
if duplicate_index is not None:
|
||||
consumed_existing_ids.add(
|
||||
existing_messages[duplicate_index]["message_id"]
|
||||
existing_duplicate = existing_messages[duplicate_index]
|
||||
existing_id = str(existing_duplicate["message_id"])
|
||||
# Prefer the authoritative transcript on an equal score, but
|
||||
# do not replace a stronger canonical result surface with a
|
||||
# lower-priority mirror. The existing cache id and timestamp
|
||||
# remain stable either way.
|
||||
prefer_candidate = (
|
||||
self._message_preference_score(normalized_message)
|
||||
>= self._message_preference_score(existing_duplicate)
|
||||
)
|
||||
merged_duplicate = self._merge_duplicate_messages(
|
||||
existing_duplicate,
|
||||
normalized_message,
|
||||
prefer_candidate=prefer_candidate,
|
||||
)
|
||||
persisted, did_change = await self._persist_merged_message(
|
||||
existing_duplicate,
|
||||
merged_duplicate,
|
||||
channel_id=channel_id,
|
||||
project_id=project_id,
|
||||
preserve_timestamp=True,
|
||||
)
|
||||
if did_change:
|
||||
semantic_index.replace(duplicate_index, persisted)
|
||||
inserted_messages.append(persisted)
|
||||
changed_existing = True
|
||||
consumed_existing_ids.add(existing_id)
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -1674,11 +1756,16 @@ class ChatStore:
|
||||
channel_id=channel_id,
|
||||
project_id=project_id,
|
||||
candidate=normalized_message,
|
||||
prefer_candidate=True,
|
||||
)
|
||||
if merged is not None:
|
||||
persisted, did_change = merged
|
||||
merged_id = normalized_message["message_id"]
|
||||
existing_ids.add(merged_id)
|
||||
existing_positions[merged_id] = semantic_index.append(merged)
|
||||
existing_positions[merged_id] = semantic_index.append(persisted)
|
||||
if did_change:
|
||||
inserted_messages.append(persisted)
|
||||
changed_existing = True
|
||||
continue
|
||||
metadata = dict(normalized_message.get("metadata", {}) or {})
|
||||
metadata.setdefault("ui_message_id", normalized_message["message_id"])
|
||||
|
||||
+75
-63
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>OpenOPC Pixel Office</title>
|
||||
<script type="module" crossorigin src="./assets/index-Cson66Y4.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CTK-c7_K.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DEvLDWDw.css">
|
||||
</head>
|
||||
|
||||
@@ -127,16 +127,37 @@ assert.match(
|
||||
'runtime events carrying a non-empty tool_name must update stable displayTool (empty tool_name keeps the sticky last command)',
|
||||
)
|
||||
|
||||
// 8. Assistant streaming drafts must disappear at real terminal boundaries.
|
||||
// 8. A streaming draft is one mounted logical turn. Completion/checkpoint
|
||||
// events cannot remove it before the matching persisted final is merged.
|
||||
assert.match(
|
||||
src,
|
||||
/evt\.type === 'turn_completed' \|\| evt\.type === 'turn_failed' \|\| evt\.type === 'checkpoint_saved'/,
|
||||
'runtime terminal/checkpoint events must clear task-mode Live Reply drafts',
|
||||
/const turnId = resolveCanonicalTurnId\(data\) \|\| undefined/,
|
||||
'runtime deltas must use the shared canonical-turn resolver',
|
||||
)
|
||||
assert.match(
|
||||
src,
|
||||
/detailHasFinalForDraft[\s\S]*runtime_v2_assistant[\s\S]*ss\.clearDraft\(detailTaskId\)/,
|
||||
'session_detail backfill of the final runtime assistant turn must clear matching Live Reply drafts',
|
||||
/const startsNewCanonicalTurn = evt\.type === 'turn_started'[\s\S]*turnId !== activeDraftTurnId[\s\S]*evt\.type === 'turn_failed' \|\| startsNewCanonicalTurn/,
|
||||
'only failure or a genuinely new canonical turn may clear an uncommitted draft',
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/evt\.type === 'turn_completed'[^\n]*clearDraft|evt\.type === 'checkpoint_saved'[^\n]*clearDraft/,
|
||||
'turn completion and checkpoint persistence must not clear a draft ahead of its final message',
|
||||
)
|
||||
assert.match(
|
||||
src,
|
||||
/detailHasFinalForDraft[\s\S]*terminalAssistantTurnId\(message\) === draftTurnId[\s\S]*ss\.clearDraft\(detailTaskId\)/,
|
||||
'session_detail may clear only after merging the matching terminal assistant turn',
|
||||
)
|
||||
assert.match(
|
||||
src,
|
||||
/cs\.addMessageFromBackend\(mapped\)[\s\S]*terminalTurnId = terminalAssistantTurnId\(mapped\)[\s\S]*terminalTurnId === activeDraftTurnId[\s\S]*clearDraft\(taskId\)/,
|
||||
'session_message/chat_new_message must merge a matching terminal final before clearing its draft',
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/mapped\.sender !== 'user'\)\s*\{\s*sessionStoreRef\.current\?\.clearDraft/,
|
||||
'an unrelated non-user session message must never clear an active draft',
|
||||
)
|
||||
|
||||
// 9. Summary/full pagination and transport-local failures have independent
|
||||
|
||||
@@ -24,6 +24,7 @@ import { companyRuntimeControlPatchForBoardStatus } from './lib/sessionRuntime'
|
||||
import { getExecutionTurnId } from './lib/workItemRuntimeIds'
|
||||
import { normalizeSessionCompanyProfile, normalizeSessionExecMode } from './lib/sessionIdentity'
|
||||
import { extractSessionRecruitmentByRole, sessionChannelId } from './lib/sessionRecruitment'
|
||||
import { resolveCanonicalTurnId, terminalAssistantTurnId } from './lib/turnIdentity'
|
||||
import { unassignAgent } from './game/map/OfficeStore'
|
||||
import type { AgentAnimStatus, EmployeeAssignment, KanbanPhase, KanbanTask, RoleAggregatedStatus, RoleWorkItemSummary, Session, TaskPreferredAgent } from './types/kanban'
|
||||
|
||||
@@ -919,9 +920,11 @@ export default function App() {
|
||||
const taskId = typeof data.task_id === 'string' ? data.task_id : ''
|
||||
if (taskId) {
|
||||
const isDeltaEvent = evt.type === 'assistant_delta' || evt.type === 'thinking_delta'
|
||||
const bufferedDraftTurnId = pendingDeltaFlushRef.current.get(taskId)?.draftTurnId
|
||||
// Store-write ordering guarantee: everything buffered for this
|
||||
// task lands before a non-delta event (turn boundaries call
|
||||
// clearDraft; the draft must be flushed first, not after).
|
||||
// task lands before a non-delta event. A matching terminal
|
||||
// message replaces the draft later; flushing here must not make
|
||||
// a completion boundary remove the visible turn prematurely.
|
||||
if (!isDeltaEvent) flushPendingDeltas(taskId)
|
||||
const ss = sessionStoreRef.current
|
||||
const bs = boardStoreRef.current
|
||||
@@ -931,13 +934,10 @@ export default function App() {
|
||||
: ''
|
||||
const executionMode = typeof data.execution_mode === 'string' ? data.execution_mode : ''
|
||||
const isTaskModeRuntime = executionMode === 'task_mode' || projectionId === 'task_mode_execution'
|
||||
const turnId = typeof data.turn_id === 'string' && data.turn_id
|
||||
? data.turn_id
|
||||
: typeof data.canonical_turn_id === 'string' && data.canonical_turn_id
|
||||
? data.canonical_turn_id
|
||||
: typeof data.execution_turn_id === 'string' && data.execution_turn_id
|
||||
? data.execution_turn_id
|
||||
: undefined
|
||||
// Drafts represent one logical assistant turn. The shared
|
||||
// resolver deliberately prefers conversation identity over the
|
||||
// iteration-scoped runtime turn id.
|
||||
const turnId = resolveCanonicalTurnId(data) || undefined
|
||||
const marksCompanyRuntime =
|
||||
!!projectionId && projectionId !== 'task_mode_execution' && !isTaskModeRuntime
|
||||
if (marksCompanyRuntime && !isDeltaEvent && existingSession?.isCompanyRuntime !== true) {
|
||||
@@ -993,7 +993,14 @@ export default function App() {
|
||||
entry.kanbanPatch = { ...entry.kanbanPatch, ...kanbanPatch }
|
||||
scheduleDeltaFlush()
|
||||
} else {
|
||||
if (evt.type === 'turn_started' || evt.type === 'turn_completed' || evt.type === 'turn_failed' || evt.type === 'checkpoint_saved') {
|
||||
const activeDraftTurnId = String(
|
||||
bufferedDraftTurnId ?? existingSession?.draftTurnId ?? '',
|
||||
).trim()
|
||||
const startsNewCanonicalTurn = evt.type === 'turn_started'
|
||||
&& !!turnId
|
||||
&& !!activeDraftTurnId
|
||||
&& turnId !== activeDraftTurnId
|
||||
if (evt.type === 'turn_failed' || startsNewCanonicalTurn) {
|
||||
ss?.clearDraft(taskId)
|
||||
}
|
||||
ss?.updateSession(taskId, runtimePartial)
|
||||
@@ -1084,14 +1091,9 @@ export default function App() {
|
||||
payload.client_history_page === true,
|
||||
)
|
||||
const draftTurnId = String(existingSession?.draftTurnId ?? '').trim()
|
||||
const detailHasFinalForDraft = !!draftTurnId && detailMessages.some((message) => {
|
||||
if (message.sender === 'user') return false
|
||||
const metadata = message.metadata ?? {}
|
||||
const transcriptKind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
|
||||
if (transcriptKind !== 'runtime_v2_assistant') return false
|
||||
const messageTurnId = String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
|
||||
return messageTurnId === draftTurnId
|
||||
})
|
||||
const detailHasFinalForDraft = !!draftTurnId
|
||||
&& !!cs
|
||||
&& detailMessages.some(message => terminalAssistantTurnId(message) === draftTurnId)
|
||||
if (detailHasFinalForDraft) {
|
||||
ss.clearDraft(detailTaskId)
|
||||
}
|
||||
@@ -1672,8 +1674,14 @@ export default function App() {
|
||||
console.debug('[onSessionMessage]', mapped.sender, mapped.channelId, mapped.content?.slice(0, 60))
|
||||
cs.addMessageFromBackend(mapped)
|
||||
const taskId = mapped.channelId.startsWith('session:') ? mapped.channelId.slice('session:'.length) : ''
|
||||
const terminalTurnId = terminalAssistantTurnId(mapped)
|
||||
const activeDraftTurnId = String(
|
||||
sessionStoreRef.current?.sessions.find(session => session.taskId === taskId)?.draftTurnId ?? '',
|
||||
).trim()
|
||||
if (taskId && mapped.sender !== 'user') {
|
||||
sessionStoreRef.current?.clearDraft(taskId)
|
||||
if (terminalTurnId && terminalTurnId === activeDraftTurnId) {
|
||||
sessionStoreRef.current?.clearDraft(taskId)
|
||||
}
|
||||
// Force refresh — session messages are critical content that must sync
|
||||
scheduleSessionDetailRefresh(taskId, 'full', true)
|
||||
}
|
||||
|
||||
@@ -241,6 +241,153 @@ assert.equal(repeatedNativeCompanySync.length, 1)
|
||||
assert.equal(repeatedNativeCompanySync[0].metadata?.ui_timeline_id, 'message:native-raw-1')
|
||||
assert.equal(repeatedNativeCompanySync[0].timestamp, 5)
|
||||
|
||||
const structuredDeliveryMerge = __chatStoreTestUtils.dedupeMessages([
|
||||
{
|
||||
...nativeCompanyRawTurn,
|
||||
content: 'Raw runtime wording before the canonical result wrapper.',
|
||||
metadata: {
|
||||
...nativeCompanyRawTurn.metadata,
|
||||
result_delivery_id: 'result:company-turn-1:attempt:0',
|
||||
},
|
||||
},
|
||||
{
|
||||
...companyRoleResult,
|
||||
content: 'Canonical committed wording may differ without creating another row.',
|
||||
metadata: {
|
||||
...companyRoleResult.metadata,
|
||||
result_delivery_id: 'result:company-turn-1:attempt:0',
|
||||
},
|
||||
},
|
||||
])
|
||||
assert.equal(structuredDeliveryMerge.length, 1)
|
||||
assert.equal(structuredDeliveryMerge[0].id, 'role-result-1')
|
||||
assert.equal(
|
||||
structuredDeliveryMerge[0].content,
|
||||
'Canonical committed wording may differ without creating another row.',
|
||||
)
|
||||
|
||||
const deliveryMirrorWithLongerWrapper = __chatStoreTestUtils.dedupeMessages([
|
||||
{
|
||||
...nativeCompanyRawTurn,
|
||||
content: 'Runtime wrapper that must not outrank the committed surface.\n\nCanonical result body.',
|
||||
metadata: {
|
||||
...nativeCompanyRawTurn.metadata,
|
||||
result_delivery_id: 'result:company-turn-wrapper:attempt:0',
|
||||
},
|
||||
},
|
||||
{
|
||||
...companyRoleResult,
|
||||
content: 'Canonical result body.',
|
||||
metadata: {
|
||||
...companyRoleResult.metadata,
|
||||
result_delivery_id: 'result:company-turn-wrapper:attempt:0',
|
||||
},
|
||||
},
|
||||
])
|
||||
assert.equal(deliveryMirrorWithLongerWrapper.length, 1)
|
||||
assert.equal(
|
||||
deliveryMirrorWithLongerWrapper[0].content,
|
||||
'Canonical result body.',
|
||||
'delivery mirrors follow surface authority; suffix repair only applies to one persistent message id',
|
||||
)
|
||||
|
||||
const ctoDispatchContent = `Both work items have been successfully dispatched to my senior engineer. Here's the status:
|
||||
|
||||
## Dispatch Summary
|
||||
|
||||
**Work Item 1: OpenOPC Source Code Architecture Deep-Dive Analysis**
|
||||
- ID: \`1ed5f5f1-ac41-49a1-b1fa-23bbc9adab82\`
|
||||
- Owner: senior_engineer
|
||||
- Scope: \`openopc-source-analysis\`
|
||||
- Output: \`/data2/bjdwhzzh/project-hku/OpenOPC_workplace/0009/openopc-architecture-analysis.md\`
|
||||
- Covers: Layered architecture, the work-item state machine, collaboration policy, and seat executor mechanisms.
|
||||
|
||||
**Work Item 2: External Multi-Agent Frameworks Architecture Research**
|
||||
- ID: \`d0307208-6b95-44c1-9b51-6bf073bbdcef\`
|
||||
- Owner: senior_engineer
|
||||
|
||||
Both work items are independent and can execute in parallel.`
|
||||
|
||||
const ctoCompanyFinal: ChatMessage = {
|
||||
id: 'runtime-v2-company-assistant-final:cto-turn-9',
|
||||
channelId: 'session:cto-work-item',
|
||||
sender: 'cto',
|
||||
senderName: 'CTO',
|
||||
content: ctoDispatchContent,
|
||||
timestamp: 20,
|
||||
mentions: [],
|
||||
metadata: {
|
||||
source: 'engine',
|
||||
transcript_kind: 'runtime_v2_company_assistant',
|
||||
canonical_turn_id: 'cto-turn-9',
|
||||
ui_message_id: 'runtime-v2-company-assistant-final:cto-turn-9',
|
||||
},
|
||||
}
|
||||
|
||||
const repeatedCtoSnapshot = __chatStoreTestUtils.dedupeMessages([
|
||||
ctoCompanyFinal,
|
||||
{ ...ctoCompanyFinal, metadata: { ...ctoCompanyFinal.metadata } },
|
||||
])
|
||||
assert.equal(repeatedCtoSnapshot.length, 1)
|
||||
assert.equal(
|
||||
repeatedCtoSnapshot[0].content,
|
||||
ctoDispatchContent,
|
||||
'comparison normalization must never peel Work Item, ID, or Owner fields from rendered content',
|
||||
)
|
||||
|
||||
let repeatedCtoMerge = [ctoCompanyFinal]
|
||||
for (let replay = 0; replay < 6; replay += 1) {
|
||||
repeatedCtoMerge = __chatStoreTestUtils.mergeMessagesIntoExisting(
|
||||
repeatedCtoMerge,
|
||||
[{ ...ctoCompanyFinal, metadata: { ...ctoCompanyFinal.metadata } }],
|
||||
)
|
||||
assert.equal(repeatedCtoMerge.length, 1)
|
||||
assert.equal(
|
||||
repeatedCtoMerge[0].content,
|
||||
ctoDispatchContent,
|
||||
`identical MERGE replay ${replay + 1} must preserve the complete source text`,
|
||||
)
|
||||
}
|
||||
|
||||
const truncatedCtoContent = ctoDispatchContent.slice(
|
||||
ctoDispatchContent.indexOf('OpenOPC Source Code Architecture Deep-Dive Analysis**'),
|
||||
)
|
||||
const truncatedCtoFinal: ChatMessage = {
|
||||
...ctoCompanyFinal,
|
||||
content: truncatedCtoContent,
|
||||
}
|
||||
|
||||
let interleavedCtoReplay = [ctoCompanyFinal]
|
||||
for (const replayedMessage of [
|
||||
truncatedCtoFinal,
|
||||
ctoCompanyFinal,
|
||||
truncatedCtoFinal,
|
||||
ctoCompanyFinal,
|
||||
]) {
|
||||
interleavedCtoReplay = __chatStoreTestUtils.mergeMessagesIntoExisting(
|
||||
interleavedCtoReplay,
|
||||
[{ ...replayedMessage, metadata: { ...replayedMessage.metadata } }],
|
||||
)
|
||||
assert.equal(interleavedCtoReplay.length, 1)
|
||||
assert.equal(
|
||||
interleavedCtoReplay[0].content,
|
||||
ctoDispatchContent,
|
||||
'a same-identity truncated cache replay must neither replace nor duplicate the complete message',
|
||||
)
|
||||
}
|
||||
|
||||
const repairedCtoReplay = __chatStoreTestUtils.mergeMessagesIntoExisting(
|
||||
[truncatedCtoFinal],
|
||||
[ctoCompanyFinal],
|
||||
)
|
||||
assert.equal(repairedCtoReplay.length, 1)
|
||||
assert.equal(repairedCtoReplay[0].content, ctoDispatchContent)
|
||||
assert.equal(
|
||||
__chatStoreTestUtils.mergeMessagesIntoExisting(repairedCtoReplay, [truncatedCtoFinal])[0].content,
|
||||
ctoDispatchContent,
|
||||
'once a complete same-identity source arrives, later truncated replays must not regress it',
|
||||
)
|
||||
|
||||
const mountedHighPriorityResult: ChatMessage = {
|
||||
...companyRoleResult,
|
||||
id: 'mounted-high-result',
|
||||
|
||||
@@ -23,8 +23,8 @@ function messageMetadata(message: ChatMessage): Record<string, unknown> {
|
||||
return (message.metadata ?? {}) as Record<string, unknown>
|
||||
}
|
||||
|
||||
function normalizeMessageContent(content: string): string {
|
||||
const normalized = String(content ?? '')
|
||||
function normalizeMessageFormatting(content: string): string {
|
||||
return String(content ?? '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.split('\n')
|
||||
@@ -32,6 +32,10 @@ function normalizeMessageContent(content: string): string {
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeMessageContent(content: string): string {
|
||||
const normalized = normalizeMessageFormatting(content)
|
||||
const titleStripped = stripNarrativeTitlePrefix(normalized)
|
||||
const paragraphs = titleStripped.split(/\n{2,}/).map(part => part.trim()).filter(Boolean)
|
||||
if (paragraphs.length > 1 && /^Verification:\s/i.test(paragraphs[paragraphs.length - 1])) {
|
||||
@@ -41,21 +45,40 @@ function normalizeMessageContent(content: string): string {
|
||||
}
|
||||
|
||||
function stripNarrativeTitlePrefix(content: string): string {
|
||||
const trimmed = String(content || '').trim()
|
||||
const markdownTitle = trimmed.match(/^\*\*(.{8,160}?)\*\*:\s+([\s\S]+)$/)
|
||||
if (markdownTitle) {
|
||||
let trimmed = String(content || '').trim()
|
||||
// Only an explicit, first-line "**Title**: body" wrapper is removable.
|
||||
// Searching for an arbitrary `: ` in the first N characters is destructive:
|
||||
// ordinary Markdown such as "**Work Item 1: ...**" and list fields such as
|
||||
// "- ID: ..." would be peeled one layer at a time on repeated syncs.
|
||||
for (;;) {
|
||||
const markdownTitle = trimmed.match(/^\*\*([^\r\n]{8,160}?)\*\*:(?:[ \t]+|\r?\n+)([\s\S]+)$/)
|
||||
if (!markdownTitle) return trimmed
|
||||
const body = markdownTitle[2].trim()
|
||||
if (body.length >= 80) return body
|
||||
if (body.length < 80 || body === trimmed) return trimmed
|
||||
trimmed = 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 losslessIdentityContent(
|
||||
existing: ChatMessage,
|
||||
candidate: ChatMessage,
|
||||
preferred: ChatMessage,
|
||||
sharesConcreteIdentity: boolean,
|
||||
): string {
|
||||
if (!sharesConcreteIdentity) return preferred.content
|
||||
|
||||
const existingComparable = normalizeMessageFormatting(existing.content)
|
||||
const candidateComparable = normalizeMessageFormatting(candidate.content)
|
||||
if (!existingComparable || !candidateComparable || existingComparable === candidateComparable) {
|
||||
return preferred.content
|
||||
}
|
||||
|
||||
// A cache/detail replay can contain a prefix-truncated copy of the same
|
||||
// persistent message. Preserve the lossless source regardless of arrival
|
||||
// order; unrelated edits still follow the normal preference rules.
|
||||
if (existingComparable.endsWith(candidateComparable)) return existing.content
|
||||
if (candidateComparable.endsWith(existingComparable)) return candidate.content
|
||||
return preferred.content
|
||||
}
|
||||
|
||||
function messageIdentityKeys(message: ChatMessage): Set<string> {
|
||||
@@ -66,6 +89,9 @@ function messageIdentityKeys(message: ChatMessage): Set<string> {
|
||||
for (const value of [
|
||||
message.id,
|
||||
typeof metadata.ui_message_id === 'string' ? metadata.ui_message_id : '',
|
||||
typeof metadata.result_delivery_id === 'string' && metadata.result_delivery_id.trim()
|
||||
? `delivery:${metadata.result_delivery_id.trim()}`
|
||||
: '',
|
||||
checkpointType && checkpointId ? `checkpoint:${checkpointType}:${checkpointId}` : '',
|
||||
]) {
|
||||
const normalized = String(value ?? '').trim()
|
||||
@@ -81,7 +107,7 @@ function scopedMessageIdentityKeys(message: ChatMessage): Set<string> {
|
||||
}
|
||||
|
||||
function isDerivedIdentityKey(value: string): boolean {
|
||||
return value.startsWith('checkpoint:')
|
||||
return value.startsWith('checkpoint:') || value.startsWith('delivery:')
|
||||
}
|
||||
|
||||
function messageTimestamp(message: ChatMessage): number {
|
||||
@@ -229,18 +255,17 @@ function mergeDuplicateMessages(
|
||||
|
||||
const existingIds = messageIdentityKeys(existing)
|
||||
const candidateIds = messageIdentityKeys(candidate)
|
||||
let sharesConcreteIdentity = false
|
||||
let canonicalId = ''
|
||||
for (const id of existingIds) {
|
||||
if (candidateIds.has(id) && !isDerivedIdentityKey(id)) {
|
||||
canonicalId = id
|
||||
break
|
||||
if (!candidateIds.has(id)) continue
|
||||
if (!isDerivedIdentityKey(id)) {
|
||||
sharesConcreteIdentity = true
|
||||
if (!canonicalId) canonicalId = id
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeMessageContent(preferred.content)
|
||||
const content = normalizedContent && normalizedContent === normalizeMessageContent(secondary.content)
|
||||
? normalizedContent
|
||||
: preferred.content
|
||||
const content = losslessIdentityContent(existing, candidate, preferred, sharesConcreteIdentity)
|
||||
|
||||
const existingCheckpointId = String(messageMetadata(existing).checkpoint_id ?? '').trim()
|
||||
const candidateCheckpointId = String(messageMetadata(candidate).checkpoint_id ?? '').trim()
|
||||
|
||||
@@ -17,6 +17,16 @@ assert.match(
|
||||
assert.match(messageListSource, /scrollPolicy = 'follow'/, 'main transcript behavior should default to follow mode')
|
||||
assert.doesNotMatch(messageListSource, /useVirtualizer/, 'chat transcript must use stable normal DOM rows')
|
||||
assert.doesNotMatch(messageListSource, /PROGRAMMATIC_SCROLL_GRACE_MS/, 'scroll behavior must not regress to timer-based intent guessing')
|
||||
assert.doesNotMatch(
|
||||
messageListSource,
|
||||
/seenNarrativeMessages|seenProjectUpdates/,
|
||||
'MessageList must not independently remove durable rows using rendered content',
|
||||
)
|
||||
assert.match(
|
||||
messageListSource,
|
||||
/terminalAssistantTurnId\(message\)/,
|
||||
'draft suppression must use the shared committed-turn resolver',
|
||||
)
|
||||
|
||||
const progressWithoutServerId = {
|
||||
type: 'status_change' as const,
|
||||
@@ -147,7 +157,16 @@ const companyTurnKeys = [
|
||||
metadata: { kind: 'company_role_result', canonical_turn_id: sharedCompanyTurn },
|
||||
}),
|
||||
]
|
||||
assert.equal(new Set(companyTurnKeys).size, companyTurnKeys.length, 'distinct company rows sharing a turn need unique DOM keys')
|
||||
assert.equal(
|
||||
companyTurnKeys[1],
|
||||
companyTurnKeys[2],
|
||||
'company draft/final surfaces for one canonical turn must reuse the same DOM slot',
|
||||
)
|
||||
assert.notEqual(
|
||||
companyTurnKeys[2],
|
||||
companyTurnKeys[3],
|
||||
'a separately committed role result keeps its result-delivery identity',
|
||||
)
|
||||
|
||||
const narrativeItems = buildNarrativeMessageItems([
|
||||
baseMessage('m1', '[Company:cto::execute::abc] starting Research source reliability', 1000),
|
||||
@@ -163,35 +182,63 @@ assert.equal(narrativeItems[0].kind === 'ops-bundle' ? narrativeItems[0].events.
|
||||
assert.equal(narrativeItems[1].kind, 'message')
|
||||
assert.equal(narrativeItems[2].kind, 'ops-bundle')
|
||||
|
||||
const longResult = 'Completed the focused recheck and produced the QA artifact with caveats for downstream aggregation.'
|
||||
const dedupedProjectUpdates = buildNarrativeMessageItems([
|
||||
baseMessage('u1', prefixedPayload, 2000, 'qa_analyst'),
|
||||
baseMessage('u2', `**Report #1: Recheck remediated screen**: ${prefixedPayload}`, 2000, 'qa_analyst'),
|
||||
], { isCompanyRuntime: true, detailMode: 'summary' })
|
||||
assert.equal(dedupedProjectUpdates.length, 1)
|
||||
assert.equal(
|
||||
dedupedProjectUpdates.length,
|
||||
2,
|
||||
'renderer must preserve distinct project-update rows; upstream identity owns consolidation',
|
||||
)
|
||||
assert.equal(dedupedProjectUpdates[0].kind, 'message')
|
||||
assert.equal(dedupedProjectUpdates[0].kind === 'message' ? dedupedProjectUpdates[0].msg.id : '', 'u1')
|
||||
|
||||
const longResult = 'Completed the focused recheck and produced the QA artifact with caveats for downstream aggregation.'
|
||||
const dedupedNarrativeMessages = buildNarrativeMessageItems([
|
||||
const identicalDurableNarratives = buildNarrativeMessageItems([
|
||||
baseMessage('same-content-1', longResult, 2500, 'qa_analyst'),
|
||||
baseMessage('same-content-2', longResult, 2500, 'qa_analyst'),
|
||||
], { isCompanyRuntime: true, detailMode: 'summary' })
|
||||
assert.deepEqual(
|
||||
identicalDurableNarratives.map(item => item.kind === 'message' ? item.msg.id : item.id),
|
||||
['same-content-1', 'same-content-2'],
|
||||
'distinct stable identities must survive even when sender, timestamp, and content are identical',
|
||||
)
|
||||
|
||||
const ambiguousNarrativeMessages = buildNarrativeMessageItems([
|
||||
baseMessage('n1', longResult, 3000, 'qa_analyst'),
|
||||
baseMessage('n2', `Recheck remediated ten-bagger candidate screen: ${longResult}`, 3000, 'qa_analyst'),
|
||||
], { isCompanyRuntime: true, detailMode: 'summary' })
|
||||
assert.equal(dedupedNarrativeMessages.length, 1)
|
||||
assert.equal(dedupedNarrativeMessages[0].kind === 'message' ? dedupedNarrativeMessages[0].msg.id : '', 'n1')
|
||||
assert.equal(
|
||||
ambiguousNarrativeMessages.length,
|
||||
2,
|
||||
'plain narrative prefixes are content and must not be stripped to guess message identity',
|
||||
)
|
||||
|
||||
const duplicatedResultSurface = buildNarrativeMessageItems([
|
||||
{
|
||||
...baseMessage('r1', longResult, 4000, 'chao'),
|
||||
metadata: { source: 'engine', transcript_kind: 'child_task_result' },
|
||||
metadata: {
|
||||
source: 'engine',
|
||||
transcript_kind: 'child_task_result',
|
||||
result_delivery_id: 'delivery-r1',
|
||||
},
|
||||
},
|
||||
{
|
||||
...baseMessage('r2', `Deliver final result to user: ${longResult}`, 4500, 'system'),
|
||||
senderName: 'Company Member',
|
||||
metadata: { source: 'runtime_event', kind: 'worker_notification', notification_kind: 'task_complete' },
|
||||
metadata: {
|
||||
source: 'engine',
|
||||
transcript_kind: 'child_result',
|
||||
result_delivery_id: 'delivery-r1',
|
||||
},
|
||||
},
|
||||
], { isCompanyRuntime: true, detailMode: 'summary' })
|
||||
assert.equal(duplicatedResultSurface.length, 1)
|
||||
assert.equal(duplicatedResultSurface[0].kind === 'message' ? duplicatedResultSurface[0].msg.id : '', 'r1')
|
||||
assert.equal(
|
||||
duplicatedResultSurface.length,
|
||||
2,
|
||||
'MessageList must not run a second result consolidator after the store/company projection',
|
||||
)
|
||||
|
||||
const fullItems = buildNarrativeMessageItems([
|
||||
baseMessage('m1', '[Company:cto::execute::abc] starting Research source reliability', 1000),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ProgressEntry, RoleWorkItemSummary, Session, WorkItemProgressEntry
|
||||
import { progressEntryKey } from '../lib/progressEntryKey'
|
||||
import { stableMessageTimelineKey } from '../lib/messageTimelineIdentity'
|
||||
import { isMessageVisibleAtDetailLevel, resultSurfaceDedupeKey } from '../lib/workItemSessions'
|
||||
import { resolveCanonicalTurnId, terminalAssistantTurnId } from '../lib/turnIdentity'
|
||||
import { IconCopy, IconCheck, IconChat, IconSparkle, IconShield, IconActivity, IconChevron } from './SvgIcons'
|
||||
import { AgentProgressBlock, AgentProgressEntryCard, INLINE_PROGRESS_ENTRY_TYPES } from './AgentProgressBlock'
|
||||
import { MarkdownBody } from './MarkdownBody'
|
||||
@@ -345,13 +346,6 @@ export function messageTimelineKey(message: ChatMessage): string {
|
||||
return stableMessageTimelineKey(message)
|
||||
}
|
||||
|
||||
function terminalAssistantTurnId(message: ChatMessage): string {
|
||||
const timelineKey = messageTimelineKey(message)
|
||||
return timelineKey.startsWith('turn:assistant:')
|
||||
? timelineKey.slice('turn:assistant:'.length)
|
||||
: ''
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
@@ -400,44 +394,6 @@ 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 isResultSurfaceMessage(message: ChatMessage): boolean {
|
||||
const transcriptKind = String(message.metadata?.transcript_kind ?? message.metadata?.kind ?? '').trim()
|
||||
if ([
|
||||
'runtime_v2_assistant',
|
||||
'runtime_v2_company_assistant',
|
||||
'top_level_reply',
|
||||
'company_role_result',
|
||||
'company_role_result_retry',
|
||||
'child_task_result',
|
||||
'child_task_result_retry',
|
||||
'child_result',
|
||||
].includes(transcriptKind)) {
|
||||
return true
|
||||
}
|
||||
if (String(message.metadata?.kind ?? '').trim() === 'worker_notification') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function parseJsonObjectText(content: string): Record<string, unknown> | null {
|
||||
const trimmed = String(content || '').trim()
|
||||
if (!trimmed) return null
|
||||
@@ -830,8 +786,6 @@ export function buildNarrativeMessageItems(
|
||||
const items: TimelineItem[] = []
|
||||
let bundle: SystemOpsBundleEvent[] = []
|
||||
let bundleSortOrder = 0
|
||||
const seenProjectUpdates = new Set<string>()
|
||||
const seenNarrativeMessages = new Set<string>()
|
||||
|
||||
const flushBundle = () => {
|
||||
if (bundle.length === 0) return
|
||||
@@ -854,33 +808,6 @@ export function buildNarrativeMessageItems(
|
||||
bundle.push(ops)
|
||||
return
|
||||
}
|
||||
const projectUpdate = detailMode === 'summary' ? parseProjectUpdatePayload(msg.content) : null
|
||||
if (projectUpdate) {
|
||||
const dedupeKey = [
|
||||
msg.sender,
|
||||
Math.round(msg.timestamp / 1000),
|
||||
projectUpdate.kind,
|
||||
compactWhitespace(projectUpdate.summary || projectUpdate.acceptanceSummary || '').slice(0, 500),
|
||||
projectUpdate.verdict ?? '',
|
||||
projectUpdate.deliverables.map(item => `${item.name}:${item.path}`).join('|').slice(0, 800),
|
||||
].join('\u0001')
|
||||
if (seenProjectUpdates.has(dedupeKey)) return
|
||||
seenProjectUpdates.add(dedupeKey)
|
||||
}
|
||||
if (detailMode === 'summary' && !isCheckpointCardMetadata(msg.metadata)) {
|
||||
const canonicalContent = compactWhitespace(stripNarrativeTitlePrefix(msg.content)).slice(0, 1200)
|
||||
if (canonicalContent) {
|
||||
const dedupeKey = isResultSurfaceMessage(msg)
|
||||
? ['result', canonicalContent].join('\u0001')
|
||||
: [
|
||||
msg.sender,
|
||||
Math.round(msg.timestamp / 1000),
|
||||
canonicalContent,
|
||||
].join('\u0001')
|
||||
if (seenNarrativeMessages.has(dedupeKey)) return
|
||||
seenNarrativeMessages.add(dedupeKey)
|
||||
}
|
||||
}
|
||||
flushBundle()
|
||||
items.push({
|
||||
kind: 'message',
|
||||
@@ -1331,7 +1258,7 @@ export const MessageList = React.memo(function MessageList({
|
||||
for (const message of timelineMessages) {
|
||||
const thinking = String(message.metadata?.runtime_thinking ?? '').trim()
|
||||
if (!thinking) continue
|
||||
const turnId = String(message.metadata?.canonical_turn_id ?? message.metadata?.turn_id ?? '').trim()
|
||||
const turnId = resolveCanonicalTurnId(message.metadata)
|
||||
if (turnId && thinkingProgressTurnIds.has(turnId)) continue
|
||||
entries.push({
|
||||
type: 'thinking' as const,
|
||||
|
||||
@@ -1,4 +1,103 @@
|
||||
import type { ChatMessage } from '../types/chat'
|
||||
import { resolveCanonicalTurnId, terminalAssistantTurnId } from './turnIdentity'
|
||||
|
||||
const COMMITTED_RESULT_SURFACE_KINDS = new Set([
|
||||
'child_task_result',
|
||||
'child_task_result_retry',
|
||||
'company_role_result',
|
||||
'company_role_result_retry',
|
||||
'child_result',
|
||||
'top_level_reply',
|
||||
])
|
||||
|
||||
const RUNTIME_RESULT_SURFACE_KINDS = new Set([
|
||||
'runtime_v2_assistant',
|
||||
'runtime_v2_company_assistant',
|
||||
])
|
||||
|
||||
function metadataValue(metadata: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = String(metadata[key] ?? '').trim()
|
||||
if (value) return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function resultGenerationSuffix(metadata: Record<string, unknown>, kind: string): string {
|
||||
const explicitAttempt = metadataValue(
|
||||
metadata,
|
||||
'result_attempt',
|
||||
'attempt',
|
||||
'attempt_index',
|
||||
'retry_count',
|
||||
'retryCount',
|
||||
)
|
||||
const attempt = explicitAttempt || (kind.endsWith('_retry') ? 'retry' : '')
|
||||
const revision = metadataValue(
|
||||
metadata,
|
||||
'result_revision',
|
||||
'delivery_revision',
|
||||
'revision',
|
||||
)
|
||||
return [
|
||||
attempt ? `attempt:${attempt}` : '',
|
||||
revision ? `revision:${revision}` : '',
|
||||
].filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
function withResultGeneration(
|
||||
base: string,
|
||||
metadata: Record<string, unknown>,
|
||||
kind: string,
|
||||
): string {
|
||||
const suffix = resultGenerationSuffix(metadata, kind)
|
||||
return suffix ? `${base}:${suffix}` : base
|
||||
}
|
||||
|
||||
/** Stable protocol identity shared by mirrors of one committed result. */
|
||||
export function stableResultDeliveryKey(message: ChatMessage): string {
|
||||
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
||||
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
|
||||
if (!COMMITTED_RESULT_SURFACE_KINDS.has(kind) && !RUNTIME_RESULT_SURFACE_KINDS.has(kind)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const deliveryId = metadataValue(
|
||||
metadata,
|
||||
'canonical_delivery_id',
|
||||
'result_delivery_id',
|
||||
'delivery_id',
|
||||
)
|
||||
if (deliveryId) return `delivery:${deliveryId}`
|
||||
|
||||
if (RUNTIME_RESULT_SURFACE_KINDS.has(kind)) {
|
||||
const turnId = resolveCanonicalTurnId(metadata)
|
||||
return turnId ? `turn:${turnId}` : ''
|
||||
}
|
||||
|
||||
// A committed result belongs to a task/work-item delivery, not merely to
|
||||
// the surrounding conversation turn. Parallel roles commonly share that
|
||||
// turn, so a turn-only fallback would collapse independent deliveries.
|
||||
const explicitSourceTaskId = metadataValue(metadata, 'source_task_id', 'child_task_id')
|
||||
const sourceTaskId = explicitSourceTaskId || (
|
||||
kind === 'top_level_reply'
|
||||
? ''
|
||||
: metadataValue(metadata, 'task_id', 'taskId')
|
||||
)
|
||||
if (sourceTaskId) {
|
||||
return withResultGeneration(`source-task:${sourceTaskId}`, metadata, kind)
|
||||
}
|
||||
|
||||
const workItemId = metadataValue(metadata, 'work_item_id', 'work_item_projection_id')
|
||||
if (workItemId) {
|
||||
return withResultGeneration(`work-item:${workItemId}`, metadata, kind)
|
||||
}
|
||||
|
||||
const childSessionId = metadataValue(metadata, 'child_session_id')
|
||||
return childSessionId
|
||||
? withResultGeneration(`child-session:${childSessionId}`, metadata, kind)
|
||||
: ''
|
||||
}
|
||||
|
||||
export function stableMessageTimelineKey(message: ChatMessage): string {
|
||||
const metadata = message.metadata ?? {}
|
||||
@@ -21,10 +120,19 @@ export function stableMessageTimelineKey(message: ChatMessage): string {
|
||||
const retainedTimelineId = String(metadata.ui_timeline_id ?? '').trim()
|
||||
if (retainedTimelineId) return retainedTimelineId
|
||||
|
||||
const turnId = String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
|
||||
if (!isUserTurn && turnId && transcriptKind === 'runtime_v2_assistant') {
|
||||
// Only a streamed runtime terminal owns the live draft's React slot.
|
||||
// Committed result surfaces may expose the same conversation turn through
|
||||
// terminalAssistantTurnId, but their row identity must remain delivery/task
|
||||
// scoped so parallel role results cannot collide.
|
||||
const turnId = RUNTIME_RESULT_SURFACE_KINDS.has(transcriptKind)
|
||||
? terminalAssistantTurnId(message)
|
||||
: ''
|
||||
if (!isUserTurn && turnId) {
|
||||
return `turn:assistant:${turnId}`
|
||||
}
|
||||
|
||||
const resultDeliveryKey = stableResultDeliveryKey(message)
|
||||
if (!isUserTurn && resultDeliveryKey) return `result:${resultDeliveryKey}`
|
||||
|
||||
return `message:${message.id}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ChatMessage, ChatMessageMeta } from '../types/chat'
|
||||
|
||||
const TERMINAL_ASSISTANT_KINDS = new Set([
|
||||
'runtime_v2_assistant',
|
||||
'runtime_v2_company_assistant',
|
||||
'child_task_result',
|
||||
'child_task_result_retry',
|
||||
'company_role_result',
|
||||
'company_role_result_retry',
|
||||
'child_result',
|
||||
'top_level_reply',
|
||||
])
|
||||
|
||||
export function resolveCanonicalTurnId(
|
||||
metadata: ChatMessageMeta | Record<string, unknown> | null | undefined,
|
||||
): string {
|
||||
const source = (metadata ?? {}) as Record<string, unknown>
|
||||
for (const key of [
|
||||
'canonical_turn_id',
|
||||
'conversation_turn_id',
|
||||
'turn_id',
|
||||
'execution_turn_id',
|
||||
]) {
|
||||
const value = String(source[key] ?? '').trim()
|
||||
if (value) return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function terminalAssistantTurnId(message: ChatMessage): string {
|
||||
if (message.sender === 'user') return ''
|
||||
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
||||
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
|
||||
if (!TERMINAL_ASSISTANT_KINDS.has(kind)) return ''
|
||||
if (kind === 'runtime_v2_company_assistant') {
|
||||
// Company tool-call iterations intentionally share this transcript kind
|
||||
// and canonical turn with the final answer. Treating every iteration as
|
||||
// terminal hides the live draft during detail refresh, then grows it back
|
||||
// on the next delta. Only the actual final surface may replace the draft.
|
||||
const uiMessageId = String(metadata.ui_message_id ?? '').trim()
|
||||
const isFinal = metadata.company_final_turn === true
|
||||
|| !!String(metadata.result_delivery_id ?? '').trim()
|
||||
|| uiMessageId.startsWith('runtime-v2-company-assistant-final:')
|
||||
// Snapshot translation retains final-vs-intermediate as visibility even
|
||||
// when reading records created before structured delivery ids existed.
|
||||
|| String(metadata.detail_visibility ?? '').trim() === 'summary'
|
||||
if (!isFinal) return ''
|
||||
}
|
||||
return resolveCanonicalTurnId(metadata)
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import type { ChatMessage } from '../types/chat'
|
||||
import type { Session } from '../types/kanban'
|
||||
import { mapBackendSession, mergeSessionDetailHasMore } from './collabSync'
|
||||
import { stableMessageTimelineKey, stableResultDeliveryKey } from './messageTimelineIdentity'
|
||||
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
|
||||
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation, selectCompanySummaryMessages } from './workItemSessions'
|
||||
import { resolveCanonicalTurnId, terminalAssistantTurnId } from './turnIdentity'
|
||||
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation, resultSurfaceDedupeKey, selectCompanySummaryMessages } from './workItemSessions'
|
||||
|
||||
function makeSession(overrides: Partial<Session> & Pick<Session, 'taskId' | 'channelId' | 'title' | 'status' | 'columnId' | 'assigneeIds' | 'priority' | 'tags' | 'progressLog' | 'createdAt' | 'updatedAt' | 'messageCount'>): Session {
|
||||
return {
|
||||
@@ -338,12 +340,380 @@ assert.deepEqual(
|
||||
[
|
||||
'parent-user',
|
||||
'canonical-role-result',
|
||||
'summary-company-final',
|
||||
'summary-terminal-b',
|
||||
'child-checkpoint',
|
||||
'child-checkpoint-response',
|
||||
].sort(),
|
||||
)
|
||||
|
||||
const companyRuntimeTurn = resultMessage(
|
||||
'company-runtime-turn',
|
||||
'session:company-child',
|
||||
'A company runtime terminal surface.',
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'runtime_v2_company_assistant',
|
||||
canonical_turn_id: 'company-turn-0009',
|
||||
result_delivery_id: 'company-delivery-0009',
|
||||
},
|
||||
'assistant',
|
||||
)
|
||||
assert.equal(
|
||||
stableMessageTimelineKey(companyRuntimeTurn),
|
||||
'turn:assistant:company-turn-0009',
|
||||
)
|
||||
|
||||
const conversationOnlyRuntimeTurn = resultMessage(
|
||||
'company-runtime-conversation-turn',
|
||||
'session:company-child',
|
||||
'A terminal surface with only its conversation identity.',
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'runtime_v2_company_assistant',
|
||||
conversation_turn_id: 'conversation-turn-only',
|
||||
turn_id: 'iteration-turn-must-not-win',
|
||||
execution_turn_id: 'execution-turn-must-not-win',
|
||||
detail_visibility: 'summary',
|
||||
},
|
||||
'assistant',
|
||||
)
|
||||
assert.equal(
|
||||
resolveCanonicalTurnId(conversationOnlyRuntimeTurn.metadata),
|
||||
'conversation-turn-only',
|
||||
)
|
||||
assert.equal(terminalAssistantTurnId(conversationOnlyRuntimeTurn), 'conversation-turn-only')
|
||||
assert.equal(
|
||||
terminalAssistantTurnId({
|
||||
...conversationOnlyRuntimeTurn,
|
||||
metadata: {
|
||||
...conversationOnlyRuntimeTurn.metadata,
|
||||
detail_visibility: 'full',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
'a company tool-call iteration must not suppress the active draft',
|
||||
)
|
||||
assert.equal(
|
||||
stableMessageTimelineKey(conversationOnlyRuntimeTurn),
|
||||
'turn:assistant:conversation-turn-only',
|
||||
)
|
||||
assert.equal(
|
||||
terminalAssistantTurnId({
|
||||
...conversationOnlyRuntimeTurn,
|
||||
metadata: {
|
||||
...conversationOnlyRuntimeTurn.metadata,
|
||||
transcript_kind: 'company_role_result',
|
||||
},
|
||||
}),
|
||||
'conversation-turn-only',
|
||||
'committed result kinds must participate in terminal-turn matching',
|
||||
)
|
||||
assert.equal(
|
||||
stableMessageTimelineKey({
|
||||
...conversationOnlyRuntimeTurn,
|
||||
metadata: {
|
||||
...conversationOnlyRuntimeTurn.metadata,
|
||||
transcript_kind: 'company_role_result',
|
||||
source_task_id: 'committed-source-task',
|
||||
},
|
||||
}),
|
||||
'result:source-task:committed-source-task',
|
||||
'a committed terminal may expose its turn for matching without taking the runtime draft key',
|
||||
)
|
||||
|
||||
const project0009CtoResult = [
|
||||
'Both work items have been successfully dispatched to my senior engineer. Here\'s the status:',
|
||||
'',
|
||||
'## Dispatch Summary',
|
||||
'',
|
||||
'**Work Item 1: OpenOPC Source Code Architecture Deep-Dive Analysis**',
|
||||
'- ID: `1ed5f5f1-ac41-49a1-b1fa-23bbc9adab82`',
|
||||
'- Owner: senior_engineer',
|
||||
'- Scope: `openopc-source-analysis`',
|
||||
'- Output: `/data2/bjdwhzzh/project-hku/OpenOPC_workplace/0009/openopc-architecture-analysis.md`',
|
||||
'- Covers: Layered architecture, collaboration policy, seat executor pattern, and self-evolution mechanisms.',
|
||||
'',
|
||||
'**Work Item 2: External Multi-Agent Frameworks Architecture Research**',
|
||||
'- ID: `d0307208-6b95-44c1-9b51-6bf073bbdcef`',
|
||||
'- Owner: senior_engineer',
|
||||
'- Scope: `external-frameworks-research`',
|
||||
'- Output: `/data2/bjdwhzzh/project-hku/OpenOPC_workplace/0009/external-frameworks-analysis.md`',
|
||||
'',
|
||||
'Both are independent and can execute in parallel. The runtime will monitor their completion.',
|
||||
].join('\n')
|
||||
|
||||
const project0009WorkItemSuffix = project0009CtoResult.slice(
|
||||
project0009CtoResult.indexOf('OpenOPC Source Code Architecture Deep-Dive Analysis'),
|
||||
)
|
||||
const project0009IdSuffix = project0009CtoResult.slice(
|
||||
project0009CtoResult.indexOf('`1ed5f5f1-ac41-49a1-b1fa-23bbc9adab82`'),
|
||||
)
|
||||
|
||||
// Content fallback must not treat arbitrary Markdown colons as removable
|
||||
// narrative wrappers. The old normalization repeatedly turned the full 0009
|
||||
// result into these two shorter variants, which changed the rendered height.
|
||||
const fallback0009Full = resultMessage(
|
||||
'0009-fallback-full',
|
||||
'session:company-root',
|
||||
project0009CtoResult,
|
||||
{ source: 'engine', transcript_kind: 'child_result' },
|
||||
'assistant',
|
||||
)
|
||||
const fallback0009WorkItem = resultMessage(
|
||||
'0009-fallback-work-item',
|
||||
'session:company-child',
|
||||
project0009WorkItemSuffix,
|
||||
{ source: 'engine', transcript_kind: 'runtime_v2_assistant' },
|
||||
'assistant',
|
||||
)
|
||||
const fallback0009Id = resultMessage(
|
||||
'0009-fallback-id',
|
||||
'session:company-child',
|
||||
project0009IdSuffix,
|
||||
{ source: 'engine', transcript_kind: 'runtime_v2_assistant' },
|
||||
'assistant',
|
||||
)
|
||||
assert.notEqual(resultSurfaceDedupeKey(fallback0009Full), resultSurfaceDedupeKey(fallback0009WorkItem))
|
||||
assert.notEqual(resultSurfaceDedupeKey(fallback0009WorkItem), resultSurfaceDedupeKey(fallback0009Id))
|
||||
|
||||
const committed0009Parent = {
|
||||
...resultMessage(
|
||||
'0009-parent-result',
|
||||
'session:company-root',
|
||||
project0009CtoResult,
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'child_result',
|
||||
source_task_id: 'cto-task-0009',
|
||||
},
|
||||
'assistant',
|
||||
),
|
||||
timestamp: 2_000,
|
||||
}
|
||||
const committed0009Child = {
|
||||
...resultMessage(
|
||||
'0009-child-result',
|
||||
'session:company-child',
|
||||
project0009CtoResult,
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'child_task_result',
|
||||
task_id: 'cto-task-0009',
|
||||
},
|
||||
'assistant',
|
||||
),
|
||||
timestamp: 2_010,
|
||||
}
|
||||
const raw0009WorkItem = {
|
||||
...fallback0009WorkItem,
|
||||
metadata: {
|
||||
...fallback0009WorkItem.metadata,
|
||||
detail_visibility: 'summary' as const,
|
||||
canonical_turn_id: 'cto-turn-0009',
|
||||
},
|
||||
timestamp: 2_020,
|
||||
}
|
||||
const raw0009Id = {
|
||||
...fallback0009Id,
|
||||
metadata: {
|
||||
...fallback0009Id.metadata,
|
||||
detail_visibility: 'summary' as const,
|
||||
canonical_turn_id: 'cto-turn-0009',
|
||||
},
|
||||
timestamp: 2_030,
|
||||
}
|
||||
|
||||
for (const messages of [
|
||||
[committed0009Parent, committed0009Child, raw0009WorkItem, raw0009Id],
|
||||
[raw0009Id, raw0009WorkItem, committed0009Child, committed0009Parent],
|
||||
]) {
|
||||
const summary = selectCompanySummaryMessages(messages, 'session:company-root')
|
||||
assert.equal(summary.length, 1)
|
||||
assert.equal(summary[0]?.id, '0009-child-result')
|
||||
assert.equal(summary[0]?.content, project0009CtoResult)
|
||||
assert.equal(stableMessageTimelineKey(summary[0]!), 'result:source-task:cto-task-0009')
|
||||
}
|
||||
|
||||
const sameRoleText = 'The role completed its independent architecture analysis and committed the result.'
|
||||
const multiRoleSummary = selectCompanySummaryMessages([
|
||||
resultMessage(
|
||||
'0009-cto-role-result',
|
||||
'session:company-cto',
|
||||
sameRoleText,
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'company_role_result',
|
||||
work_item_projection_id: '0009-cto-work-item',
|
||||
},
|
||||
'assistant',
|
||||
),
|
||||
resultMessage(
|
||||
'0009-coo-role-result',
|
||||
'session:company-coo',
|
||||
sameRoleText,
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'company_role_result',
|
||||
work_item_projection_id: '0009-coo-work-item',
|
||||
},
|
||||
'assistant',
|
||||
),
|
||||
], 'session:company-root')
|
||||
assert.deepEqual(
|
||||
multiRoleSummary.map(message => stableMessageTimelineKey(message)).sort(),
|
||||
[
|
||||
'result:work-item:0009-coo-work-item',
|
||||
'result:work-item:0009-cto-work-item',
|
||||
],
|
||||
)
|
||||
|
||||
const sharedConversationTurn = 'shared-company-conversation-turn'
|
||||
const parallelRoleResults = [
|
||||
resultMessage(
|
||||
'parallel-cto-result',
|
||||
'session:company-cto',
|
||||
'CTO completed the architecture assessment.',
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'company_role_result',
|
||||
canonical_turn_id: sharedConversationTurn,
|
||||
work_item_projection_id: 'architecture-assessment',
|
||||
},
|
||||
'assistant',
|
||||
),
|
||||
resultMessage(
|
||||
'parallel-coo-result',
|
||||
'session:company-coo',
|
||||
'COO completed the feature assessment.',
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'company_role_result',
|
||||
canonical_turn_id: sharedConversationTurn,
|
||||
work_item_projection_id: 'feature-assessment',
|
||||
},
|
||||
'assistant',
|
||||
),
|
||||
]
|
||||
assert.deepEqual(
|
||||
parallelRoleResults.map(resultSurfaceDedupeKey),
|
||||
[
|
||||
'result:work-item:architecture-assessment',
|
||||
'result:work-item:feature-assessment',
|
||||
],
|
||||
'parallel committed roles must use work-item identity before a shared conversation turn',
|
||||
)
|
||||
assert.equal(
|
||||
selectCompanySummaryMessages(parallelRoleResults, 'session:company-root').length,
|
||||
2,
|
||||
)
|
||||
|
||||
const versionedSourceResult = resultMessage(
|
||||
'versioned-source-result',
|
||||
'session:company-child',
|
||||
'Versioned result.',
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'company_role_result_retry',
|
||||
source_task_id: 'source-task-versioned',
|
||||
retry_count: 2,
|
||||
delivery_revision: 4,
|
||||
} as ChatMessage['metadata'],
|
||||
'assistant',
|
||||
)
|
||||
assert.equal(
|
||||
stableResultDeliveryKey(versionedSourceResult),
|
||||
'source-task:source-task-versioned:attempt:2:revision:4',
|
||||
)
|
||||
|
||||
const fullEqualPriorityResult = {
|
||||
...resultMessage(
|
||||
'full-equal-priority',
|
||||
'session:company-child-a',
|
||||
'The complete authoritative body includes every required architectural conclusion and its supporting rationale.',
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'company_role_result',
|
||||
result_delivery_id: 'deterministic-delivery',
|
||||
},
|
||||
'assistant',
|
||||
),
|
||||
timestamp: 2_100,
|
||||
}
|
||||
const truncatedEqualPriorityResult = {
|
||||
...resultMessage(
|
||||
'truncated-equal-priority',
|
||||
'session:company-child-b',
|
||||
'supporting rationale.',
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'company_role_result',
|
||||
result_delivery_id: 'deterministic-delivery',
|
||||
},
|
||||
'assistant',
|
||||
),
|
||||
timestamp: 2_200,
|
||||
}
|
||||
for (const groups of [
|
||||
[[fullEqualPriorityResult], [truncatedEqualPriorityResult]],
|
||||
[[truncatedEqualPriorityResult], [fullEqualPriorityResult]],
|
||||
]) {
|
||||
const merged = mergeConversationMessages(groups)
|
||||
assert.equal(merged.length, 1)
|
||||
assert.equal(merged[0]?.id, 'full-equal-priority')
|
||||
assert.equal(merged[0]?.content, fullEqualPriorityResult.content)
|
||||
assert.equal(merged[0]?.timestamp, 2_100)
|
||||
assert.equal(
|
||||
stableMessageTimelineKey(merged[0]!),
|
||||
'result:delivery:deterministic-delivery',
|
||||
)
|
||||
const replayed = mergeConversationMessages([
|
||||
merged,
|
||||
[truncatedEqualPriorityResult],
|
||||
])
|
||||
assert.equal(replayed.length, 1)
|
||||
assert.equal(replayed[0]?.id, 'full-equal-priority')
|
||||
assert.equal(replayed[0]?.content, fullEqualPriorityResult.content)
|
||||
}
|
||||
|
||||
const equalLengthStableWinner = {
|
||||
...resultMessage(
|
||||
'z-stable-winner',
|
||||
'session:company-child-a',
|
||||
'BBBB',
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'company_role_result',
|
||||
result_delivery_id: 'equal-length-delivery',
|
||||
},
|
||||
'assistant',
|
||||
),
|
||||
timestamp: 3_200,
|
||||
}
|
||||
const equalLengthLoser = {
|
||||
...resultMessage(
|
||||
'a-stable-loser',
|
||||
'session:company-child-b',
|
||||
'AAAA',
|
||||
{
|
||||
source: 'engine',
|
||||
transcript_kind: 'company_role_result',
|
||||
result_delivery_id: 'equal-length-delivery',
|
||||
},
|
||||
'assistant',
|
||||
),
|
||||
timestamp: 3_100,
|
||||
}
|
||||
for (const groups of [
|
||||
[[equalLengthStableWinner], [equalLengthLoser]],
|
||||
[[equalLengthLoser], [equalLengthStableWinner]],
|
||||
]) {
|
||||
const firstMerge = mergeConversationMessages(groups)
|
||||
assert.equal(firstMerge[0]?.id, 'z-stable-winner')
|
||||
assert.equal(firstMerge[0]?.content, 'BBBB')
|
||||
assert.equal(firstMerge[0]?.timestamp, 3_100)
|
||||
const replayed = mergeConversationMessages([firstMerge, [equalLengthLoser]])
|
||||
assert.equal(replayed[0]?.id, 'z-stable-winner')
|
||||
assert.equal(replayed[0]?.content, 'BBBB')
|
||||
}
|
||||
assert.equal(companyHeaderView?.status, 'running')
|
||||
assert.equal(companyHeaderView?.contextTokens, 0)
|
||||
assert.equal(companyHeaderView?.contextWindow, 128000)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ChatMessage } from '../types/chat'
|
||||
import type { ProgressEntry, Session } from '../types/kanban'
|
||||
import { getContextUsageMetrics } from './contextUsage'
|
||||
import { isSessionWorking } from './sessionRuntime'
|
||||
import { stableMessageTimelineKey } from './messageTimelineIdentity'
|
||||
import { stableMessageTimelineKey, stableResultDeliveryKey } from './messageTimelineIdentity'
|
||||
|
||||
const CONTEXT_TOKENS_RE = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+tokens/i
|
||||
const USED_PCT_RE = /(\d{1,3})%\s*used/i
|
||||
@@ -20,22 +20,32 @@ 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
|
||||
function normalizeResultContentFallback(content: string): string {
|
||||
let normalized = String(content || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.split('\n')
|
||||
.map(line => line.trimEnd())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
|
||||
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
|
||||
// Some result mirrors add one explicit Markdown narrative label. Only
|
||||
// remove that anchored wrapper; a colon later in Markdown (for example
|
||||
// "**Work Item 1: ..." or "- ID: ...") is message content, not a title.
|
||||
for (;;) {
|
||||
const markdownTitle = normalized.match(/^\*\*([^\r\n]{8,160}?)\*\*:(?:[ \t]+|\r?\n+)([\s\S]+)$/)
|
||||
if (!markdownTitle) break
|
||||
const body = markdownTitle[2].trim()
|
||||
if (body.length < 80 || body === normalized) break
|
||||
normalized = body
|
||||
}
|
||||
|
||||
const paragraphs = normalized.split(/\n{2,}/).map(part => part.trim()).filter(Boolean)
|
||||
if (paragraphs.length > 1 && /^Verification:\s/i.test(paragraphs[paragraphs.length - 1])) {
|
||||
normalized = paragraphs.slice(0, -1).join('\n\n').trim()
|
||||
}
|
||||
return compactWhitespace(normalized).slice(0, 2000)
|
||||
}
|
||||
|
||||
function resultSurfacePriority(message: ChatMessage): number {
|
||||
@@ -67,9 +77,52 @@ function resultSurfacePriority(message: ChatMessage): number {
|
||||
return 0
|
||||
}
|
||||
|
||||
function normalizedResultContentLength(message: ChatMessage): number {
|
||||
return String(message.content ?? '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.split('\n')
|
||||
.map(line => line.trimEnd())
|
||||
.join('\n')
|
||||
.trim()
|
||||
.length
|
||||
}
|
||||
|
||||
function resultAuthorityScore(message: ChatMessage): number {
|
||||
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
||||
let score = 0
|
||||
if (String(metadata.source ?? '').trim() === 'engine') score += 4
|
||||
if (String(metadata.source_result_message_id ?? '').trim()) score += 2
|
||||
if (metadata.authoritative_output === true || metadata.canonical_result === true) score += 1
|
||||
return score
|
||||
}
|
||||
|
||||
function compareResultSurfacePreference(left: ChatMessage, right: ChatMessage): number {
|
||||
const numericComparisons: Array<[number, number]> = [
|
||||
[resultSurfacePriority(left), resultSurfacePriority(right)],
|
||||
[resultAuthorityScore(left), resultAuthorityScore(right)],
|
||||
[normalizedResultContentLength(left), normalizedResultContentLength(right)],
|
||||
]
|
||||
for (const [leftValue, rightValue] of numericComparisons) {
|
||||
if (leftValue !== rightValue) return leftValue > rightValue ? 1 : -1
|
||||
}
|
||||
const idComparison = left.id.localeCompare(right.id)
|
||||
if (idComparison !== 0) return idComparison
|
||||
const contentComparison = left.content.localeCompare(right.content)
|
||||
if (contentComparison !== 0) return contentComparison
|
||||
// Result chronology is later rewritten to the earliest underlying delivery.
|
||||
// Timestamp is therefore safe only after immutable identity/content have
|
||||
// tied; it must never be able to flip the selected surface on replay.
|
||||
if (left.timestamp === right.timestamp) return 0
|
||||
return left.timestamp > right.timestamp ? 1 : -1
|
||||
}
|
||||
|
||||
export function resultSurfaceDedupeKey(message: ChatMessage): string {
|
||||
if (resultSurfacePriority(message) <= 0) return ''
|
||||
const content = compactWhitespace(stripNarrativeTitlePrefix(message.content)).slice(0, 2000)
|
||||
const deliveryKey = stableResultDeliveryKey(message)
|
||||
if (deliveryKey) return `result:${deliveryKey}`
|
||||
|
||||
const content = normalizeResultContentFallback(message.content)
|
||||
return content ? `result:${content}` : ''
|
||||
}
|
||||
|
||||
@@ -492,7 +545,7 @@ export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatM
|
||||
const existingIndex = resultKeyIndex.get(resultKey)
|
||||
if (existingIndex !== undefined) {
|
||||
const existing = merged[existingIndex]
|
||||
const candidateWins = resultSurfacePriority(message) > resultSurfacePriority(existing)
|
||||
const candidateWins = compareResultSurfacePreference(message, existing) > 0
|
||||
const preferred = candidateWins ? message : existing
|
||||
const secondary = candidateWins ? existing : message
|
||||
merged[existingIndex] = {
|
||||
@@ -505,7 +558,7 @@ export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatM
|
||||
metadata: {
|
||||
...(secondary.metadata ?? {}),
|
||||
...(preferred.metadata ?? {}),
|
||||
ui_timeline_id: stableMessageTimelineKey(existing),
|
||||
ui_timeline_id: resultKey || stableMessageTimelineKey(existing),
|
||||
},
|
||||
}
|
||||
continue
|
||||
@@ -541,22 +594,6 @@ export function selectCompanySummaryMessages(
|
||||
messages: ChatMessage[],
|
||||
parentChannelId: string,
|
||||
): ChatMessage[] {
|
||||
const terminalAssistantTurn = (message: ChatMessage): string => {
|
||||
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
||||
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
|
||||
if (kind !== 'runtime_v2_assistant' && kind !== 'runtime_v2_company_assistant') return ''
|
||||
return String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
|
||||
}
|
||||
const parentTerminalTurns = new Set(
|
||||
messages
|
||||
.filter(message => (
|
||||
message.channelId === parentChannelId
|
||||
&& isMessageVisibleAtDetailLevel(message, 'summary')
|
||||
))
|
||||
.map(terminalAssistantTurn)
|
||||
.filter(Boolean),
|
||||
)
|
||||
const childTerminalByTurn = new Map<string, ChatMessage>()
|
||||
const durableMessages: ChatMessage[] = []
|
||||
for (const message of messages) {
|
||||
if (message.channelId === parentChannelId) {
|
||||
@@ -584,38 +621,8 @@ export function selectCompanySummaryMessages(
|
||||
'top_level_reply',
|
||||
].includes(kind)) {
|
||||
durableMessages.push(message)
|
||||
continue
|
||||
}
|
||||
// Snapshot builder deliberately marks the final runtime surface as
|
||||
// summary-visible. Preserve that durable contract instead of reusing the
|
||||
// result-dedupe priority table as a visibility threshold.
|
||||
const isSummaryTerminal = String(metadata.detail_visibility ?? '').trim() === 'summary'
|
||||
&& (kind === 'runtime_v2_assistant' || kind === 'runtime_v2_company_assistant')
|
||||
if (!isSummaryTerminal) continue
|
||||
const turnId = terminalAssistantTurn(message)
|
||||
if (!turnId) {
|
||||
durableMessages.push(message)
|
||||
continue
|
||||
}
|
||||
if (parentTerminalTurns.has(turnId)) continue
|
||||
const existing = childTerminalByTurn.get(turnId)
|
||||
if (!existing) {
|
||||
childTerminalByTurn.set(turnId, message)
|
||||
continue
|
||||
}
|
||||
const existingPriority = resultSurfacePriority(existing)
|
||||
const candidatePriority = resultSurfacePriority(message)
|
||||
if (candidatePriority > existingPriority) {
|
||||
childTerminalByTurn.set(turnId, message)
|
||||
} else if (
|
||||
candidatePriority === existingPriority
|
||||
&& (message.timestamp > existing.timestamp
|
||||
|| (message.timestamp === existing.timestamp && message.id.localeCompare(existing.id) > 0))
|
||||
) {
|
||||
childTerminalByTurn.set(turnId, message)
|
||||
}
|
||||
}
|
||||
durableMessages.push(...childTerminalByTurn.values())
|
||||
return mergeConversationMessages([durableMessages])
|
||||
}
|
||||
|
||||
|
||||
@@ -472,7 +472,7 @@ async function runTimelineIdentityCases(page: Page, baseUrl: string): Promise<vo
|
||||
collapsed: !!committed.querySelector('.msg-collapse-toggle'),
|
||||
}
|
||||
})
|
||||
assert.equal(finalizedDraft.reused, true, 'draft -> runtime_v2_assistant final must reuse the same outer timeline DOM node')
|
||||
assert.equal(finalizedDraft.reused, true, 'draft -> runtime_v2_company_assistant final must reuse the same outer timeline DOM node')
|
||||
assert.equal(finalizedDraft.collapsed, false, 'a mounted expanded draft must not auto-collapse when its final arrives')
|
||||
assert.ok(
|
||||
finalizedDraft.height >= draftGeometry.height - 4,
|
||||
|
||||
@@ -366,7 +366,8 @@ function Fixture() {
|
||||
mentions: [],
|
||||
metadata: {
|
||||
canonical_turn_id: LIVE_TURN_ID,
|
||||
transcript_kind: 'runtime_v2_assistant',
|
||||
result_delivery_id: `result:${LIVE_TURN_ID}:attempt:0`,
|
||||
transcript_kind: 'runtime_v2_company_assistant',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -341,7 +341,13 @@ export interface ChatMessageMeta {
|
||||
/** UI-only identity retained across semantic result-surface replacement. */
|
||||
ui_timeline_id?: string
|
||||
canonical_turn_id?: string
|
||||
conversation_turn_id?: string
|
||||
turn_id?: string
|
||||
execution_turn_id?: string
|
||||
result_delivery_id?: string
|
||||
source_result_message_id?: string
|
||||
source_task_id?: string
|
||||
child_session_id?: string
|
||||
execution_mode?: string
|
||||
transcript_kind?: string
|
||||
detail_visibility?: DetailVisibility
|
||||
|
||||
@@ -192,23 +192,33 @@ def _normalize_duplicate_content(content: Any) -> str:
|
||||
|
||||
def _strip_narrative_title_prefix(content: str) -> str:
|
||||
trimmed = str(content or "").strip()
|
||||
markdown_title = re.match(r"^\*\*(.{8,160}?)\*\*:\s+([\s\S]+)$", trimmed)
|
||||
if markdown_title:
|
||||
# Comparison-only canonicalization may unwrap a deliberate leading title,
|
||||
# but must never interpret a colon inside ordinary Markdown body text as a
|
||||
# wrapper boundary. Resolve explicit nested wrappers to a fixed point so
|
||||
# the comparison key itself is idempotent.
|
||||
while True:
|
||||
markdown_title = re.match(r"^\*\*(.{8,160}?)\*\*:\s+([\s\S]+)$", trimmed)
|
||||
if not markdown_title:
|
||||
return trimmed
|
||||
body = markdown_title.group(2).strip()
|
||||
if len(body) >= 80:
|
||||
return body
|
||||
colon_index = trimmed.find(": ")
|
||||
if colon_index < 8 or colon_index > 160:
|
||||
return trimmed
|
||||
prefix = trimmed[:colon_index].replace("*", "").strip()
|
||||
body = trimmed[colon_index + 2 :].strip()
|
||||
if len(body) < 80:
|
||||
return trimmed
|
||||
if not re.search(r"[A-Za-z\u4e00-\u9fff]", prefix):
|
||||
return trimmed
|
||||
if re.match(r"^(https?|file)$", prefix, flags=re.IGNORECASE):
|
||||
return trimmed
|
||||
return body
|
||||
if len(body) < 80 or body == trimmed:
|
||||
return trimmed
|
||||
trimmed = body
|
||||
|
||||
|
||||
def _select_duplicate_display_content(
|
||||
preferred: dict[str, Any],
|
||||
secondary: dict[str, Any],
|
||||
) -> str:
|
||||
"""Return one original surface body, never the lossy comparison key."""
|
||||
preferred_content = str(preferred.get("content", "") or "")
|
||||
secondary_content = str(secondary.get("content", "") or "")
|
||||
preferred_key = _normalize_duplicate_content(preferred_content)
|
||||
if not preferred_key or preferred_key != _normalize_duplicate_content(secondary_content):
|
||||
return preferred_content
|
||||
if secondary_content.strip() == preferred_key and preferred_content.strip() != preferred_key:
|
||||
return secondary_content
|
||||
return preferred_content
|
||||
|
||||
|
||||
def _normalize_transcript_detail_level(value: Any) -> TranscriptDetailLevel:
|
||||
@@ -1132,7 +1142,19 @@ def _transcript_item_to_ui_message(
|
||||
**({"runtime_thinking": runtime_thinking} if runtime_thinking else {}),
|
||||
**({
|
||||
key: message_metadata.get(key)
|
||||
for key in ("canonical_turn_id", "turn_id")
|
||||
for key in (
|
||||
"canonical_turn_id",
|
||||
"turn_id",
|
||||
"result_delivery_id",
|
||||
"source_result_message_id",
|
||||
"source_task_id",
|
||||
"child_session_id",
|
||||
"conversation_turn_id",
|
||||
"execution_turn_id",
|
||||
"work_item_projection_id",
|
||||
"work_item_turn_type",
|
||||
"runtime_session_id",
|
||||
)
|
||||
if message_metadata.get(key)
|
||||
}),
|
||||
**ui_meta,
|
||||
@@ -1193,18 +1215,10 @@ def collapse_adjacent_transcript_duplicates(messages: list[dict[str, Any]]) -> l
|
||||
):
|
||||
merged_visibility = "full"
|
||||
merged_metadata["detail_visibility"] = merged_visibility
|
||||
preferred_content = str(preferred.get("content", "") or "")
|
||||
secondary_content = str(secondary.get("content", "") or "")
|
||||
normalized_content = _normalize_duplicate_content(preferred_content)
|
||||
merged_content = (
|
||||
normalized_content
|
||||
if normalized_content and normalized_content == _normalize_duplicate_content(secondary_content)
|
||||
else preferred_content
|
||||
)
|
||||
collapsed[-1] = {
|
||||
**secondary,
|
||||
**preferred,
|
||||
"content": merged_content,
|
||||
"content": _select_duplicate_display_content(preferred, secondary),
|
||||
"metadata": merged_metadata,
|
||||
}
|
||||
return collapsed
|
||||
|
||||
Reference in New Issue
Block a user