fix(ui): stabilize workplace chat scrolling
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
"""Shared visibility rules for persisted session transcript messages.
|
||||||
|
|
||||||
|
The database pager and the Office UI renderer must agree on this boundary.
|
||||||
|
If either side independently classifies transcript kinds, a summary request can
|
||||||
|
page over rows that the renderer later drops and leave the caller with a cursor
|
||||||
|
that never advances through the visible timeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
TranscriptDetailLevel = Literal["summary", "full"]
|
||||||
|
|
||||||
|
FULL_DETAIL_ONLY_TRANSCRIPT_KINDS: frozenset[str] = frozenset({
|
||||||
|
"runtime_v2_user_turn",
|
||||||
|
"runtime_v2_intermediate_assistant",
|
||||||
|
"runtime_v2_company_assistant",
|
||||||
|
"runtime_v2_tool_output",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_transcript_detail_level(value: Any) -> TranscriptDetailLevel:
|
||||||
|
return "full" if str(value or "").strip().lower() == "full" else "summary"
|
||||||
|
|
||||||
|
|
||||||
|
def transcript_metadata_visible(
|
||||||
|
metadata: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
detail_level: TranscriptDetailLevel | str = "summary",
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether a persisted transcript row belongs to a detail view.
|
||||||
|
|
||||||
|
``company_final_turn`` deliberately overrides the kind classification: a
|
||||||
|
company role's final reply is the durable user-visible result even when its
|
||||||
|
transport kind is normally reserved for the full execution transcript.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if normalize_transcript_detail_level(detail_level) == "full":
|
||||||
|
return True
|
||||||
|
normalized_metadata = dict(metadata or {})
|
||||||
|
if normalized_metadata.get("company_final_turn") is True:
|
||||||
|
return True
|
||||||
|
kind = str(normalized_metadata.get("kind", "") or "").strip()
|
||||||
|
return kind not in FULL_DETAIL_ONLY_TRANSCRIPT_KINDS
|
||||||
|
|
||||||
|
|
||||||
|
def transcript_visibility_sql(
|
||||||
|
*,
|
||||||
|
detail_level: TranscriptDetailLevel | str,
|
||||||
|
metadata_column: str = "metadata",
|
||||||
|
) -> tuple[str, tuple[str, ...]]:
|
||||||
|
"""Build the SQLite predicate equivalent of ``transcript_metadata_visible``.
|
||||||
|
|
||||||
|
``metadata_column`` is supplied only by internal, static query construction;
|
||||||
|
callers must not pass user-controlled identifiers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if normalize_transcript_detail_level(detail_level) == "full":
|
||||||
|
return "", ()
|
||||||
|
placeholders = ",".join("?" for _ in FULL_DETAIL_ONLY_TRANSCRIPT_KINDS)
|
||||||
|
predicate = (
|
||||||
|
"AND (COALESCE(json_extract("
|
||||||
|
f"{metadata_column}, '$.company_final_turn'), 0) = 1 "
|
||||||
|
"OR COALESCE(json_extract("
|
||||||
|
f"{metadata_column}, '$.kind'), '') NOT IN ({placeholders})) "
|
||||||
|
)
|
||||||
|
return predicate, tuple(sorted(FULL_DETAIL_ONLY_TRANSCRIPT_KINDS))
|
||||||
|
|
||||||
|
|
||||||
|
def rendered_transcript_metadata_visible(
|
||||||
|
metadata: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
detail_level: TranscriptDetailLevel | str = "summary",
|
||||||
|
) -> bool:
|
||||||
|
"""Apply the visibility marker written by the transcript renderer."""
|
||||||
|
|
||||||
|
if normalize_transcript_detail_level(detail_level) == "full":
|
||||||
|
return True
|
||||||
|
visibility = str(dict(metadata or {}).get("detail_visibility", "summary") or "summary")
|
||||||
|
return visibility.strip().lower() != "full"
|
||||||
|
|
||||||
|
|
||||||
|
def rendered_transcript_visibility_sql(
|
||||||
|
*,
|
||||||
|
detail_level: TranscriptDetailLevel | str,
|
||||||
|
metadata_column: str = "metadata",
|
||||||
|
) -> str:
|
||||||
|
"""SQLite predicate equivalent of ``rendered_transcript_metadata_visible``."""
|
||||||
|
|
||||||
|
if normalize_transcript_detail_level(detail_level) == "full":
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
" AND lower(COALESCE(json_extract("
|
||||||
|
f"{metadata_column}, '$.detail_visibility'), 'summary')) != 'full'"
|
||||||
|
)
|
||||||
+11
-12
@@ -65,6 +65,10 @@ from opc.core.models import (
|
|||||||
normalize_role_runtime_status,
|
normalize_role_runtime_status,
|
||||||
)
|
)
|
||||||
from opc.core.models import Phase
|
from opc.core.models import Phase
|
||||||
|
from opc.core.transcript_visibility import (
|
||||||
|
normalize_transcript_detail_level,
|
||||||
|
transcript_visibility_sql,
|
||||||
|
)
|
||||||
from opc.layer2_organization.phase import (
|
from opc.layer2_organization.phase import (
|
||||||
DONE_PHASES,
|
DONE_PHASES,
|
||||||
IN_PROGRESS_PHASES,
|
IN_PROGRESS_PHASES,
|
||||||
@@ -6192,20 +6196,17 @@ class OPCStore:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
assert self._db
|
assert self._db
|
||||||
normalized_limit = max(1, min(int(limit), 500))
|
normalized_limit = max(1, min(int(limit), 500))
|
||||||
normalized_detail_level = str(detail_level or "summary").strip().lower()
|
normalized_detail_level = normalize_transcript_detail_level(detail_level)
|
||||||
hidden_kinds = () if normalized_detail_level == "full" else (
|
visibility_sql, visibility_params = transcript_visibility_sql(
|
||||||
"runtime_v2_user_turn",
|
detail_level=normalized_detail_level,
|
||||||
"runtime_v2_assistant",
|
|
||||||
)
|
)
|
||||||
query = (
|
query = (
|
||||||
"SELECT * FROM session_messages "
|
"SELECT * FROM session_messages "
|
||||||
"WHERE session_id = ? AND summary_flag = 0 "
|
"WHERE session_id = ? AND summary_flag = 0 "
|
||||||
)
|
)
|
||||||
params: list[Any] = [session_id]
|
params: list[Any] = [session_id]
|
||||||
if hidden_kinds:
|
query += visibility_sql
|
||||||
placeholders = ",".join("?" for _ in hidden_kinds)
|
params.extend(visibility_params)
|
||||||
query += f"AND COALESCE(json_extract(metadata, '$.kind'), '') NOT IN ({placeholders}) "
|
|
||||||
params.extend(hidden_kinds)
|
|
||||||
normalized_before_id = str(before_message_id or "").strip()
|
normalized_before_id = str(before_message_id or "").strip()
|
||||||
if before_created_at is not None:
|
if before_created_at is not None:
|
||||||
before_iso = before_created_at.isoformat()
|
before_iso = before_created_at.isoformat()
|
||||||
@@ -6246,10 +6247,8 @@ class OPCStore:
|
|||||||
"WHERE session_id = ? AND summary_flag = 0 "
|
"WHERE session_id = ? AND summary_flag = 0 "
|
||||||
)
|
)
|
||||||
count_params: list[Any] = [session_id]
|
count_params: list[Any] = [session_id]
|
||||||
if hidden_kinds:
|
count_query += visibility_sql
|
||||||
placeholders = ",".join("?" for _ in hidden_kinds)
|
count_params.extend(visibility_params)
|
||||||
count_query += f"AND COALESCE(json_extract(metadata, '$.kind'), '') NOT IN ({placeholders})"
|
|
||||||
count_params.extend(hidden_kinds)
|
|
||||||
async with self._db.execute(count_query, count_params) as cursor:
|
async with self._db.execute(count_query, count_params) as cursor:
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
total_count = int(row[0] or 0) if row else 0
|
total_count = int(row[0] or 0) if row else 0
|
||||||
|
|||||||
@@ -7,14 +7,19 @@ Channel/message format uses snake_case to match what collabSync.ts expects.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import heapq
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import struct
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
from opc.core.transcript_visibility import rendered_transcript_visibility_sql
|
||||||
from opc.layer3_agent.adapters.codex_adapter import CodexAdapter
|
from opc.layer3_agent.adapters.codex_adapter import CodexAdapter
|
||||||
|
|
||||||
_LOCKED_ERROR_MARKERS = ("database is locked", "database table is locked")
|
_LOCKED_ERROR_MARKERS = ("database is locked", "database table is locked")
|
||||||
@@ -28,6 +33,505 @@ def _is_locked_error(exc: BaseException) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _MessageMatchState:
|
||||||
|
"""Prepared fields used by semantic message de-duplication.
|
||||||
|
|
||||||
|
Transcript rows can contain several kilobytes of Markdown. Preparing the
|
||||||
|
normalized content once prevents every candidate comparison from repeating
|
||||||
|
that work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
channel_id: str
|
||||||
|
identity_keys: frozenset[str]
|
||||||
|
role_bucket: str
|
||||||
|
normalized_content: str
|
||||||
|
reply_to_id: str
|
||||||
|
is_result_surface: bool
|
||||||
|
has_engine_source: bool
|
||||||
|
timestamp: float
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_message(
|
||||||
|
cls,
|
||||||
|
owner: Any,
|
||||||
|
message: dict[str, Any],
|
||||||
|
) -> _MessageMatchState:
|
||||||
|
return cls(
|
||||||
|
channel_id=str(message.get("channel_id", "") or ""),
|
||||||
|
identity_keys=frozenset(owner._message_identity_keys(message)),
|
||||||
|
role_bucket=owner._message_role_bucket(message),
|
||||||
|
normalized_content=owner._normalize_duplicate_content(message.get("content", "")),
|
||||||
|
reply_to_id=str(message.get("reply_to_id", "") or ""),
|
||||||
|
is_result_surface=owner._message_is_result_surface(message),
|
||||||
|
has_engine_source=owner._message_has_engine_source(message),
|
||||||
|
timestamp=owner._message_timestamp(message),
|
||||||
|
)
|
||||||
|
|
||||||
|
def matches(self, candidate: _MessageMatchState, *, duplicate_window: float) -> bool:
|
||||||
|
if self.channel_id != candidate.channel_id:
|
||||||
|
return False
|
||||||
|
if self.identity_keys & candidate.identity_keys:
|
||||||
|
return True
|
||||||
|
if self.role_bucket != candidate.role_bucket:
|
||||||
|
return False
|
||||||
|
if self.normalized_content != candidate.normalized_content:
|
||||||
|
return False
|
||||||
|
both_result_surfaces = self.is_result_surface and candidate.is_result_surface
|
||||||
|
if not both_result_surfaces and self.reply_to_id != candidate.reply_to_id:
|
||||||
|
return False
|
||||||
|
if not (self.has_engine_source or candidate.has_engine_source):
|
||||||
|
return False
|
||||||
|
if (
|
||||||
|
not both_result_surfaces
|
||||||
|
and self.timestamp
|
||||||
|
and candidate.timestamp
|
||||||
|
and abs(self.timestamp - candidate.timestamp) > duplicate_window
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _TimestampRangeNode:
|
||||||
|
"""Treap node augmented with the greatest timeline index below it."""
|
||||||
|
|
||||||
|
key: tuple[float, int]
|
||||||
|
priority: int
|
||||||
|
left: _TimestampRangeNode | None = None
|
||||||
|
right: _TimestampRangeNode | None = None
|
||||||
|
max_index: int = -1
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.max_index = self.key[1]
|
||||||
|
|
||||||
|
|
||||||
|
class _TimestampRangeTree:
|
||||||
|
"""Dynamic timestamp range -> latest timeline index map.
|
||||||
|
|
||||||
|
A deterministic treap avoids depending on insertion order while supporting
|
||||||
|
insert, delete, and inclusive range maximum in expected O(log n).
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MASK_64 = (1 << 64) - 1
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._root: _TimestampRangeNode | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _priority(cls, index: int) -> int:
|
||||||
|
# SplitMix64 is a bijective mixer for the practical index range, giving
|
||||||
|
# deterministic pseudo-random treap priorities without global RNG state.
|
||||||
|
value = (index + 0x9E3779B97F4A7C15) & cls._MASK_64
|
||||||
|
value = ((value ^ (value >> 30)) * 0xBF58476D1CE4E5B9) & cls._MASK_64
|
||||||
|
value = ((value ^ (value >> 27)) * 0x94D049BB133111EB) & cls._MASK_64
|
||||||
|
return value ^ (value >> 31)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _refresh(node: _TimestampRangeNode | None) -> None:
|
||||||
|
if node is None:
|
||||||
|
return
|
||||||
|
node.max_index = max(
|
||||||
|
node.key[1],
|
||||||
|
node.left.max_index if node.left is not None else -1,
|
||||||
|
node.right.max_index if node.right is not None else -1,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _split(
|
||||||
|
cls,
|
||||||
|
node: _TimestampRangeNode | None,
|
||||||
|
key: tuple[float, float | int],
|
||||||
|
) -> tuple[_TimestampRangeNode | None, _TimestampRangeNode | None]:
|
||||||
|
if node is None:
|
||||||
|
return None, None
|
||||||
|
if node.key < key:
|
||||||
|
node.right, right = cls._split(node.right, key)
|
||||||
|
cls._refresh(node)
|
||||||
|
return node, right
|
||||||
|
left, node.left = cls._split(node.left, key)
|
||||||
|
cls._refresh(node)
|
||||||
|
return left, node
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _merge(
|
||||||
|
cls,
|
||||||
|
left: _TimestampRangeNode | None,
|
||||||
|
right: _TimestampRangeNode | None,
|
||||||
|
) -> _TimestampRangeNode | None:
|
||||||
|
if left is None:
|
||||||
|
return right
|
||||||
|
if right is None:
|
||||||
|
return left
|
||||||
|
if left.priority > right.priority:
|
||||||
|
left.right = cls._merge(left.right, right)
|
||||||
|
cls._refresh(left)
|
||||||
|
return left
|
||||||
|
right.left = cls._merge(left, right.left)
|
||||||
|
cls._refresh(right)
|
||||||
|
return right
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _insert(
|
||||||
|
cls,
|
||||||
|
root: _TimestampRangeNode | None,
|
||||||
|
node: _TimestampRangeNode,
|
||||||
|
) -> _TimestampRangeNode:
|
||||||
|
if root is None:
|
||||||
|
return node
|
||||||
|
if node.key == root.key:
|
||||||
|
return root
|
||||||
|
if node.priority > root.priority:
|
||||||
|
node.left, node.right = cls._split(root, node.key)
|
||||||
|
cls._refresh(node)
|
||||||
|
return node
|
||||||
|
if node.key < root.key:
|
||||||
|
root.left = cls._insert(root.left, node)
|
||||||
|
else:
|
||||||
|
root.right = cls._insert(root.right, node)
|
||||||
|
cls._refresh(root)
|
||||||
|
return root
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _remove(
|
||||||
|
cls,
|
||||||
|
root: _TimestampRangeNode | None,
|
||||||
|
key: tuple[float, int],
|
||||||
|
) -> _TimestampRangeNode | None:
|
||||||
|
if root is None:
|
||||||
|
return None
|
||||||
|
if key == root.key:
|
||||||
|
return cls._merge(root.left, root.right)
|
||||||
|
if key < root.key:
|
||||||
|
root.left = cls._remove(root.left, key)
|
||||||
|
else:
|
||||||
|
root.right = cls._remove(root.right, key)
|
||||||
|
cls._refresh(root)
|
||||||
|
return root
|
||||||
|
|
||||||
|
def add(self, timestamp: float, index: int) -> None:
|
||||||
|
self._root = self._insert(
|
||||||
|
self._root,
|
||||||
|
_TimestampRangeNode(
|
||||||
|
key=(timestamp, index),
|
||||||
|
priority=self._priority(index),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove(self, timestamp: float, index: int) -> None:
|
||||||
|
self._root = self._remove(self._root, (timestamp, index))
|
||||||
|
|
||||||
|
def latest(self) -> int | None:
|
||||||
|
return self._root.max_index if self._root is not None else None
|
||||||
|
|
||||||
|
def latest_in_range(self, lower: float, upper: float) -> int | None:
|
||||||
|
left, middle_and_right = self._split(self._root, (lower, -1))
|
||||||
|
middle, right = self._split(middle_and_right, (upper, math.inf))
|
||||||
|
result = middle.max_index if middle is not None else None
|
||||||
|
self._root = self._merge(left, self._merge(middle, right))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class _MessageMatchIndex:
|
||||||
|
"""Versioned indexes for finding the latest semantic duplicate.
|
||||||
|
|
||||||
|
The legacy implementation scanned every previously emitted row backwards.
|
||||||
|
Identity and result-surface matches use max-heaps. Ordinary matches use an
|
||||||
|
exact timestamp range tree, so backfilling old history cannot repeatedly
|
||||||
|
scan newer same-content rows outside the duplicate window. Replacing a
|
||||||
|
merged row bumps its heap version and moves its timestamp-tree entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
owner: Any,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
self._owner = owner
|
||||||
|
self._messages = messages
|
||||||
|
self._versions: list[int] = [0 for _ in messages]
|
||||||
|
self._states = [
|
||||||
|
_MessageMatchState.from_message(owner, message)
|
||||||
|
for message in messages
|
||||||
|
]
|
||||||
|
self._identity_heaps: dict[tuple[str, str], list[tuple[int, int]]] = {}
|
||||||
|
self._timed_trees: dict[tuple[str, str, str, str], _TimestampRangeTree] = {}
|
||||||
|
self._timed_engine_trees: dict[tuple[str, str, str, str], _TimestampRangeTree] = {}
|
||||||
|
self._timed_unbounded_heaps: dict[tuple[str, str, str, str], list[tuple[int, int]]] = {}
|
||||||
|
self._timed_engine_unbounded_heaps: dict[tuple[str, str, str, str], list[tuple[int, int]]] = {}
|
||||||
|
self._result_heaps: dict[tuple[str, str, str], list[tuple[int, int]]] = {}
|
||||||
|
self._result_engine_heaps: dict[tuple[str, str, str], list[tuple[int, int]]] = {}
|
||||||
|
for index in range(len(messages)):
|
||||||
|
self._push(index)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _push_heap(
|
||||||
|
heaps: dict[Any, list[tuple[int, int]]],
|
||||||
|
key: Any,
|
||||||
|
index: int,
|
||||||
|
version: int,
|
||||||
|
) -> None:
|
||||||
|
heapq.heappush(heaps.setdefault(key, []), (-index, version))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _semantic_key(state: _MessageMatchState) -> tuple[str, str, str]:
|
||||||
|
return (state.channel_id, state.role_bucket, state.normalized_content)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _timed_key(cls, state: _MessageMatchState) -> tuple[str, str, str, str]:
|
||||||
|
return (*cls._semantic_key(state), state.reply_to_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _timestamp_is_unbounded(timestamp: float) -> bool:
|
||||||
|
# The legacy predicate deliberately skipped its window check for zero;
|
||||||
|
# NaN also made ``abs(delta) > window`` false and must stay equivalent.
|
||||||
|
return timestamp == 0 or math.isnan(timestamp)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _float_to_ordered_int(value: float) -> int:
|
||||||
|
"""Map an IEEE-754 double to an integer with numeric sort order."""
|
||||||
|
bits = struct.unpack(">Q", struct.pack(">d", value))[0]
|
||||||
|
if bits & (1 << 63):
|
||||||
|
return (~bits) & ((1 << 64) - 1)
|
||||||
|
return bits | (1 << 63)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ordered_int_to_float(value: int) -> float:
|
||||||
|
if value & (1 << 63):
|
||||||
|
bits = value & ((1 << 63) - 1)
|
||||||
|
else:
|
||||||
|
bits = (~value) & ((1 << 64) - 1)
|
||||||
|
return struct.unpack(">d", struct.pack(">Q", bits))[0]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _finite_timestamp_match_bounds(
|
||||||
|
cls,
|
||||||
|
candidate_timestamp: float,
|
||||||
|
window: float,
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
"""Exact finite-float bounds accepted by the legacy delta predicate.
|
||||||
|
|
||||||
|
Computing ``candidate +/- window`` is subtly insufficient around a
|
||||||
|
rounding boundary (for example ``2.0 - (-1e-300)`` rounds to exactly
|
||||||
|
``2.0``). Binary searching the ordered double domain preserves the
|
||||||
|
old IEEE-754 comparison exactly. The domain is fixed at 64 bits, so
|
||||||
|
this adds constant work before the tree's O(log n) range query.
|
||||||
|
"""
|
||||||
|
candidate_order = cls._float_to_ordered_int(candidate_timestamp)
|
||||||
|
|
||||||
|
lower_rejected = cls._float_to_ordered_int(-math.inf)
|
||||||
|
lower_accepted = candidate_order
|
||||||
|
while lower_rejected + 1 < lower_accepted:
|
||||||
|
middle = (lower_rejected + lower_accepted) // 2
|
||||||
|
value = cls._ordered_int_to_float(middle)
|
||||||
|
if abs(value - candidate_timestamp) <= window:
|
||||||
|
lower_accepted = middle
|
||||||
|
else:
|
||||||
|
lower_rejected = middle
|
||||||
|
|
||||||
|
upper_accepted = candidate_order
|
||||||
|
upper_rejected = cls._float_to_ordered_int(math.inf)
|
||||||
|
while upper_accepted + 1 < upper_rejected:
|
||||||
|
middle = (upper_accepted + upper_rejected) // 2
|
||||||
|
value = cls._ordered_int_to_float(middle)
|
||||||
|
if abs(value - candidate_timestamp) <= window:
|
||||||
|
upper_accepted = middle
|
||||||
|
else:
|
||||||
|
upper_rejected = middle
|
||||||
|
|
||||||
|
return (
|
||||||
|
cls._ordered_int_to_float(lower_accepted),
|
||||||
|
cls._ordered_int_to_float(upper_accepted),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_to_tree(
|
||||||
|
trees: dict[tuple[str, str, str, str], _TimestampRangeTree],
|
||||||
|
key: tuple[str, str, str, str],
|
||||||
|
timestamp: float,
|
||||||
|
index: int,
|
||||||
|
) -> None:
|
||||||
|
trees.setdefault(key, _TimestampRangeTree()).add(timestamp, index)
|
||||||
|
|
||||||
|
def _push(self, index: int) -> None:
|
||||||
|
state = self._states[index]
|
||||||
|
version = self._versions[index]
|
||||||
|
for identity_key in state.identity_keys:
|
||||||
|
self._push_heap(
|
||||||
|
self._identity_heaps,
|
||||||
|
(state.channel_id, identity_key),
|
||||||
|
index,
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
|
||||||
|
semantic_key = self._semantic_key(state)
|
||||||
|
timed_key = self._timed_key(state)
|
||||||
|
if self._timestamp_is_unbounded(state.timestamp):
|
||||||
|
self._push_heap(
|
||||||
|
self._timed_unbounded_heaps,
|
||||||
|
timed_key,
|
||||||
|
index,
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
if state.has_engine_source:
|
||||||
|
self._push_heap(
|
||||||
|
self._timed_engine_unbounded_heaps,
|
||||||
|
timed_key,
|
||||||
|
index,
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._add_to_tree(
|
||||||
|
self._timed_trees,
|
||||||
|
timed_key,
|
||||||
|
state.timestamp,
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
if state.has_engine_source:
|
||||||
|
self._add_to_tree(
|
||||||
|
self._timed_engine_trees,
|
||||||
|
timed_key,
|
||||||
|
state.timestamp,
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
if state.is_result_surface:
|
||||||
|
self._push_heap(self._result_heaps, semantic_key, index, version)
|
||||||
|
if state.has_engine_source:
|
||||||
|
self._push_heap(self._result_engine_heaps, semantic_key, index, version)
|
||||||
|
|
||||||
|
def prepare(self, message: dict[str, Any]) -> _MessageMatchState:
|
||||||
|
return _MessageMatchState.from_message(self._owner, message)
|
||||||
|
|
||||||
|
def append(
|
||||||
|
self,
|
||||||
|
message: dict[str, Any],
|
||||||
|
*,
|
||||||
|
prepared_state: _MessageMatchState | None = None,
|
||||||
|
) -> int:
|
||||||
|
index = len(self._messages)
|
||||||
|
self._messages.append(message)
|
||||||
|
self._versions.append(0)
|
||||||
|
self._states.append(prepared_state or self.prepare(message))
|
||||||
|
self._push(index)
|
||||||
|
return index
|
||||||
|
|
||||||
|
def replace(self, index: int, message: dict[str, Any]) -> None:
|
||||||
|
old_state = self._states[index]
|
||||||
|
if not self._timestamp_is_unbounded(old_state.timestamp):
|
||||||
|
timed_key = self._timed_key(old_state)
|
||||||
|
tree = self._timed_trees.get(timed_key)
|
||||||
|
if tree is not None:
|
||||||
|
tree.remove(old_state.timestamp, index)
|
||||||
|
if old_state.has_engine_source:
|
||||||
|
engine_tree = self._timed_engine_trees.get(timed_key)
|
||||||
|
if engine_tree is not None:
|
||||||
|
engine_tree.remove(old_state.timestamp, index)
|
||||||
|
self._messages[index] = message
|
||||||
|
self._versions[index] += 1
|
||||||
|
self._states[index] = _MessageMatchState.from_message(self._owner, message)
|
||||||
|
self._push(index)
|
||||||
|
|
||||||
|
def _latest_from_heap(
|
||||||
|
self,
|
||||||
|
heap: list[tuple[int, int]] | None,
|
||||||
|
candidate: _MessageMatchState,
|
||||||
|
excluded_message_ids: set[str],
|
||||||
|
) -> int | None:
|
||||||
|
if not heap:
|
||||||
|
return None
|
||||||
|
while heap:
|
||||||
|
negative_index, version = heap[0]
|
||||||
|
index = -negative_index
|
||||||
|
if version != self._versions[index]:
|
||||||
|
heapq.heappop(heap)
|
||||||
|
continue
|
||||||
|
message_id = str(self._messages[index].get("message_id", "") or "")
|
||||||
|
if message_id in excluded_message_ids:
|
||||||
|
heapq.heappop(heap)
|
||||||
|
continue
|
||||||
|
existing = self._states[index]
|
||||||
|
if existing.matches(
|
||||||
|
candidate,
|
||||||
|
duplicate_window=self._owner._DUPLICATE_WINDOW_SECONDS,
|
||||||
|
):
|
||||||
|
return index
|
||||||
|
# Identity/result/unbounded timed buckets are exact. A current
|
||||||
|
# non-match therefore cannot become valid for this bucket later.
|
||||||
|
heapq.heappop(heap)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _latest_from_time_tree(
|
||||||
|
self,
|
||||||
|
tree: _TimestampRangeTree | None,
|
||||||
|
candidate: _MessageMatchState,
|
||||||
|
excluded_message_ids: set[str],
|
||||||
|
) -> int | None:
|
||||||
|
if tree is None:
|
||||||
|
return None
|
||||||
|
while True:
|
||||||
|
if self._timestamp_is_unbounded(candidate.timestamp):
|
||||||
|
index = tree.latest()
|
||||||
|
else:
|
||||||
|
window = self._owner._DUPLICATE_WINDOW_SECONDS
|
||||||
|
if math.isfinite(candidate.timestamp):
|
||||||
|
lower, upper = self._finite_timestamp_match_bounds(
|
||||||
|
candidate.timestamp,
|
||||||
|
window,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lower = upper = candidate.timestamp
|
||||||
|
index = tree.latest_in_range(lower, upper)
|
||||||
|
if index is None:
|
||||||
|
return None
|
||||||
|
existing = self._states[index]
|
||||||
|
message_id = str(self._messages[index].get("message_id", "") or "")
|
||||||
|
if message_id not in excluded_message_ids and existing.matches(
|
||||||
|
candidate,
|
||||||
|
duplicate_window=self._owner._DUPLICATE_WINDOW_SECONDS,
|
||||||
|
):
|
||||||
|
return index
|
||||||
|
# Consumed entries never become eligible again during this index's
|
||||||
|
# lifetime. Signature changes use ``replace`` and reinsert exactly.
|
||||||
|
tree.remove(existing.timestamp, index)
|
||||||
|
|
||||||
|
def latest_match(
|
||||||
|
self,
|
||||||
|
candidate_message: dict[str, Any],
|
||||||
|
*,
|
||||||
|
excluded_message_ids: set[str] | None = None,
|
||||||
|
prepared_state: _MessageMatchState | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
candidate = prepared_state or self.prepare(candidate_message)
|
||||||
|
excluded = excluded_message_ids or set()
|
||||||
|
heaps: list[list[tuple[int, int]] | None] = []
|
||||||
|
for identity_key in candidate.identity_keys:
|
||||||
|
heaps.append(self._identity_heaps.get((candidate.channel_id, identity_key)))
|
||||||
|
|
||||||
|
semantic_key = self._semantic_key(candidate)
|
||||||
|
timed_key = self._timed_key(candidate)
|
||||||
|
if candidate.has_engine_source:
|
||||||
|
timed_tree = self._timed_trees.get(timed_key)
|
||||||
|
heaps.append(self._timed_unbounded_heaps.get(timed_key))
|
||||||
|
else:
|
||||||
|
timed_tree = self._timed_engine_trees.get(timed_key)
|
||||||
|
heaps.append(self._timed_engine_unbounded_heaps.get(timed_key))
|
||||||
|
if candidate.is_result_surface:
|
||||||
|
if candidate.has_engine_source:
|
||||||
|
heaps.append(self._result_heaps.get(semantic_key))
|
||||||
|
else:
|
||||||
|
heaps.append(self._result_engine_heaps.get(semantic_key))
|
||||||
|
|
||||||
|
matches: list[int] = [
|
||||||
|
index
|
||||||
|
for heap in heaps
|
||||||
|
if (index := self._latest_from_heap(heap, candidate, excluded)) is not None
|
||||||
|
]
|
||||||
|
timed_index = self._latest_from_time_tree(timed_tree, candidate, excluded)
|
||||||
|
if timed_index is not None:
|
||||||
|
matches.append(timed_index)
|
||||||
|
return max(matches) if matches else None
|
||||||
|
|
||||||
|
|
||||||
class ChatStore:
|
class ChatStore:
|
||||||
"""Chat channels + messages in ui_state.db.
|
"""Chat channels + messages in ui_state.db.
|
||||||
|
|
||||||
@@ -167,29 +671,10 @@ class ChatStore:
|
|||||||
existing: dict[str, Any],
|
existing: dict[str, Any],
|
||||||
candidate: dict[str, Any],
|
candidate: dict[str, Any],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if str(existing.get("channel_id", "") or "") != str(candidate.get("channel_id", "") or ""):
|
return _MessageMatchState.from_message(cls, existing).matches(
|
||||||
return False
|
_MessageMatchState.from_message(cls, candidate),
|
||||||
if cls._message_identity_keys(existing) & cls._message_identity_keys(candidate):
|
duplicate_window=cls._DUPLICATE_WINDOW_SECONDS,
|
||||||
return True
|
)
|
||||||
if cls._message_role_bucket(existing) != cls._message_role_bucket(candidate):
|
|
||||||
return False
|
|
||||||
if cls._normalize_duplicate_content(existing.get("content", "")) != cls._normalize_duplicate_content(candidate.get("content", "")):
|
|
||||||
return False
|
|
||||||
both_result_surfaces = cls._message_is_result_surface(existing) and cls._message_is_result_surface(candidate)
|
|
||||||
if not both_result_surfaces and str(existing.get("reply_to_id", "") or "") != str(candidate.get("reply_to_id", "") or ""):
|
|
||||||
return False
|
|
||||||
if not (cls._message_has_engine_source(existing) or cls._message_has_engine_source(candidate)):
|
|
||||||
return False
|
|
||||||
existing_ts = cls._message_timestamp(existing)
|
|
||||||
candidate_ts = cls._message_timestamp(candidate)
|
|
||||||
if (
|
|
||||||
not both_result_surfaces
|
|
||||||
and existing_ts
|
|
||||||
and candidate_ts
|
|
||||||
and abs(existing_ts - candidate_ts) > cls._DUPLICATE_WINDOW_SECONDS
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _merge_duplicate_messages(
|
def _merge_duplicate_messages(
|
||||||
@@ -266,16 +751,23 @@ class ChatStore:
|
|||||||
|
|
||||||
def _dedupe_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _dedupe_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
deduped: list[dict[str, Any]] = []
|
deduped: list[dict[str, Any]] = []
|
||||||
|
match_index = _MessageMatchIndex(
|
||||||
|
self,
|
||||||
|
deduped,
|
||||||
|
)
|
||||||
for message in sorted(messages, key=self._message_timestamp):
|
for message in sorted(messages, key=self._message_timestamp):
|
||||||
match_index: int | None = None
|
prepared_state = match_index.prepare(message)
|
||||||
for index in range(len(deduped) - 1, -1, -1):
|
duplicate_index = match_index.latest_match(
|
||||||
if self._messages_semantically_match(deduped[index], message):
|
message,
|
||||||
match_index = index
|
prepared_state=prepared_state,
|
||||||
break
|
)
|
||||||
if match_index is None:
|
if duplicate_index is None:
|
||||||
deduped.append(message)
|
match_index.append(message, prepared_state=prepared_state)
|
||||||
continue
|
continue
|
||||||
deduped[match_index] = self._merge_duplicate_messages(deduped[match_index], message)
|
match_index.replace(
|
||||||
|
duplicate_index,
|
||||||
|
self._merge_duplicate_messages(deduped[duplicate_index], message),
|
||||||
|
)
|
||||||
return deduped
|
return deduped
|
||||||
|
|
||||||
async def _message_scope(self, message_id: str) -> tuple[str, str] | None:
|
async def _message_scope(self, message_id: str) -> tuple[str, str] | None:
|
||||||
@@ -1060,6 +1552,14 @@ class ChatStore:
|
|||||||
existing_rows = await cursor.fetchall()
|
existing_rows = await cursor.fetchall()
|
||||||
existing_messages = [self._row_to_message_dict(row) for row in existing_rows]
|
existing_messages = [self._row_to_message_dict(row) for row in existing_rows]
|
||||||
existing_ids = {message["message_id"] for message in existing_messages}
|
existing_ids = {message["message_id"] for message in existing_messages}
|
||||||
|
existing_positions = {
|
||||||
|
message["message_id"]: index
|
||||||
|
for index, message in enumerate(existing_messages)
|
||||||
|
}
|
||||||
|
semantic_index = _MessageMatchIndex(
|
||||||
|
self,
|
||||||
|
existing_messages,
|
||||||
|
)
|
||||||
consumed_existing_ids: set[str] = set()
|
consumed_existing_ids: set[str] = set()
|
||||||
inserted_messages: list[dict[str, Any]] = []
|
inserted_messages: list[dict[str, Any]] = []
|
||||||
changed_existing = False
|
changed_existing = False
|
||||||
@@ -1078,11 +1578,9 @@ class ChatStore:
|
|||||||
}
|
}
|
||||||
mid = normalized_message["message_id"]
|
mid = normalized_message["message_id"]
|
||||||
if mid in existing_ids:
|
if mid in existing_ids:
|
||||||
existing_match = next(
|
existing_index = existing_positions.get(mid)
|
||||||
(existing for existing in existing_messages if existing["message_id"] == mid),
|
if existing_index is not None:
|
||||||
None,
|
existing_match = existing_messages[existing_index]
|
||||||
)
|
|
||||||
if existing_match is not None:
|
|
||||||
merged_existing = self._merge_duplicate_messages(existing_match, normalized_message)
|
merged_existing = self._merge_duplicate_messages(existing_match, normalized_message)
|
||||||
if not self._message_persisted_equal(existing_match, merged_existing):
|
if not self._message_persisted_equal(existing_match, merged_existing):
|
||||||
merged_timestamp = self._message_timestamp(merged_existing) or time.time()
|
merged_timestamp = self._message_timestamp(merged_existing) or time.time()
|
||||||
@@ -1102,13 +1600,10 @@ class ChatStore:
|
|||||||
project_id,
|
project_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for idx, existing in enumerate(existing_messages):
|
semantic_index.replace(existing_index, {
|
||||||
if existing["message_id"] == mid:
|
|
||||||
existing_messages[idx] = {
|
|
||||||
**merged_existing,
|
**merged_existing,
|
||||||
"created_at": merged_timestamp,
|
"created_at": merged_timestamp,
|
||||||
}
|
})
|
||||||
break
|
|
||||||
inserted_messages.append({
|
inserted_messages.append({
|
||||||
**merged_existing,
|
**merged_existing,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
@@ -1131,7 +1626,7 @@ class ChatStore:
|
|||||||
)
|
)
|
||||||
if merged is not None:
|
if merged is not None:
|
||||||
existing_ids.add(mid)
|
existing_ids.add(mid)
|
||||||
existing_messages.append(merged)
|
existing_positions[mid] = semantic_index.append(merged)
|
||||||
continue
|
continue
|
||||||
if existing_scope and existing_scope != (channel_id, project_id):
|
if existing_scope and existing_scope != (channel_id, project_id):
|
||||||
metadata = dict(normalized_message.get("metadata", {}) or {})
|
metadata = dict(normalized_message.get("metadata", {}) or {})
|
||||||
@@ -1144,17 +1639,14 @@ class ChatStore:
|
|||||||
)
|
)
|
||||||
normalized_message["message_id"] = mid
|
normalized_message["message_id"] = mid
|
||||||
|
|
||||||
duplicate_existing = next(
|
duplicate_index = semantic_index.latest_match(
|
||||||
(
|
normalized_message,
|
||||||
existing
|
excluded_message_ids=consumed_existing_ids,
|
||||||
for existing in reversed(existing_messages)
|
)
|
||||||
if existing["message_id"] not in consumed_existing_ids
|
if duplicate_index is not None:
|
||||||
and self._messages_semantically_match(existing, normalized_message)
|
consumed_existing_ids.add(
|
||||||
),
|
existing_messages[duplicate_index]["message_id"]
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
if duplicate_existing is not None:
|
|
||||||
consumed_existing_ids.add(duplicate_existing["message_id"])
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1184,8 +1676,9 @@ class ChatStore:
|
|||||||
candidate=normalized_message,
|
candidate=normalized_message,
|
||||||
)
|
)
|
||||||
if merged is not None:
|
if merged is not None:
|
||||||
existing_ids.add(normalized_message["message_id"])
|
merged_id = normalized_message["message_id"]
|
||||||
existing_messages.append(merged)
|
existing_ids.add(merged_id)
|
||||||
|
existing_positions[merged_id] = semantic_index.append(merged)
|
||||||
continue
|
continue
|
||||||
metadata = dict(normalized_message.get("metadata", {}) or {})
|
metadata = dict(normalized_message.get("metadata", {}) or {})
|
||||||
metadata.setdefault("ui_message_id", normalized_message["message_id"])
|
metadata.setdefault("ui_message_id", normalized_message["message_id"])
|
||||||
@@ -1216,7 +1709,7 @@ class ChatStore:
|
|||||||
)
|
)
|
||||||
inserted_messages.append(normalized_message)
|
inserted_messages.append(normalized_message)
|
||||||
existing_ids.add(mid)
|
existing_ids.add(mid)
|
||||||
existing_messages.append({
|
existing_positions[mid] = semantic_index.append({
|
||||||
**normalized_message,
|
**normalized_message,
|
||||||
"created_at": normalized_message["timestamp"],
|
"created_at": normalized_message["timestamp"],
|
||||||
})
|
})
|
||||||
@@ -1253,49 +1746,124 @@ class ChatStore:
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
before_timestamp: float | None = None,
|
before_timestamp: float | None = None,
|
||||||
before_message_id: str | None = None,
|
before_message_id: str | None = None,
|
||||||
|
detail_level: str = "full",
|
||||||
project_id: str = "default",
|
project_id: str = "default",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return a paginated, de-duplicated channel slice in chronological order."""
|
"""Return the message slice from :meth:`get_channel_messages_page_info`.
|
||||||
fetch_limit = max(limit * 8, limit + 1, 1)
|
|
||||||
|
This compatibility wrapper intentionally delegates cursor handling to
|
||||||
|
the exact pager so callers cannot accidentally paginate raw rows before
|
||||||
|
renderer visibility and semantic de-duplication have been applied.
|
||||||
|
"""
|
||||||
|
page = await self.get_channel_messages_page_info(
|
||||||
|
channel_id,
|
||||||
|
limit=limit,
|
||||||
|
before_timestamp=before_timestamp,
|
||||||
|
before_message_id=before_message_id,
|
||||||
|
detail_level=detail_level,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
return page["messages"]
|
||||||
|
|
||||||
|
async def _get_channel_visible_messages(
|
||||||
|
self,
|
||||||
|
channel_id: str,
|
||||||
|
*,
|
||||||
|
detail_level: str,
|
||||||
|
project_id: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Load the final UI-visible, de-duplicated channel timeline.
|
||||||
|
|
||||||
|
The cache stores both transcript backfill and UI-only rows such as
|
||||||
|
approval cards and legacy notices. SQL can exclude detail-only rows,
|
||||||
|
but only the message formatter's semantic merge can determine the
|
||||||
|
final rows. Consequently the merge must happen across the complete
|
||||||
|
visible set before a page boundary is chosen.
|
||||||
|
"""
|
||||||
query = (
|
query = (
|
||||||
"SELECT message_id, channel_id, sender, sender_name, content, "
|
"SELECT message_id, channel_id, sender, sender_name, content, "
|
||||||
"timestamp, reply_to_id, mentions, metadata "
|
"timestamp, reply_to_id, mentions, metadata "
|
||||||
"FROM messages WHERE channel_id = ? AND project_id = ?"
|
"FROM messages WHERE channel_id = ? AND project_id = ?"
|
||||||
)
|
)
|
||||||
params: list[Any] = [channel_id, project_id]
|
query += rendered_transcript_visibility_sql(
|
||||||
|
detail_level=detail_level,
|
||||||
|
)
|
||||||
|
query += " ORDER BY timestamp ASC, message_id ASC"
|
||||||
|
cursor = await self._db.execute(query, (channel_id, project_id))
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
messages = [self._row_to_message_dict(row) for row in rows]
|
||||||
|
deduped = self._dedupe_messages(messages)
|
||||||
|
return sorted(
|
||||||
|
deduped,
|
||||||
|
key=lambda message: (
|
||||||
|
self._message_timestamp(message),
|
||||||
|
str(message.get("message_id", "") or ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_channel_messages_page_info(
|
||||||
|
self,
|
||||||
|
channel_id: str,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
before_timestamp: float | None = None,
|
||||||
|
before_message_id: str | None = None,
|
||||||
|
detail_level: str = "full",
|
||||||
|
project_id: str = "default",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return an exact final-visible page and its pagination metadata.
|
||||||
|
|
||||||
|
``total_count`` counts the de-duplicated UI rows for the whole channel;
|
||||||
|
``has_more`` describes rows older than the returned page for the given
|
||||||
|
cursor. Both values include UI-only messages that have no transcript
|
||||||
|
counterpart.
|
||||||
|
"""
|
||||||
|
messages = await self._get_channel_visible_messages(
|
||||||
|
channel_id,
|
||||||
|
detail_level=detail_level,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
total_count = len(messages)
|
||||||
|
candidates = messages
|
||||||
normalized_before_id = str(before_message_id or "").strip()
|
normalized_before_id = str(before_message_id or "").strip()
|
||||||
if before_timestamp is not None:
|
if before_timestamp is not None:
|
||||||
|
normalized_before_timestamp = float(before_timestamp)
|
||||||
if normalized_before_id:
|
if normalized_before_id:
|
||||||
query += " AND (timestamp < ? OR (timestamp = ? AND message_id < ?))"
|
candidates = [
|
||||||
params.extend([before_timestamp, before_timestamp, normalized_before_id])
|
message
|
||||||
|
for message in messages
|
||||||
|
if (
|
||||||
|
self._message_timestamp(message),
|
||||||
|
str(message.get("message_id", "") or ""),
|
||||||
|
) < (normalized_before_timestamp, normalized_before_id)
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
query += " AND timestamp < ?"
|
candidates = [
|
||||||
params.append(before_timestamp)
|
message
|
||||||
query += " ORDER BY timestamp DESC, message_id DESC LIMIT ?"
|
for message in messages
|
||||||
params.append(fetch_limit)
|
if self._message_timestamp(message) < normalized_before_timestamp
|
||||||
|
]
|
||||||
|
normalized_limit = max(int(limit), 1)
|
||||||
|
return {
|
||||||
|
"messages": candidates[-normalized_limit:],
|
||||||
|
"has_more": len(candidates) > normalized_limit,
|
||||||
|
"total_count": total_count,
|
||||||
|
}
|
||||||
|
|
||||||
cursor = await self._db.execute(query, tuple(params))
|
async def get_channel_visible_message_count(
|
||||||
rows = await cursor.fetchall()
|
self,
|
||||||
messages = [self._row_to_message_dict(row) for row in rows]
|
channel_id: str,
|
||||||
messages.reverse()
|
project_id: str = "default",
|
||||||
messages = self._dedupe_messages(messages)
|
*,
|
||||||
if len(messages) > limit:
|
detail_level: str = "full",
|
||||||
messages = messages[-limit:]
|
) -> int:
|
||||||
return messages
|
|
||||||
|
|
||||||
async def get_channel_visible_message_count(self, channel_id: str, project_id: str = "default") -> int:
|
|
||||||
"""Return the de-duplicated visible message count for a channel."""
|
"""Return the de-duplicated visible message count for a channel."""
|
||||||
cursor = await self._db.execute(
|
messages = await self._get_channel_visible_messages(
|
||||||
"SELECT message_id, channel_id, sender, sender_name, content, "
|
channel_id,
|
||||||
"timestamp, reply_to_id, mentions, metadata "
|
detail_level=detail_level,
|
||||||
"FROM messages WHERE channel_id = ? AND project_id = ? ORDER BY timestamp ASC",
|
project_id=project_id,
|
||||||
(channel_id, project_id),
|
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
return len(messages)
|
||||||
if not rows:
|
|
||||||
return 0
|
|
||||||
messages = [self._row_to_message_dict(row) for row in rows]
|
|
||||||
return len(self._dedupe_messages(messages))
|
|
||||||
|
|
||||||
async def get_unresolved_checkpoint_messages(
|
async def get_unresolved_checkpoint_messages(
|
||||||
self,
|
self,
|
||||||
@@ -1646,6 +2214,9 @@ class ChatStore:
|
|||||||
preview = " ".join(detail.split())
|
preview = " ".join(detail.split())
|
||||||
folded = dict(target)
|
folded = dict(target)
|
||||||
folded.update(entry)
|
folded.update(entry)
|
||||||
|
# The folded stream is one UI timeline row. Preserve its creation
|
||||||
|
# timestamp so reconnect snapshots cannot move it around tools.
|
||||||
|
folded["timestamp"] = target.get("timestamp", entry.get("timestamp"))
|
||||||
folded["detail"] = detail
|
folded["detail"] = detail
|
||||||
folded["summary"] = preview[:120].rstrip() + ("..." if len(preview) > 120 else "")
|
folded["summary"] = preview[:120].rstrip() + ("..." if len(preview) > 120 else "")
|
||||||
merged[index_by_key[key]] = folded
|
merged[index_by_key[key]] = folded
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,9 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="data:," />
|
<link rel="icon" href="data:," />
|
||||||
<title>OpenOPC Pixel Office</title>
|
<title>OpenOPC Pixel Office</title>
|
||||||
<script type="module" crossorigin src="./assets/index-D7a_jL_7.js"></script>
|
<script type="module" crossorigin src="./assets/index-02tfsorH.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
|
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-CMqG6mW8.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BCWwLlJm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -139,4 +139,17 @@ assert.match(
|
|||||||
'session_detail backfill of the final runtime assistant turn must clear matching Live Reply drafts',
|
'session_detail backfill of the final runtime assistant turn must clear matching Live Reply drafts',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 9. Summary/full pagination and transport-local failures have independent
|
||||||
|
// lifecycle state. A locally failed Promise never reaches onAck.
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/void client\.sessionDetail\([\s\S]*?\.then\(\(payload\) => \{[\s\S]*?payload\.ok !== false[\s\S]*?detailLoading: false/,
|
||||||
|
'debounced session_detail refresh must clear loading on transport-local failure',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/mergeSessionDetailHasMore\([\s\S]*?payload\.client_history_page === true[\s\S]*?fullHasMore: detailHasMore[\s\S]*?summaryHasMore: detailHasMore/,
|
||||||
|
'session_detail ACK must persist pagination state under its detail policy',
|
||||||
|
)
|
||||||
|
|
||||||
console.log('App.test.tsx: OK (org handlers + snapshot boundary + runtime displayTool/draft contract)')
|
console.log('App.test.tsx: OK (org handlers + snapshot boundary + runtime displayTool/draft contract)')
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { ExecutionPanel } from './kanban/ExecutionPanel'
|
|||||||
import { ProjectSelector } from './components/ProjectSelector'
|
import { ProjectSelector } from './components/ProjectSelector'
|
||||||
import { OrgTab } from './org/OrgTab'
|
import { OrgTab } from './org/OrgTab'
|
||||||
import { notifyTaskAssigned } from './lib/taskChatBridge'
|
import { notifyTaskAssigned } from './lib/taskChatBridge'
|
||||||
import { mapCollabSyncPayload, mapBackendMessage, mapBackendChannel, mapBackendSession, mapBackendBoard, mapBackendColumn, mapBackendTask } from './lib/collabSync'
|
import { mapCollabSyncPayload, mapBackendMessage, mapBackendChannel, mapBackendSession, mapBackendBoard, mapBackendColumn, mapBackendTask, mergeSessionDetailHasMore } from './lib/collabSync'
|
||||||
import { normalizeOrgInfoPayload } from './lib/runtimeOrg'
|
import { normalizeOrgInfoPayload } from './lib/runtimeOrg'
|
||||||
import { companyRuntimeControlPatchForBoardStatus } from './lib/sessionRuntime'
|
import { companyRuntimeControlPatchForBoardStatus } from './lib/sessionRuntime'
|
||||||
import { getExecutionTurnId } from './lib/workItemRuntimeIds'
|
import { getExecutionTurnId } from './lib/workItemRuntimeIds'
|
||||||
@@ -621,18 +621,36 @@ export default function App() {
|
|||||||
if (generation !== projectViewGenerationRef.current) return
|
if (generation !== projectViewGenerationRef.current) return
|
||||||
// Re-check liveness at fire time (ref may have been updated by now)
|
// Re-check liveness at fire time (ref may have been updated by now)
|
||||||
if (!force && !shouldRefreshLiveSession(taskId, sessionStoreRef.current)) return
|
if (!force && !shouldRefreshLiveSession(taskId, sessionStoreRef.current)) return
|
||||||
|
const client = clientRef.current
|
||||||
sessionStoreRef.current?.updateSession(taskId, {
|
sessionStoreRef.current?.updateSession(taskId, {
|
||||||
detailLoading: true,
|
detailLoading: true,
|
||||||
detailError: undefined,
|
detailError: undefined,
|
||||||
viewGeneration: generation,
|
viewGeneration: generation,
|
||||||
})
|
})
|
||||||
clientRef.current?.sessionDetail(scopedProjectId, taskId, {
|
if (!client) {
|
||||||
|
sessionStoreRef.current?.updateSession(taskId, {
|
||||||
|
detailLoading: false,
|
||||||
|
detailError: 'connection_unavailable',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void client.sessionDetail(scopedProjectId, taskId, {
|
||||||
limit: 200,
|
limit: 200,
|
||||||
detailLevel,
|
detailLevel,
|
||||||
include: detailLevel === 'full'
|
include: detailLevel === 'full'
|
||||||
? ['messages', 'session_state', 'progress', 'work_items', 'runtime_context']
|
? ['messages', 'session_state', 'progress', 'work_items', 'runtime_context']
|
||||||
: ['messages', 'session_state'],
|
: ['messages', 'session_state'],
|
||||||
viewGeneration: generation,
|
viewGeneration: generation,
|
||||||
|
}).then((payload) => {
|
||||||
|
// Transport-local failures resolve the request Promise without
|
||||||
|
// producing a websocket ACK.
|
||||||
|
if (payload.ok !== false) return
|
||||||
|
if (scopedProjectId !== getActiveProjectId()) return
|
||||||
|
if (generation !== projectViewGenerationRef.current) return
|
||||||
|
sessionStoreRef.current?.updateSession(taskId, {
|
||||||
|
detailLoading: false,
|
||||||
|
detailError: String(payload.error ?? 'request_failed'),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}, 180)
|
}, 180)
|
||||||
pendingSessionDetailRefreshRef.current.set(timerKey, tid)
|
pendingSessionDetailRefreshRef.current.set(timerKey, tid)
|
||||||
@@ -1050,6 +1068,7 @@ export default function App() {
|
|||||||
const totalMessageCount = typeof payload.message_count === 'number'
|
const totalMessageCount = typeof payload.message_count === 'number'
|
||||||
? payload.message_count
|
? payload.message_count
|
||||||
: detailMessages.length
|
: detailMessages.length
|
||||||
|
const detailLevel = payload.detail_level === 'full' ? 'full' : 'summary'
|
||||||
const cs = chatStoreRef.current
|
const cs = chatStoreRef.current
|
||||||
if (cs && detailMessages.length > 0) {
|
if (cs && detailMessages.length > 0) {
|
||||||
cs.mergeMessagesFromBackend(detailMessages)
|
cs.mergeMessagesFromBackend(detailMessages)
|
||||||
@@ -1057,6 +1076,14 @@ export default function App() {
|
|||||||
const ss = sessionStoreRef.current
|
const ss = sessionStoreRef.current
|
||||||
if (ss && detailTaskId) {
|
if (ss && detailTaskId) {
|
||||||
const existingSession = ss.sessions.find(session => session.taskId === detailTaskId)
|
const existingSession = ss.sessions.find(session => session.taskId === detailTaskId)
|
||||||
|
const previousHasMore = detailLevel === 'full'
|
||||||
|
? existingSession?.fullHasMore
|
||||||
|
: existingSession?.summaryHasMore
|
||||||
|
const detailHasMore = mergeSessionDetailHasMore(
|
||||||
|
previousHasMore,
|
||||||
|
payload.has_more === true,
|
||||||
|
payload.client_history_page === true,
|
||||||
|
)
|
||||||
const draftTurnId = String(existingSession?.draftTurnId ?? '').trim()
|
const draftTurnId = String(existingSession?.draftTurnId ?? '').trim()
|
||||||
const detailHasFinalForDraft = !!draftTurnId && detailMessages.some((message) => {
|
const detailHasFinalForDraft = !!draftTurnId && detailMessages.some((message) => {
|
||||||
if (message.sender === 'user') return false
|
if (message.sender === 'user') return false
|
||||||
@@ -1079,8 +1106,11 @@ export default function App() {
|
|||||||
...(typeof payload.handoff_to === 'string' ? { handoffTo: payload.handoff_to } : {}),
|
...(typeof payload.handoff_to === 'string' ? { handoffTo: payload.handoff_to } : {}),
|
||||||
messageCount: totalMessageCount,
|
messageCount: totalMessageCount,
|
||||||
detailLoaded: true,
|
detailLoaded: true,
|
||||||
fullLoaded: payload.detail_level === 'full' && payload.has_more !== true,
|
...(detailLevel === 'full' ? { fullLoaded: !detailHasMore } : {}),
|
||||||
hasMore: payload.has_more === true,
|
hasMore: detailHasMore,
|
||||||
|
...(detailLevel === 'full'
|
||||||
|
? { fullHasMore: detailHasMore }
|
||||||
|
: { summaryHasMore: detailHasMore }),
|
||||||
detailLoading: false,
|
detailLoading: false,
|
||||||
detailError: undefined,
|
detailError: undefined,
|
||||||
viewGeneration: detailGeneration ?? projectViewGenerationRef.current,
|
viewGeneration: detailGeneration ?? projectViewGenerationRef.current,
|
||||||
@@ -2438,7 +2468,10 @@ export default function App() {
|
|||||||
onSessionStop={handleSessionStop}
|
onSessionStop={handleSessionStop}
|
||||||
onSessionResume={handleSessionResume}
|
onSessionResume={handleSessionResume}
|
||||||
onSessionComplete={(taskId) => clientRef.current?.sessionComplete(getActiveProjectId(), taskId)}
|
onSessionComplete={(taskId) => clientRef.current?.sessionComplete(getActiveProjectId(), taskId)}
|
||||||
onLoadSessionDetail={(taskId, opts) => clientRef.current?.sessionDetail(
|
onLoadSessionDetail={(taskId, opts) => {
|
||||||
|
const client = clientRef.current
|
||||||
|
if (!client) return
|
||||||
|
return client.sessionDetail(
|
||||||
getActiveProjectId(),
|
getActiveProjectId(),
|
||||||
taskId,
|
taskId,
|
||||||
{
|
{
|
||||||
@@ -2448,7 +2481,12 @@ export default function App() {
|
|||||||
: ['messages', 'session_state'],
|
: ['messages', 'session_state'],
|
||||||
viewGeneration: projectViewGenerationRef.current,
|
viewGeneration: projectViewGenerationRef.current,
|
||||||
},
|
},
|
||||||
)}
|
).then((payload) => {
|
||||||
|
if (payload.ok === false) {
|
||||||
|
throw new Error(String(payload.error ?? 'session_detail failed'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
onOpenExecutionPanel={(taskId) => setExecutionPanelTaskId(taskId)}
|
onOpenExecutionPanel={(taskId) => setExecutionPanelTaskId(taskId)}
|
||||||
onCollabSync={() => clientRef.current?.collabSync(getActiveProjectId(), undefined, projectViewGenerationRef.current)}
|
onCollabSync={() => clientRef.current?.collabSync(getActiveProjectId(), undefined, projectViewGenerationRef.current)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
|
|||||||
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
|
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={progressEntryKey(entry, hiddenCount + i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
|
<div key={progressEntryKey(entry)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
|
||||||
<div className="ptl-connector">
|
<div className="ptl-connector">
|
||||||
<div className="ptl-dot" style={{ color: cfg.color }}>
|
<div className="ptl-dot" style={{ color: cfg.color }}>
|
||||||
{cfg.icon}
|
{cfg.icon}
|
||||||
|
|||||||
@@ -5,6 +5,58 @@ import { mapBackendMessage } from '../lib/collabSync'
|
|||||||
import { analyzeCheckpointMessages } from './checkpointUtils'
|
import { analyzeCheckpointMessages } from './checkpointUtils'
|
||||||
import { __chatStoreTestUtils } from './ChatStore'
|
import { __chatStoreTestUtils } from './ChatStore'
|
||||||
|
|
||||||
|
const persistentTimestamps = __chatStoreTestUtils.latestPersistentMessageTimestamps([
|
||||||
|
{
|
||||||
|
id: 'db-assistant-1',
|
||||||
|
channelId: 'session:read-test',
|
||||||
|
sender: 'assistant',
|
||||||
|
senderName: 'OPC',
|
||||||
|
content: 'First persisted reply',
|
||||||
|
timestamp: 10,
|
||||||
|
mentions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-local-only',
|
||||||
|
channelId: 'session:read-test',
|
||||||
|
sender: 'user',
|
||||||
|
senderName: 'You',
|
||||||
|
content: 'Optimistic message',
|
||||||
|
timestamp: 30,
|
||||||
|
mentions: [],
|
||||||
|
metadata: { ui_message_id: 'ui-local-only' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'db-assistant-2',
|
||||||
|
channelId: 'session:read-test',
|
||||||
|
sender: 'assistant',
|
||||||
|
senderName: 'OPC',
|
||||||
|
content: 'Latest persisted reply',
|
||||||
|
timestamp: 20,
|
||||||
|
mentions: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(persistentTimestamps['session:read-test'], 20)
|
||||||
|
|
||||||
|
const unreadState = { 'session:read-test': 10 }
|
||||||
|
const advancedReadState = __chatStoreTestUtils.advanceReadTimestamp(
|
||||||
|
unreadState,
|
||||||
|
'session:read-test',
|
||||||
|
persistentTimestamps['session:read-test'],
|
||||||
|
)
|
||||||
|
assert.notEqual(advancedReadState, unreadState)
|
||||||
|
assert.equal(advancedReadState['session:read-test'], 20)
|
||||||
|
assert.equal(
|
||||||
|
__chatStoreTestUtils.advanceReadTimestamp(advancedReadState, 'session:read-test', 20),
|
||||||
|
advancedReadState,
|
||||||
|
'marking an already-read channel must preserve state identity',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
__chatStoreTestUtils.advanceReadTimestamp(advancedReadState, 'session:read-test', 15),
|
||||||
|
advancedReadState,
|
||||||
|
'an older snapshot must not move the read cursor backwards',
|
||||||
|
)
|
||||||
|
|
||||||
const syntheticCheckpoint: ChatMessage = {
|
const syntheticCheckpoint: ChatMessage = {
|
||||||
id: 'checkpoint::cp-delivery',
|
id: 'checkpoint::cp-delivery',
|
||||||
channelId: 'session:task-1',
|
channelId: 'session:task-1',
|
||||||
@@ -43,6 +95,7 @@ const mergedCheckpoint = __chatStoreTestUtils.dedupeMessages([
|
|||||||
|
|
||||||
assert.equal(mergedCheckpoint.length, 1)
|
assert.equal(mergedCheckpoint.length, 1)
|
||||||
assert.equal(mergedCheckpoint[0].id, 'db-message-1')
|
assert.equal(mergedCheckpoint[0].id, 'db-message-1')
|
||||||
|
assert.equal(mergedCheckpoint[0].timestamp, 1, 'checkpoint status updates must keep their original timeline position')
|
||||||
assert.equal(mergedCheckpoint[0].metadata?.checkpoint_status, 'ignored')
|
assert.equal(mergedCheckpoint[0].metadata?.checkpoint_status, 'ignored')
|
||||||
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).pendingMessageIds], [])
|
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).pendingMessageIds], [])
|
||||||
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).respondedMessageIds], ['db-message-1'])
|
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).respondedMessageIds], ['db-message-1'])
|
||||||
@@ -100,6 +153,42 @@ const mergedUserMessage = __chatStoreTestUtils.dedupeMessages([
|
|||||||
|
|
||||||
assert.equal(mergedUserMessage.length, 1)
|
assert.equal(mergedUserMessage.length, 1)
|
||||||
assert.equal(mergedUserMessage[0].metadata?.ui_message_id, 'ui-1')
|
assert.equal(mergedUserMessage[0].metadata?.ui_message_id, 'ui-1')
|
||||||
|
assert.equal(mergedUserMessage[0].timestamp, 4, 'backend acknowledgement must replace the optimistic client clock')
|
||||||
|
|
||||||
|
const mirroredUserMessages = __chatStoreTestUtils.dedupeMessages([
|
||||||
|
backendUserMessage,
|
||||||
|
{ ...backendUserMessage, id: 'db-user-mirror', channelId: 'session:child-task' },
|
||||||
|
])
|
||||||
|
assert.equal(
|
||||||
|
mirroredUserMessages.length,
|
||||||
|
2,
|
||||||
|
'ui_message_id mirrors in different channels must remain available to each channel projection',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
__chatStoreTestUtils.unreadMessageCounts([
|
||||||
|
{
|
||||||
|
id: 'msg-local-system',
|
||||||
|
channelId: 'session:task-1',
|
||||||
|
sender: 'system',
|
||||||
|
senderName: 'System',
|
||||||
|
content: 'Local task assignment notice',
|
||||||
|
timestamp: 100,
|
||||||
|
mentions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'db-assistant-unread',
|
||||||
|
channelId: 'session:task-1',
|
||||||
|
sender: 'assistant',
|
||||||
|
senderName: 'OPC',
|
||||||
|
content: 'Persisted reply',
|
||||||
|
timestamp: 90,
|
||||||
|
mentions: [],
|
||||||
|
},
|
||||||
|
], {}),
|
||||||
|
{ 'session:task-1': 1 },
|
||||||
|
'local-only system rows must not become unread entries that markRead can never cover',
|
||||||
|
)
|
||||||
|
|
||||||
const nativeCompanyRawTurn: ChatMessage = {
|
const nativeCompanyRawTurn: ChatMessage = {
|
||||||
id: 'native-raw-1',
|
id: 'native-raw-1',
|
||||||
@@ -137,6 +226,44 @@ const mergedNativeCompanyDuplicate = __chatStoreTestUtils.dedupeMessages([
|
|||||||
assert.equal(mergedNativeCompanyDuplicate.length, 1)
|
assert.equal(mergedNativeCompanyDuplicate.length, 1)
|
||||||
assert.equal(mergedNativeCompanyDuplicate[0].id, 'role-result-1')
|
assert.equal(mergedNativeCompanyDuplicate[0].id, 'role-result-1')
|
||||||
assert.equal(mergedNativeCompanyDuplicate[0].senderName, 'Chao')
|
assert.equal(mergedNativeCompanyDuplicate[0].senderName, 'Chao')
|
||||||
|
assert.equal(mergedNativeCompanyDuplicate[0].timestamp, 5, 'semantic result replacement must retain its original timeline position')
|
||||||
|
assert.equal(
|
||||||
|
mergedNativeCompanyDuplicate[0].metadata?.ui_timeline_id,
|
||||||
|
'message:native-raw-1',
|
||||||
|
'semantic result replacement must retain the already-mounted row identity',
|
||||||
|
)
|
||||||
|
const repeatedNativeCompanySync = __chatStoreTestUtils.dedupeMessages([
|
||||||
|
...mergedNativeCompanyDuplicate,
|
||||||
|
nativeCompanyRawTurn,
|
||||||
|
companyRoleResult,
|
||||||
|
])
|
||||||
|
assert.equal(repeatedNativeCompanySync.length, 1)
|
||||||
|
assert.equal(repeatedNativeCompanySync[0].metadata?.ui_timeline_id, 'message:native-raw-1')
|
||||||
|
assert.equal(repeatedNativeCompanySync[0].timestamp, 5)
|
||||||
|
|
||||||
|
const mountedHighPriorityResult: ChatMessage = {
|
||||||
|
...companyRoleResult,
|
||||||
|
id: 'mounted-high-result',
|
||||||
|
timestamp: 10,
|
||||||
|
metadata: { source: 'engine', transcript_kind: 'child_task_result' },
|
||||||
|
}
|
||||||
|
const olderLowPrioritySurface: ChatMessage = {
|
||||||
|
...nativeCompanyRawTurn,
|
||||||
|
id: 'older-low-result',
|
||||||
|
timestamp: 4,
|
||||||
|
}
|
||||||
|
const historyBackfillMerge = __chatStoreTestUtils.mergeMessagesIntoExisting(
|
||||||
|
[mountedHighPriorityResult],
|
||||||
|
[olderLowPrioritySurface],
|
||||||
|
)
|
||||||
|
assert.equal(historyBackfillMerge.length, 1)
|
||||||
|
assert.equal(historyBackfillMerge[0].id, 'mounted-high-result')
|
||||||
|
assert.equal(historyBackfillMerge[0].timestamp, 10, 'history backfill must not move an already-mounted result row')
|
||||||
|
assert.equal(
|
||||||
|
historyBackfillMerge[0].metadata?.ui_timeline_id,
|
||||||
|
'message:mounted-high-result',
|
||||||
|
'history backfill must retain the mounted high-priority result key',
|
||||||
|
)
|
||||||
|
|
||||||
const mappedTaskGeneralistMessage = mapBackendMessage({
|
const mappedTaskGeneralistMessage = mapBackendMessage({
|
||||||
message_id: 'legacy-task-generalist',
|
message_id: 'legacy-task-generalist',
|
||||||
@@ -152,4 +279,4 @@ const mappedTaskGeneralistMessage = mapBackendMessage({
|
|||||||
|
|
||||||
assert.equal(mappedTaskGeneralistMessage.senderName, 'OPC')
|
assert.equal(mappedTaskGeneralistMessage.senderName, 'OPC')
|
||||||
|
|
||||||
console.log('ChatStore.test.ts: OK (optimistic, checkpoint, and company result identity merging)')
|
console.log('ChatStore.test.ts: OK (read cursors, optimistic, checkpoint, and company result identity merging)')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useMemo, useReducer, useState } from 'react'
|
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
|
||||||
import type { ChatChannel, ChatMessage } from '../types/chat'
|
import type { ChatChannel, ChatMessage } from '../types/chat'
|
||||||
|
import { stableMessageTimelineKey } from '../lib/messageTimelineIdentity'
|
||||||
|
|
||||||
function uid(): string {
|
function uid(): string {
|
||||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
@@ -73,6 +74,12 @@ function messageIdentityKeys(message: ChatMessage): Set<string> {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scopedMessageIdentityKeys(message: ChatMessage): Set<string> {
|
||||||
|
return new Set(
|
||||||
|
[...messageIdentityKeys(message)].map(key => `${message.channelId}\u0000${key}`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function isDerivedIdentityKey(value: string): boolean {
|
function isDerivedIdentityKey(value: string): boolean {
|
||||||
return value.startsWith('checkpoint:')
|
return value.startsWith('checkpoint:')
|
||||||
}
|
}
|
||||||
@@ -81,6 +88,52 @@ function messageTimestamp(message: ChatMessage): number {
|
|||||||
return typeof message.timestamp === 'number' ? message.timestamp : 0
|
return typeof message.timestamp === 'number' ? message.timestamp : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOptimisticMessage(message: ChatMessage): boolean {
|
||||||
|
return String(message.id ?? '').startsWith('msg-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPersistentMessage(message: ChatMessage): boolean {
|
||||||
|
// sendMessage creates client-only optimistic rows with this prefix. Once the
|
||||||
|
// backend acknowledges one, message deduplication replaces its identity with
|
||||||
|
// the persistent ui_message_id / backend message id.
|
||||||
|
return !isOptimisticMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestPersistentMessageTimestamps(messages: ChatMessage[]): Record<string, number> {
|
||||||
|
const latest: Record<string, number> = {}
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!isPersistentMessage(message)) continue
|
||||||
|
const timestamp = messageTimestamp(message)
|
||||||
|
if (timestamp > (latest[message.channelId] ?? 0)) {
|
||||||
|
latest[message.channelId] = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceReadTimestamp(
|
||||||
|
state: Record<string, number>,
|
||||||
|
channelId: string,
|
||||||
|
timestamp: number,
|
||||||
|
): Record<string, number> {
|
||||||
|
if (timestamp <= (state[channelId] ?? 0)) return state
|
||||||
|
return { ...state, [channelId]: timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
function unreadMessageCounts(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
readTimestamps: Record<string, number>,
|
||||||
|
): Record<string, number> {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!isPersistentMessage(message) || message.sender === 'user') continue
|
||||||
|
const lastRead = readTimestamps[message.channelId] ?? 0
|
||||||
|
if (messageTimestamp(message) <= lastRead) continue
|
||||||
|
counts[message.channelId] = (counts[message.channelId] ?? 0) + 1
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
function messageRoleBucket(message: ChatMessage): 'user' | 'assistant' {
|
function messageRoleBucket(message: ChatMessage): 'user' | 'assistant' {
|
||||||
const sender = String(message.sender ?? '').trim().toLowerCase()
|
const sender = String(message.sender ?? '').trim().toLowerCase()
|
||||||
const metadata = messageMetadata(message)
|
const metadata = messageMetadata(message)
|
||||||
@@ -154,7 +207,12 @@ function mergeDuplicateMessages(
|
|||||||
let preferred = existing
|
let preferred = existing
|
||||||
let secondary = candidate
|
let secondary = candidate
|
||||||
|
|
||||||
if (preferCandidate) {
|
const existingOptimistic = isOptimisticMessage(existing)
|
||||||
|
const candidateOptimistic = isOptimisticMessage(candidate)
|
||||||
|
if (existingOptimistic !== candidateOptimistic) {
|
||||||
|
preferred = existingOptimistic ? candidate : existing
|
||||||
|
secondary = existingOptimistic ? existing : candidate
|
||||||
|
} else if (preferCandidate) {
|
||||||
preferred = candidate
|
preferred = candidate
|
||||||
secondary = existing
|
secondary = existing
|
||||||
} else if (messagePreferenceScore(candidate) > messagePreferenceScore(existing)) {
|
} else if (messagePreferenceScore(candidate) > messagePreferenceScore(existing)) {
|
||||||
@@ -184,14 +242,30 @@ function mergeDuplicateMessages(
|
|||||||
? normalizedContent
|
? normalizedContent
|
||||||
: preferred.content
|
: preferred.content
|
||||||
|
|
||||||
|
const existingCheckpointId = String(messageMetadata(existing).checkpoint_id ?? '').trim()
|
||||||
|
const candidateCheckpointId = String(messageMetadata(candidate).checkpoint_id ?? '').trim()
|
||||||
|
const preservesCheckpointPosition = !!existingCheckpointId && existingCheckpointId === candidateCheckpointId
|
||||||
|
const replacesResultSurface = isResultSurface(existing) && isResultSurface(candidate)
|
||||||
|
const retainedTimelineId = replacesResultSurface
|
||||||
|
? String(
|
||||||
|
messageMetadata(existing).ui_timeline_id
|
||||||
|
?? messageMetadata(candidate).ui_timeline_id
|
||||||
|
?? stableMessageTimelineKey(existing),
|
||||||
|
).trim()
|
||||||
|
: ''
|
||||||
|
const mergedMetadata = { ...messageMetadata(secondary), ...messageMetadata(preferred) }
|
||||||
|
if (retainedTimelineId) mergedMetadata.ui_timeline_id = retainedTimelineId
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...secondary,
|
...secondary,
|
||||||
...preferred,
|
...preferred,
|
||||||
...(canonicalId ? { id: canonicalId } : {}),
|
...(canonicalId ? { id: canonicalId } : {}),
|
||||||
content,
|
content,
|
||||||
metadata: { ...messageMetadata(secondary), ...messageMetadata(preferred) },
|
metadata: mergedMetadata,
|
||||||
mentions,
|
mentions,
|
||||||
timestamp: messageTimestamp(preferred) || messageTimestamp(secondary),
|
timestamp: preservesCheckpointPosition || replacesResultSurface
|
||||||
|
? messageTimestamp(existing) || messageTimestamp(candidate)
|
||||||
|
: messageTimestamp(preferred) || messageTimestamp(secondary),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +275,7 @@ function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
|
|||||||
const identityKeyToIdx = new Map<string, number>()
|
const identityKeyToIdx = new Map<string, number>()
|
||||||
|
|
||||||
for (const message of [...messages].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
|
for (const message of [...messages].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
|
||||||
const candidateIds = messageIdentityKeys(message)
|
const candidateIds = scopedMessageIdentityKeys(message)
|
||||||
let matchIndex = -1
|
let matchIndex = -1
|
||||||
let preferCandidate = false
|
let preferCandidate = false
|
||||||
|
|
||||||
@@ -242,7 +316,7 @@ function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register all identity keys for the merged/inserted message for fast future lookups
|
// Register all identity keys for the merged/inserted message for fast future lookups
|
||||||
for (const id of messageIdentityKeys(deduped[insertIdx])) {
|
for (const id of scopedMessageIdentityKeys(deduped[insertIdx])) {
|
||||||
if (!identityKeyToIdx.has(id)) identityKeyToIdx.set(id, insertIdx)
|
if (!identityKeyToIdx.has(id)) identityKeyToIdx.set(id, insertIdx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,8 +324,63 @@ function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
|
|||||||
return deduped
|
return deduped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeMessagesIntoExisting(
|
||||||
|
state: ChatMessage[],
|
||||||
|
incoming: ChatMessage[],
|
||||||
|
): ChatMessage[] {
|
||||||
|
const merged = [...state]
|
||||||
|
const identityKeyToIdx = new Map<string, number>()
|
||||||
|
merged.forEach((message, index) => {
|
||||||
|
for (const key of scopedMessageIdentityKeys(message)) identityKeyToIdx.set(key, index)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const candidate of [...incoming].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
|
||||||
|
let matchIndex = -1
|
||||||
|
for (const key of scopedMessageIdentityKeys(candidate)) {
|
||||||
|
const index = identityKeyToIdx.get(key)
|
||||||
|
if (index !== undefined) {
|
||||||
|
matchIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchIndex < 0) {
|
||||||
|
for (let index = merged.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (messagesSemanticallyMatch(merged[index], candidate)) {
|
||||||
|
matchIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchIndex < 0) {
|
||||||
|
matchIndex = merged.length
|
||||||
|
merged.push(candidate)
|
||||||
|
} else {
|
||||||
|
const mounted = merged[matchIndex]
|
||||||
|
merged[matchIndex] = mergeDuplicateMessages(
|
||||||
|
mounted,
|
||||||
|
candidate,
|
||||||
|
mounted.id === candidate.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of scopedMessageIdentityKeys(candidate)) identityKeyToIdx.set(key, matchIndex)
|
||||||
|
for (const key of scopedMessageIdentityKeys(merged[matchIndex])) identityKeyToIdx.set(key, matchIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.sort((a, b) => (
|
||||||
|
messageTimestamp(a) === messageTimestamp(b)
|
||||||
|
? a.id.localeCompare(b.id)
|
||||||
|
: messageTimestamp(a) - messageTimestamp(b)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
export const __chatStoreTestUtils = {
|
export const __chatStoreTestUtils = {
|
||||||
|
advanceReadTimestamp,
|
||||||
dedupeMessages,
|
dedupeMessages,
|
||||||
|
latestPersistentMessageTimestamps,
|
||||||
|
mergeMessagesIntoExisting,
|
||||||
|
unreadMessageCounts,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelAction =
|
type ChannelAction =
|
||||||
@@ -335,7 +464,7 @@ function messageReducer(state: ChatMessage[], action: MessageAction): ChatMessag
|
|||||||
}
|
}
|
||||||
case 'MERGE': {
|
case 'MERGE': {
|
||||||
if (action.messages.length === 0) return state
|
if (action.messages.length === 0) return state
|
||||||
return dedupeMessages([...state, ...action.messages])
|
return mergeMessagesIntoExisting(state, action.messages)
|
||||||
}
|
}
|
||||||
case 'MARK_SENDER_DELETED': return state.map(m =>
|
case 'MARK_SENDER_DELETED': return state.map(m =>
|
||||||
m.sender === action.senderId ? { ...m, senderDeleted: true, senderName: '[已删除的 Agent]' } : m
|
m.sender === action.senderId ? { ...m, senderDeleted: true, senderName: '[已删除的 Agent]' } : m
|
||||||
@@ -372,6 +501,14 @@ export function useChatStore(): ChatStoreState {
|
|||||||
const [messages, dispatchMsg] = useReducer(messageReducer, [])
|
const [messages, dispatchMsg] = useReducer(messageReducer, [])
|
||||||
const [readTimestamps, setReadTimestamps] = useState<Record<string, number>>({})
|
const [readTimestamps, setReadTimestamps] = useState<Record<string, number>>({})
|
||||||
const [scopeProjectId, setScopeProjectId] = useState<string>('default')
|
const [scopeProjectId, setScopeProjectId] = useState<string>('default')
|
||||||
|
const readBaselineProjectRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const latestPersistentTimestamps = useMemo(
|
||||||
|
() => latestPersistentMessageTimestamps(messages),
|
||||||
|
[messages],
|
||||||
|
)
|
||||||
|
const latestPersistentTimestampsRef = useRef(latestPersistentTimestamps)
|
||||||
|
latestPersistentTimestampsRef.current = latestPersistentTimestamps
|
||||||
|
|
||||||
const messagesByChannel = useMemo<Record<string, ChatMessage[]>>(() => {
|
const messagesByChannel = useMemo<Record<string, ChatMessage[]>>(() => {
|
||||||
const buckets: Record<string, ChatMessage[]> = {}
|
const buckets: Record<string, ChatMessage[]> = {}
|
||||||
@@ -382,16 +519,10 @@ export function useChatStore(): ChatStoreState {
|
|||||||
return buckets
|
return buckets
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
const unreadCounts = useMemo<Record<string, number>>(() => {
|
const unreadCounts = useMemo(
|
||||||
const counts: Record<string, number> = {}
|
() => unreadMessageCounts(messages, readTimestamps),
|
||||||
for (const message of messages) {
|
[messages, readTimestamps],
|
||||||
if (message.sender === 'user') continue
|
)
|
||||||
const lastRead = readTimestamps[message.channelId] ?? 0
|
|
||||||
if (message.timestamp <= lastRead) continue
|
|
||||||
counts[message.channelId] = (counts[message.channelId] ?? 0) + 1
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}, [messages, readTimestamps])
|
|
||||||
|
|
||||||
const sendMessage = useCallback((opts: {
|
const sendMessage = useCallback((opts: {
|
||||||
channelId: string; sender: string; senderName: string; content: string;
|
channelId: string; sender: string; senderName: string; content: string;
|
||||||
@@ -421,7 +552,9 @@ export function useChatStore(): ChatStoreState {
|
|||||||
}, [unreadCounts])
|
}, [unreadCounts])
|
||||||
|
|
||||||
const markRead = useCallback((channelId: string) => {
|
const markRead = useCallback((channelId: string) => {
|
||||||
setReadTimestamps(prev => ({ ...prev, [channelId]: Date.now() }))
|
const latestTimestamp = latestPersistentTimestampsRef.current[channelId] ?? 0
|
||||||
|
if (latestTimestamp <= 0) return
|
||||||
|
setReadTimestamps(prev => advanceReadTimestamp(prev, channelId, latestTimestamp))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const markSenderDeleted = useCallback((agentId: string) => {
|
const markSenderDeleted = useCallback((agentId: string) => {
|
||||||
@@ -440,12 +573,15 @@ export function useChatStore(): ChatStoreState {
|
|||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
dispatchCh({ type: 'CLEAR' })
|
dispatchCh({ type: 'CLEAR' })
|
||||||
dispatchMsg({ type: 'CLEAR' })
|
dispatchMsg({ type: 'CLEAR' })
|
||||||
|
readBaselineProjectRef.current = null
|
||||||
setReadTimestamps({})
|
setReadTimestamps({})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const initFromBackend = useCallback((projectId: string, chs: ChatChannel[], msgs: ChatMessage[]) => {
|
const initFromBackend = useCallback((projectId: string, chs: ChatChannel[], msgs: ChatMessage[]) => {
|
||||||
const nextProjectId = projectId || 'default'
|
const nextProjectId = projectId || 'default'
|
||||||
const projectChanged = nextProjectId !== scopeProjectId
|
const projectChanged = nextProjectId !== scopeProjectId
|
||||||
|
const shouldResetReadBaseline = readBaselineProjectRef.current !== nextProjectId
|
||||||
|
readBaselineProjectRef.current = nextProjectId
|
||||||
setScopeProjectId(nextProjectId)
|
setScopeProjectId(nextProjectId)
|
||||||
dispatchCh({ type: 'SET', channels: chs })
|
dispatchCh({ type: 'SET', channels: chs })
|
||||||
// Backend `collab_sync` / `collab_sync_push` payloads carry the
|
// Backend `collab_sync` / `collab_sync_push` payloads carry the
|
||||||
@@ -462,14 +598,11 @@ export function useChatStore(): ChatStoreState {
|
|||||||
} else {
|
} else {
|
||||||
dispatchMsg({ type: 'MERGE', messages: msgs })
|
dispatchMsg({ type: 'MERGE', messages: msgs })
|
||||||
}
|
}
|
||||||
// Mark all loaded messages as read so they don't show as unread (#17)
|
// Establish one read baseline when entering a project. Repeated full-sync
|
||||||
const latest: Record<string, number> = {}
|
// payloads must not advance it behind the viewport controller's back.
|
||||||
for (const m of msgs) {
|
if (shouldResetReadBaseline) {
|
||||||
if (!latest[m.channelId] || m.timestamp > latest[m.channelId]) {
|
setReadTimestamps(latestPersistentMessageTimestamps(msgs))
|
||||||
latest[m.channelId] = m.timestamp
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
setReadTimestamps(prev => projectChanged ? latest : ({ ...prev, ...latest }))
|
|
||||||
}, [scopeProjectId])
|
}, [scopeProjectId])
|
||||||
|
|
||||||
const addMessageFromBackend = useCallback((msg: ChatMessage) => {
|
const addMessageFromBackend = useCallback((msg: ChatMessage) => {
|
||||||
|
|||||||
@@ -1,54 +1,38 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
import { buildNarrativeMessageItems, copyTextToClipboard, parseProjectUpdatePayload, shouldReleaseStickToBottomOnScroll } from './MessageList'
|
import { buildNarrativeMessageItems, copyTextToClipboard, messageTimelineKey, parseProjectUpdatePayload } from './MessageList'
|
||||||
|
import type { MessageScrollPolicy } from './MessageList'
|
||||||
import type { ChatMessage } from '../types/chat'
|
import type { ChatMessage } from '../types/chat'
|
||||||
|
import { progressEntryKey } from '../lib/progressEntryKey'
|
||||||
|
|
||||||
assert.equal(
|
const messageListSource = readFileSync(new URL('./MessageList.tsx', import.meta.url), 'utf8')
|
||||||
shouldReleaseStickToBottomOnScroll({
|
const supportedScrollPolicies: MessageScrollPolicy[] = ['follow', 'initial-bottom', 'manual']
|
||||||
previousScrollTop: 1200,
|
assert.deepEqual(supportedScrollPolicies, ['follow', 'initial-bottom', 'manual'])
|
||||||
nextScrollTop: 900,
|
assert.match(
|
||||||
atBottom: false,
|
messageListSource,
|
||||||
userScrolling: false,
|
/export type MessageScrollPolicy = 'follow' \| 'initial-bottom' \| 'manual'/,
|
||||||
programmaticScroll: false,
|
'MessageList must expose one unambiguous three-state scroll policy',
|
||||||
}),
|
|
||||||
true,
|
|
||||||
'scrollbar drag upward should release stick-to-bottom even without wheel/pointer events',
|
|
||||||
)
|
)
|
||||||
|
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')
|
||||||
|
|
||||||
|
const progressWithoutServerId = {
|
||||||
|
type: 'status_change' as const,
|
||||||
|
summary: 'Waiting for reviewer',
|
||||||
|
detail: 'Gate entered',
|
||||||
|
timestamp: 1234,
|
||||||
|
}
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldReleaseStickToBottomOnScroll({
|
progressEntryKey(progressWithoutServerId),
|
||||||
previousScrollTop: 1200,
|
progressEntryKey({ ...progressWithoutServerId }),
|
||||||
nextScrollTop: 900,
|
'progress identity must derive from stable event fields rather than its array position',
|
||||||
atBottom: false,
|
|
||||||
userScrolling: false,
|
|
||||||
programmaticScroll: true,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
'programmatic scrolls should not release stick-to-bottom',
|
|
||||||
)
|
)
|
||||||
|
assert.doesNotMatch(
|
||||||
assert.equal(
|
progressEntryKey(progressWithoutServerId),
|
||||||
shouldReleaseStickToBottomOnScroll({
|
/:0$/,
|
||||||
previousScrollTop: 900,
|
'progress fallback identity must not carry a shifting array index',
|
||||||
nextScrollTop: 900,
|
|
||||||
atBottom: false,
|
|
||||||
userScrolling: true,
|
|
||||||
programmaticScroll: false,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
'explicit user scroll state should release stick-to-bottom while away from bottom',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
shouldReleaseStickToBottomOnScroll({
|
|
||||||
previousScrollTop: 900,
|
|
||||||
nextScrollTop: 1200,
|
|
||||||
atBottom: true,
|
|
||||||
userScrolling: true,
|
|
||||||
programmaticScroll: false,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
'scrolling back to bottom should keep follow mode available',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const parsedUpdate = parseProjectUpdatePayload(JSON.stringify({
|
const parsedUpdate = parseProjectUpdatePayload(JSON.stringify({
|
||||||
@@ -90,6 +74,81 @@ const baseMessage = (id: string, content: string, timestamp: number, sender = 's
|
|||||||
metadata: {},
|
metadata: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
messageTimelineKey({
|
||||||
|
...baseMessage('checkpoint-message', 'Approval needed', 10),
|
||||||
|
metadata: {
|
||||||
|
checkpoint_id: 'checkpoint-42',
|
||||||
|
canonical_turn_id: 'turn-ignored',
|
||||||
|
ui_message_id: 'ui-ignored',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'checkpoint:checkpoint-42',
|
||||||
|
'checkpoint identity must win so pending/resolved updates reuse one row',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
messageTimelineKey({
|
||||||
|
...baseMessage('assistant-final', 'Final answer', 20, 'assistant'),
|
||||||
|
metadata: { canonical_turn_id: 'turn-7', transcript_kind: 'runtime_v2_assistant' },
|
||||||
|
}),
|
||||||
|
'turn:assistant:turn-7',
|
||||||
|
'assistant draft and final surfaces must share the canonical turn key',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
messageTimelineKey({
|
||||||
|
...baseMessage('user-message', 'Question', 30, 'user'),
|
||||||
|
metadata: { canonical_turn_id: 'turn-8', ui_message_id: 'ui-8' },
|
||||||
|
}),
|
||||||
|
'ui:ui-8',
|
||||||
|
'a persisted user turn must retain the optimistic ui_message_id key',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
messageTimelineKey({
|
||||||
|
...baseMessage('optimistic-message', 'Local echo', 40, 'user'),
|
||||||
|
metadata: { ui_message_id: 'ui-9' },
|
||||||
|
}),
|
||||||
|
'ui:ui-9',
|
||||||
|
'optimistic and persisted user echoes must share ui_message_id identity',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
messageTimelineKey(baseMessage('persistent-message', 'Stored message', 50, 'assistant')),
|
||||||
|
'message:persistent-message',
|
||||||
|
'messages without stronger runtime identity must fall back to the persistent id',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
messageTimelineKey({
|
||||||
|
...baseMessage('higher-priority-result', 'Final answer', 55, 'assistant'),
|
||||||
|
metadata: {
|
||||||
|
canonical_turn_id: 'turn-7',
|
||||||
|
transcript_kind: 'child_task_result',
|
||||||
|
ui_timeline_id: 'turn:assistant:turn-7',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'turn:assistant:turn-7',
|
||||||
|
'a semantic result replacement must keep the mounted native-final/draft slot',
|
||||||
|
)
|
||||||
|
|
||||||
|
const sharedCompanyTurn = 'company-turn-1'
|
||||||
|
const companyTurnKeys = [
|
||||||
|
messageTimelineKey({
|
||||||
|
...baseMessage('runtime-context', 'Execution context', 60),
|
||||||
|
metadata: { kind: 'runtime_v2_user_turn', canonical_turn_id: sharedCompanyTurn },
|
||||||
|
}),
|
||||||
|
messageTimelineKey({
|
||||||
|
...baseMessage('company-stream-1', 'First company surface', 61, 'assistant'),
|
||||||
|
metadata: { kind: 'runtime_v2_company_assistant', canonical_turn_id: sharedCompanyTurn },
|
||||||
|
}),
|
||||||
|
messageTimelineKey({
|
||||||
|
...baseMessage('company-stream-2', 'Second company surface', 62, 'assistant'),
|
||||||
|
metadata: { kind: 'runtime_v2_company_assistant', canonical_turn_id: sharedCompanyTurn },
|
||||||
|
}),
|
||||||
|
messageTimelineKey({
|
||||||
|
...baseMessage('role-result', 'Role result', 63, 'assistant'),
|
||||||
|
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')
|
||||||
|
|
||||||
const narrativeItems = buildNarrativeMessageItems([
|
const narrativeItems = buildNarrativeMessageItems([
|
||||||
baseMessage('m1', '[Company:cto::execute::abc] starting Research source reliability', 1000),
|
baseMessage('m1', '[Company:cto::execute::abc] starting Research source reliability', 1000),
|
||||||
baseMessage('m2', '[Delegating to codex] task=Research source reliability | cmd=codex exec ...', 1100),
|
baseMessage('m2', '[Delegating to codex] task=Research source reliability | cmd=codex exec ...', 1100),
|
||||||
@@ -194,4 +253,4 @@ if (originalDocument) {
|
|||||||
delete (globalThis as any).document
|
delete (globalThis as any).document
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('MessageList.test.tsx: OK (scroll + narrative timeline helpers)')
|
console.log('MessageList.test.tsx: OK (scroll contract + stable timeline identity + narrative helpers)')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -85,11 +85,16 @@ assert.doesNotMatch(src, /localResponded|setLocalResponded/, 'panel must wait fo
|
|||||||
assert.match(src, /user_input_answers/, 'structured answers must be forwarded to the backend')
|
assert.match(src, /user_input_answers/, 'structured answers must be forwarded to the backend')
|
||||||
|
|
||||||
const messageListSrc = readFileSync(join(here, 'MessageList.tsx'), 'utf8')
|
const messageListSrc = readFileSync(join(here, 'MessageList.tsx'), 'utf8')
|
||||||
const progressIndex = messageListSrc.indexOf("items.push({ kind: 'progress-block' })")
|
const timelineIndex = messageListSrc.indexOf('{processed.map(row => (')
|
||||||
const pendingIndex = messageListSrc.indexOf("items.push({ kind: 'pending-section' })")
|
const progressIndex = messageListSrc.indexOf('{showProgressBlock && (')
|
||||||
const endIndex = messageListSrc.indexOf("items.push({ kind: 'end-anchor' })")
|
const endIndex = messageListSrc.indexOf('<div className="msg-end-anchor" />')
|
||||||
assert.ok(progressIndex !== -1 && pendingIndex !== -1 && endIndex !== -1)
|
assert.ok(timelineIndex !== -1 && progressIndex !== -1 && endIndex !== -1)
|
||||||
assert.ok(progressIndex < pendingIndex, 'pending checkpoint cards should render after the progress block')
|
assert.ok(timelineIndex < progressIndex && progressIndex < endIndex)
|
||||||
assert.ok(pendingIndex < endIndex, 'pending checkpoint cards should render before the end anchor')
|
assert.doesNotMatch(messageListSrc, /kind: 'pending-section'/, 'pending cards must not be moved into a second tail section')
|
||||||
|
assert.match(
|
||||||
|
messageListSrc,
|
||||||
|
/Checkpoint cards never leave the chronological transcript/,
|
||||||
|
'task-user-input cards must remain at their creation position',
|
||||||
|
)
|
||||||
|
|
||||||
console.log('TaskUserInputPanel.test.tsx: OK (markdown and choice checkpoint panel)')
|
console.log('TaskUserInputPanel.test.tsx: OK (markdown and choice checkpoint panel)')
|
||||||
|
|||||||
@@ -81,4 +81,15 @@ const fallbackMarkup = renderToStaticMarkup(
|
|||||||
assert.match(fallbackMarkup, /CTO/)
|
assert.match(fallbackMarkup, /CTO/)
|
||||||
assert.doesNotMatch(fallbackMarkup, /Engineer/)
|
assert.doesNotMatch(fallbackMarkup, /Engineer/)
|
||||||
|
|
||||||
|
const preparingMarkup = renderToStaticMarkup(
|
||||||
|
React.createElement(WorkItemProgressCard, {
|
||||||
|
workItemLog: [],
|
||||||
|
isCompanyRuntime: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.match(preparingMarkup, /Execution Progress/)
|
||||||
|
assert.match(preparingMarkup, /Preparing company roles/)
|
||||||
|
assert.match(preparingMarkup, /role="status"/)
|
||||||
|
|
||||||
console.log('WorkItemProgressCard.test.tsx: OK (executor rollup preferred with current-owner fallback)')
|
console.log('WorkItemProgressCard.test.tsx: OK (executor rollup preferred with current-owner fallback)')
|
||||||
|
|||||||
@@ -542,8 +542,8 @@ export function WorkItemProgressCard({
|
|||||||
return isCompanyRuntime ? [] : workItemLogWorkItems
|
return isCompanyRuntime ? [] : workItemLogWorkItems
|
||||||
}, [isCompanyRuntime, roleSummaries, workItemLogWorkItems])
|
}, [isCompanyRuntime, roleSummaries, workItemLogWorkItems])
|
||||||
|
|
||||||
if (isCompanyRuntime && roleSummaries.length === 0) return null
|
const isPreparingCompanyRuntime = isCompanyRuntime && roleSummaries.length === 0
|
||||||
if (workItemLog.length === 0 && workItems.length === 0 && roleSummaries.length === 0) return null
|
if (!isCompanyRuntime && workItemLog.length === 0 && workItems.length === 0 && roleSummaries.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="wi-progress-card">
|
<div className="wi-progress-card">
|
||||||
@@ -571,6 +571,12 @@ export function WorkItemProgressCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isPreparingCompanyRuntime && (
|
||||||
|
<div className="wi-progress-pipeline wi-progress-pipeline-empty" role="status">
|
||||||
|
Preparing company roles…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -794,6 +794,16 @@
|
|||||||
/* ══════════════════════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════════════════════
|
||||||
Message List
|
Message List
|
||||||
══════════════════════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
.msg-list-shell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.msg-list {
|
.msg-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -803,16 +813,82 @@
|
|||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
scrollbar-color: var(--border) transparent;
|
scrollbar-color: var(--border) transparent;
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
overflow-anchor: none;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.msg-list:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-list-following {
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-list-browsing {
|
||||||
|
overflow-anchor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-list-content {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-timeline-row {
|
||||||
|
overflow-anchor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-list-following .msg-timeline-row {
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
|
||||||
.msg-end-anchor {
|
.msg-end-anchor {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.msg-list-floating-actions {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 8;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: calc(100% - 24px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-list-float-btn {
|
||||||
|
pointer-events: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: min(320px, 70vw);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 72%, var(--accent) 28%);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 92%, var(--accent) 8%);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.28);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-list-float-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 84%, var(--accent) 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-list-pending-btn {
|
||||||
|
border-color: color-mix(in srgb, var(--yellow) 55%, var(--border));
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
.msg-history-hint {
|
.msg-history-hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -849,33 +925,6 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-pending-section {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 12px 16px 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
background: linear-gradient(180deg, transparent 0%, var(--bg-elevated) 28px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-pending-header {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin: 0 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-pending-stack {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-row-pending {
|
|
||||||
animation: msg-enter 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Welcome ──────────────────────────────────────────────────────────── */
|
/* ── Welcome ──────────────────────────────────────────────────────────── */
|
||||||
.msg-welcome {
|
.msg-welcome {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ const UNIX_MS_THRESHOLD = 1_000_000_000_000
|
|||||||
const WORK_ITEM_EVENT_RE = /^\[Company:([^\]]+)\]\s*(.*)$/
|
const WORK_ITEM_EVENT_RE = /^\[Company:([^\]]+)\]\s*(.*)$/
|
||||||
const COMPANY_RUNTIME_EVENT_RE = /^\[Company\]\s*(.*)$/
|
const COMPANY_RUNTIME_EVENT_RE = /^\[Company\]\s*(.*)$/
|
||||||
|
|
||||||
|
export function mergeSessionDetailHasMore(
|
||||||
|
previous: boolean | undefined,
|
||||||
|
incoming: boolean,
|
||||||
|
isHistoryPage: boolean,
|
||||||
|
): boolean {
|
||||||
|
// A latest-page refresh only describes that 200-row response. It must not
|
||||||
|
// reopen an older-history cursor that the user already exhausted.
|
||||||
|
if (!isHistoryPage && previous === false) return false
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAgentRuntimeStatus(rawStatus: unknown, rawAgentStatus: unknown): AgentAnimStatus | undefined {
|
function normalizeAgentRuntimeStatus(rawStatus: unknown, rawAgentStatus: unknown): AgentAnimStatus | undefined {
|
||||||
if (rawAgentStatus === 'idle' || rawAgentStatus === 'reflecting' || rawAgentStatus === 'tool_active') {
|
if (rawAgentStatus === 'idle' || rawAgentStatus === 'reflecting' || rawAgentStatus === 'tool_active') {
|
||||||
return rawAgentStatus
|
return rawAgentStatus
|
||||||
@@ -52,6 +63,16 @@ function mapBackendProgressLog(raw: any): ProgressEntry[] {
|
|||||||
: typeof entry.streamId === 'string'
|
: typeof entry.streamId === 'string'
|
||||||
? entry.streamId
|
? entry.streamId
|
||||||
: undefined,
|
: undefined,
|
||||||
|
toolCallId: typeof entry.tool_call_id === 'string'
|
||||||
|
? entry.tool_call_id
|
||||||
|
: typeof entry.toolCallId === 'string'
|
||||||
|
? entry.toolCallId
|
||||||
|
: undefined,
|
||||||
|
permissionGroupKey: typeof entry.permission_group_key === 'string'
|
||||||
|
? entry.permission_group_key
|
||||||
|
: typeof entry.permissionGroupKey === 'string'
|
||||||
|
? entry.permissionGroupKey
|
||||||
|
: undefined,
|
||||||
seq: typeof entry.seq === 'number' && Number.isFinite(entry.seq) ? entry.seq : undefined,
|
seq: typeof entry.seq === 'number' && Number.isFinite(entry.seq) ? entry.seq : undefined,
|
||||||
executionMode: typeof entry.execution_mode === 'string'
|
executionMode: typeof entry.execution_mode === 'string'
|
||||||
? entry.execution_mode
|
? entry.execution_mode
|
||||||
@@ -633,6 +654,8 @@ export function mapBackendSession(raw: any): Session {
|
|||||||
detailLoaded: raw.detail_loaded ?? raw.detailLoaded,
|
detailLoaded: raw.detail_loaded ?? raw.detailLoaded,
|
||||||
fullLoaded: raw.full_loaded ?? raw.fullLoaded,
|
fullLoaded: raw.full_loaded ?? raw.fullLoaded,
|
||||||
hasMore: raw.has_more ?? raw.hasMore,
|
hasMore: raw.has_more ?? raw.hasMore,
|
||||||
|
summaryHasMore: raw.summary_has_more ?? raw.summaryHasMore,
|
||||||
|
fullHasMore: raw.full_has_more ?? raw.fullHasMore,
|
||||||
detailLoading: raw.detail_loading ?? raw.detailLoading,
|
detailLoading: raw.detail_loading ?? raw.detailLoading,
|
||||||
detailError: raw.detail_error ?? raw.detailError,
|
detailError: raw.detail_error ?? raw.detailError,
|
||||||
viewGeneration: raw.view_generation ?? raw.viewGeneration,
|
viewGeneration: raw.view_generation ?? raw.viewGeneration,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ChatMessage } from '../types/chat'
|
||||||
|
|
||||||
|
export function stableMessageTimelineKey(message: ChatMessage): string {
|
||||||
|
const metadata = message.metadata ?? {}
|
||||||
|
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
|
||||||
|
if (checkpointId) return `checkpoint:${checkpointId}`
|
||||||
|
|
||||||
|
const uiMessageId = String(metadata.ui_message_id ?? '').trim()
|
||||||
|
const transcriptKind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
|
||||||
|
const metadataRole = String((metadata as Record<string, unknown>).role ?? '').trim().toLowerCase()
|
||||||
|
const isUserTurn = message.sender === 'user'
|
||||||
|
|| metadataRole === 'user'
|
||||||
|
|| transcriptKind === 'runtime_v2_user_turn'
|
||||||
|
|| transcriptKind === 'top_level_user_turn'
|
||||||
|
// The optimistic and persisted user surfaces share one client identity.
|
||||||
|
if (isUserTurn && uiMessageId) return `ui:${uiMessageId}`
|
||||||
|
|
||||||
|
// ChatStore attaches this only when one semantic result surface replaces
|
||||||
|
// another. It preserves the already-mounted row without entering protocol
|
||||||
|
// or persistence data.
|
||||||
|
const retainedTimelineId = String(metadata.ui_timeline_id ?? '').trim()
|
||||||
|
if (retainedTimelineId) return retainedTimelineId
|
||||||
|
|
||||||
|
const turnId = String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
|
||||||
|
if (!isUserTurn && turnId && transcriptKind === 'runtime_v2_assistant') {
|
||||||
|
return `turn:assistant:${turnId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `message:${message.id}`
|
||||||
|
}
|
||||||
@@ -7,30 +7,39 @@ function compact(value: unknown): string {
|
|||||||
.slice(0, 96)
|
.slice(0, 96)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function progressEntryKey(entry: ProgressEntry, fallbackIndex = 0): string {
|
export function progressEntryKey(entry: ProgressEntry): string {
|
||||||
const stableId = entry.itemId || entry.streamId || entry.toolCallId || entry.permissionGroupKey
|
const stableId = entry.itemId || entry.streamId || entry.toolCallId || entry.permissionGroupKey
|
||||||
if (stableId) {
|
if (stableId) {
|
||||||
return `${entry.type}:${compact(entry.turnId)}:${compact(stableId)}`
|
return `${entry.type}:${compact(entry.turnId)}:${compact(stableId)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === 'thinking') {
|
if (entry.type === 'thinking' || entry.type === 'assistant') {
|
||||||
return `thinking:${compact(entry.turnId) || compact(entry.executionMode) || compact(entry.summary) || 'stream'}:${fallbackIndex}`
|
return `${entry.type}:${compact(entry.turnId) || compact(entry.executionMode) || 'stream'}:${
|
||||||
|
Number.isFinite(entry.timestamp) ? entry.timestamp : ''
|
||||||
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === 'tool_call' && entry.turnId) {
|
if (entry.type === 'tool_call') {
|
||||||
return `tool:${compact(entry.turnId)}:${compact(entry.summary) || 'tool'}:${fallbackIndex}`
|
return `tool:${compact(entry.turnId) || 'turnless'}:${compact(entry.summary) || 'tool'}:${
|
||||||
|
Number.isFinite(entry.timestamp) ? entry.timestamp : ''
|
||||||
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.turnId && typeof entry.seq === 'number') {
|
if (entry.turnId && typeof entry.seq === 'number') {
|
||||||
return `${entry.type}:${compact(entry.turnId)}:seq:${entry.seq}`
|
return `${entry.type}:${compact(entry.turnId)}:seq:${entry.seq}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
const fallbackParts: Array<string | number> = [
|
||||||
entry.type,
|
entry.type,
|
||||||
compact(entry.turnId),
|
compact(entry.turnId),
|
||||||
|
]
|
||||||
|
if (typeof entry.seq === 'number' && Number.isFinite(entry.seq)) {
|
||||||
|
fallbackParts.push(`seq-${entry.seq}`)
|
||||||
|
}
|
||||||
|
fallbackParts.push(
|
||||||
Number.isFinite(entry.timestamp) ? entry.timestamp : '',
|
Number.isFinite(entry.timestamp) ? entry.timestamp : '',
|
||||||
compact(entry.summary),
|
compact(entry.summary),
|
||||||
compact(entry.detail),
|
compact(entry.detail),
|
||||||
fallbackIndex,
|
)
|
||||||
].join(':')
|
return fallbackParts.join(':')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { appendProgressEntry } from './progressLog'
|
import type { ProgressEntry } from '../types/kanban'
|
||||||
|
import { mapBackendSession } from './collabSync'
|
||||||
|
import { progressEntryKey } from './progressEntryKey'
|
||||||
|
import { appendProgressEntry, normalizeProgressLog } from './progressLog'
|
||||||
|
|
||||||
let log = appendProgressEntry([], {
|
let log = appendProgressEntry([], {
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
@@ -149,3 +152,144 @@ assert.equal(assistantLog.length, 2)
|
|||||||
assert.equal(assistantLog[0]?.detail, '文件已成功写入(278 行)。')
|
assert.equal(assistantLog[0]?.detail, '文件已成功写入(278 行)。')
|
||||||
assert.equal(assistantLog[0]?.summary, '文件已成功写入(278 行)。')
|
assert.equal(assistantLog[0]?.summary, '文件已成功写入(278 行)。')
|
||||||
assert.equal(assistantLog[1]?.detail, '采集完成报告')
|
assert.equal(assistantLog[1]?.detail, '采集完成报告')
|
||||||
|
|
||||||
|
// A live client receives these as individual deltas. A reconnect receives the
|
||||||
|
// same rows as a full snake_case snapshot. Both paths must produce identical
|
||||||
|
// row identities: otherwise React remounts progress rows and browser anchoring
|
||||||
|
// sees a false remove/insert pair during every full sync.
|
||||||
|
const snapshotSeconds = 1_700_000_000
|
||||||
|
const snapshotRows = [
|
||||||
|
{
|
||||||
|
timestamp: snapshotSeconds,
|
||||||
|
type: 'status_change',
|
||||||
|
summary: 'Running',
|
||||||
|
detail: 'phase=running',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: snapshotSeconds + 0.1,
|
||||||
|
type: 'status_change',
|
||||||
|
summary: 'Running',
|
||||||
|
detail: 'phase=running',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: snapshotSeconds + 1,
|
||||||
|
type: 'thinking',
|
||||||
|
summary: 'Thinking',
|
||||||
|
detail: 'Need ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: snapshotSeconds + 1.1,
|
||||||
|
type: 'thinking',
|
||||||
|
summary: 'Thinking',
|
||||||
|
detail: 'context',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: snapshotSeconds + 2,
|
||||||
|
type: 'assistant',
|
||||||
|
summary: 'Answer',
|
||||||
|
detail: 'Answer ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: snapshotSeconds + 2.1,
|
||||||
|
type: 'assistant',
|
||||||
|
summary: 'ready',
|
||||||
|
detail: 'ready',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: snapshotSeconds + 3,
|
||||||
|
type: 'tool_call',
|
||||||
|
summary: 'file_read',
|
||||||
|
detail: '{"path":',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: snapshotSeconds + 3.1,
|
||||||
|
type: 'tool_call',
|
||||||
|
summary: 'file_read',
|
||||||
|
detail: '"README.md"}',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const liveDeltas: ProgressEntry[] = snapshotRows.map(entry => ({
|
||||||
|
timestamp: entry.timestamp * 1000,
|
||||||
|
type: entry.type,
|
||||||
|
summary: entry.summary,
|
||||||
|
detail: entry.detail,
|
||||||
|
}))
|
||||||
|
const liveSnapshot = liveDeltas.reduce<ProgressEntry[]>(
|
||||||
|
(entries, entry) => appendProgressEntry(entries, entry),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const normalizedSnapshot = normalizeProgressLog(liveDeltas)
|
||||||
|
const mappedSnapshot = mapBackendSession({
|
||||||
|
task_id: 'progress-snapshot',
|
||||||
|
channel_id: 'session:progress-snapshot',
|
||||||
|
progress_log: snapshotRows,
|
||||||
|
}).progressLog
|
||||||
|
|
||||||
|
assert.equal(liveSnapshot.length, 4)
|
||||||
|
assert.deepEqual(
|
||||||
|
liveSnapshot.map(entry => entry.type),
|
||||||
|
['status_change', 'thinking', 'assistant', 'tool_call'],
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizedSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
|
||||||
|
liveSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
mappedSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
|
||||||
|
liveSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
liveSnapshot.map(entry => entry.timestamp),
|
||||||
|
[
|
||||||
|
snapshotSeconds * 1000,
|
||||||
|
(snapshotSeconds + 1) * 1000,
|
||||||
|
(snapshotSeconds + 2) * 1000,
|
||||||
|
(snapshotSeconds + 3) * 1000,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
liveSnapshot.map(progressEntryKey),
|
||||||
|
[
|
||||||
|
'status_change::1700000000000:Running:phase=running',
|
||||||
|
'thinking:stream:1700000001000',
|
||||||
|
'assistant:stream:1700000002000',
|
||||||
|
'tool:turnless:file_read:1700000003000',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert.ok(liveSnapshot.every(entry => (
|
||||||
|
!entry.itemId
|
||||||
|
&& !entry.streamId
|
||||||
|
&& !entry.toolCallId
|
||||||
|
&& !entry.permissionGroupKey
|
||||||
|
)))
|
||||||
|
|
||||||
|
// Persisted snake_case identifiers must survive the full-sync bridge. These
|
||||||
|
// identifiers take precedence over mutable summaries and timestamps when the
|
||||||
|
// UI derives a row key.
|
||||||
|
const mappedStableIds = mapBackendSession({
|
||||||
|
task_id: 'progress-stable-ids',
|
||||||
|
channel_id: 'session:progress-stable-ids',
|
||||||
|
progress_log: [
|
||||||
|
{
|
||||||
|
timestamp: snapshotSeconds + 10,
|
||||||
|
type: 'tool_call',
|
||||||
|
summary: 'shell_exec',
|
||||||
|
tool_call_id: 'call-42',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: snapshotSeconds + 11,
|
||||||
|
type: 'autonomy',
|
||||||
|
summary: 'shell_exec: ask',
|
||||||
|
permission_group_key: 'tool:shell_exec/python:domain:example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).progressLog
|
||||||
|
|
||||||
|
assert.equal(mappedStableIds[0]?.toolCallId, 'call-42')
|
||||||
|
assert.equal(mappedStableIds[1]?.permissionGroupKey, 'tool:shell_exec/python:domain:example.com')
|
||||||
|
assert.equal(progressEntryKey(mappedStableIds[0]!), 'tool_call::call-42')
|
||||||
|
assert.equal(
|
||||||
|
progressEntryKey(mappedStableIds[1]!),
|
||||||
|
'autonomy::tool:shell_exec/python:domain:example.com',
|
||||||
|
)
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry
|
|||||||
// the same way as thinking streams.
|
// the same way as thinking streams.
|
||||||
const detail = mergeText(left.detail ?? '', right.detail ?? '', 'thinking')
|
const detail = mergeText(left.detail ?? '', right.detail ?? '', 'thinking')
|
||||||
return {
|
return {
|
||||||
timestamp: right.timestamp,
|
// A stream occupies the timeline slot where it began. Deltas update the
|
||||||
|
// row in place instead of repeatedly re-sorting it around tool events.
|
||||||
|
timestamp: left.timestamp,
|
||||||
type: left.type,
|
type: left.type,
|
||||||
summary: summarizeThinking(detail, right.summary || left.summary),
|
summary: summarizeThinking(detail, right.summary || left.summary),
|
||||||
detail: detail || undefined,
|
detail: detail || undefined,
|
||||||
@@ -107,7 +109,7 @@ function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry
|
|||||||
if (left.type === 'tool_call') {
|
if (left.type === 'tool_call') {
|
||||||
const mergedDetail = mergeText(left.detail ?? '', right.detail ?? '', 'tool_call')
|
const mergedDetail = mergeText(left.detail ?? '', right.detail ?? '', 'tool_call')
|
||||||
return {
|
return {
|
||||||
timestamp: right.timestamp,
|
timestamp: left.timestamp,
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
summary: right.summary || left.summary,
|
summary: right.summary || left.summary,
|
||||||
detail: mergedDetail || undefined,
|
detail: mergedDetail || undefined,
|
||||||
@@ -152,7 +154,7 @@ export function appendProgressEntry(
|
|||||||
if (isDuplicateProgress(last, normalized)) {
|
if (isDuplicateProgress(last, normalized)) {
|
||||||
return clampEntries([
|
return clampEntries([
|
||||||
...log.slice(0, actualIndex),
|
...log.slice(0, actualIndex),
|
||||||
{ ...last, timestamp: normalized.timestamp },
|
last,
|
||||||
...log.slice(actualIndex + 1),
|
...log.slice(actualIndex + 1),
|
||||||
], maxEntries)
|
], maxEntries)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import type { ChatMessage } from '../types/chat'
|
import type { ChatMessage } from '../types/chat'
|
||||||
import type { Session } from '../types/kanban'
|
import type { Session } from '../types/kanban'
|
||||||
import { mapBackendSession } from './collabSync'
|
import { mapBackendSession, mergeSessionDetailHasMore } from './collabSync'
|
||||||
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
|
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
|
||||||
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation } from './workItemSessions'
|
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation, selectCompanySummaryMessages } from './workItemSessions'
|
||||||
|
|
||||||
function makeSession(overrides: Partial<Session> & Pick<Session, 'taskId' | 'channelId' | 'title' | 'status' | 'columnId' | 'assigneeIds' | 'priority' | 'tags' | 'progressLog' | 'createdAt' | 'updatedAt' | 'messageCount'>): Session {
|
function makeSession(overrides: Partial<Session> & Pick<Session, 'taskId' | 'channelId' | 'title' | 'status' | 'columnId' | 'assigneeIds' | 'priority' | 'tags' | 'progressLog' | 'createdAt' | 'updatedAt' | 'messageCount'>): Session {
|
||||||
return {
|
return {
|
||||||
@@ -189,6 +189,161 @@ const mergedDeliveryMessages = mergeConversationMessages([
|
|||||||
|
|
||||||
assert.equal(mergedDeliveryMessages.length, 1)
|
assert.equal(mergedDeliveryMessages.length, 1)
|
||||||
assert.equal(mergedDeliveryMessages[0]?.id, 'child-direct')
|
assert.equal(mergedDeliveryMessages[0]?.id, 'child-direct')
|
||||||
|
|
||||||
|
const earlierResult = {
|
||||||
|
...resultMessage(
|
||||||
|
'earlier-parent-result',
|
||||||
|
'session:company-root',
|
||||||
|
finalBody,
|
||||||
|
{ source: 'engine', transcript_kind: 'child_result' },
|
||||||
|
),
|
||||||
|
timestamp: 900,
|
||||||
|
}
|
||||||
|
const authoritativeResult = {
|
||||||
|
...resultMessage(
|
||||||
|
'later-authoritative-result',
|
||||||
|
'session:company-child',
|
||||||
|
finalBody,
|
||||||
|
{ source: 'engine', transcript_kind: 'child_task_result' },
|
||||||
|
),
|
||||||
|
timestamp: 1_100,
|
||||||
|
}
|
||||||
|
for (const groups of [
|
||||||
|
[[earlierResult], [authoritativeResult]],
|
||||||
|
[[authoritativeResult], [earlierResult]],
|
||||||
|
]) {
|
||||||
|
const result = mergeConversationMessages(groups)
|
||||||
|
assert.equal(result.length, 1)
|
||||||
|
assert.equal(result[0]?.id, 'later-authoritative-result')
|
||||||
|
assert.equal(result[0]?.timestamp, 900, 'result chronology must not depend on channel traversal order')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCheckpoint = {
|
||||||
|
...resultMessage(
|
||||||
|
'pending-checkpoint-surface',
|
||||||
|
'session:company-root',
|
||||||
|
'Approval required.',
|
||||||
|
{ checkpoint_id: 'shared-checkpoint', checkpoint_type: 'human_escalation', status: 'pending' },
|
||||||
|
'system',
|
||||||
|
),
|
||||||
|
timestamp: 1_200,
|
||||||
|
}
|
||||||
|
const resolvedCheckpoint = {
|
||||||
|
...resultMessage(
|
||||||
|
'resolved-checkpoint-surface',
|
||||||
|
'session:company-child',
|
||||||
|
'Approval required.',
|
||||||
|
{ checkpoint_id: 'shared-checkpoint', checkpoint_type: 'human_escalation', status: 'resolved' },
|
||||||
|
'system',
|
||||||
|
),
|
||||||
|
timestamp: 1_300,
|
||||||
|
}
|
||||||
|
const mergedCheckpoint = mergeConversationMessages([[pendingCheckpoint], [resolvedCheckpoint]])
|
||||||
|
assert.equal(mergedCheckpoint.length, 1)
|
||||||
|
assert.equal(mergedCheckpoint[0]?.id, 'pending-checkpoint-surface')
|
||||||
|
assert.equal(mergedCheckpoint[0]?.timestamp, 1_200)
|
||||||
|
assert.equal(mergedCheckpoint[0]?.metadata?.status, 'resolved')
|
||||||
|
|
||||||
|
const companySummaryMessages = selectCompanySummaryMessages([
|
||||||
|
resultMessage(
|
||||||
|
'parent-user',
|
||||||
|
'session:company-root',
|
||||||
|
'Please investigate the issue.',
|
||||||
|
{ source: 'ui' },
|
||||||
|
'user',
|
||||||
|
),
|
||||||
|
resultMessage(
|
||||||
|
'child-transient',
|
||||||
|
'session:company-child',
|
||||||
|
'A child draft or internal assistant turn must stay out of the parent transcript.',
|
||||||
|
{ source: 'runtime_event', transcript_kind: 'runtime_v2_assistant' },
|
||||||
|
'assistant',
|
||||||
|
),
|
||||||
|
resultMessage(
|
||||||
|
'canonical-role-result',
|
||||||
|
'session:company-child',
|
||||||
|
'The canonical role delivery remains visible in the company summary.',
|
||||||
|
{ source: 'engine', transcript_kind: 'company_role_result' },
|
||||||
|
'assistant',
|
||||||
|
),
|
||||||
|
resultMessage(
|
||||||
|
'summary-company-final',
|
||||||
|
'session:company-child',
|
||||||
|
'A summary-visible company final remains when no canonical role mirror exists.',
|
||||||
|
{ source: 'engine', kind: 'runtime_v2_company_assistant', detail_visibility: 'summary' },
|
||||||
|
'assistant',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...resultMessage(
|
||||||
|
'parent-full-only-terminal',
|
||||||
|
'session:company-root',
|
||||||
|
'A full-only parent surface must neither render nor suppress the committed child summary.',
|
||||||
|
{
|
||||||
|
source: 'engine',
|
||||||
|
transcript_kind: 'runtime_v2_assistant',
|
||||||
|
detail_visibility: 'full',
|
||||||
|
canonical_turn_id: 'shared-terminal-turn',
|
||||||
|
},
|
||||||
|
'assistant',
|
||||||
|
),
|
||||||
|
timestamp: 1400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...resultMessage(
|
||||||
|
'summary-terminal-a',
|
||||||
|
'session:company-child',
|
||||||
|
'First authoritative terminal for one shared canonical turn.',
|
||||||
|
{
|
||||||
|
source: 'engine',
|
||||||
|
transcript_kind: 'runtime_v2_assistant',
|
||||||
|
detail_visibility: 'summary',
|
||||||
|
canonical_turn_id: 'shared-terminal-turn',
|
||||||
|
},
|
||||||
|
'assistant',
|
||||||
|
),
|
||||||
|
timestamp: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...resultMessage(
|
||||||
|
'summary-terminal-b',
|
||||||
|
'session:company-sibling',
|
||||||
|
'A second terminal surface with different content must not duplicate the turn.',
|
||||||
|
{
|
||||||
|
source: 'engine',
|
||||||
|
transcript_kind: 'runtime_v2_assistant',
|
||||||
|
detail_visibility: 'summary',
|
||||||
|
canonical_turn_id: 'shared-terminal-turn',
|
||||||
|
},
|
||||||
|
'assistant',
|
||||||
|
),
|
||||||
|
timestamp: 1300,
|
||||||
|
},
|
||||||
|
resultMessage(
|
||||||
|
'child-checkpoint',
|
||||||
|
'session:company-child',
|
||||||
|
'Approval is required.',
|
||||||
|
{ checkpoint_id: 'checkpoint-child', checkpoint_type: 'company_work_item_gate' },
|
||||||
|
'assistant',
|
||||||
|
),
|
||||||
|
resultMessage(
|
||||||
|
'child-checkpoint-response',
|
||||||
|
'session:company-child',
|
||||||
|
'Approved.',
|
||||||
|
{ response_to_checkpoint_id: 'checkpoint-child', ui_message_id: 'ui-checkpoint-response' },
|
||||||
|
'user',
|
||||||
|
),
|
||||||
|
], 'session:company-root')
|
||||||
|
assert.deepEqual(
|
||||||
|
companySummaryMessages.map(message => message.id).sort(),
|
||||||
|
[
|
||||||
|
'parent-user',
|
||||||
|
'canonical-role-result',
|
||||||
|
'summary-company-final',
|
||||||
|
'summary-terminal-b',
|
||||||
|
'child-checkpoint',
|
||||||
|
'child-checkpoint-response',
|
||||||
|
].sort(),
|
||||||
|
)
|
||||||
assert.equal(companyHeaderView?.status, 'running')
|
assert.equal(companyHeaderView?.status, 'running')
|
||||||
assert.equal(companyHeaderView?.contextTokens, 0)
|
assert.equal(companyHeaderView?.contextTokens, 0)
|
||||||
assert.equal(companyHeaderView?.contextWindow, 128000)
|
assert.equal(companyHeaderView?.contextWindow, 128000)
|
||||||
@@ -369,4 +524,15 @@ assert.equal(mappedCompanySession.execMode, 'company')
|
|||||||
assert.equal(mappedCompanySession.companyProfile, 'corporate')
|
assert.equal(mappedCompanySession.companyProfile, 'corporate')
|
||||||
assert.equal(mappedCompanySession.orgId, undefined)
|
assert.equal(mappedCompanySession.orgId, undefined)
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
mergeSessionDetailHasMore(false, true, false),
|
||||||
|
false,
|
||||||
|
'a cursorless live refresh must not reopen an exhausted history boundary',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
mergeSessionDetailHasMore(false, true, true),
|
||||||
|
true,
|
||||||
|
'a real cursor page may advance the scoped history boundary',
|
||||||
|
)
|
||||||
|
|
||||||
console.log('workItemSessions origin-task linking checks passed')
|
console.log('workItemSessions origin-task linking checks passed')
|
||||||
|
|||||||
@@ -2,11 +2,20 @@ import type { ChatMessage } from '../types/chat'
|
|||||||
import type { ProgressEntry, Session } from '../types/kanban'
|
import type { ProgressEntry, Session } from '../types/kanban'
|
||||||
import { getContextUsageMetrics } from './contextUsage'
|
import { getContextUsageMetrics } from './contextUsage'
|
||||||
import { isSessionWorking } from './sessionRuntime'
|
import { isSessionWorking } from './sessionRuntime'
|
||||||
|
import { stableMessageTimelineKey } from './messageTimelineIdentity'
|
||||||
|
|
||||||
const CONTEXT_TOKENS_RE = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+tokens/i
|
const CONTEXT_TOKENS_RE = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+tokens/i
|
||||||
const USED_PCT_RE = /(\d{1,3})%\s*used/i
|
const USED_PCT_RE = /(\d{1,3})%\s*used/i
|
||||||
const REMAINING_PCT_RE = /(\d{1,3})%\s*remaining/i
|
const REMAINING_PCT_RE = /(\d{1,3})%\s*remaining/i
|
||||||
|
|
||||||
|
export function isMessageVisibleAtDetailLevel(
|
||||||
|
message: ChatMessage,
|
||||||
|
detailLevel: 'summary' | 'full',
|
||||||
|
): boolean {
|
||||||
|
if (detailLevel === 'full') return true
|
||||||
|
return String(message.metadata?.detail_visibility ?? 'summary').trim() !== 'full'
|
||||||
|
}
|
||||||
|
|
||||||
function compactWhitespace(value: string): string {
|
function compactWhitespace(value: string): string {
|
||||||
return value.replace(/\s+/g, ' ').trim()
|
return value.replace(/\s+/g, ' ').trim()
|
||||||
}
|
}
|
||||||
@@ -58,7 +67,7 @@ function resultSurfacePriority(message: ChatMessage): number {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultSurfaceDedupeKey(message: ChatMessage): string {
|
export function resultSurfaceDedupeKey(message: ChatMessage): string {
|
||||||
if (resultSurfacePriority(message) <= 0) return ''
|
if (resultSurfacePriority(message) <= 0) return ''
|
||||||
const content = compactWhitespace(stripNarrativeTitlePrefix(message.content)).slice(0, 2000)
|
const content = compactWhitespace(stripNarrativeTitlePrefix(message.content)).slice(0, 2000)
|
||||||
return content ? `result:${content}` : ''
|
return content ? `result:${content}` : ''
|
||||||
@@ -454,10 +463,29 @@ export function getConversationHeaderSession(
|
|||||||
export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatMessage[] {
|
export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatMessage[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const resultKeyIndex = new Map<string, number>()
|
const resultKeyIndex = new Map<string, number>()
|
||||||
|
const checkpointIndex = new Map<string, number>()
|
||||||
const merged: ChatMessage[] = []
|
const merged: ChatMessage[] = []
|
||||||
for (const group of messageGroups) {
|
for (const group of messageGroups) {
|
||||||
for (const message of group) {
|
for (const message of group) {
|
||||||
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
||||||
|
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
|
||||||
|
if (checkpointId) {
|
||||||
|
const existingIndex = checkpointIndex.get(checkpointId)
|
||||||
|
if (existingIndex !== undefined) {
|
||||||
|
const existing = merged[existingIndex]
|
||||||
|
const latest = message.timestamp >= existing.timestamp ? message : existing
|
||||||
|
const earliest = message.timestamp < existing.timestamp ? message : existing
|
||||||
|
merged[existingIndex] = {
|
||||||
|
...existing,
|
||||||
|
...latest,
|
||||||
|
id: existing.id,
|
||||||
|
channelId: existing.channelId,
|
||||||
|
timestamp: earliest.timestamp,
|
||||||
|
metadata: { ...(existing.metadata ?? {}), ...(latest.metadata ?? {}) },
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
const uiMessageId = typeof metadata.ui_message_id === 'string'
|
const uiMessageId = typeof metadata.ui_message_id === 'string'
|
||||||
? metadata.ui_message_id.trim()
|
? metadata.ui_message_id.trim()
|
||||||
: ''
|
: ''
|
||||||
@@ -465,16 +493,32 @@ export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatM
|
|||||||
if (resultKey) {
|
if (resultKey) {
|
||||||
const existingIndex = resultKeyIndex.get(resultKey)
|
const existingIndex = resultKeyIndex.get(resultKey)
|
||||||
if (existingIndex !== undefined) {
|
if (existingIndex !== undefined) {
|
||||||
if (resultSurfacePriority(message) > resultSurfacePriority(merged[existingIndex])) {
|
const existing = merged[existingIndex]
|
||||||
merged[existingIndex] = message
|
const candidateWins = resultSurfacePriority(message) > resultSurfacePriority(existing)
|
||||||
|
const preferred = candidateWins ? message : existing
|
||||||
|
const secondary = candidateWins ? existing : message
|
||||||
|
merged[existingIndex] = {
|
||||||
|
...secondary,
|
||||||
|
...preferred,
|
||||||
|
// A result surface keeps the chronology of the first underlying
|
||||||
|
// delivery, independent of which related channel happened to be
|
||||||
|
// traversed first for this render.
|
||||||
|
timestamp: Math.min(existing.timestamp, message.timestamp),
|
||||||
|
metadata: {
|
||||||
|
...(secondary.metadata ?? {}),
|
||||||
|
...(preferred.metadata ?? {}),
|
||||||
|
ui_timeline_id: stableMessageTimelineKey(existing),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resultKeyIndex.set(resultKey, merged.length)
|
resultKeyIndex.set(resultKey, merged.length)
|
||||||
}
|
}
|
||||||
const dedupeKey = resultKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
|
const checkpointKey = checkpointId ? `checkpoint:${checkpointId}` : ''
|
||||||
|
const dedupeKey = resultKey || checkpointKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
|
||||||
if (seen.has(dedupeKey)) continue
|
if (seen.has(dedupeKey)) continue
|
||||||
seen.add(dedupeKey)
|
seen.add(dedupeKey)
|
||||||
|
if (checkpointId) checkpointIndex.set(checkpointId, merged.length)
|
||||||
merged.push(message)
|
merged.push(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -485,6 +529,98 @@ export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatM
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the durable transcript shown by a company/org parent session.
|
||||||
|
*
|
||||||
|
* A parent conversation may observe every related runtime channel so that
|
||||||
|
* canonical role deliveries can be surfaced in one place. Those channels
|
||||||
|
* also contain transient child turns, however, and rendering all of them in
|
||||||
|
* the parent transcript makes the visible timeline change whenever the
|
||||||
|
* runtime projection selects a different child. Keep the parent's committed
|
||||||
|
* messages and only admit the canonical cross-channel result surfaces.
|
||||||
|
*/
|
||||||
|
export function selectCompanySummaryMessages(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
parentChannelId: string,
|
||||||
|
): ChatMessage[] {
|
||||||
|
const terminalAssistantTurn = (message: ChatMessage): string => {
|
||||||
|
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
||||||
|
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
|
||||||
|
if (kind !== 'runtime_v2_assistant' && kind !== 'runtime_v2_company_assistant') return ''
|
||||||
|
return String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
|
||||||
|
}
|
||||||
|
const parentTerminalTurns = new Set(
|
||||||
|
messages
|
||||||
|
.filter(message => (
|
||||||
|
message.channelId === parentChannelId
|
||||||
|
&& isMessageVisibleAtDetailLevel(message, 'summary')
|
||||||
|
))
|
||||||
|
.map(terminalAssistantTurn)
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
const childTerminalByTurn = new Map<string, ChatMessage>()
|
||||||
|
const durableMessages: ChatMessage[] = []
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.channelId === parentChannelId) {
|
||||||
|
if (isMessageVisibleAtDetailLevel(message, 'summary')) {
|
||||||
|
durableMessages.push(message)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
||||||
|
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
|
||||||
|
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
|
||||||
|
const checkpointType = String(metadata.checkpoint_type ?? '').trim()
|
||||||
|
const checkpointResponseId = String(metadata.response_to_checkpoint_id ?? '').trim()
|
||||||
|
const escalationResponseId = String(metadata.response_to_escalation_id ?? '').trim()
|
||||||
|
if ((checkpointId && checkpointType) || checkpointResponseId || escalationResponseId) {
|
||||||
|
durableMessages.push(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ([
|
||||||
|
'company_role_result',
|
||||||
|
'company_role_result_retry',
|
||||||
|
'child_task_result',
|
||||||
|
'child_task_result_retry',
|
||||||
|
'child_result',
|
||||||
|
'top_level_reply',
|
||||||
|
].includes(kind)) {
|
||||||
|
durableMessages.push(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Snapshot builder deliberately marks the final runtime surface as
|
||||||
|
// summary-visible. Preserve that durable contract instead of reusing the
|
||||||
|
// result-dedupe priority table as a visibility threshold.
|
||||||
|
const isSummaryTerminal = String(metadata.detail_visibility ?? '').trim() === 'summary'
|
||||||
|
&& (kind === 'runtime_v2_assistant' || kind === 'runtime_v2_company_assistant')
|
||||||
|
if (!isSummaryTerminal) continue
|
||||||
|
const turnId = terminalAssistantTurn(message)
|
||||||
|
if (!turnId) {
|
||||||
|
durableMessages.push(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (parentTerminalTurns.has(turnId)) continue
|
||||||
|
const existing = childTerminalByTurn.get(turnId)
|
||||||
|
if (!existing) {
|
||||||
|
childTerminalByTurn.set(turnId, message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const existingPriority = resultSurfacePriority(existing)
|
||||||
|
const candidatePriority = resultSurfacePriority(message)
|
||||||
|
if (candidatePriority > existingPriority) {
|
||||||
|
childTerminalByTurn.set(turnId, message)
|
||||||
|
} else if (
|
||||||
|
candidatePriority === existingPriority
|
||||||
|
&& (message.timestamp > existing.timestamp
|
||||||
|
|| (message.timestamp === existing.timestamp && message.id.localeCompare(existing.id) > 0))
|
||||||
|
) {
|
||||||
|
childTerminalByTurn.set(turnId, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
durableMessages.push(...childTerminalByTurn.values())
|
||||||
|
return mergeConversationMessages([durableMessages])
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeConversationProgressLog(timelineSessions: Session[]): ProgressEntry[] {
|
export function mergeConversationProgressLog(timelineSessions: Session[]): ProgressEntry[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const merged: ProgressEntry[] = []
|
const merged: ProgressEntry[] = []
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { VisualSocketClient } from './wsClient'
|
||||||
|
|
||||||
|
type TestSocketClient = {
|
||||||
|
handleMessage: (raw: unknown) => void
|
||||||
|
pendingQueue: string[]
|
||||||
|
pendingSessionDetailRequests: Array<{
|
||||||
|
queued: boolean
|
||||||
|
settled: boolean
|
||||||
|
timeout: ReturnType<typeof setTimeout> | null
|
||||||
|
}>
|
||||||
|
timeoutSessionDetailRequest: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliverAck = (
|
||||||
|
client: VisualSocketClient,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
;(client as unknown as TestSocketClient).handleMessage(JSON.stringify({
|
||||||
|
type: 'ack',
|
||||||
|
payload,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushPromises = async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new VisualSocketClient('ws://unit.test', {})
|
||||||
|
|
||||||
|
// A summary and a full request for the same task are distinct correlations.
|
||||||
|
// Neither Promise may settle merely because the request was queued locally.
|
||||||
|
const summaryPromise = client.sessionDetail('project-a', 'task-1', { detailLevel: 'summary' })
|
||||||
|
const fullPromise = client.sessionDetail('project-a', 'task-1', { detailLevel: 'full' })
|
||||||
|
const summarySettlements: Array<Record<string, unknown>> = []
|
||||||
|
const fullSettlements: Array<Record<string, unknown>> = []
|
||||||
|
void summaryPromise.then(payload => { summarySettlements.push(payload) })
|
||||||
|
void fullPromise.then(payload => { fullSettlements.push(payload) })
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
assert.equal(summarySettlements.length, 0, 'summary Promise must remain pending before its ACK')
|
||||||
|
assert.equal(fullSettlements.length, 0, 'full Promise must remain pending before its ACK')
|
||||||
|
|
||||||
|
deliverAck(client, {
|
||||||
|
ok: true,
|
||||||
|
action: 'session_detail',
|
||||||
|
project_id: 'project-a',
|
||||||
|
task_id: 'task-1',
|
||||||
|
detail_level: 'full',
|
||||||
|
marker: 'full-ack',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
assert.equal(fullSettlements[0]?.marker, 'full-ack', 'the matching full ACK must settle the full request')
|
||||||
|
assert.equal(summarySettlements.length, 0, 'a full ACK must not settle the same task\'s summary request')
|
||||||
|
|
||||||
|
deliverAck(client, {
|
||||||
|
ok: true,
|
||||||
|
action: 'session_detail',
|
||||||
|
project_id: 'project-a',
|
||||||
|
task_id: 'task-1',
|
||||||
|
detail_level: 'summary',
|
||||||
|
marker: 'summary-ack',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
assert.equal(summarySettlements[0]?.marker, 'summary-ack', 'the matching summary ACK must settle the summary request')
|
||||||
|
assert.equal(summarySettlements[0]?.client_history_page, false, 'a request without a cursor is not a history page')
|
||||||
|
|
||||||
|
const historyPagePromise = client.sessionDetail('project-a', 'task-history', {
|
||||||
|
detailLevel: 'summary',
|
||||||
|
beforeCreatedAt: 123,
|
||||||
|
})
|
||||||
|
deliverAck(client, {
|
||||||
|
ok: true,
|
||||||
|
action: 'session_detail',
|
||||||
|
project_id: 'project-a',
|
||||||
|
task_id: 'task-history',
|
||||||
|
detail_level: 'summary',
|
||||||
|
})
|
||||||
|
assert.equal((await historyPagePromise).client_history_page, true, 'a cursor request must be identified as a history page')
|
||||||
|
|
||||||
|
// Requests with the same correlation fields are settled in request order.
|
||||||
|
const firstPromise = client.sessionDetail('project-a', 'task-2', { detailLevel: 'summary' })
|
||||||
|
const secondPromise = client.sessionDetail('project-a', 'task-2', { detailLevel: 'summary' })
|
||||||
|
const firstSettlements: Array<Record<string, unknown>> = []
|
||||||
|
const secondSettlements: Array<Record<string, unknown>> = []
|
||||||
|
void firstPromise.then(payload => { firstSettlements.push(payload) })
|
||||||
|
void secondPromise.then(payload => { secondSettlements.push(payload) })
|
||||||
|
|
||||||
|
deliverAck(client, {
|
||||||
|
ok: true,
|
||||||
|
action: 'session_detail',
|
||||||
|
project_id: 'project-a',
|
||||||
|
task_id: 'task-2',
|
||||||
|
detail_level: 'summary',
|
||||||
|
marker: 'first-ack',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
assert.equal(firstSettlements[0]?.marker, 'first-ack', 'the first matching ACK must settle the oldest request')
|
||||||
|
assert.equal(secondSettlements.length, 0, 'the second same-scope request must remain pending after one ACK')
|
||||||
|
|
||||||
|
deliverAck(client, {
|
||||||
|
ok: true,
|
||||||
|
action: 'session_detail',
|
||||||
|
project_id: 'project-a',
|
||||||
|
task_id: 'task-2',
|
||||||
|
detail_level: 'summary',
|
||||||
|
marker: 'second-ack',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
assert.equal(secondSettlements[0]?.marker, 'second-ack', 'the next matching ACK must settle the next FIFO request')
|
||||||
|
|
||||||
|
// The backend's early store-not-ready path cannot echo request fields. The
|
||||||
|
// client must still correlate and normalize that error instead of leaving the
|
||||||
|
// history single-flight Promise pending forever.
|
||||||
|
const storeNotReadyPromise = client.sessionDetail('project-a', 'task-store', {
|
||||||
|
detailLevel: 'summary',
|
||||||
|
viewGeneration: 9,
|
||||||
|
})
|
||||||
|
const storeNotReadySettlements: Array<Record<string, unknown>> = []
|
||||||
|
void storeNotReadyPromise.then(payload => { storeNotReadySettlements.push(payload) })
|
||||||
|
deliverAck(client, {
|
||||||
|
ok: false,
|
||||||
|
action: 'create_session',
|
||||||
|
error: 'store_not_ready',
|
||||||
|
project_id: 'project-a',
|
||||||
|
view_generation: 9,
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
assert.equal(
|
||||||
|
storeNotReadySettlements.length,
|
||||||
|
0,
|
||||||
|
'store_not_ready for another explicit action must not settle a session_detail request',
|
||||||
|
)
|
||||||
|
deliverAck(client, {
|
||||||
|
ok: false,
|
||||||
|
error: 'store_not_ready',
|
||||||
|
project_id: 'project-a',
|
||||||
|
view_generation: 9,
|
||||||
|
})
|
||||||
|
const storeNotReady = await storeNotReadyPromise
|
||||||
|
assert.equal(storeNotReady.action, 'session_detail')
|
||||||
|
assert.equal(storeNotReady.task_id, 'task-store')
|
||||||
|
assert.equal(storeNotReady.detail_level, 'summary')
|
||||||
|
|
||||||
|
// A sent request timeout releases its caller, but leaves a settled FIFO
|
||||||
|
// tombstone so a late ACK cannot be mis-correlated to a newer request.
|
||||||
|
const timeoutClient = new VisualSocketClient('ws://unit.test', {})
|
||||||
|
const sentPromise = timeoutClient.sessionDetail('project-a', 'task-timeout', {
|
||||||
|
detailLevel: 'summary',
|
||||||
|
})
|
||||||
|
const sentSettlements: Array<Record<string, unknown>> = []
|
||||||
|
void sentPromise.then(payload => { sentSettlements.push(payload) })
|
||||||
|
const timeoutInternals = timeoutClient as unknown as TestSocketClient
|
||||||
|
timeoutInternals.pendingSessionDetailRequests[0].queued = false
|
||||||
|
timeoutInternals.pendingQueue = []
|
||||||
|
timeoutInternals.timeoutSessionDetailRequest(0)
|
||||||
|
await flushPromises()
|
||||||
|
assert.equal(sentSettlements[0]?.error, 'request_timeout', 'sent timeout must release the loading caller')
|
||||||
|
assert.equal(timeoutInternals.pendingSessionDetailRequests.length, 1, 'sent timeout must retain a FIFO tombstone')
|
||||||
|
assert.equal(timeoutInternals.pendingSessionDetailRequests[0].settled, true, 'the retained request must be a settled tombstone')
|
||||||
|
assert.equal(timeoutInternals.pendingSessionDetailRequests[0].timeout, null, 'sent request timer must be released')
|
||||||
|
|
||||||
|
const afterTimeoutPromise = timeoutClient.sessionDetail('project-a', 'task-timeout', {
|
||||||
|
detailLevel: 'summary',
|
||||||
|
})
|
||||||
|
const afterTimeoutSettlements: Array<Record<string, unknown>> = []
|
||||||
|
void afterTimeoutPromise.then(payload => { afterTimeoutSettlements.push(payload) })
|
||||||
|
deliverAck(timeoutClient, {
|
||||||
|
ok: true,
|
||||||
|
action: 'session_detail',
|
||||||
|
project_id: 'project-a',
|
||||||
|
task_id: 'task-timeout',
|
||||||
|
detail_level: 'summary',
|
||||||
|
marker: 'old-request-ack',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
assert.equal(sentSettlements.length, 1, 'a late ACK must be consumed by the older tombstone')
|
||||||
|
assert.equal(sentSettlements[0]?.error, 'request_timeout', 'a late ACK cannot resettle the timed-out Promise')
|
||||||
|
assert.equal(afterTimeoutSettlements.length, 0, 'the first ACK must not settle the newer same-scope request')
|
||||||
|
assert.equal(timeoutInternals.pendingSessionDetailRequests.length, 1, 'the newer request must remain pending')
|
||||||
|
|
||||||
|
deliverAck(timeoutClient, {
|
||||||
|
ok: true,
|
||||||
|
action: 'session_detail',
|
||||||
|
project_id: 'project-a',
|
||||||
|
task_id: 'task-timeout',
|
||||||
|
detail_level: 'summary',
|
||||||
|
marker: 'current-request-ack',
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
assert.equal(afterTimeoutSettlements[0]?.marker, 'current-request-ack', 'the next ACK must settle the newer request')
|
||||||
|
|
||||||
|
const queuedTimeoutClient = new VisualSocketClient('ws://unit.test', {})
|
||||||
|
const queuedTimeout = queuedTimeoutClient.sessionDetail('project-a', 'task-queued-timeout', {
|
||||||
|
beforeMessageId: 'older-message',
|
||||||
|
})
|
||||||
|
const queuedTimeoutInternals = queuedTimeoutClient as unknown as TestSocketClient
|
||||||
|
queuedTimeoutInternals.timeoutSessionDetailRequest(0)
|
||||||
|
const queuedTimeoutFailure = await queuedTimeout
|
||||||
|
assert.equal(queuedTimeoutFailure.error, 'request_timeout', 'a request still queued locally may time out')
|
||||||
|
assert.equal(queuedTimeoutFailure.client_history_page, true, 'synthetic failures must preserve history-page correlation')
|
||||||
|
assert.equal(queuedTimeoutInternals.pendingSessionDetailRequests.length, 0)
|
||||||
|
assert.equal(queuedTimeoutInternals.pendingQueue.length, 0)
|
||||||
|
|
||||||
|
const disconnectClient = new VisualSocketClient('ws://unit.test', {})
|
||||||
|
const disconnected = disconnectClient.sessionDetail('project-a', 'task-disconnect')
|
||||||
|
disconnectClient.disconnect()
|
||||||
|
assert.equal((await disconnected).error, 'disconnected', 'disconnect must settle every pending detail request')
|
||||||
|
|
||||||
|
const saturatedClient = new VisualSocketClient('ws://unit.test', {})
|
||||||
|
;(saturatedClient as unknown as TestSocketClient).pendingQueue = Array.from({ length: 100 }, () => '{}')
|
||||||
|
const saturated = await saturatedClient.sessionDetail('project-a', 'task-saturated')
|
||||||
|
assert.equal(saturated.error, 'send_queue_full', 'a saturated transport queue must fail immediately')
|
||||||
|
|
||||||
|
console.log('wsClient.test.ts: OK (session_detail correlation and lifecycle cleanup)')
|
||||||
@@ -174,6 +174,9 @@ const PROJECT_SCOPED_MESSAGE_TYPES = new Set([
|
|||||||
'comms_read_message',
|
'comms_read_message',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const SESSION_DETAIL_REQUEST_TIMEOUT_MS = 30_000
|
||||||
|
type SendDisposition = 'sent' | 'queued' | 'queue-full' | 'send-failed'
|
||||||
|
|
||||||
export class VisualSocketClient {
|
export class VisualSocketClient {
|
||||||
private ws: WebSocket | null = null
|
private ws: WebSocket | null = null
|
||||||
private reconnectTimer: number | null = null
|
private reconnectTimer: number | null = null
|
||||||
@@ -182,6 +185,18 @@ export class VisualSocketClient {
|
|||||||
private pendingQueue: string[] = []
|
private pendingQueue: string[] = []
|
||||||
private heartbeatTimer: number | null = null
|
private heartbeatTimer: number | null = null
|
||||||
private pongTimer: number | null = null
|
private pongTimer: number | null = null
|
||||||
|
private pendingSessionDetailRequests: Array<{
|
||||||
|
projectId: string
|
||||||
|
taskId: string
|
||||||
|
detailLevel: 'summary' | 'full'
|
||||||
|
viewGeneration?: number
|
||||||
|
historyPage: boolean
|
||||||
|
wireData: string
|
||||||
|
queued: boolean
|
||||||
|
settled: boolean
|
||||||
|
timeout: ReturnType<typeof setTimeout> | null
|
||||||
|
resolve: (payload: Record<string, unknown>) => void
|
||||||
|
}> = []
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private url: string,
|
private url: string,
|
||||||
@@ -211,6 +226,7 @@ export class VisualSocketClient {
|
|||||||
}
|
}
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
this.stopHeartbeat()
|
this.stopHeartbeat()
|
||||||
|
this.failPendingSessionDetailRequests('connection_closed')
|
||||||
this.handlers.onStatus?.('disconnected')
|
this.handlers.onStatus?.('disconnected')
|
||||||
this.ws = null
|
this.ws = null
|
||||||
if (!this.closedByUser) {
|
if (!this.closedByUser) {
|
||||||
@@ -222,6 +238,7 @@ export class VisualSocketClient {
|
|||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.closedByUser = true
|
this.closedByUser = true
|
||||||
this.stopHeartbeat()
|
this.stopHeartbeat()
|
||||||
|
this.failPendingSessionDetailRequests('disconnected')
|
||||||
if (this.reconnectTimer !== null) {
|
if (this.reconnectTimer !== null) {
|
||||||
window.clearTimeout(this.reconnectTimer)
|
window.clearTimeout(this.reconnectTimer)
|
||||||
this.reconnectTimer = null
|
this.reconnectTimer = null
|
||||||
@@ -230,18 +247,24 @@ export class VisualSocketClient {
|
|||||||
this.ws = null
|
this.ws = null
|
||||||
}
|
}
|
||||||
|
|
||||||
send(payload: Record<string, unknown>): void {
|
send(payload: Record<string, unknown>): SendDisposition {
|
||||||
if (!this.ensureProjectScope(payload)) {
|
if (!this.ensureProjectScope(payload)) {
|
||||||
return
|
return 'send-failed'
|
||||||
}
|
}
|
||||||
const data = JSON.stringify(payload)
|
const data = JSON.stringify(payload)
|
||||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
if (this.pendingQueue.length < PENDING_QUEUE_MAX) {
|
if (this.pendingQueue.length < PENDING_QUEUE_MAX) {
|
||||||
this.pendingQueue.push(data)
|
this.pendingQueue.push(data)
|
||||||
|
return 'queued'
|
||||||
}
|
}
|
||||||
return
|
return 'queue-full'
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
this.ws.send(data)
|
this.ws.send(data)
|
||||||
|
return 'sent'
|
||||||
|
} catch {
|
||||||
|
return 'send-failed'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Agent management ───────────────────────────────────────────────────
|
// ── Agent management ───────────────────────────────────────────────────
|
||||||
@@ -411,18 +434,50 @@ export class VisualSocketClient {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
opts?: { limit?: number; beforeCreatedAt?: number; beforeMessageId?: string; detailLevel?: 'summary' | 'full'; include?: string[]; viewGeneration?: number },
|
opts?: { limit?: number; beforeCreatedAt?: number; beforeMessageId?: string; detailLevel?: 'summary' | 'full'; include?: string[]; viewGeneration?: number },
|
||||||
): void {
|
): Promise<Record<string, unknown>> {
|
||||||
const pid = this.requireProjectId(projectId, 'session_detail')
|
const pid = this.requireProjectId(projectId, 'session_detail')
|
||||||
this.send({
|
const detailLevel = opts?.detailLevel ?? 'summary'
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const payload = {
|
||||||
type: 'session_detail',
|
type: 'session_detail',
|
||||||
project_id: pid,
|
project_id: pid,
|
||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
limit: opts?.limit,
|
limit: opts?.limit,
|
||||||
before_created_at: opts?.beforeCreatedAt,
|
before_created_at: opts?.beforeCreatedAt,
|
||||||
before_message_id: opts?.beforeMessageId,
|
before_message_id: opts?.beforeMessageId,
|
||||||
detail_level: opts?.detailLevel,
|
detail_level: detailLevel,
|
||||||
include: opts?.include,
|
include: opts?.include,
|
||||||
view_generation: opts?.viewGeneration,
|
view_generation: opts?.viewGeneration,
|
||||||
|
}
|
||||||
|
const wireData = JSON.stringify(payload)
|
||||||
|
const request = {
|
||||||
|
projectId: pid,
|
||||||
|
taskId,
|
||||||
|
detailLevel,
|
||||||
|
viewGeneration: opts?.viewGeneration,
|
||||||
|
historyPage: opts?.beforeCreatedAt !== undefined || !!opts?.beforeMessageId,
|
||||||
|
wireData,
|
||||||
|
queued: false,
|
||||||
|
settled: false,
|
||||||
|
timeout: null as ReturnType<typeof setTimeout> | null,
|
||||||
|
resolve,
|
||||||
|
}
|
||||||
|
request.timeout = setTimeout(() => {
|
||||||
|
const index = this.pendingSessionDetailRequests.indexOf(request)
|
||||||
|
if (index >= 0) this.timeoutSessionDetailRequest(index)
|
||||||
|
}, SESSION_DETAIL_REQUEST_TIMEOUT_MS)
|
||||||
|
this.pendingSessionDetailRequests.push(request)
|
||||||
|
const disposition = this.send(payload)
|
||||||
|
request.queued = disposition === 'queued'
|
||||||
|
if (disposition === 'queue-full' || disposition === 'send-failed') {
|
||||||
|
const index = this.pendingSessionDetailRequests.indexOf(request)
|
||||||
|
if (index >= 0) {
|
||||||
|
this.failSessionDetailRequest(
|
||||||
|
index,
|
||||||
|
disposition === 'queue-full' ? 'send_queue_full' : 'send_failed',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,9 +711,13 @@ export class VisualSocketClient {
|
|||||||
case 'event':
|
case 'event':
|
||||||
this.handlers.onEvent?.(parsed.payload)
|
this.handlers.onEvent?.(parsed.payload)
|
||||||
break
|
break
|
||||||
case 'ack':
|
case 'ack': {
|
||||||
this.handlers.onAck?.(parsed.payload)
|
const ackPayload = this.settleSessionDetailRequest(
|
||||||
|
parsed.payload as unknown as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
this.handlers.onAck?.(ackPayload as typeof parsed.payload)
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'channel_created':
|
case 'channel_created':
|
||||||
this.handlers.onChannelCreated?.(parsed.payload)
|
this.handlers.onChannelCreated?.(parsed.payload)
|
||||||
break
|
break
|
||||||
@@ -799,11 +858,109 @@ export class VisualSocketClient {
|
|||||||
} catch (e) { console.error('[wsClient] Error handling message:', parsed.type, e) }
|
} catch (e) { console.error('[wsClient] Error handling message:', parsed.type, e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private settleSessionDetailRequest(payload: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const action = typeof payload.action === 'string' ? payload.action.trim() : ''
|
||||||
|
const isSessionDetailAck = action === 'session_detail'
|
||||||
|
|| (!action && payload.error === 'store_not_ready')
|
||||||
|
if (!isSessionDetailAck) return payload
|
||||||
|
const projectId = this.normalizeProjectId(payload.project_id ?? payload.projectId)
|
||||||
|
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
|
||||||
|
const detailLevel = payload.detail_level === 'full' ? 'full' : payload.detail_level === 'summary' ? 'summary' : ''
|
||||||
|
const viewGeneration = typeof payload.view_generation === 'number' ? payload.view_generation : undefined
|
||||||
|
const index = this.pendingSessionDetailRequests.findIndex(request => (
|
||||||
|
(!projectId || request.projectId === projectId)
|
||||||
|
&& (!taskId || request.taskId === taskId)
|
||||||
|
&& (!detailLevel || request.detailLevel === detailLevel)
|
||||||
|
&& (viewGeneration === undefined || request.viewGeneration === viewGeneration)
|
||||||
|
))
|
||||||
|
if (index < 0) return payload
|
||||||
|
const [request] = this.pendingSessionDetailRequests.splice(index, 1)
|
||||||
|
if (request.timeout !== null) clearTimeout(request.timeout)
|
||||||
|
const normalizedPayload = {
|
||||||
|
...payload,
|
||||||
|
action: 'session_detail',
|
||||||
|
project_id: projectId || request.projectId,
|
||||||
|
task_id: taskId || request.taskId,
|
||||||
|
detail_level: detailLevel || request.detailLevel,
|
||||||
|
client_history_page: request.historyPage,
|
||||||
|
}
|
||||||
|
if (!request.settled) {
|
||||||
|
request.settled = true
|
||||||
|
request.resolve(normalizedPayload)
|
||||||
|
}
|
||||||
|
return normalizedPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
private timeoutSessionDetailRequest(index: number): void {
|
||||||
|
const request = this.pendingSessionDetailRequests[index]
|
||||||
|
if (!request) return
|
||||||
|
if (request.queued) {
|
||||||
|
this.failSessionDetailRequest(index, 'request_timeout')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.timeout !== null) clearTimeout(request.timeout)
|
||||||
|
request.timeout = null
|
||||||
|
if (!request.settled) {
|
||||||
|
request.settled = true
|
||||||
|
request.resolve(this.sessionDetailFailurePayload(request, 'request_timeout'))
|
||||||
|
}
|
||||||
|
// Keep a settled tombstone in FIFO order until its ACK or connection
|
||||||
|
// cleanup. Removing it would let a late ACK settle a newer request with
|
||||||
|
// identical correlation fields; closing the shared socket would interrupt
|
||||||
|
// unrelated runtime events.
|
||||||
|
}
|
||||||
|
|
||||||
|
private failSessionDetailRequest(index: number, error: string): void {
|
||||||
|
const [request] = this.pendingSessionDetailRequests.splice(index, 1)
|
||||||
|
if (!request) return
|
||||||
|
if (request.timeout !== null) clearTimeout(request.timeout)
|
||||||
|
if (request.queued) {
|
||||||
|
const queuedIndex = this.pendingQueue.indexOf(request.wireData)
|
||||||
|
if (queuedIndex >= 0) this.pendingQueue.splice(queuedIndex, 1)
|
||||||
|
}
|
||||||
|
if (!request.settled) request.resolve(this.sessionDetailFailurePayload(request, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
private sessionDetailFailurePayload(
|
||||||
|
request: {
|
||||||
|
projectId: string
|
||||||
|
taskId: string
|
||||||
|
detailLevel: 'summary' | 'full'
|
||||||
|
historyPage: boolean
|
||||||
|
},
|
||||||
|
error: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
action: 'session_detail',
|
||||||
|
error,
|
||||||
|
project_id: request.projectId,
|
||||||
|
task_id: request.taskId,
|
||||||
|
detail_level: request.detailLevel,
|
||||||
|
client_history_page: request.historyPage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private failPendingSessionDetailRequests(error: string): void {
|
||||||
|
while (this.pendingSessionDetailRequests.length > 0) {
|
||||||
|
this.failSessionDetailRequest(0, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private flushPendingQueue(): void {
|
private flushPendingQueue(): void {
|
||||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
|
||||||
const queued = this.pendingQueue.splice(0)
|
const queued = this.pendingQueue.splice(0)
|
||||||
for (const data of queued) {
|
for (const data of queued) {
|
||||||
|
const detailRequestIndex = this.pendingSessionDetailRequests.findIndex(
|
||||||
|
request => request.queued && request.wireData === data,
|
||||||
|
)
|
||||||
|
try {
|
||||||
this.ws.send(data)
|
this.ws.send(data)
|
||||||
|
if (detailRequestIndex >= 0) this.pendingSessionDetailRequests[detailRequestIndex].queued = false
|
||||||
|
} catch {
|
||||||
|
if (detailRequestIndex >= 0) this.failSessionDetailRequest(detailRequestIndex, 'send_failed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"typecheck": "tsc -b",
|
"typecheck": "tsc -b",
|
||||||
|
"test:scroll": "node --import tsx ./tests/message-list-scroll.spec.ts",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ function sessionReducer(state: Session[], action: SessionAction): Session[] {
|
|||||||
detailLoaded: existing.detailLoaded ?? incoming.detailLoaded,
|
detailLoaded: existing.detailLoaded ?? incoming.detailLoaded,
|
||||||
fullLoaded: existing.fullLoaded ?? incoming.fullLoaded,
|
fullLoaded: existing.fullLoaded ?? incoming.fullLoaded,
|
||||||
hasMore: incoming.hasMore ?? existing.hasMore,
|
hasMore: incoming.hasMore ?? existing.hasMore,
|
||||||
|
summaryHasMore: incoming.summaryHasMore ?? existing.summaryHasMore,
|
||||||
|
fullHasMore: incoming.fullHasMore ?? existing.fullHasMore,
|
||||||
detailLoading: incoming.detailLoading ?? existing.detailLoading,
|
detailLoading: incoming.detailLoading ?? existing.detailLoading,
|
||||||
detailError: incoming.detailError ?? existing.detailError,
|
detailError: incoming.detailError ?? existing.detailError,
|
||||||
viewGeneration: incoming.viewGeneration ?? existing.viewGeneration,
|
viewGeneration: incoming.viewGeneration ?? existing.viewGeneration,
|
||||||
@@ -314,6 +316,8 @@ function sessionReducer(state: Session[], action: SessionAction): Session[] {
|
|||||||
detailLoaded: nextSession.detailLoaded ?? s.detailLoaded,
|
detailLoaded: nextSession.detailLoaded ?? s.detailLoaded,
|
||||||
fullLoaded: nextSession.fullLoaded ?? s.fullLoaded,
|
fullLoaded: nextSession.fullLoaded ?? s.fullLoaded,
|
||||||
hasMore: nextSession.hasMore ?? s.hasMore,
|
hasMore: nextSession.hasMore ?? s.hasMore,
|
||||||
|
summaryHasMore: nextSession.summaryHasMore ?? s.summaryHasMore,
|
||||||
|
fullHasMore: nextSession.fullHasMore ?? s.fullHasMore,
|
||||||
detailLoading: nextSession.detailLoading ?? s.detailLoading,
|
detailLoading: nextSession.detailLoading ?? s.detailLoading,
|
||||||
detailError: nextSession.detailError ?? s.detailError,
|
detailError: nextSession.detailError ?? s.detailError,
|
||||||
viewGeneration: nextSession.viewGeneration ?? s.viewGeneration,
|
viewGeneration: nextSession.viewGeneration ?? s.viewGeneration,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MessageList scroll regression fixture</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/tests/message-list-scroll.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { chromium, type Browser, type Page } from 'playwright'
|
||||||
|
import { createServer, type ViteDevServer } from 'vite'
|
||||||
|
|
||||||
|
// Run from frontend_src:
|
||||||
|
// node --import tsx ./tests/message-list-scroll.spec.ts
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const FRONTEND_ROOT = resolve(__dirname, '..')
|
||||||
|
|
||||||
|
interface ScrollMetrics {
|
||||||
|
scrollTop: number
|
||||||
|
scrollHeight: number
|
||||||
|
clientHeight: number
|
||||||
|
bottomGap: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FixtureTelemetry {
|
||||||
|
markReadCalls: number
|
||||||
|
renders: number
|
||||||
|
scrollEvents: number
|
||||||
|
scrollTopWrites: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle(page: Page, milliseconds = 80): Promise<void> {
|
||||||
|
await page.evaluate(() => new Promise<void>((resolveFrame) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()))
|
||||||
|
}))
|
||||||
|
await page.waitForTimeout(milliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function metrics(page: Page): Promise<ScrollMetrics> {
|
||||||
|
return page.locator('.msg-list').evaluate((element) => {
|
||||||
|
const list = element as HTMLElement
|
||||||
|
return {
|
||||||
|
scrollTop: list.scrollTop,
|
||||||
|
scrollHeight: list.scrollHeight,
|
||||||
|
clientHeight: list.clientHeight,
|
||||||
|
bottomGap: list.scrollHeight - list.clientHeight - list.scrollTop,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoFixture(
|
||||||
|
page: Page,
|
||||||
|
baseUrl: string,
|
||||||
|
policy: string,
|
||||||
|
extraQuery = '',
|
||||||
|
): Promise<void> {
|
||||||
|
const suffix = extraQuery ? `&${extraQuery}` : ''
|
||||||
|
await page.goto(`${baseUrl}tests/message-list-scroll.html?policy=${policy}${suffix}`)
|
||||||
|
await page.waitForFunction(() => window.__messageListFixtureReady === true)
|
||||||
|
await page.waitForSelector('.msg-list')
|
||||||
|
await settle(page, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetTelemetry(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.resetTelemetry())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function telemetry(page: Page): Promise<FixtureTelemetry> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const value = window.__messageListFixture?.telemetry()
|
||||||
|
if (!value) throw new Error('fixture telemetry is unavailable')
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendMessages(page: Page, count: number): Promise<void> {
|
||||||
|
await page.evaluate((amount) => window.__messageListFixture?.appendMessages(amount), count)
|
||||||
|
await page.waitForSelector(`text=fixture-marker-${String(225 + count - 1).padStart(4, '0')}`)
|
||||||
|
await settle(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function firstFullyVisibleMarker(page: Page): Promise<{ marker: string; top: number }> {
|
||||||
|
return page.locator('.msg-list').evaluate((element) => {
|
||||||
|
const list = element as HTMLElement
|
||||||
|
const listRect = list.getBoundingClientRect()
|
||||||
|
for (const row of Array.from(list.querySelectorAll<HTMLElement>('.msg-row'))) {
|
||||||
|
const rect = row.getBoundingClientRect()
|
||||||
|
const markerMatch = /fixture-marker-(\d+)/.exec(row.textContent || '')
|
||||||
|
const marker = markerMatch?.[0]
|
||||||
|
if (markerMatch && Number(markerMatch[1]) % 5 === 0) continue
|
||||||
|
if (marker && rect.top >= listRect.top + 1 && rect.bottom <= listRect.bottom - 1) {
|
||||||
|
return { marker, top: rect.top }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('no fully visible fixture row found')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markerTop(page: Page, marker: string): Promise<number> {
|
||||||
|
return page.locator('.msg-list').evaluate((element, expectedMarker) => {
|
||||||
|
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-row'))
|
||||||
|
.find((candidate) => candidate.textContent?.includes(expectedMarker))
|
||||||
|
if (!row) throw new Error(`marker row not found: ${expectedMarker}`)
|
||||||
|
return row.getBoundingClientRect().top
|
||||||
|
}, marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markerViewportOffset(page: Page, marker: string): Promise<number> {
|
||||||
|
return page.locator('.msg-list').evaluate((element, expectedMarker) => {
|
||||||
|
const list = element as HTMLElement
|
||||||
|
const row = Array.from(list.querySelectorAll<HTMLElement>('.msg-row'))
|
||||||
|
.find((candidate) => candidate.textContent?.includes(expectedMarker))
|
||||||
|
if (!row) throw new Error(`marker row not found: ${expectedMarker}`)
|
||||||
|
return row.getBoundingClientRect().top - list.getBoundingClientRect().top
|
||||||
|
}, marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nearestMarkerAbove(page: Page, anchorMarker: string): Promise<{ marker: string; index: number }> {
|
||||||
|
return page.locator('.msg-list').evaluate((element, expectedAnchor) => {
|
||||||
|
const rows = Array.from(element.querySelectorAll<HTMLElement>('.msg-row'))
|
||||||
|
const anchor = rows.find((row) => row.textContent?.includes(expectedAnchor))
|
||||||
|
if (!anchor) throw new Error(`anchor row not found: ${expectedAnchor}`)
|
||||||
|
const anchorTop = anchor.getBoundingClientRect().top
|
||||||
|
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||||
|
const row = rows[index]
|
||||||
|
if (row.getBoundingClientRect().bottom > anchorTop - 1) continue
|
||||||
|
const marker = /fixture-marker-(\d+)/.exec(row.textContent || '')
|
||||||
|
if (marker) return { marker: marker[0], index: Number(marker[1]) }
|
||||||
|
}
|
||||||
|
throw new Error('no rendered fixture row exists above the viewport anchor')
|
||||||
|
}, anchorMarker)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickHistoryWithoutScrolling(page: Page): Promise<void> {
|
||||||
|
await page.locator('.msg-history-load-btn').evaluate((element) => {
|
||||||
|
;(element as HTMLButtonElement).click()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFollowAndBrowsingCases(page: Page, baseUrl: string): Promise<void> {
|
||||||
|
await gotoFixture(page, baseUrl, 'follow')
|
||||||
|
|
||||||
|
const initial = await metrics(page)
|
||||||
|
assert.ok(initial.scrollHeight > initial.clientHeight * 8, 'fixture must exercise a long, 200+ row transcript')
|
||||||
|
assert.ok(Math.abs(initial.bottomGap) <= 2, `follow policy should initially settle at bottom; gap=${initial.bottomGap}`)
|
||||||
|
|
||||||
|
// Regression for scrollToEnd -> onMarkRead -> state -> changed callback ->
|
||||||
|
// layout effect -> scrollToEnd. Once settled, an idle transcript must not
|
||||||
|
// keep invoking markRead, re-rendering itself, or even assigning scrollTop.
|
||||||
|
await resetTelemetry(page)
|
||||||
|
await page.waitForTimeout(2_000)
|
||||||
|
const idle = await telemetry(page)
|
||||||
|
assert.equal(idle.markReadCalls, 0, 'idle bottom transcript must not run a markRead feedback loop')
|
||||||
|
assert.equal(idle.renders, 0, 'idle bottom transcript must not re-render from markRead feedback')
|
||||||
|
assert.equal(idle.scrollEvents, 0, 'idle bottom transcript must not keep writing its scroll position')
|
||||||
|
assert.equal(idle.scrollTopWrites, 0, 'idle bottom transcript must perform zero direct scrollTop writes for two seconds')
|
||||||
|
|
||||||
|
await resetTelemetry(page)
|
||||||
|
await appendMessages(page, 5)
|
||||||
|
const afterAppend = await metrics(page)
|
||||||
|
const followTelemetry = await telemetry(page)
|
||||||
|
assert.ok(Math.abs(afterAppend.bottomGap) <= 2, `follow policy must stay at bottom after append; gap=${afterAppend.bottomGap}`)
|
||||||
|
assert.ok(followTelemetry.markReadCalls <= 1, `one append batch may mark read at most once; calls=${followTelemetry.markReadCalls}`)
|
||||||
|
assert.ok(followTelemetry.scrollEvents <= 1, `one append batch may scroll at most once; events=${followTelemetry.scrollEvents}`)
|
||||||
|
assert.ok(followTelemetry.scrollTopWrites <= 1, `one append batch may assign scrollTop at most once; writes=${followTelemetry.scrollTopWrites}`)
|
||||||
|
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.growDraft(4_000))
|
||||||
|
await page.waitForSelector('.msg-row-draft')
|
||||||
|
await settle(page, 150)
|
||||||
|
const afterDraftGrowth = await metrics(page)
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(afterDraftGrowth.bottomGap) <= 2,
|
||||||
|
`follow policy must absorb a large live-reply height change; gap=${afterDraftGrowth.bottomGap}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use a real browser wheel input, not a synthetic scrollTop-only change, to
|
||||||
|
// transition from FOLLOWING to BROWSING.
|
||||||
|
const list = page.locator('.msg-list')
|
||||||
|
await list.hover()
|
||||||
|
await page.mouse.wheel(0, -1_100)
|
||||||
|
await settle(page)
|
||||||
|
const detached = await metrics(page)
|
||||||
|
assert.ok(detached.bottomGap > 400, `wheel-up must detach from bottom immediately; gap=${detached.bottomGap}`)
|
||||||
|
const anchorBefore = await firstFullyVisibleMarker(page)
|
||||||
|
|
||||||
|
await resetTelemetry(page)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__messageListFixture?.appendMessages(15)
|
||||||
|
window.__messageListFixture?.growDraft(5_000)
|
||||||
|
})
|
||||||
|
await page.waitForSelector('text=fixture-marker-0244')
|
||||||
|
await settle(page, 150)
|
||||||
|
const anchorAfterTop = await markerTop(page, anchorBefore.marker)
|
||||||
|
const browsingTelemetry = await telemetry(page)
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(anchorAfterTop - anchorBefore.top) <= 1,
|
||||||
|
`browsing anchor moved by ${anchorAfterTop - anchorBefore.top}px during append storm`,
|
||||||
|
)
|
||||||
|
assert.equal(browsingTelemetry.markReadCalls, 0, 'browsing updates must not mark the detached transcript read')
|
||||||
|
assert.equal(browsingTelemetry.scrollEvents, 0, 'browsing updates must not write the transcript scroll position')
|
||||||
|
|
||||||
|
// A Markdown row which grows above the anchor is the same geometry change
|
||||||
|
// produced by an image load or an expanded approval card. Native anchoring
|
||||||
|
// plus the single controller must hold the visible row exactly in place.
|
||||||
|
const rowAbove = await nearestMarkerAbove(page, anchorBefore.marker)
|
||||||
|
await page.evaluate(({ index }) => window.__messageListFixture?.growMessage(index, 6_000), rowAbove)
|
||||||
|
await settle(page, 150)
|
||||||
|
const anchorAfterHeightChange = await markerTop(page, anchorBefore.marker)
|
||||||
|
const afterHeightChange = await metrics(page)
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(anchorAfterHeightChange - anchorBefore.top) <= 1,
|
||||||
|
`browsing anchor moved by ${anchorAfterHeightChange - anchorBefore.top}px when ${rowAbove.marker} grew above it`,
|
||||||
|
)
|
||||||
|
assert.ok(afterHeightChange.bottomGap > 400, 'height growth above a browsing anchor must not pull the transcript to bottom')
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('.msg-list').getAttribute('data-viewport-mode'),
|
||||||
|
'browsing',
|
||||||
|
'height growth must retain browsing mode',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result surfaces can be replaced by a higher-priority durable projection
|
||||||
|
// and full-sync payloads rebuild every message object. Neither operation may
|
||||||
|
// give JavaScript permission to write the browsing scroll position.
|
||||||
|
await resetTelemetry(page)
|
||||||
|
await page.locator('.msg-list').evaluate((element, marker) => {
|
||||||
|
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
|
||||||
|
.find(candidate => candidate.textContent?.includes(marker))
|
||||||
|
if (!row) throw new Error(`result anchor row is missing: ${marker}`)
|
||||||
|
row.dataset.identityProbe = 'stable-result-row'
|
||||||
|
}, anchorBefore.marker)
|
||||||
|
const anchorIndex = Number(anchorBefore.marker.slice('fixture-marker-'.length))
|
||||||
|
await page.evaluate((index) => window.__messageListFixture?.upgradeResultSurface(index), anchorIndex)
|
||||||
|
await settle(page, 120)
|
||||||
|
const resultReplacement = await page.locator('.msg-list').evaluate((element, marker) => {
|
||||||
|
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
|
||||||
|
.find(candidate => candidate.textContent?.includes(marker))
|
||||||
|
if (!row) throw new Error(`replaced result anchor row is missing: ${marker}`)
|
||||||
|
return {
|
||||||
|
top: row.getBoundingClientRect().top,
|
||||||
|
probe: row.dataset.identityProbe,
|
||||||
|
key: row.dataset.timelineKey,
|
||||||
|
}
|
||||||
|
}, anchorBefore.marker)
|
||||||
|
assert.ok(Math.abs(resultReplacement.top - anchorBefore.top) <= 1, 'result-surface replacement must preserve the browsing anchor')
|
||||||
|
assert.equal(resultReplacement.probe, 'stable-result-row', 'result-surface replacement must reuse the anchored DOM node')
|
||||||
|
assert.equal(resultReplacement.key, `turn:assistant:fixture-turn-${anchorIndex}`, 'result replacement must retain the first surface timeline key')
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.repeatFullSync())
|
||||||
|
await settle(page, 120)
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(await markerTop(page, anchorBefore.marker) - anchorBefore.top) <= 1,
|
||||||
|
'repeated full sync must preserve the browsing anchor',
|
||||||
|
)
|
||||||
|
const syncTelemetry = await telemetry(page)
|
||||||
|
assert.equal(syncTelemetry.scrollTopWrites, 0, 'result upgrade and repeated full sync must not directly write browsing scrollTop')
|
||||||
|
|
||||||
|
// Resizing the panel changes the viewport rather than the message content.
|
||||||
|
// The controller restores the same row offset through its sole observer.
|
||||||
|
const offsetBeforeResize = await markerViewportOffset(page, anchorBefore.marker)
|
||||||
|
await page.setViewportSize({ width: 1280, height: 620 })
|
||||||
|
await settle(page, 150)
|
||||||
|
const offsetAfterResize = await markerViewportOffset(page, anchorBefore.marker)
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(offsetAfterResize - offsetBeforeResize) <= 1,
|
||||||
|
`panel resize moved the browsing anchor by ${offsetAfterResize - offsetBeforeResize}px`,
|
||||||
|
)
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 })
|
||||||
|
await settle(page, 150)
|
||||||
|
|
||||||
|
// First expose the 25 locally-windowed rows, then exercise the real remote
|
||||||
|
// history callback which prepends 40 rows. DOM clicks avoid Playwright
|
||||||
|
// scrolling the history button into view before the assertion.
|
||||||
|
await clickHistoryWithoutScrolling(page)
|
||||||
|
await settle(page, 120)
|
||||||
|
const anchorAfterLocalHistory = await markerTop(page, anchorBefore.marker)
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(anchorAfterLocalHistory - anchorBefore.top) <= 1,
|
||||||
|
`browsing anchor moved by ${anchorAfterLocalHistory - anchorBefore.top}px when the local window expanded`,
|
||||||
|
)
|
||||||
|
await clickHistoryWithoutScrolling(page)
|
||||||
|
await page.waitForSelector('text=history-marker-00-039')
|
||||||
|
await settle(page, 150)
|
||||||
|
const anchorAfterPrepend = await markerTop(page, anchorBefore.marker)
|
||||||
|
const afterPrepend = await metrics(page)
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(anchorAfterPrepend - anchorBefore.top) <= 1,
|
||||||
|
`browsing anchor moved by ${anchorAfterPrepend - anchorBefore.top}px after history prepend`,
|
||||||
|
)
|
||||||
|
assert.ok(afterPrepend.bottomGap > 400, 'history prepend must not pull a browsing transcript to bottom')
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('.msg-list').getAttribute('data-viewport-mode'),
|
||||||
|
'browsing',
|
||||||
|
'history prepend must retain browsing mode',
|
||||||
|
)
|
||||||
|
|
||||||
|
const jumpToLatest = page.getByRole('button', { name: /(?:latest|最新)/i })
|
||||||
|
await jumpToLatest.waitFor({ state: 'visible' })
|
||||||
|
await jumpToLatest.click()
|
||||||
|
await settle(page)
|
||||||
|
const resumed = await metrics(page)
|
||||||
|
assert.ok(Math.abs(resumed.bottomGap) <= 2, `jump-to-latest must resume follow mode; gap=${resumed.bottomGap}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInputIntentCases(page: Page, baseUrl: string): Promise<void> {
|
||||||
|
const list = page.locator('.msg-list')
|
||||||
|
|
||||||
|
await gotoFixture(page, baseUrl, 'follow')
|
||||||
|
await list.focus()
|
||||||
|
await page.keyboard.press('PageUp')
|
||||||
|
await settle(page)
|
||||||
|
assert.equal(await list.getAttribute('data-viewport-mode'), 'browsing', 'PageUp must immediately enter browsing mode')
|
||||||
|
assert.ok((await metrics(page)).bottomGap > 100, 'PageUp must detach the transcript from the tail')
|
||||||
|
|
||||||
|
await gotoFixture(page, baseUrl, 'follow')
|
||||||
|
const listBox = await list.boundingBox()
|
||||||
|
if (!listBox) throw new Error('scrollbar fixture has no bounding box')
|
||||||
|
const beforeDrag = await metrics(page)
|
||||||
|
const scrollbarWidth = await list.evaluate((element) => {
|
||||||
|
const viewport = element as HTMLElement
|
||||||
|
return viewport.offsetWidth - viewport.clientWidth
|
||||||
|
})
|
||||||
|
const scrollbarX = listBox.x + listBox.width - Math.max(2, scrollbarWidth / 2)
|
||||||
|
const scrollbarTrackY = listBox.y + beforeDrag.clientHeight / 2
|
||||||
|
await page.mouse.click(scrollbarX, scrollbarTrackY)
|
||||||
|
await settle(page)
|
||||||
|
const afterDrag = await metrics(page)
|
||||||
|
assert.equal(
|
||||||
|
await list.getAttribute('data-viewport-mode'),
|
||||||
|
'browsing',
|
||||||
|
`scrollbar interaction must immediately enter browsing mode; width=${scrollbarWidth} before=${JSON.stringify(beforeDrag)} after=${JSON.stringify(afterDrag)}`,
|
||||||
|
)
|
||||||
|
assert.ok(afterDrag.bottomGap > 100, 'scrollbar interaction must detach the transcript from the tail')
|
||||||
|
|
||||||
|
// Some assistive/native scroll paths expose only the resulting scroll
|
||||||
|
// event. A downward move while already browsing must replace the stored
|
||||||
|
// anchor so a later resize cannot restore an older viewport position.
|
||||||
|
await gotoFixture(page, baseUrl, 'follow')
|
||||||
|
await list.hover()
|
||||||
|
await page.mouse.wheel(0, -2_400)
|
||||||
|
await settle(page)
|
||||||
|
await list.evaluate((element) => {
|
||||||
|
const viewport = element as HTMLElement
|
||||||
|
viewport.scrollTop += 1_000
|
||||||
|
})
|
||||||
|
await settle(page)
|
||||||
|
const nativeDownAnchor = await firstFullyVisibleMarker(page)
|
||||||
|
const nativeDownOffset = await markerViewportOffset(page, nativeDownAnchor.marker)
|
||||||
|
await page.setViewportSize({ width: 1280, height: 650 })
|
||||||
|
await settle(page, 150)
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(await markerViewportOffset(page, nativeDownAnchor.marker) - nativeDownOffset) <= 1,
|
||||||
|
'untagged downward browsing scroll must become the anchor used by a later viewport resize',
|
||||||
|
)
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 })
|
||||||
|
await settle(page, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTouchIntentCase(browser: Browser, baseUrl: string): Promise<void> {
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, hasTouch: true })
|
||||||
|
try {
|
||||||
|
await gotoFixture(page, baseUrl, 'follow')
|
||||||
|
const list = page.locator('.msg-list')
|
||||||
|
const box = await list.boundingBox()
|
||||||
|
if (!box) throw new Error('touch fixture has no bounding box')
|
||||||
|
const cdp = await page.context().newCDPSession(page)
|
||||||
|
const x = box.x + box.width / 2
|
||||||
|
const startY = box.y + box.height * 0.45
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchStart',
|
||||||
|
touchPoints: [{ x, y: startY }],
|
||||||
|
})
|
||||||
|
for (const delta of [35, 75, 120, 170]) {
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [{ x, y: startY + delta }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||||
|
await settle(page, 150)
|
||||||
|
assert.equal(await list.getAttribute('data-viewport-mode'), 'browsing', 'native touch swipe must enter browsing mode')
|
||||||
|
assert.ok((await metrics(page)).bottomGap > 100, 'native touch swipe must detach the transcript from the tail')
|
||||||
|
} finally {
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTimelineIdentityCases(page: Page, baseUrl: string): Promise<void> {
|
||||||
|
await gotoFixture(page, baseUrl, 'follow')
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.addSharedTurnCompanyRows([4]))
|
||||||
|
await page.waitForSelector('text=shared-company-row-4')
|
||||||
|
await settle(page)
|
||||||
|
await page.locator('.msg-list').evaluate((element) => {
|
||||||
|
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
|
||||||
|
.find(candidate => candidate.textContent?.includes('shared-company-row-4'))
|
||||||
|
if (!row) throw new Error('mounted shared-turn owner is missing')
|
||||||
|
row.dataset.identityProbe = 'shared-turn-owner'
|
||||||
|
})
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.addSharedTurnCompanyRows([0, 1, 2, 3]))
|
||||||
|
await page.waitForSelector('text=shared-company-row-3')
|
||||||
|
await settle(page)
|
||||||
|
const sharedTurnKeys = await page.evaluate(() => [0, 1, 2, 3, 4].map((index) => {
|
||||||
|
const row = Array.from(document.querySelectorAll<HTMLElement>('.msg-timeline-row'))
|
||||||
|
.find((candidate) => candidate.textContent?.includes(`shared-company-row-${index}`))
|
||||||
|
if (!row?.dataset.timelineKey) throw new Error(`shared company row ${index} is missing a timeline key`)
|
||||||
|
return row.dataset.timelineKey
|
||||||
|
}))
|
||||||
|
assert.equal(new Set(sharedTurnKeys).size, 5, `company rows sharing a canonical turn need unique DOM keys: ${sharedTurnKeys.join(', ')}`)
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('.msg-list').evaluate((element) => {
|
||||||
|
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
|
||||||
|
.find(candidate => candidate.textContent?.includes('shared-company-row-4'))
|
||||||
|
return row?.dataset.identityProbe
|
||||||
|
}),
|
||||||
|
'shared-turn-owner',
|
||||||
|
'inserting an older duplicate-turn row must not remount the existing owner',
|
||||||
|
)
|
||||||
|
|
||||||
|
await gotoFixture(page, baseUrl, 'follow')
|
||||||
|
const resultMarker = 'fixture-marker-0211'
|
||||||
|
const initialResultIdentity = await page.locator('.msg-list').evaluate((element, marker) => {
|
||||||
|
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
|
||||||
|
.find(candidate => candidate.textContent?.includes(marker))
|
||||||
|
if (!row) throw new Error('cross-channel result fixture row is missing')
|
||||||
|
row.dataset.identityProbe = 'cross-channel-result-owner'
|
||||||
|
return { key: row.dataset.timelineKey }
|
||||||
|
}, resultMarker)
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.mergeResultGroupOrder(211, true))
|
||||||
|
await settle(page, 120)
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.mergeResultGroupOrder(211, false))
|
||||||
|
await settle(page, 120)
|
||||||
|
const reorderedResult = await page.locator('.msg-list').evaluate((element, marker) => {
|
||||||
|
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
|
||||||
|
.find(candidate => candidate.textContent?.includes(marker))
|
||||||
|
if (!row) throw new Error('reordered cross-channel result row is missing')
|
||||||
|
return { key: row.dataset.timelineKey, probe: row.dataset.identityProbe }
|
||||||
|
}, resultMarker)
|
||||||
|
assert.equal(reorderedResult.key, initialResultIdentity.key, 'cross-channel result group order must not change its mounted timeline key')
|
||||||
|
assert.equal(reorderedResult.probe, 'cross-channel-result-owner', 'cross-channel result group order must reuse the mounted DOM row')
|
||||||
|
|
||||||
|
await gotoFixture(page, baseUrl, 'follow')
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.growDraft(9_000))
|
||||||
|
await page.waitForSelector('.msg-row-draft')
|
||||||
|
await settle(page)
|
||||||
|
const draftGeometry = await page.evaluate(() => {
|
||||||
|
const list = document.querySelector<HTMLElement>('.msg-list')
|
||||||
|
window.__rememberedDraftTimelineRow = document.querySelector(
|
||||||
|
'[data-timeline-key="turn:assistant:fixture-live-turn"]',
|
||||||
|
)
|
||||||
|
const row = window.__rememberedDraftTimelineRow as HTMLElement | null
|
||||||
|
if (!list || !row) throw new Error('live draft timeline wrapper is missing')
|
||||||
|
list.scrollTop = Math.max(0, row.offsetTop + row.offsetHeight / 2 - list.clientHeight / 2)
|
||||||
|
return { height: row.getBoundingClientRect().height }
|
||||||
|
})
|
||||||
|
await settle(page, 120)
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('.msg-list').getAttribute('data-viewport-mode'),
|
||||||
|
'browsing',
|
||||||
|
'moving into the middle of a long draft must enter browsing mode',
|
||||||
|
)
|
||||||
|
const draftScrollTop = (await metrics(page)).scrollTop
|
||||||
|
await resetTelemetry(page)
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.finalizeDraft())
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const row = document.querySelector('[data-timeline-key="turn:assistant:fixture-live-turn"]')
|
||||||
|
return !!row && !row.querySelector('.msg-row-draft')
|
||||||
|
})
|
||||||
|
await settle(page)
|
||||||
|
const finalizedDraft = await page.evaluate(() => {
|
||||||
|
const list = document.querySelector<HTMLElement>('.msg-list')
|
||||||
|
const committed = document.querySelector<HTMLElement>('[data-timeline-key="turn:assistant:fixture-live-turn"]')
|
||||||
|
if (!list || !committed) throw new Error('committed live turn is missing')
|
||||||
|
return {
|
||||||
|
reused: committed === window.__rememberedDraftTimelineRow,
|
||||||
|
height: committed.getBoundingClientRect().height,
|
||||||
|
scrollTop: list.scrollTop,
|
||||||
|
mode: list.dataset.viewportMode,
|
||||||
|
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.collapsed, false, 'a mounted expanded draft must not auto-collapse when its final arrives')
|
||||||
|
assert.ok(
|
||||||
|
finalizedDraft.height >= draftGeometry.height - 4,
|
||||||
|
`draft -> final must not collapse the long turn (${draftGeometry.height}px -> ${finalizedDraft.height}px)`,
|
||||||
|
)
|
||||||
|
assert.ok(Math.abs(finalizedDraft.scrollTop - draftScrollTop) <= 1, 'draft -> final must preserve a browsing viewport inside the turn')
|
||||||
|
assert.equal(finalizedDraft.mode, 'browsing', 'draft -> final must not resume following while the user is browsing')
|
||||||
|
assert.equal((await telemetry(page)).scrollTopWrites, 0, 'draft -> final must not programmatically write browsing scrollTop')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProgressCapIdentityCase(page: Page, baseUrl: string): Promise<void> {
|
||||||
|
await gotoFixture(page, baseUrl, 'follow', 'progress=1')
|
||||||
|
await page.waitForSelector('text=progress-marker-0099')
|
||||||
|
const list = page.locator('.msg-list')
|
||||||
|
const before = await list.evaluate((element) => {
|
||||||
|
const viewport = element as HTMLElement
|
||||||
|
const row = Array.from(viewport.querySelectorAll<HTMLElement>('.msg-timeline-row'))
|
||||||
|
.find(candidate => candidate.textContent?.includes('progress-marker-0040'))
|
||||||
|
if (!row) throw new Error('progress cap anchor row is missing')
|
||||||
|
viewport.scrollTop += row.getBoundingClientRect().top - viewport.getBoundingClientRect().top - 80
|
||||||
|
row.dataset.identityProbe = 'stable-progress-row'
|
||||||
|
return { key: row.dataset.timelineKey }
|
||||||
|
})
|
||||||
|
await settle(page, 120)
|
||||||
|
assert.equal(await list.getAttribute('data-viewport-mode'), 'browsing', 'scrolling to an old progress row must enter browsing mode')
|
||||||
|
const topBefore = await list.evaluate((element) => {
|
||||||
|
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
|
||||||
|
.find(candidate => candidate.textContent?.includes('progress-marker-0040'))
|
||||||
|
if (!row) throw new Error('progress cap anchor row disappeared before append')
|
||||||
|
return row.getBoundingClientRect().top
|
||||||
|
})
|
||||||
|
|
||||||
|
await resetTelemetry(page)
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.appendProgressEntries(1))
|
||||||
|
await page.waitForSelector('text=progress-marker-0100')
|
||||||
|
await settle(page, 150)
|
||||||
|
const after = await list.evaluate((element) => {
|
||||||
|
const viewport = element as HTMLElement
|
||||||
|
const row = Array.from(viewport.querySelectorAll<HTMLElement>('.msg-timeline-row'))
|
||||||
|
.find(candidate => candidate.textContent?.includes('progress-marker-0040'))
|
||||||
|
if (!row) throw new Error('progress cap anchor row disappeared after append')
|
||||||
|
return {
|
||||||
|
key: row.dataset.timelineKey,
|
||||||
|
probe: row.dataset.identityProbe,
|
||||||
|
top: row.getBoundingClientRect().top,
|
||||||
|
mode: viewport.dataset.viewportMode,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(after.key, before.key, 'progress key must survive the 100-entry window shift')
|
||||||
|
assert.equal(after.probe, 'stable-progress-row', 'React must retain the anchored progress DOM node')
|
||||||
|
assert.ok(Math.abs(after.top - topBefore) <= 1, `progress cap moved browsing anchor by ${after.top - topBefore}px`)
|
||||||
|
assert.equal(after.mode, 'browsing', 'progress cap append must retain browsing mode')
|
||||||
|
assert.equal((await telemetry(page)).scrollTopWrites, 0, 'progress cap append must not programmatically write browsing scrollTop')
|
||||||
|
assert.equal(await page.getByText('progress-marker-0000').count(), 0, 'the fixture must actually evict the oldest progress row')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runExternalProgressGeometryCase(page: Page, baseUrl: string): Promise<void> {
|
||||||
|
await gotoFixture(page, baseUrl, 'follow', 'externalProgress=1')
|
||||||
|
const list = page.locator('.msg-list')
|
||||||
|
await list.hover()
|
||||||
|
await page.mouse.wheel(0, -1_300)
|
||||||
|
await settle(page)
|
||||||
|
const anchor = await firstFullyVisibleMarker(page)
|
||||||
|
const offsetBefore = await markerViewportOffset(page, anchor.marker)
|
||||||
|
const heightBefore = await list.evaluate(element => (element as HTMLElement).clientHeight)
|
||||||
|
|
||||||
|
await resetTelemetry(page)
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.setExternalRoleCount(12))
|
||||||
|
await page.waitForSelector('text=Fixture Role 11')
|
||||||
|
await settle(page, 150)
|
||||||
|
|
||||||
|
const heightAfter = await list.evaluate(element => (element as HTMLElement).clientHeight)
|
||||||
|
const offsetAfter = await markerViewportOffset(page, anchor.marker)
|
||||||
|
const progressTelemetry = await telemetry(page)
|
||||||
|
assert.equal(heightAfter, heightBefore, 'role additions must not resize the message viewport')
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(offsetAfter - offsetBefore) <= 1,
|
||||||
|
`external Execution Progress moved the browsing anchor by ${offsetAfter - offsetBefore}px`,
|
||||||
|
)
|
||||||
|
assert.equal(progressTelemetry.scrollTopWrites, 0, 'external role additions must not write browsing scrollTop')
|
||||||
|
assert.equal(
|
||||||
|
await list.getAttribute('data-viewport-mode'),
|
||||||
|
'browsing',
|
||||||
|
'external role additions must retain browsing mode',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHiddenPendingCase(page: Page, baseUrl: string): Promise<void> {
|
||||||
|
await gotoFixture(page, baseUrl, 'follow')
|
||||||
|
const initiallyHidden = await page.evaluate(() => !Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
|
||||||
|
.some((element) => element.textContent?.trim() === 'Approval checkpoint 0'))
|
||||||
|
assert.equal(initiallyHidden, true, 'the oldest pending checkpoint must begin outside the 200-row DOM window')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Pending actions/i }).click()
|
||||||
|
await page.waitForFunction(() => Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
|
||||||
|
.some((element) => element.textContent?.trim() === 'Approval checkpoint 0'))
|
||||||
|
await settle(page, 120)
|
||||||
|
const focused = await page.evaluate(() => {
|
||||||
|
const list = document.querySelector<HTMLElement>('.msg-list')
|
||||||
|
const row = document.querySelector<HTMLElement>('[data-timeline-key="checkpoint:fixture-checkpoint-0"]')
|
||||||
|
if (!list || !row) throw new Error('focused pending checkpoint row is missing')
|
||||||
|
const listRect = list.getBoundingClientRect()
|
||||||
|
const rowRect = row.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
mode: list.dataset.viewportMode,
|
||||||
|
visible: rowRect.bottom > listRect.top && rowRect.top < listRect.bottom,
|
||||||
|
bottomGap: list.scrollHeight - list.clientHeight - list.scrollTop,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(focused.mode, 'browsing', 'locating an old pending checkpoint must enter browsing mode')
|
||||||
|
assert.equal(focused.visible, true, 'pending reminder must locate the checkpoint outside the initial 200-row window')
|
||||||
|
assert.ok(focused.bottomGap > 100, 'locating an old pending checkpoint must not jump back to latest')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCheckpointCase(page: Page, baseUrl: string): Promise<void> {
|
||||||
|
await gotoFixture(page, baseUrl, 'follow')
|
||||||
|
await page.waitForSelector('text=Approval checkpoint 110')
|
||||||
|
await page.waitForSelector('text=fixture-marker-0111')
|
||||||
|
|
||||||
|
const chronological = await page.evaluate(() => {
|
||||||
|
const checkpointTitle = Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
|
||||||
|
.find((element) => element.textContent?.includes('Approval checkpoint 110'))
|
||||||
|
const checkpointRow = checkpointTitle?.closest('.msg-row')
|
||||||
|
const nextRow = Array.from(document.querySelectorAll<HTMLElement>('.msg-row'))
|
||||||
|
.find((element) => element.textContent?.includes('fixture-marker-0111'))
|
||||||
|
if (!checkpointRow || !nextRow) throw new Error('checkpoint chronology nodes are missing')
|
||||||
|
window.__rememberedCheckpointRow = checkpointRow
|
||||||
|
return !!(checkpointRow.compareDocumentPosition(nextRow) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
})
|
||||||
|
assert.equal(chronological, true, 'pending checkpoint must remain before its chronological successor')
|
||||||
|
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.resolveCheckpoint())
|
||||||
|
await page.waitForSelector('text=Approved fixture checkpoint')
|
||||||
|
await settle(page)
|
||||||
|
const retainedNode = await page.evaluate(() => {
|
||||||
|
const checkpointTitle = Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
|
||||||
|
.find((element) => element.textContent?.includes('Approval checkpoint 110'))
|
||||||
|
const currentRow = checkpointTitle?.closest('.msg-row')
|
||||||
|
return !!currentRow
|
||||||
|
&& currentRow === window.__rememberedCheckpointRow
|
||||||
|
&& document.contains(window.__rememberedCheckpointRow ?? null)
|
||||||
|
})
|
||||||
|
assert.equal(retainedNode, true, 'resolving a checkpoint must update the same chronological DOM row')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPolicyCases(page: Page, baseUrl: string): Promise<void> {
|
||||||
|
await gotoFixture(page, baseUrl, 'manual')
|
||||||
|
const manual = await metrics(page)
|
||||||
|
assert.ok(manual.scrollTop <= 2, `manual policy must not perform initial scrolling; scrollTop=${manual.scrollTop}`)
|
||||||
|
|
||||||
|
await gotoFixture(page, baseUrl, 'initial-bottom')
|
||||||
|
const initial = await metrics(page)
|
||||||
|
assert.ok(Math.abs(initial.bottomGap) <= 2, `initial-bottom must initially reach bottom; gap=${initial.bottomGap}`)
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.repeatFullSync())
|
||||||
|
await settle(page, 100)
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.growTailLayout(1_000))
|
||||||
|
await settle(page, 150)
|
||||||
|
const afterEquivalentSyncLayout = await metrics(page)
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(afterEquivalentSyncLayout.bottomGap) <= 2,
|
||||||
|
`equivalent full sync must not end initial-bottom late-layout following; gap=${afterEquivalentSyncLayout.bottomGap}`,
|
||||||
|
)
|
||||||
|
await gotoFixture(page, baseUrl, 'initial-bottom')
|
||||||
|
await page.evaluate(() => window.__messageListFixture?.appendMessages(6))
|
||||||
|
await page.waitForSelector('text=fixture-marker-0230')
|
||||||
|
await settle(page)
|
||||||
|
const afterAppend = await metrics(page)
|
||||||
|
assert.ok(afterAppend.bottomGap > 100, 'initial-bottom policy must not follow later appends')
|
||||||
|
|
||||||
|
// initial-bottom remains a browsing policy after its first layout. The
|
||||||
|
// return affordance must exist even before new data, and reaching the tail
|
||||||
|
// through explicit keyboard input must mark the latest durable row once.
|
||||||
|
await gotoFixture(page, baseUrl, 'initial-bottom')
|
||||||
|
const list = page.locator('.msg-list')
|
||||||
|
await list.hover()
|
||||||
|
await page.mouse.wheel(0, -1_100)
|
||||||
|
await settle(page)
|
||||||
|
const latestButton = page.locator('.msg-list-latest-btn')
|
||||||
|
await latestButton.waitFor({ state: 'visible' })
|
||||||
|
assert.equal((await latestButton.textContent())?.trim(), 'Back to latest', 'browsing needs a return control before new messages arrive')
|
||||||
|
await resetTelemetry(page)
|
||||||
|
await appendMessages(page, 1)
|
||||||
|
assert.equal((await telemetry(page)).markReadCalls, 0, 'detached initial-bottom append must remain unread')
|
||||||
|
assert.match((await latestButton.textContent()) ?? '', /1 new/, 'a durable tail append must increment the detached counter')
|
||||||
|
await list.focus()
|
||||||
|
await page.keyboard.press('End')
|
||||||
|
await settle(page, 120)
|
||||||
|
assert.ok(Math.abs((await metrics(page)).bottomGap) <= 2, 'End must reach the strict bottom under initial-bottom policy')
|
||||||
|
assert.equal((await telemetry(page)).markReadCalls, 1, 'user reaching strict bottom must mark the latest row exactly once')
|
||||||
|
await page.waitForFunction(() => !document.querySelector('.msg-list-latest-btn'))
|
||||||
|
|
||||||
|
// Old history is earlier than the detach boundary and must never be counted
|
||||||
|
// as a new tail delivery, including after the local 200-row window expands.
|
||||||
|
await gotoFixture(page, baseUrl, 'initial-bottom')
|
||||||
|
await list.hover()
|
||||||
|
await page.mouse.wheel(0, -1_100)
|
||||||
|
await settle(page)
|
||||||
|
await clickHistoryWithoutScrolling(page)
|
||||||
|
await settle(page, 100)
|
||||||
|
await clickHistoryWithoutScrolling(page)
|
||||||
|
await page.waitForSelector('text=history-marker-00-039')
|
||||||
|
await settle(page, 120)
|
||||||
|
assert.equal(
|
||||||
|
(await page.locator('.msg-list-latest-btn').textContent())?.trim(),
|
||||||
|
'Back to latest',
|
||||||
|
'prepended history must not increment the detached new-message counter',
|
||||||
|
)
|
||||||
|
|
||||||
|
await gotoFixture(page, baseUrl, 'initial-bottom', 'empty=1')
|
||||||
|
assert.equal(await page.locator('.msg-timeline-row').count(), 0, 'empty-summary fixture must begin without a visible timeline row')
|
||||||
|
await page.getByRole('button', { name: 'Load older messages' }).click()
|
||||||
|
await page.waitForSelector('text=history-marker-00-039')
|
||||||
|
assert.ok(await page.locator('.msg-timeline-row').count() > 0, 'an empty filtered summary must still continue history pagination')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
let server: ViteDevServer | undefined
|
||||||
|
let browser: Browser | undefined
|
||||||
|
const pageErrors: string[] = []
|
||||||
|
const consoleErrors: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
server = await createServer({
|
||||||
|
root: FRONTEND_ROOT,
|
||||||
|
logLevel: 'error',
|
||||||
|
server: { host: '127.0.0.1', port: 0, strictPort: false },
|
||||||
|
})
|
||||||
|
await server.listen()
|
||||||
|
const address = server.httpServer?.address()
|
||||||
|
if (!address || typeof address === 'string') throw new Error('Vite did not expose a TCP port')
|
||||||
|
const baseUrl = `http://127.0.0.1:${address.port}/`
|
||||||
|
|
||||||
|
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
|
||||||
|
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||||
|
})
|
||||||
|
|
||||||
|
await runFollowAndBrowsingCases(page, baseUrl)
|
||||||
|
await runInputIntentCases(page, baseUrl)
|
||||||
|
await runTouchIntentCase(browser, baseUrl)
|
||||||
|
await runTimelineIdentityCases(page, baseUrl)
|
||||||
|
await runProgressCapIdentityCase(page, baseUrl)
|
||||||
|
await runExternalProgressGeometryCase(page, baseUrl)
|
||||||
|
await runHiddenPendingCase(page, baseUrl)
|
||||||
|
await runCheckpointCase(page, baseUrl)
|
||||||
|
await runPolicyCases(page, baseUrl)
|
||||||
|
|
||||||
|
assert.deepEqual(pageErrors, [], `browser page errors:\n${pageErrors.join('\n')}`)
|
||||||
|
assert.deepEqual(consoleErrors, [], `browser console errors:\n${consoleErrors.join('\n')}`)
|
||||||
|
console.log('message-list-scroll.spec.ts: OK (scroll writes, follow, browsing geometry/history, stable keys, checkpoints, policies)')
|
||||||
|
} finally {
|
||||||
|
await browser?.close()
|
||||||
|
await server?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main()
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
import '../index.css'
|
||||||
|
import { MessageList } from '../chat/MessageList'
|
||||||
|
import { WorkItemProgressCard } from '../chat/WorkItemProgressCard'
|
||||||
|
import { __chatStoreTestUtils } from '../chat/ChatStore'
|
||||||
|
import { mergeConversationMessages } from '../lib/workItemSessions'
|
||||||
|
import type { ChatMessage } from '../types/chat'
|
||||||
|
import type { ProgressEntry, RoleWorkItemSummary } from '../types/kanban'
|
||||||
|
|
||||||
|
type ScrollPolicy = 'follow' | 'initial-bottom' | 'manual'
|
||||||
|
|
||||||
|
interface FixtureTelemetry {
|
||||||
|
markReadCalls: number
|
||||||
|
renders: number
|
||||||
|
scrollEvents: number
|
||||||
|
scrollTopWrites: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageListFixtureApi {
|
||||||
|
appendMessages(count: number): void
|
||||||
|
appendProgressEntries(count: number): void
|
||||||
|
addSharedTurnCompanyRows(indices?: number[]): void
|
||||||
|
finalizeDraft(): void
|
||||||
|
growDraft(characters: number): void
|
||||||
|
growMessage(index: number, characters: number): void
|
||||||
|
growTailLayout(pixels: number): void
|
||||||
|
mergeResultGroupOrder(index: number, parentFirst: boolean): void
|
||||||
|
repeatFullSync(): void
|
||||||
|
resolveCheckpoint(): void
|
||||||
|
resetTelemetry(): void
|
||||||
|
setExternalRoleCount(count: number): void
|
||||||
|
telemetry(): FixtureTelemetry
|
||||||
|
upgradeResultSurface(index: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__messageListFixture?: MessageListFixtureApi
|
||||||
|
__messageListFixtureReady?: boolean
|
||||||
|
__rememberedCheckpointRow?: Element | null
|
||||||
|
__rememberedDraftTimelineRow?: Element | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_ID = 'session:scroll-regression'
|
||||||
|
const BASE_TIMESTAMP = Date.UTC(2026, 6, 13, 8, 0, 0)
|
||||||
|
const INITIAL_MESSAGE_COUNT = 225
|
||||||
|
const CHECKPOINT_INDEX = 110
|
||||||
|
const CHECKPOINT_ID = 'fixture-checkpoint-110'
|
||||||
|
const CHECKPOINT_INDICES = new Set([0, 50, 70, 90, CHECKPOINT_INDEX, 130, 150, 170, 190])
|
||||||
|
const SHARED_COMPANY_TURN_ID = 'fixture-shared-company-turn'
|
||||||
|
const LIVE_TURN_ID = 'fixture-live-turn'
|
||||||
|
const INITIAL_PROGRESS_COUNT = 100
|
||||||
|
|
||||||
|
// Count direct JS writes, including no-op writes which do not dispatch a
|
||||||
|
// scroll event. This catches the historical idle scrollToEnd feedback loop.
|
||||||
|
const scrollTopProbe = { writes: 0 }
|
||||||
|
const scrollTopDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
|
||||||
|
if (scrollTopDescriptor?.get && scrollTopDescriptor.set && scrollTopDescriptor.configurable) {
|
||||||
|
Object.defineProperty(Element.prototype, 'scrollTop', {
|
||||||
|
...scrollTopDescriptor,
|
||||||
|
set(value: number) {
|
||||||
|
if ((this as Element).classList?.contains('msg-list')) scrollTopProbe.writes += 1
|
||||||
|
scrollTopDescriptor.set!.call(this, value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageContent(index: number): string {
|
||||||
|
const detail = index % 37 === 0
|
||||||
|
? `\n\n${'Long-form company result with citations, caveats, and acceptance evidence. '.repeat(85)}`
|
||||||
|
: index % 9 === 0
|
||||||
|
? '\n\nThis deliberately longer paragraph exercises dynamic Markdown height without relying on a simplified test-only row. '.repeat(3)
|
||||||
|
: '\n\nA stable production transcript row.'
|
||||||
|
return `fixture-marker-${String(index).padStart(4, '0')} ${detail}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessage(index: number): ChatMessage {
|
||||||
|
if (CHECKPOINT_INDICES.has(index)) {
|
||||||
|
const checkpointId = `fixture-checkpoint-${index}`
|
||||||
|
return {
|
||||||
|
id: `fixture-message-${index}`,
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
sender: 'system',
|
||||||
|
senderName: 'OPC',
|
||||||
|
content: 'Checkpoint fixture payload',
|
||||||
|
timestamp: BASE_TIMESTAMP + index * 1_000,
|
||||||
|
mentions: [],
|
||||||
|
metadata: {
|
||||||
|
checkpoint_type: 'human_escalation',
|
||||||
|
checkpoint_id: checkpointId,
|
||||||
|
escalation_id: checkpointId,
|
||||||
|
escalation_type: 'decision_needed',
|
||||||
|
prompt: `Approval checkpoint ${index}\nKeep this card at its chronological position.`,
|
||||||
|
summary: 'Chronological checkpoint regression fixture',
|
||||||
|
options: [
|
||||||
|
{ id: 'approve', label: 'Approve' },
|
||||||
|
{ id: 'deny', label: 'Deny' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUser = index % 5 === 0
|
||||||
|
return {
|
||||||
|
id: `fixture-message-${index}`,
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
sender: isUser ? 'user' : `fixture-agent-${index % 4}`,
|
||||||
|
senderName: isUser ? 'You' : `Fixture Agent ${index % 4}`,
|
||||||
|
content: messageContent(index),
|
||||||
|
timestamp: BASE_TIMESTAMP + index * 1_000,
|
||||||
|
mentions: [],
|
||||||
|
metadata: isUser
|
||||||
|
? { ui_message_id: `fixture-ui-${index}` }
|
||||||
|
: {
|
||||||
|
source: 'engine',
|
||||||
|
canonical_turn_id: `fixture-turn-${index}`,
|
||||||
|
transcript_kind: 'runtime_v2_assistant',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialMessages(): ChatMessage[] {
|
||||||
|
return Array.from({ length: INITIAL_MESSAGE_COUNT }, (_, index) => buildMessage(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHistoryMessage(batch: number, offset: number): ChatMessage {
|
||||||
|
return {
|
||||||
|
id: `history-message-${batch}-${offset}`,
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
sender: `history-agent-${offset % 3}`,
|
||||||
|
senderName: `History Agent ${offset % 3}`,
|
||||||
|
content: `history-marker-${String(batch).padStart(2, '0')}-${String(offset).padStart(3, '0')}\n\nA prepended historical row.`,
|
||||||
|
timestamp: BASE_TIMESTAMP - ((batch + 1) * 100_000) + offset * 1_000,
|
||||||
|
mentions: [],
|
||||||
|
metadata: { canonical_turn_id: `history-turn-${batch}-${offset}` },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSharedTurnCompanyMessage(index: number, transcriptKind: string): ChatMessage {
|
||||||
|
return {
|
||||||
|
id: `shared-company-message-${index}`,
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
sender: `company-role-${index}`,
|
||||||
|
senderName: `Company Role ${index}`,
|
||||||
|
content: `shared-company-row-${index} — independent committed company surface`,
|
||||||
|
timestamp: BASE_TIMESTAMP + 2_000_000 + index,
|
||||||
|
mentions: [],
|
||||||
|
metadata: {
|
||||||
|
canonical_turn_id: SHARED_COMPANY_TURN_ID,
|
||||||
|
transcript_kind: transcriptKind,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProgressEntry(index: number): ProgressEntry {
|
||||||
|
return {
|
||||||
|
type: 'status_change',
|
||||||
|
summary: `progress-marker-${String(index).padStart(4, '0')}`,
|
||||||
|
detail: `Stable no-id status event ${index}`,
|
||||||
|
timestamp: BASE_TIMESTAMP + 4_000_000 + index * 1_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoleWorkItems(count: number): Record<string, RoleWorkItemSummary> {
|
||||||
|
return Object.fromEntries(Array.from({ length: count }, (_, index) => {
|
||||||
|
const roleId = `fixture-role-${index}`
|
||||||
|
return [roleId, {
|
||||||
|
roleKey: roleId,
|
||||||
|
roleId,
|
||||||
|
roleName: `Fixture Role ${index}`,
|
||||||
|
runtimeStatus: index % 3 === 0 ? 'reflecting' : 'idle',
|
||||||
|
aggregatedStatus: index % 3 === 0 ? 'active' : 'pending',
|
||||||
|
workItems: [{
|
||||||
|
workItemId: `fixture-work-item-${index}`,
|
||||||
|
workItemProjectionId: `fixture-projection-${index}`,
|
||||||
|
phase: index % 3 === 0 ? 'running' : 'queued',
|
||||||
|
kanbanColumn: index % 3 === 0 ? 'in_progress' : 'todo',
|
||||||
|
title: `Fixture Work Item ${index}`,
|
||||||
|
executorRoleId: roleId,
|
||||||
|
executorRoleName: `Fixture Role ${index}`,
|
||||||
|
createdAt: BASE_TIMESTAMP + index,
|
||||||
|
updatedAt: BASE_TIMESTAMP + index,
|
||||||
|
executionTurnId: `fixture-role-turn-${index}`,
|
||||||
|
progressLog: [],
|
||||||
|
}],
|
||||||
|
} satisfies RoleWorkItemSummary]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fixture() {
|
||||||
|
const query = useMemo(() => new URLSearchParams(window.location.search), [])
|
||||||
|
const policy = (query.get('policy') || 'follow') as ScrollPolicy
|
||||||
|
const progressFixture = query.get('progress') === '1'
|
||||||
|
const externalProgressFixture = query.get('externalProgress') === '1'
|
||||||
|
const emptySummaryFixture = query.get('empty') === '1'
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
||||||
|
const initial = buildInitialMessages()
|
||||||
|
if (!emptySummaryFixture) return initial
|
||||||
|
return initial.map(message => ({
|
||||||
|
...message,
|
||||||
|
metadata: { ...(message.metadata ?? {}), detail_visibility: 'full' },
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const [progressLog, setProgressLog] = useState<ProgressEntry[]>(() => (
|
||||||
|
progressFixture
|
||||||
|
? Array.from({ length: INITIAL_PROGRESS_COUNT }, (_, index) => buildProgressEntry(index))
|
||||||
|
: []
|
||||||
|
))
|
||||||
|
const [externalRoleWorkItems, setExternalRoleWorkItems] = useState<Record<string, RoleWorkItemSummary>>({})
|
||||||
|
const [draftText, setDraftText] = useState('')
|
||||||
|
const draftTextRef = useRef(draftText)
|
||||||
|
draftTextRef.current = draftText
|
||||||
|
const historyBatchRef = useRef(0)
|
||||||
|
// This state deliberately changes from markRead. The former implementation
|
||||||
|
// accidentally made that state update part of a scroll-to-bottom feedback
|
||||||
|
// loop; a correct MessageList must not care that this callback is recreated.
|
||||||
|
const [, setReadVersion] = useState(0)
|
||||||
|
const telemetryRef = useRef<FixtureTelemetry>({ markReadCalls: 0, renders: 0, scrollEvents: 0, scrollTopWrites: 0 })
|
||||||
|
telemetryRef.current.renders += 1
|
||||||
|
|
||||||
|
const appendMessages = useCallback((count: number) => {
|
||||||
|
setMessages((current) => {
|
||||||
|
const start = current.reduce((max, message) => {
|
||||||
|
const match = /^fixture-message-(\d+)$/.exec(message.id)
|
||||||
|
return match ? Math.max(max, Number(match[1]) + 1) : max
|
||||||
|
}, INITIAL_MESSAGE_COUNT)
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
...Array.from({ length: count }, (_, offset) => buildMessage(start + offset)),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const appendProgressEntries = useCallback((count: number) => {
|
||||||
|
setProgressLog((current) => {
|
||||||
|
const start = current.reduce((max, entry) => {
|
||||||
|
const match = /^progress-marker-(\d+)$/.exec(entry.summary)
|
||||||
|
return match ? Math.max(max, Number(match[1]) + 1) : max
|
||||||
|
}, INITIAL_PROGRESS_COUNT)
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
...Array.from({ length: count }, (_, offset) => buildProgressEntry(start + offset)),
|
||||||
|
].slice(-INITIAL_PROGRESS_COUNT)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const growDraft = useCallback((characters: number) => {
|
||||||
|
setDraftText((current) => `${current}${' live-reply-content'.repeat(Math.max(1, Math.ceil(characters / 19)))}`)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const growMessage = useCallback((index: number, characters: number) => {
|
||||||
|
setMessages((current) => current.map((message) => message.id === `fixture-message-${index}`
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
content: `${message.content}\n\n${'Expanded content above the viewport anchor. '.repeat(Math.max(1, Math.ceil(characters / 44)))}`,
|
||||||
|
}
|
||||||
|
: message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const repeatFullSync = useCallback(() => {
|
||||||
|
setMessages((current) => current.map((message) => ({
|
||||||
|
...message,
|
||||||
|
metadata: message.metadata ? { ...message.metadata } : undefined,
|
||||||
|
})))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const growTailLayout = useCallback((pixels: number) => {
|
||||||
|
const tail = Array.from(document.querySelectorAll<HTMLElement>('.msg-timeline-row')).at(-1)
|
||||||
|
if (!tail) throw new Error('tail timeline row is missing')
|
||||||
|
let spacer = tail.querySelector<HTMLElement>('[data-late-layout-spacer]')
|
||||||
|
if (!spacer) {
|
||||||
|
spacer = document.createElement('div')
|
||||||
|
spacer.dataset.lateLayoutSpacer = 'true'
|
||||||
|
tail.appendChild(spacer)
|
||||||
|
}
|
||||||
|
spacer.style.height = `${pixels}px`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const upgradeResultSurface = useCallback((index: number) => {
|
||||||
|
setMessages((current) => {
|
||||||
|
const existing = current.find(message => message.id === `fixture-message-${index}`)
|
||||||
|
if (!existing || existing.sender === 'user') return current
|
||||||
|
return __chatStoreTestUtils.dedupeMessages([
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
...existing,
|
||||||
|
id: `fixture-upgraded-result-${index}`,
|
||||||
|
sender: 'fixture-company-role',
|
||||||
|
senderName: 'Fixture Company Role',
|
||||||
|
metadata: {
|
||||||
|
...existing.metadata,
|
||||||
|
source: 'engine',
|
||||||
|
canonical_turn_id: `fixture-upgraded-turn-${index}`,
|
||||||
|
transcript_kind: 'child_task_result',
|
||||||
|
detail_visibility: 'summary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const mergeResultGroupOrder = useCallback((index: number, parentFirst: boolean) => {
|
||||||
|
const native = buildMessage(index)
|
||||||
|
if (native.sender === 'user') throw new Error('result group fixture needs an assistant row')
|
||||||
|
const authoritative: ChatMessage = {
|
||||||
|
...native,
|
||||||
|
id: `fixture-cross-channel-result-${index}`,
|
||||||
|
channelId: 'session:scroll-regression-child',
|
||||||
|
sender: 'fixture-company-role',
|
||||||
|
senderName: 'Fixture Company Role',
|
||||||
|
timestamp: native.timestamp + 10,
|
||||||
|
metadata: {
|
||||||
|
...native.metadata,
|
||||||
|
canonical_turn_id: `fixture-authoritative-turn-${index}`,
|
||||||
|
transcript_kind: 'child_task_result',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const selected = mergeConversationMessages(parentFirst
|
||||||
|
? [[authoritative], [native]]
|
||||||
|
: [[native], [authoritative]])
|
||||||
|
setMessages((current) => [
|
||||||
|
...current.filter(message => (
|
||||||
|
message.id !== native.id
|
||||||
|
&& message.id !== authoritative.id
|
||||||
|
)),
|
||||||
|
...selected,
|
||||||
|
])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addSharedTurnCompanyRows = useCallback((indices = [0, 1, 2, 3, 4]) => {
|
||||||
|
const transcriptKinds = [
|
||||||
|
'company_role_result',
|
||||||
|
'child_result',
|
||||||
|
'runtime_v2_intermediate_assistant',
|
||||||
|
'runtime_v2_assistant',
|
||||||
|
'runtime_v2_assistant',
|
||||||
|
]
|
||||||
|
setMessages((current) => {
|
||||||
|
const existingIds = new Set(current.map(message => message.id))
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
...indices
|
||||||
|
.filter(index => !existingIds.has(`shared-company-message-${index}`))
|
||||||
|
.map(index => buildSharedTurnCompanyMessage(index, transcriptKinds[index] ?? 'runtime_v2_assistant')),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const finalizeDraft = useCallback(() => {
|
||||||
|
const committedContent = draftTextRef.current.trim() || 'fixture-live-final-content — committed assistant response'
|
||||||
|
setDraftText('')
|
||||||
|
setMessages((current) => current.some((message) => message.id === 'fixture-live-final')
|
||||||
|
? current
|
||||||
|
: [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: 'fixture-live-final',
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
sender: 'fixture-agent-final',
|
||||||
|
senderName: 'Fixture Final Agent',
|
||||||
|
content: committedContent,
|
||||||
|
timestamp: BASE_TIMESTAMP + 3_000_000,
|
||||||
|
mentions: [],
|
||||||
|
metadata: {
|
||||||
|
canonical_turn_id: LIVE_TURN_ID,
|
||||||
|
transcript_kind: 'runtime_v2_assistant',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadOlderHistory = useCallback(() => {
|
||||||
|
const batch = historyBatchRef.current
|
||||||
|
historyBatchRef.current += 1
|
||||||
|
setMessages((current) => [
|
||||||
|
...Array.from({ length: 40 }, (_, offset) => buildHistoryMessage(batch, offset)),
|
||||||
|
...current,
|
||||||
|
])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resolveCheckpoint = useCallback(() => {
|
||||||
|
setMessages((current) => {
|
||||||
|
if (current.some((message) => message.id === 'fixture-checkpoint-response')) return current
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: 'fixture-checkpoint-response',
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
sender: 'user',
|
||||||
|
senderName: 'You',
|
||||||
|
content: 'Approved fixture checkpoint',
|
||||||
|
timestamp: BASE_TIMESTAMP + 1_000_000,
|
||||||
|
mentions: [],
|
||||||
|
metadata: {
|
||||||
|
ui_message_id: 'fixture-checkpoint-response-ui',
|
||||||
|
response_to_checkpoint_id: CHECKPOINT_ID,
|
||||||
|
response_to_checkpoint_type: 'human_escalation',
|
||||||
|
checkpoint_reply_kind: 'approve',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetTelemetry = useCallback(() => {
|
||||||
|
telemetryRef.current.markReadCalls = 0
|
||||||
|
telemetryRef.current.renders = 0
|
||||||
|
telemetryRef.current.scrollEvents = 0
|
||||||
|
scrollTopProbe.writes = 0
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
window.__messageListFixture = {
|
||||||
|
appendMessages,
|
||||||
|
appendProgressEntries,
|
||||||
|
addSharedTurnCompanyRows,
|
||||||
|
finalizeDraft,
|
||||||
|
growDraft,
|
||||||
|
growMessage,
|
||||||
|
growTailLayout,
|
||||||
|
mergeResultGroupOrder,
|
||||||
|
repeatFullSync,
|
||||||
|
resolveCheckpoint,
|
||||||
|
resetTelemetry,
|
||||||
|
setExternalRoleCount: (count: number) => setExternalRoleWorkItems(buildRoleWorkItems(count)),
|
||||||
|
telemetry: () => ({ ...telemetryRef.current, scrollTopWrites: scrollTopProbe.writes }),
|
||||||
|
upgradeResultSurface,
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkRead = () => {
|
||||||
|
telemetryRef.current.markReadCalls += 1
|
||||||
|
setReadVersion((value) => value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use an intersection type so the fixture can be committed alongside the
|
||||||
|
// implementation change: the old component ignores scrollPolicy and fails
|
||||||
|
// these checks; the new public contract consumes it.
|
||||||
|
const ProductionMessageList = MessageList as React.ComponentType<
|
||||||
|
React.ComponentProps<typeof MessageList> & { scrollPolicy: ScrollPolicy }
|
||||||
|
>
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.__messageListFixtureReady = true
|
||||||
|
return () => {
|
||||||
|
window.__messageListFixtureReady = false
|
||||||
|
delete window.__messageListFixture
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const list = document.querySelector('.msg-list')
|
||||||
|
const countScroll = () => {
|
||||||
|
telemetryRef.current.scrollEvents += 1
|
||||||
|
}
|
||||||
|
list?.addEventListener('scroll', countScroll, { passive: true })
|
||||||
|
return () => list?.removeEventListener('scroll', countScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app-shell theme-paper message-list-scroll-fixture">
|
||||||
|
<div className="message-list-scroll-fixture-header">
|
||||||
|
Production MessageList fixture — {policy}
|
||||||
|
</div>
|
||||||
|
<section className="message-list-scroll-fixture-body">
|
||||||
|
{externalProgressFixture && (
|
||||||
|
<div className="ctx-work-item-progress">
|
||||||
|
<WorkItemProgressCard
|
||||||
|
workItemLog={[]}
|
||||||
|
roleWorkItems={externalRoleWorkItems}
|
||||||
|
isCompanyRuntime
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ProductionMessageList
|
||||||
|
messages={messages}
|
||||||
|
channelName="Scroll regression"
|
||||||
|
viewKind="session"
|
||||||
|
detailMode={progressFixture ? 'full' : 'summary'}
|
||||||
|
scrollPolicy={policy}
|
||||||
|
draftAssistantText={draftText}
|
||||||
|
draftUpdatedAt={BASE_TIMESTAMP + 900_000}
|
||||||
|
draftTurnId={LIVE_TURN_ID}
|
||||||
|
isCompanyRuntime
|
||||||
|
onSend={() => undefined}
|
||||||
|
onMarkRead={handleMarkRead}
|
||||||
|
hasOlderHistory
|
||||||
|
totalMessageCount={messages.length + 100}
|
||||||
|
onLoadOlderHistory={loadOlderHistory}
|
||||||
|
progressLog={progressLog}
|
||||||
|
showWorkItemRuntimeCard={false}
|
||||||
|
showRuntimeProgress={progressFixture}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.textContent = `
|
||||||
|
.message-list-scroll-fixture {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 620px;
|
||||||
|
height: 720px;
|
||||||
|
max-height: 92vh;
|
||||||
|
margin: 4vh auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.message-list-scroll-fixture-header {
|
||||||
|
flex: 0 0 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.message-list-scroll-fixture-body {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
if (!root) throw new Error('MessageList fixture root is missing')
|
||||||
|
createRoot(root).render(<Fixture />)
|
||||||
@@ -338,6 +338,8 @@ export interface ChatMessageMeta {
|
|||||||
kind?: string
|
kind?: string
|
||||||
ui_message_id?: string
|
ui_message_id?: string
|
||||||
ui_created_at?: number
|
ui_created_at?: number
|
||||||
|
/** UI-only identity retained across semantic result-surface replacement. */
|
||||||
|
ui_timeline_id?: string
|
||||||
canonical_turn_id?: string
|
canonical_turn_id?: string
|
||||||
turn_id?: string
|
turn_id?: string
|
||||||
execution_mode?: string
|
execution_mode?: string
|
||||||
|
|||||||
@@ -298,6 +298,10 @@ export interface Session {
|
|||||||
detailLoaded?: boolean
|
detailLoaded?: boolean
|
||||||
fullLoaded?: boolean
|
fullLoaded?: boolean
|
||||||
hasMore?: boolean
|
hasMore?: boolean
|
||||||
|
/** Pagination state for the committed company/task transcript. */
|
||||||
|
summaryHasMore?: boolean
|
||||||
|
/** Pagination state for the full child/runtime transcript. */
|
||||||
|
fullHasMore?: boolean
|
||||||
detailLoading?: boolean
|
detailLoading?: boolean
|
||||||
detailError?: string
|
detailError?: string
|
||||||
viewGeneration?: number
|
viewGeneration?: number
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import type { Session } from '../types/kanban'
|
import type { Session } from '../types/kanban'
|
||||||
import { composerExecModeForSession } from './ContextPanel'
|
import {
|
||||||
|
composerExecModeForSession,
|
||||||
|
conversationHasOlderHistory,
|
||||||
|
sessionHasMoreForDetail,
|
||||||
|
} from './ContextPanel'
|
||||||
|
|
||||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||||
return {
|
return {
|
||||||
@@ -49,4 +53,44 @@ assert.equal(
|
|||||||
'org',
|
'org',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const independentlyPaged = makeSession({
|
||||||
|
hasMore: false,
|
||||||
|
summaryHasMore: false,
|
||||||
|
fullHasMore: true,
|
||||||
|
messageCount: 400,
|
||||||
|
})
|
||||||
|
assert.equal(sessionHasMoreForDetail(independentlyPaged, 'summary'), false)
|
||||||
|
assert.equal(sessionHasMoreForDetail(independentlyPaged, 'full'), true)
|
||||||
|
assert.equal(
|
||||||
|
conversationHasOlderHistory([independentlyPaged], 200, 'summary'),
|
||||||
|
false,
|
||||||
|
'a full-detail ACK must not reopen summary history',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
conversationHasOlderHistory([independentlyPaged], 200, 'full'),
|
||||||
|
true,
|
||||||
|
'full history must retain its own cursor state',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
conversationHasOlderHistory([independentlyPaged], 200, 'full', false),
|
||||||
|
true,
|
||||||
|
'a known scoped cursor remains loadable while a company turn is running',
|
||||||
|
)
|
||||||
|
|
||||||
|
const summaryOnlyState = makeSession({
|
||||||
|
hasMore: false,
|
||||||
|
summaryHasMore: true,
|
||||||
|
messageCount: 400,
|
||||||
|
})
|
||||||
|
assert.equal(
|
||||||
|
sessionHasMoreForDetail(summaryOnlyState, 'full'),
|
||||||
|
undefined,
|
||||||
|
'generic hasMore is no longer authoritative after a scoped policy is known',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
conversationHasOlderHistory([makeSession({ messageCount: 400 })], 200, 'summary', false),
|
||||||
|
false,
|
||||||
|
'only the racy message-count fallback is suppressed during active generation',
|
||||||
|
)
|
||||||
|
|
||||||
console.log('ContextPanel composer identity checks passed')
|
console.log('ContextPanel composer identity checks passed')
|
||||||
|
|||||||
@@ -193,6 +193,45 @@ function hasCompanyRuntimeIdentity(session: Session): boolean {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCompanyRuntimeSession(
|
||||||
|
session: Session | null | undefined,
|
||||||
|
relatedSessionCount = 0,
|
||||||
|
): boolean {
|
||||||
|
if (!session) return false
|
||||||
|
const mode = String(session.execMode ?? '').trim().toLowerCase()
|
||||||
|
return relatedSessionCount > 0
|
||||||
|
|| hasCompanyRuntimeIdentity(session)
|
||||||
|
|| mode === 'company'
|
||||||
|
|| mode === 'org'
|
||||||
|
|| mode === 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionHasMoreForDetail(
|
||||||
|
session: Session,
|
||||||
|
detailLevel: 'summary' | 'full',
|
||||||
|
): boolean | undefined {
|
||||||
|
const scoped = detailLevel === 'full' ? session.fullHasMore : session.summaryHasMore
|
||||||
|
if (scoped !== undefined) return scoped
|
||||||
|
// Old snapshots only expose the unscoped value. Once either scoped cursor
|
||||||
|
// has been observed, the generic field may describe the other policy.
|
||||||
|
if (session.summaryHasMore === undefined && session.fullHasMore === undefined) {
|
||||||
|
return session.hasMore
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conversationHasOlderHistory(
|
||||||
|
sessions: Session[],
|
||||||
|
displayedMessageCount: number,
|
||||||
|
detailLevel: 'summary' | 'full',
|
||||||
|
allowMessageCountFallback = true,
|
||||||
|
): boolean {
|
||||||
|
const pagination = sessions.map(session => sessionHasMoreForDetail(session, detailLevel))
|
||||||
|
if (pagination.some(hasMore => hasMore === true)) return true
|
||||||
|
if (sessions.length !== 1 || pagination[0] === false) return false
|
||||||
|
return allowMessageCountFallback && sessions[0].messageCount > displayedMessageCount
|
||||||
|
}
|
||||||
|
|
||||||
function hasCustomRuntimeIdentity(session: Session): boolean {
|
function hasCustomRuntimeIdentity(session: Session): boolean {
|
||||||
const rawMode = String(session.execMode ?? '').trim().toLowerCase()
|
const rawMode = String(session.execMode ?? '').trim().toLowerCase()
|
||||||
const normalizedMode = normalizePanelExecMode(session.execMode)
|
const normalizedMode = normalizePanelExecMode(session.execMode)
|
||||||
@@ -498,7 +537,7 @@ export function ContextPanel({
|
|||||||
const isChildDetail = activeView.kind === 'child-detail'
|
const isChildDetail = activeView.kind === 'child-detail'
|
||||||
const isTaskDetail = activeView.kind === 'task-detail'
|
const isTaskDetail = activeView.kind === 'task-detail'
|
||||||
|
|
||||||
const isCompanyRuntime = !!(activeSession && (activeSession.isCompanyRuntime || childSessions.length > 0))
|
const isCompanyRuntime = isCompanyRuntimeSession(activeSession, childSessions.length)
|
||||||
const showTabs = activeView.kind === 'session' && activeSession
|
const showTabs = activeView.kind === 'session' && activeSession
|
||||||
const canSend = isSecretary ? true : !!activeSession
|
const canSend = isSecretary ? true : !!activeSession
|
||||||
const showSessionStrip = !isChildDetail && openSessions.length > 0
|
const showSessionStrip = !isChildDetail && openSessions.length > 0
|
||||||
@@ -553,10 +592,14 @@ export function ContextPanel({
|
|||||||
const matched = activeConversation.timelineSessions.find(
|
const matched = activeConversation.timelineSessions.find(
|
||||||
(session) => session.channelId === oldestMessage.channelId,
|
(session) => session.channelId === oldestMessage.channelId,
|
||||||
)
|
)
|
||||||
if (matched) return matched
|
if (matched && sessionHasMoreForDetail(matched, activeDetailMode) !== false) return matched
|
||||||
}
|
}
|
||||||
|
const knownTarget = activeConversation.timelineSessions.find(
|
||||||
|
session => sessionHasMoreForDetail(session, activeDetailMode) === true,
|
||||||
|
)
|
||||||
|
if (knownTarget) return knownTarget
|
||||||
return activeDisplaySession ?? activeSession
|
return activeDisplaySession ?? activeSession
|
||||||
}, [activeConversation.timelineSessions, activeDisplaySession, activeSession])
|
}, [activeConversation.timelineSessions, activeDetailMode, activeDisplaySession, activeSession])
|
||||||
|
|
||||||
// Child detail: find the agent for this session
|
// Child detail: find the agent for this session
|
||||||
const childDetailAgent = useMemo(() => {
|
const childDetailAgent = useMemo(() => {
|
||||||
@@ -706,24 +749,20 @@ export function ContextPanel({
|
|||||||
draftTurnId={childDetailSession.draftTurnId}
|
draftTurnId={childDetailSession.draftTurnId}
|
||||||
onMarkRead={onMarkRead}
|
onMarkRead={onMarkRead}
|
||||||
hasOlderHistory={
|
hasOlderHistory={
|
||||||
// The `messageCount > loaded.length` race flashes the
|
// A scoped backend cursor remains actionable during live
|
||||||
// "Load older messages" hint every ~1s while the agent
|
// work. Only the racy messageCount fallback is suppressed.
|
||||||
// streams: backend bumps count, new message arrives
|
conversationHasOlderHistory(
|
||||||
// at chatStore 1 tick later, hint appears then hides.
|
[childDetailSession],
|
||||||
// The insertion/removal of the hint row also triggers
|
childDetailMessages.length,
|
||||||
// auto-scroll, which pushes the user's own input off
|
'full',
|
||||||
// the top of the viewport. Suppress the hint while the
|
!isSessionWorking(childDetailSession),
|
||||||
// session is actively working — any transient delta
|
)
|
||||||
// during active turns is almost always in-flight new
|
|
||||||
// messages, not an older-history gap.
|
|
||||||
!isSessionWorking(childDetailSession)
|
|
||||||
&& childDetailSession.messageCount > childDetailMessages.length
|
|
||||||
}
|
}
|
||||||
totalMessageCount={childDetailSession.messageCount}
|
totalMessageCount={childDetailSession.messageCount}
|
||||||
onLoadOlderHistory={(oldestMessage) => onLoadSessionHistory?.(childDetailSession.taskId, oldestMessage, 'full')}
|
onLoadOlderHistory={(oldestMessage) => onLoadSessionHistory?.(childDetailSession.taskId, oldestMessage, 'full')}
|
||||||
loadingOlderHistory={isSessionHistoryLoading?.(childDetailSession.taskId) ?? false}
|
loadingOlderHistory={isSessionHistoryLoading?.(childDetailSession.taskId) ?? false}
|
||||||
autoScroll={false}
|
scrollPolicy="initial-bottom"
|
||||||
initialScrollToBottom
|
scrollScope={childDetailSession.channelId}
|
||||||
showRuntimeProgress
|
showRuntimeProgress
|
||||||
renderUserMarkdown
|
renderUserMarkdown
|
||||||
/>
|
/>
|
||||||
@@ -911,7 +950,7 @@ export function ContextPanel({
|
|||||||
.map(id => agents.find(agent => agent.agent_id === id)?.name ?? id)
|
.map(id => agents.find(agent => agent.agent_id === id)?.name ?? id)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const runtimeLabel = sessionRuntimeLabel(sessionConversationSession ?? session, activeChildCount)
|
const runtimeLabel = sessionRuntimeLabel(sessionConversationSession ?? session, activeChildCount)
|
||||||
const sessionIsCompanyRuntime = !!(session.isCompanyRuntime || sessionChildren.length > 0)
|
const sessionIsCompanyRuntime = isCompanyRuntimeSession(session, sessionChildren.length)
|
||||||
const sessionDisplaySession = sessionConversation.displaySession ?? session
|
const sessionDisplaySession = sessionConversation.displaySession ?? session
|
||||||
const sessionProgressLog = mergeConversationProgressLog(sessionConversation.timelineSessions)
|
const sessionProgressLog = mergeConversationProgressLog(sessionConversation.timelineSessions)
|
||||||
const sessionMessageCount = getConversationMessageCount(sessionConversation.timelineSessions)
|
const sessionMessageCount = getConversationMessageCount(sessionConversation.timelineSessions)
|
||||||
@@ -970,37 +1009,52 @@ export function ContextPanel({
|
|||||||
channelName={sessionDisplaySession?.title ?? session.title}
|
channelName={sessionDisplaySession?.title ?? session.title}
|
||||||
viewKind="session"
|
viewKind="session"
|
||||||
detailMode={sessionDetailLevel(sessionDisplaySession)}
|
detailMode={sessionDetailLevel(sessionDisplaySession)}
|
||||||
agentStatus={sessionConversationSession?.agentStatus ?? sessionDisplaySession?.agentStatus}
|
agentStatus={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.agentStatus ?? sessionDisplaySession?.agentStatus)}
|
||||||
currentTool={sessionConversationSession?.currentTool ?? sessionDisplaySession?.currentTool}
|
currentTool={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.currentTool ?? sessionDisplaySession?.currentTool)}
|
||||||
toolElapsedMs={sessionConversationSession?.toolElapsedMs ?? sessionDisplaySession?.toolElapsedMs}
|
toolElapsedMs={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.toolElapsedMs ?? sessionDisplaySession?.toolElapsedMs)}
|
||||||
lastToolSummary={sessionConversationSession?.lastToolSummary ?? sessionDisplaySession?.lastToolSummary}
|
lastToolSummary={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.lastToolSummary ?? sessionDisplaySession?.lastToolSummary)}
|
||||||
progressLog={sessionProgressLog}
|
progressLog={sessionIsCompanyRuntime ? undefined : sessionProgressLog}
|
||||||
draftAssistantText={sessionConversationSession?.draftAssistantText ?? sessionDisplaySession?.draftAssistantText}
|
draftAssistantText={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftAssistantText ?? sessionDisplaySession?.draftAssistantText)}
|
||||||
draftUpdatedAt={sessionConversationSession?.draftUpdatedAt ?? sessionDisplaySession?.draftUpdatedAt}
|
draftUpdatedAt={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftUpdatedAt ?? sessionDisplaySession?.draftUpdatedAt)}
|
||||||
draftIteration={sessionConversationSession?.draftIteration ?? sessionDisplaySession?.draftIteration}
|
draftIteration={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftIteration ?? sessionDisplaySession?.draftIteration)}
|
||||||
draftTurnId={sessionConversationSession?.draftTurnId ?? sessionDisplaySession?.draftTurnId}
|
draftTurnId={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftTurnId ?? sessionDisplaySession?.draftTurnId)}
|
||||||
isCompanyRuntime={sessionConversationSession?.isCompanyRuntime ?? sessionIsCompanyRuntime}
|
isCompanyRuntime={sessionIsCompanyRuntime}
|
||||||
workItemLog={sessionConversationSession?.workItemLog ?? session.workItemLog}
|
workItemLog={sessionConversationSession?.workItemLog ?? session.workItemLog}
|
||||||
childSessions={sessionWorkItemRoleSessions}
|
childSessions={sessionWorkItemRoleSessions}
|
||||||
|
showWorkItemRuntimeCard={!sessionIsCompanyRuntime}
|
||||||
onSend={(content, _taskId, metadata) => onSessionSend?.(session.taskId, content, undefined, metadata)}
|
onSend={(content, _taskId, metadata) => onSessionSend?.(session.taskId, content, undefined, metadata)}
|
||||||
onWorkItemClick={onWorkItemClick}
|
onWorkItemClick={onWorkItemClick}
|
||||||
onWorkItemOpenSession={onWorkItemOpenSession}
|
onWorkItemOpenSession={onWorkItemOpenSession}
|
||||||
onMarkRead={() => onSessionMarkRead?.(session.taskId)}
|
onMarkRead={() => onSessionMarkRead?.(session.taskId)}
|
||||||
|
scrollScope={session.channelId}
|
||||||
hasOlderHistory={
|
hasOlderHistory={
|
||||||
// Suppress during active work — see note
|
// Keep known cursors available during live work;
|
||||||
// on the childDetailSession case above.
|
// suppress only the count-based fallback.
|
||||||
!sessionConversation.timelineSessions.some(isSessionWorking)
|
conversationHasOlderHistory(
|
||||||
&& sessionMessageCount > sessionMessages.length
|
sessionConversation.timelineSessions,
|
||||||
|
sessionMessages.length,
|
||||||
|
sessionDetailLevel(sessionDisplaySession ?? session),
|
||||||
|
!sessionConversation.timelineSessions.some(isSessionWorking),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
totalMessageCount={sessionMessageCount}
|
totalMessageCount={sessionMessageCount}
|
||||||
onLoadOlderHistory={(oldestMessage) => {
|
onLoadOlderHistory={(oldestMessage) => {
|
||||||
const targetSession = sessionConversation.timelineSessions.find(
|
const detailLevel = sessionDetailLevel(sessionDisplaySession ?? session)
|
||||||
|
const matchedSession = sessionConversation.timelineSessions.find(
|
||||||
(timelineSession) => timelineSession.channelId === oldestMessage?.channelId,
|
(timelineSession) => timelineSession.channelId === oldestMessage?.channelId,
|
||||||
|
)
|
||||||
|
const targetSession = (
|
||||||
|
matchedSession
|
||||||
|
&& sessionHasMoreForDetail(matchedSession, detailLevel) !== false
|
||||||
|
? matchedSession
|
||||||
|
: undefined
|
||||||
|
) ?? sessionConversation.timelineSessions.find(
|
||||||
|
timelineSession => sessionHasMoreForDetail(timelineSession, detailLevel) === true,
|
||||||
) ?? sessionDisplaySession ?? session
|
) ?? sessionDisplaySession ?? session
|
||||||
return onLoadSessionHistory?.(
|
return onLoadSessionHistory?.(
|
||||||
targetSession.taskId,
|
targetSession.taskId,
|
||||||
oldestMessage,
|
oldestMessage,
|
||||||
sessionDetailLevel(targetSession, { childDetail: targetSession.mode === 'child' }),
|
detailLevel,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
loadingOlderHistory={sessionHistoryLoading}
|
loadingOlderHistory={sessionHistoryLoading}
|
||||||
@@ -1062,6 +1116,7 @@ export function ContextPanel({
|
|||||||
detailMode="summary"
|
detailMode="summary"
|
||||||
onSend={onMessageSend}
|
onSend={onMessageSend}
|
||||||
onMarkRead={onMarkRead}
|
onMarkRead={onMarkRead}
|
||||||
|
scrollScope={channelId}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1077,6 +1132,7 @@ export function ContextPanel({
|
|||||||
detailMode="summary"
|
detailMode="summary"
|
||||||
onSend={onMessageSend}
|
onSend={onMessageSend}
|
||||||
onMarkRead={onMarkRead}
|
onMarkRead={onMarkRead}
|
||||||
|
scrollScope={secretaryChannelId}
|
||||||
/>
|
/>
|
||||||
<MessageComposer
|
<MessageComposer
|
||||||
disabled={false}
|
disabled={false}
|
||||||
@@ -1099,7 +1155,7 @@ export function ContextPanel({
|
|||||||
onComplete={(activeHeaderSession ?? activeSession).status !== 'done' && (activeHeaderSession ?? activeSession).status !== 'cancelled' ? onComplete : undefined}
|
onComplete={(activeHeaderSession ?? activeSession).status !== 'done' && (activeHeaderSession ?? activeSession).status !== 'cancelled' ? onComplete : undefined}
|
||||||
onResume={onResume}
|
onResume={onResume}
|
||||||
/>
|
/>
|
||||||
{isCompanyRuntime && (hasRoleWorkItems || activeWorkItemLog.length > 0 || activeWorkItemRoleSessions.length > 0) && (
|
{isCompanyRuntime && (
|
||||||
<div className="ctx-work-item-progress">
|
<div className="ctx-work-item-progress">
|
||||||
<WorkItemProgressCard
|
<WorkItemProgressCard
|
||||||
workItemLog={activeWorkItemLog}
|
workItemLog={activeWorkItemLog}
|
||||||
@@ -1117,16 +1173,16 @@ export function ContextPanel({
|
|||||||
channelName={channelName}
|
channelName={channelName}
|
||||||
viewKind="session"
|
viewKind="session"
|
||||||
detailMode={activeDetailMode}
|
detailMode={activeDetailMode}
|
||||||
agentStatus={activeConversationSession?.agentStatus ?? activeDisplaySession?.agentStatus}
|
agentStatus={isCompanyRuntime ? undefined : (activeConversationSession?.agentStatus ?? activeDisplaySession?.agentStatus)}
|
||||||
currentTool={activeConversationSession?.currentTool ?? activeDisplaySession?.currentTool}
|
currentTool={isCompanyRuntime ? undefined : (activeConversationSession?.currentTool ?? activeDisplaySession?.currentTool)}
|
||||||
toolElapsedMs={activeConversationSession?.toolElapsedMs ?? activeDisplaySession?.toolElapsedMs}
|
toolElapsedMs={isCompanyRuntime ? undefined : (activeConversationSession?.toolElapsedMs ?? activeDisplaySession?.toolElapsedMs)}
|
||||||
lastToolSummary={activeConversationSession?.lastToolSummary ?? activeDisplaySession?.lastToolSummary}
|
lastToolSummary={isCompanyRuntime ? undefined : (activeConversationSession?.lastToolSummary ?? activeDisplaySession?.lastToolSummary)}
|
||||||
progressLog={activeConversationProgress}
|
progressLog={isCompanyRuntime ? undefined : activeConversationProgress}
|
||||||
draftAssistantText={activeConversationSession?.draftAssistantText ?? activeDisplaySession?.draftAssistantText}
|
draftAssistantText={isCompanyRuntime ? undefined : (activeConversationSession?.draftAssistantText ?? activeDisplaySession?.draftAssistantText)}
|
||||||
draftUpdatedAt={activeConversationSession?.draftUpdatedAt ?? activeDisplaySession?.draftUpdatedAt}
|
draftUpdatedAt={isCompanyRuntime ? undefined : (activeConversationSession?.draftUpdatedAt ?? activeDisplaySession?.draftUpdatedAt)}
|
||||||
draftIteration={activeConversationSession?.draftIteration ?? activeDisplaySession?.draftIteration}
|
draftIteration={isCompanyRuntime ? undefined : (activeConversationSession?.draftIteration ?? activeDisplaySession?.draftIteration)}
|
||||||
draftTurnId={activeConversationSession?.draftTurnId ?? activeDisplaySession?.draftTurnId}
|
draftTurnId={isCompanyRuntime ? undefined : (activeConversationSession?.draftTurnId ?? activeDisplaySession?.draftTurnId)}
|
||||||
isCompanyRuntime={activeConversationSession?.isCompanyRuntime ?? isCompanyRuntime}
|
isCompanyRuntime={isCompanyRuntime}
|
||||||
workItemLog={activeWorkItemLog}
|
workItemLog={activeWorkItemLog}
|
||||||
roleWorkItems={activeRoleWorkItems}
|
roleWorkItems={activeRoleWorkItems}
|
||||||
executorRoleWorkItems={activeExecutorRoleWorkItems}
|
executorRoleWorkItems={activeExecutorRoleWorkItems}
|
||||||
@@ -1135,11 +1191,16 @@ export function ContextPanel({
|
|||||||
onWorkItemClick={onWorkItemClick}
|
onWorkItemClick={onWorkItemClick}
|
||||||
onWorkItemOpenSession={onWorkItemOpenSession}
|
onWorkItemOpenSession={onWorkItemOpenSession}
|
||||||
onMarkRead={onMarkRead}
|
onMarkRead={onMarkRead}
|
||||||
|
scrollScope={channelId}
|
||||||
hasOlderHistory={
|
hasOlderHistory={
|
||||||
// Suppress during active work — see note on the
|
// Keep known cursors available during live work;
|
||||||
// childDetailSession case above.
|
// suppress only the count-based fallback.
|
||||||
!activeConversation.timelineSessions.some(isSessionWorking)
|
conversationHasOlderHistory(
|
||||||
&& activeConversationMessageCount > messages.length
|
activeConversation.timelineSessions,
|
||||||
|
messages.length,
|
||||||
|
activeDetailMode,
|
||||||
|
!activeConversation.timelineSessions.some(isSessionWorking),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
totalMessageCount={activeConversationMessageCount}
|
totalMessageCount={activeConversationMessageCount}
|
||||||
onLoadOlderHistory={(oldestMessage) => {
|
onLoadOlderHistory={(oldestMessage) => {
|
||||||
@@ -1148,7 +1209,7 @@ export function ContextPanel({
|
|||||||
return onLoadSessionHistory?.(
|
return onLoadSessionHistory?.(
|
||||||
targetSession.taskId,
|
targetSession.taskId,
|
||||||
oldestMessage,
|
oldestMessage,
|
||||||
sessionDetailLevel(targetSession, { childDetail: targetSession.mode === 'child' }),
|
activeDetailMode,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
loadingOlderHistory={activeConversationLoading}
|
loadingOlderHistory={activeConversationLoading}
|
||||||
|
|||||||
@@ -297,9 +297,11 @@ export function TaskDetailView({
|
|||||||
{linkedSession && linkedSessionMessages && linkedSessionMessages.length > 0 ? (
|
{linkedSession && linkedSessionMessages && linkedSessionMessages.length > 0 ? (
|
||||||
<div className="task-detail-linked-messages">
|
<div className="task-detail-linked-messages">
|
||||||
<MessageList
|
<MessageList
|
||||||
|
key={linkedSession.channelId}
|
||||||
messages={linkedSessionMessages}
|
messages={linkedSessionMessages}
|
||||||
channelName={linkedSession.title ?? 'Runtime Session'}
|
channelName={linkedSession.title ?? 'Runtime Session'}
|
||||||
detailMode="summary"
|
detailMode="summary"
|
||||||
|
scrollScope={linkedSession.channelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : linkedSession ? (
|
) : linkedSession ? (
|
||||||
|
|||||||
@@ -10,10 +10,74 @@ assert.match(src, /makeOptimisticUserMessageId/, 'ordinary composer sends must c
|
|||||||
assert.match(src, /chatStore\.sendMessage/, 'ordinary composer sends must echo the user message locally before backend response')
|
assert.match(src, /chatStore\.sendMessage/, 'ordinary composer sends must echo the user message locally before backend response')
|
||||||
assert.match(src, /ui_message_id: uiMessageId/, 'optimistic local message and websocket metadata must share ui_message_id')
|
assert.match(src, /ui_message_id: uiMessageId/, 'optimistic local message and websocket metadata must share ui_message_id')
|
||||||
assert.match(src, /checkpointReplyId/, 'checkpoint replies must be excluded from ordinary optimistic composer echo')
|
assert.match(src, /checkpointReplyId/, 'checkpoint replies must be excluded from ordinary optimistic composer echo')
|
||||||
|
assert.match(src, /const \{ markRead \} = chatStore/, 'workspace must consume the stable markRead action directly')
|
||||||
|
assert.doesNotMatch(src, /chatStore\.markRead/, 'workspace mark-read callbacks must not depend on the aggregate chatStore object')
|
||||||
|
assert.equal(
|
||||||
|
[...src.matchAll(/\bmarkRead\(/g)].length,
|
||||||
|
2,
|
||||||
|
'markRead must only be invoked by the active viewport and per-session viewport callbacks',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const handleMarkRead = useCallback\(\(\) => \{\s*for \(const visibleChannelId of visibleChannelIds\) \{\s*markRead\(visibleChannelId\)\s*\}\s*\}, \[visibleChannelIds, markRead\]\)/,
|
||||||
|
'the active transcript viewport must mark every channel represented by the visible company timeline',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const handleMarkSessionRead = useCallback\(\(taskId: string\) => \{[\s\S]*?if \(session\) markRead\(session\.channelId\)[\s\S]*?\}, \[sessions, markRead\]\)/,
|
||||||
|
'each multi-session transcript viewport callback must own its channel markRead',
|
||||||
|
)
|
||||||
|
assert.match(src, /onMarkRead=\{handleMarkRead\}/, 'the active viewport must receive the markRead callback')
|
||||||
|
assert.match(src, /onSessionMarkRead=\{handleMarkSessionRead\}/, 'multi-session viewports must receive their scoped markRead callback')
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/const outgoing = metadata\?\.ui_message_id\s*\?\s*metadata\s*:\s*\{ \.\.\.\(metadata \?\? \{\}\), ui_message_id: makeOptimisticUserMessageId\(\) \}/,
|
/const outgoing = metadata\?\.ui_message_id\s*\?\s*metadata\s*:\s*\{ \.\.\.\(metadata \?\? \{\}\), ui_message_id: makeOptimisticUserMessageId\(\) \}/,
|
||||||
'every session send must carry a client-generated ui_message_id so the backend can deduplicate re-deliveries',
|
'every session send must carry a client-generated ui_message_id so the backend can deduplicate re-deliveries',
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('WorkspacePage.test.ts: OK (optimistic composer echo wiring)')
|
const requestHistoryStart = src.indexOf('const requestSessionHistory = useCallback')
|
||||||
|
const requestHistoryEnd = src.indexOf('const isSessionHistoryLoading = useCallback', requestHistoryStart)
|
||||||
|
assert.ok(requestHistoryStart >= 0 && requestHistoryEnd > requestHistoryStart, 'history request implementation must be present')
|
||||||
|
const requestHistorySrc = src.slice(requestHistoryStart, requestHistoryEnd)
|
||||||
|
assert.doesNotMatch(
|
||||||
|
requestHistorySrc,
|
||||||
|
/setTimeout/,
|
||||||
|
'history single-flight completion must follow the transport Promise, not a fixed 800ms timer',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
requestHistorySrc,
|
||||||
|
/Promise\.resolve\(request\)[\s\S]*?\.finally\(\(\) => \{[\s\S]*?historyRequestInFlightRef\.current\.delete\(requestKey\)/,
|
||||||
|
'history single-flight state must be released only when the transport request settles',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
requestHistorySrc,
|
||||||
|
/const generation = historyRequestGenerationRef\.current[\s\S]*?const requestKey = \[\s*generation,/,
|
||||||
|
'history claims must be scoped to a project generation',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
requestHistorySrc,
|
||||||
|
/historyRequestInFlightRef\.current\.delete\(requestKey\)\s*if \(historyRequestGenerationRef\.current !== generation\) return/,
|
||||||
|
'an old project Promise must not clear loading state for a newer project generation',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
requestHistorySrc,
|
||||||
|
/oldestMessage && targetChannelId && oldestMessage\.channelId !== targetChannelId[\s\S]*?getChannelMessagesRef\.current\(targetChannelId\)\.find\([\s\S]*?isMessageVisibleAtDetailLevel\(message, detailLevel\)/,
|
||||||
|
'multi-channel history must use a selected target cursor visible to the requested detail policy',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/if \(autoHistoryRequestRef\.current\.scope !== activeSessionId\) \{\s*autoHistoryRequestRef\.current\.scope = activeSessionId\s*autoHistoryRequestRef\.current\.active\.clear\(\)/,
|
||||||
|
'switching the active transcript scope must clear prior auto-history claims',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const historyTargets = activeConversation\.timelineSessions\.length > 0\s*\? activeConversation\.timelineSessions/,
|
||||||
|
'company history must enumerate the root and child timeline sessions',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const detailLevel = isCompanyConversation\(activeSession, childSessions\.length\)\s*\? 'summary'[\s\S]*?requestSessionHistory\(\s*session\.taskId,\s*undefined,\s*detailLevel/,
|
||||||
|
'company root and child history requests must use summary detail independently',
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('WorkspacePage.test.ts: OK (composer, mark-read, and history wiring)')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { AgentInfo, OrgInfoPayload, SavedOrgSummary } from '../types/visual'
|
import type { AgentInfo, OrgInfoPayload, SavedOrgSummary } from '../types/visual'
|
||||||
import { WorkItemRecoveryPanel } from './WorkItemRecoveryPanel'
|
import { WorkItemRecoveryPanel } from './WorkItemRecoveryPanel'
|
||||||
import type { ChatMessage, CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
|
import type { ChatMessage, CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
|
||||||
@@ -14,8 +14,10 @@ import { BoardSelector } from '../kanban/BoardSelector'
|
|||||||
import {
|
import {
|
||||||
getConversationPeerSessions,
|
getConversationPeerSessions,
|
||||||
getWorkItemChildSessions,
|
getWorkItemChildSessions,
|
||||||
|
isMessageVisibleAtDetailLevel,
|
||||||
mergeConversationMessages,
|
mergeConversationMessages,
|
||||||
projectSessionConversation,
|
projectSessionConversation,
|
||||||
|
selectCompanySummaryMessages,
|
||||||
} from '../lib/workItemSessions'
|
} from '../lib/workItemSessions'
|
||||||
import { getRuntimeOrgView } from '../lib/runtimeOrg'
|
import { getRuntimeOrgView } from '../lib/runtimeOrg'
|
||||||
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
|
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
|
||||||
@@ -125,6 +127,18 @@ function sessionDetailLevel(
|
|||||||
return session.execMode === 'company' || session.execMode === 'org' || session.execMode === 'custom' ? 'summary' : 'full'
|
return session.execMode === 'company' || session.execMode === 'org' || session.execMode === 'custom' ? 'summary' : 'full'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCompanyConversation(session: Session | null | undefined, relatedSessionCount = 0): boolean {
|
||||||
|
if (!session) return false
|
||||||
|
const mode = String(session.execMode ?? '').trim().toLowerCase()
|
||||||
|
return relatedSessionCount > 0
|
||||||
|
|| !!session.isCompanyRuntime
|
||||||
|
|| !!session.roleWorkItems
|
||||||
|
|| !!session.executorRoleWorkItems
|
||||||
|
|| mode === 'company'
|
||||||
|
|| mode === 'org'
|
||||||
|
|| mode === 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
function sessionBoardId(session: Session | null | undefined): string | null {
|
function sessionBoardId(session: Session | null | undefined): string | null {
|
||||||
const boardId = String(session?.originTaskId ?? session?.taskId ?? '').trim()
|
const boardId = String(session?.originTaskId ?? session?.taskId ?? '').trim()
|
||||||
return boardId || null
|
return boardId || null
|
||||||
@@ -244,7 +258,7 @@ interface WorkspacePageProps {
|
|||||||
onLoadSessionDetail?: (
|
onLoadSessionDetail?: (
|
||||||
taskId: string,
|
taskId: string,
|
||||||
opts?: { beforeCreatedAt?: number; beforeMessageId?: string; limit?: number; detailLevel?: 'summary' | 'full'; include?: string[] },
|
opts?: { beforeCreatedAt?: number; beforeMessageId?: string; limit?: number; detailLevel?: 'summary' | 'full'; include?: string[] },
|
||||||
) => void
|
) => Promise<void> | void
|
||||||
onOpenExecutionPanel?: (taskId: string) => void
|
onOpenExecutionPanel?: (taskId: string) => void
|
||||||
onCollabSync?: () => void
|
onCollabSync?: () => void
|
||||||
orgInfoData?: OrgInfoPayload | null
|
orgInfoData?: OrgInfoPayload | null
|
||||||
@@ -304,6 +318,7 @@ export function WorkspacePage({
|
|||||||
onSavedOrgLoad,
|
onSavedOrgLoad,
|
||||||
}: WorkspacePageProps) {
|
}: WorkspacePageProps) {
|
||||||
const { sessions, activeSessionId, activeSession } = sessionStore
|
const { sessions, activeSessionId, activeSession } = sessionStore
|
||||||
|
const { markRead } = chatStore
|
||||||
|
|
||||||
// ── Panel state ──
|
// ── Panel state ──
|
||||||
const [panelState, setPanelState] = useState<'collapsed' | 'open' | 'maximized'>('collapsed')
|
const [panelState, setPanelState] = useState<'collapsed' | 'open' | 'maximized'>('collapsed')
|
||||||
@@ -320,10 +335,18 @@ export function WorkspacePage({
|
|||||||
const [multiSessionView, setMultiSessionView] = useState(false)
|
const [multiSessionView, setMultiSessionView] = useState(false)
|
||||||
const [sessionHistoryLoading, setSessionHistoryLoading] = useState<Record<string, boolean>>({})
|
const [sessionHistoryLoading, setSessionHistoryLoading] = useState<Record<string, boolean>>({})
|
||||||
const onLoadSessionDetailRef = useRef(onLoadSessionDetail)
|
const onLoadSessionDetailRef = useRef(onLoadSessionDetail)
|
||||||
const autoHistoryRequestRef = useRef<{ active: string | null; child: string | null }>({
|
const sessionsRef = useRef(sessions)
|
||||||
active: null,
|
const getChannelMessagesRef = useRef(chatStore.getChannelMessages)
|
||||||
|
const autoHistoryRequestRef = useRef<{ scope: string | null; active: Set<string>; child: string | null }>({
|
||||||
|
scope: null,
|
||||||
|
active: new Set(),
|
||||||
child: null,
|
child: null,
|
||||||
})
|
})
|
||||||
|
const historyRequestInFlightRef = useRef<Set<string>>(new Set())
|
||||||
|
const historyRequestGenerationRef = useRef(0)
|
||||||
|
|
||||||
|
sessionsRef.current = sessions
|
||||||
|
getChannelMessagesRef.current = chatStore.getChannelMessages
|
||||||
|
|
||||||
const isCompanyMode = execMode === 'company' || execMode === 'org' || execMode === 'custom'
|
const isCompanyMode = execMode === 'company' || execMode === 'org' || execMode === 'custom'
|
||||||
|
|
||||||
@@ -375,16 +398,58 @@ export function WorkspacePage({
|
|||||||
) => {
|
) => {
|
||||||
const loadSessionDetail = onLoadSessionDetailRef.current
|
const loadSessionDetail = onLoadSessionDetailRef.current
|
||||||
if (!loadSessionDetail || !taskId) return
|
if (!loadSessionDetail || !taskId) return
|
||||||
|
const generation = historyRequestGenerationRef.current
|
||||||
|
const targetSession = sessionsRef.current.find(session => session.taskId === taskId)
|
||||||
|
const targetChannelId = targetSession?.channelId
|
||||||
|
const cursorMessage = oldestMessage && targetChannelId && oldestMessage.channelId !== targetChannelId
|
||||||
|
? getChannelMessagesRef.current(targetChannelId).find(
|
||||||
|
message => isMessageVisibleAtDetailLevel(message, detailLevel),
|
||||||
|
)
|
||||||
|
: oldestMessage
|
||||||
|
const requestKey = [
|
||||||
|
generation,
|
||||||
|
taskId,
|
||||||
|
detailLevel,
|
||||||
|
cursorMessage?.timestamp ?? 'latest',
|
||||||
|
cursorMessage?.id ?? '',
|
||||||
|
].join('|')
|
||||||
|
if (historyRequestInFlightRef.current.has(requestKey)) return
|
||||||
|
// Claim the cursor synchronously before invoking the transport. Loading
|
||||||
|
// state is asynchronous and cannot serve as a single-flight guard.
|
||||||
|
historyRequestInFlightRef.current.add(requestKey)
|
||||||
setSessionHistoryLoading(prev => prev[taskId] ? prev : { ...prev, [taskId]: true })
|
setSessionHistoryLoading(prev => prev[taskId] ? prev : { ...prev, [taskId]: true })
|
||||||
loadSessionDetail(taskId, {
|
let request: Promise<void> | void
|
||||||
|
try {
|
||||||
|
request = loadSessionDetail(taskId, {
|
||||||
limit: SESSION_DETAIL_PAGE_SIZE,
|
limit: SESSION_DETAIL_PAGE_SIZE,
|
||||||
beforeCreatedAt: oldestMessage?.timestamp,
|
beforeCreatedAt: cursorMessage?.timestamp,
|
||||||
beforeMessageId: oldestMessage?.id,
|
beforeMessageId: cursorMessage?.id,
|
||||||
detailLevel,
|
detailLevel,
|
||||||
})
|
})
|
||||||
window.setTimeout(() => {
|
} catch (error) {
|
||||||
|
historyRequestInFlightRef.current.delete(requestKey)
|
||||||
|
if (historyRequestGenerationRef.current === generation) {
|
||||||
setSessionHistoryLoading(prev => prev[taskId] ? { ...prev, [taskId]: false } : prev)
|
setSessionHistoryLoading(prev => prev[taskId] ? { ...prev, [taskId]: false } : prev)
|
||||||
}, 800)
|
autoHistoryRequestRef.current.active.delete(`${taskId}:${detailLevel}`)
|
||||||
|
if (autoHistoryRequestRef.current.child === taskId) autoHistoryRequestRef.current.child = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return Promise.resolve(request).catch(() => {
|
||||||
|
if (historyRequestGenerationRef.current === generation) {
|
||||||
|
autoHistoryRequestRef.current.active.delete(`${taskId}:${detailLevel}`)
|
||||||
|
if (autoHistoryRequestRef.current.child === taskId) autoHistoryRequestRef.current.child = null
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
historyRequestInFlightRef.current.delete(requestKey)
|
||||||
|
if (historyRequestGenerationRef.current !== generation) return
|
||||||
|
const taskPrefix = `${generation}|${taskId}|`
|
||||||
|
const taskStillLoading = [...historyRequestInFlightRef.current.keys()]
|
||||||
|
.some(key => key.startsWith(taskPrefix))
|
||||||
|
if (!taskStillLoading) {
|
||||||
|
setSessionHistoryLoading(prev => prev[taskId] ? { ...prev, [taskId]: false } : prev)
|
||||||
|
}
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isSessionHistoryLoading = useCallback((taskId: string) => {
|
const isSessionHistoryLoading = useCallback((taskId: string) => {
|
||||||
@@ -392,10 +457,18 @@ export function WorkspacePage({
|
|||||||
}, [sessionHistoryLoading])
|
}, [sessionHistoryLoading])
|
||||||
|
|
||||||
// Auto-clear childDetailTaskId if session was deleted
|
// Auto-clear childDetailTaskId if session was deleted
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
autoHistoryRequestRef.current = { active: null, child: null }
|
historyRequestGenerationRef.current += 1
|
||||||
|
historyRequestInFlightRef.current.clear()
|
||||||
|
autoHistoryRequestRef.current = { scope: null, active: new Set(), child: null }
|
||||||
|
setSessionHistoryLoading({})
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
historyRequestGenerationRef.current += 1
|
||||||
|
historyRequestInFlightRef.current.clear()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (childDetailTaskId && !childDetailSession) {
|
if (childDetailTaskId && !childDetailSession) {
|
||||||
setChildDetailTaskId(null)
|
setChildDetailTaskId(null)
|
||||||
@@ -432,31 +505,37 @@ export function WorkspacePage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeSessionId) {
|
if (!activeSessionId) {
|
||||||
autoHistoryRequestRef.current.active = null
|
autoHistoryRequestRef.current.scope = null
|
||||||
|
autoHistoryRequestRef.current.active.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (autoHistoryRequestRef.current.scope !== activeSessionId) {
|
||||||
|
autoHistoryRequestRef.current.scope = activeSessionId
|
||||||
|
autoHistoryRequestRef.current.active.clear()
|
||||||
|
}
|
||||||
const historyTargets = activeConversation.timelineSessions.length > 0
|
const historyTargets = activeConversation.timelineSessions.length > 0
|
||||||
? activeConversation.timelineSessions
|
? activeConversation.timelineSessions
|
||||||
: (sessions.find(session => session.taskId === activeSessionId)
|
: (sessions.find(session => session.taskId === activeSessionId)
|
||||||
? [sessions.find(session => session.taskId === activeSessionId)!]
|
? [sessions.find(session => session.taskId === activeSessionId)!]
|
||||||
: [])
|
: [])
|
||||||
if (historyTargets.length === 0) {
|
if (historyTargets.length === 0) {
|
||||||
autoHistoryRequestRef.current.active = null
|
autoHistoryRequestRef.current.active.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const requestKey = historyTargets
|
|
||||||
.map((session) => `${session.taskId}:${sessionDetailLevel(session, { childDetail: session.mode === 'child' })}`)
|
|
||||||
.join('|')
|
|
||||||
if (autoHistoryRequestRef.current.active === requestKey) return
|
|
||||||
autoHistoryRequestRef.current.active = requestKey
|
|
||||||
for (const session of historyTargets) {
|
for (const session of historyTargets) {
|
||||||
|
const detailLevel = isCompanyConversation(activeSession, childSessions.length)
|
||||||
|
? 'summary'
|
||||||
|
: sessionDetailLevel(session)
|
||||||
|
const requestKey = `${session.taskId}:${detailLevel}`
|
||||||
|
if (autoHistoryRequestRef.current.active.has(requestKey)) continue
|
||||||
|
autoHistoryRequestRef.current.active.add(requestKey)
|
||||||
requestSessionHistory(
|
requestSessionHistory(
|
||||||
session.taskId,
|
session.taskId,
|
||||||
undefined,
|
undefined,
|
||||||
sessionDetailLevel(session, { childDetail: session.mode === 'child' }),
|
detailLevel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [activeConversation.timelineSessions, activeSessionId, requestSessionHistory, sessions])
|
}, [activeConversation.timelineSessions, activeSession, activeSessionId, childSessions.length, requestSessionHistory, sessions])
|
||||||
|
|
||||||
// Sync activeView when activeSessionId changes externally
|
// Sync activeView when activeSessionId changes externally
|
||||||
const effectiveView: ActiveView = useMemo(() => {
|
const effectiveView: ActiveView = useMemo(() => {
|
||||||
@@ -517,9 +596,13 @@ export function WorkspacePage({
|
|||||||
.reverse()
|
.reverse()
|
||||||
}
|
}
|
||||||
if (effectiveView.kind === 'session' && visibleChannelIds.length > 1) {
|
if (effectiveView.kind === 'session' && visibleChannelIds.length > 1) {
|
||||||
return mergeConversationMessages(
|
const messageGroups = visibleChannelIds.map(
|
||||||
visibleChannelIds.map((visibleChannelId) => chatStore.getChannelMessages(visibleChannelId)),
|
(visibleChannelId) => chatStore.getChannelMessages(visibleChannelId),
|
||||||
)
|
)
|
||||||
|
if (isCompanyConversation(activeSession, childSessions.length) && activeSession) {
|
||||||
|
return selectCompanySummaryMessages(messageGroups.flat(), activeSession.channelId)
|
||||||
|
}
|
||||||
|
return mergeConversationMessages(messageGroups)
|
||||||
}
|
}
|
||||||
return chatStore.getChannelMessages(channelId)
|
return chatStore.getChannelMessages(channelId)
|
||||||
}, [
|
}, [
|
||||||
@@ -528,6 +611,8 @@ export function WorkspacePage({
|
|||||||
channelId,
|
channelId,
|
||||||
effectiveView.kind,
|
effectiveView.kind,
|
||||||
activeChannelIds,
|
activeChannelIds,
|
||||||
|
activeSession,
|
||||||
|
childSessions.length,
|
||||||
visibleChannelIds,
|
visibleChannelIds,
|
||||||
])
|
])
|
||||||
const childDetailMessages = useMemo(() => {
|
const childDetailMessages = useMemo(() => {
|
||||||
@@ -607,11 +692,12 @@ export function WorkspacePage({
|
|||||||
const sessionChildren = getWorkItemChildSessions(session, sessions)
|
const sessionChildren = getWorkItemChildSessions(session, sessions)
|
||||||
const sessionPeers = getConversationPeerSessions(session, sessions)
|
const sessionPeers = getConversationPeerSessions(session, sessions)
|
||||||
const projection = projectSessionConversation(session, [...sessionPeers, ...sessionChildren])
|
const projection = projectSessionConversation(session, [...sessionPeers, ...sessionChildren])
|
||||||
result[session.taskId] = mergeConversationMessages(
|
const messageGroups = projection.timelineSessions.map((timelineSession) => (
|
||||||
projection.timelineSessions.map((timelineSession) => (
|
|
||||||
chatStore.getChannelMessages(timelineSession.channelId)
|
chatStore.getChannelMessages(timelineSession.channelId)
|
||||||
)),
|
))
|
||||||
)
|
result[session.taskId] = isCompanyConversation(session, sessionChildren.length)
|
||||||
|
? selectCompanySummaryMessages(messageGroups.flat(), session.channelId)
|
||||||
|
: mergeConversationMessages(messageGroups)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}, [openSessions, sessions, chatStore.getChannelMessages])
|
}, [openSessions, sessions, chatStore.getChannelMessages])
|
||||||
@@ -670,24 +756,16 @@ export function WorkspacePage({
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [panelState])
|
}, [panelState])
|
||||||
|
|
||||||
// Auto-mark channel as read
|
|
||||||
useEffect(() => {
|
|
||||||
if (panelState === 'collapsed') return
|
|
||||||
for (const visibleChannelId of visibleChannelIds) {
|
|
||||||
chatStore.markRead(visibleChannelId)
|
|
||||||
}
|
|
||||||
}, [visibleChannelIds, chatStore, panelState])
|
|
||||||
|
|
||||||
const handleMarkRead = useCallback(() => {
|
const handleMarkRead = useCallback(() => {
|
||||||
for (const visibleChannelId of visibleChannelIds) {
|
for (const visibleChannelId of visibleChannelIds) {
|
||||||
chatStore.markRead(visibleChannelId)
|
markRead(visibleChannelId)
|
||||||
}
|
}
|
||||||
}, [visibleChannelIds, chatStore])
|
}, [visibleChannelIds, markRead])
|
||||||
|
|
||||||
const handleMarkSessionRead = useCallback((taskId: string) => {
|
const handleMarkSessionRead = useCallback((taskId: string) => {
|
||||||
const session = sessions.find(item => item.taskId === taskId)
|
const session = sessions.find(item => item.taskId === taskId)
|
||||||
if (session) chatStore.markRead(session.channelId)
|
if (session) markRead(session.channelId)
|
||||||
}, [sessions, chatStore])
|
}, [sessions, markRead])
|
||||||
|
|
||||||
const focusSession = useCallback((taskId: string) => {
|
const focusSession = useCallback((taskId: string) => {
|
||||||
const session = sessions.find(item => item.taskId === taskId)
|
const session = sessions.find(item => item.taskId === taskId)
|
||||||
@@ -698,8 +776,7 @@ export function WorkspacePage({
|
|||||||
setPanelState('open')
|
setPanelState('open')
|
||||||
setPanelTab('chat')
|
setPanelTab('chat')
|
||||||
setChildDetailTaskId(null)
|
setChildDetailTaskId(null)
|
||||||
chatStore.markRead(session.channelId)
|
}, [sessions, ensureSessionOpen, sessionStore])
|
||||||
}, [sessions, ensureSessionOpen, sessionStore, chatStore])
|
|
||||||
|
|
||||||
const handleCloseSessionView = useCallback((taskId: string) => {
|
const handleCloseSessionView = useCallback((taskId: string) => {
|
||||||
const remaining = openSessionIds.filter(id => id !== taskId)
|
const remaining = openSessionIds.filter(id => id !== taskId)
|
||||||
@@ -711,14 +788,12 @@ export function WorkspacePage({
|
|||||||
const nextActive = remaining[remaining.length - 1] ?? null
|
const nextActive = remaining[remaining.length - 1] ?? null
|
||||||
sessionStore.setActiveSession(nextActive)
|
sessionStore.setActiveSession(nextActive)
|
||||||
if (nextActive) {
|
if (nextActive) {
|
||||||
const nextSession = sessions.find(item => item.taskId === nextActive)
|
|
||||||
setActiveView({ kind: 'session', taskId: nextActive })
|
setActiveView({ kind: 'session', taskId: nextActive })
|
||||||
setPanelTab('chat')
|
setPanelTab('chat')
|
||||||
if (nextSession) chatStore.markRead(nextSession.channelId)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setActiveView({ kind: 'activity' })
|
setActiveView({ kind: 'activity' })
|
||||||
}, [openSessionIds, childDetailTaskId, activeSessionId, sessionStore, sessions, chatStore])
|
}, [openSessionIds, childDetailTaskId, activeSessionId, sessionStore])
|
||||||
|
|
||||||
// ── Session selection (sidebar click or board card click) ──
|
// ── Session selection (sidebar click or board card click) ──
|
||||||
const handleSelectSession = useCallback((taskId: string | null) => {
|
const handleSelectSession = useCallback((taskId: string | null) => {
|
||||||
@@ -746,8 +821,7 @@ export function WorkspacePage({
|
|||||||
sessionStore.setActiveSession(null)
|
sessionStore.setActiveSession(null)
|
||||||
setChildDetailTaskId(null)
|
setChildDetailTaskId(null)
|
||||||
setPanelState('open')
|
setPanelState('open')
|
||||||
chatStore.markRead(secretaryChannelId)
|
}, [sessionStore])
|
||||||
}, [sessionStore, chatStore, secretaryChannelId])
|
|
||||||
|
|
||||||
// ── Board interactions ──
|
// ── Board interactions ──
|
||||||
const handleCardClick = useCallback((task: { id: string }) => {
|
const handleCardClick = useCallback((task: { id: string }) => {
|
||||||
@@ -999,9 +1073,8 @@ export function WorkspacePage({
|
|||||||
setChildDetailTaskId(session.taskId)
|
setChildDetailTaskId(session.taskId)
|
||||||
setPanelState('open')
|
setPanelState('open')
|
||||||
setPanelTab('chat')
|
setPanelTab('chat')
|
||||||
chatStore.markRead(session.channelId)
|
|
||||||
}
|
}
|
||||||
}, [sessions, chatStore])
|
}, [sessions])
|
||||||
|
|
||||||
const handleWorkItemClick = useCallback((executionTurnId: string) => {
|
const handleWorkItemClick = useCallback((executionTurnId: string) => {
|
||||||
// Always forward to ExecutionPanel. The panel's lookup matches against
|
// Always forward to ExecutionPanel. The panel's lookup matches against
|
||||||
|
|||||||
@@ -665,21 +665,53 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctx-body > .msg-list {
|
.ctx-body > .msg-list,
|
||||||
|
.ctx-body > .msg-list-shell {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctx-work-item-progress {
|
.ctx-work-item-progress {
|
||||||
flex-shrink: 0;
|
flex: 0 0 84px;
|
||||||
|
height: 84px;
|
||||||
|
min-height: 84px;
|
||||||
padding: 12px 12px 0;
|
padding: 12px 12px 0;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
overflow-x: hidden;
|
|
||||||
max-height: 50vh;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ctx-work-item-progress .wi-progress-card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 72px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
gap: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-work-item-progress .wi-progress-pipeline {
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-work-item-progress .wi-progress-pipeline::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-work-item-progress .wi-projection-group {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-work-item-progress .wi-progress-pipeline-empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 22px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.ctx-multi-grid {
|
.ctx-multi-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1863,13 +1895,21 @@
|
|||||||
TaskDetailView — linked session messages
|
TaskDetailView — linked session messages
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
.task-detail-linked-messages {
|
.task-detail-linked-messages {
|
||||||
max-height: 400px;
|
height: min(400px, 55vh);
|
||||||
overflow-y: auto;
|
min-height: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-detail-linked-messages > .msg-list-shell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
.task-detail-empty-hint {
|
.task-detail-empty-hint {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ import json
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Literal, TYPE_CHECKING
|
from typing import Any, TYPE_CHECKING
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from opc.core.models import normalize_role_runtime_status
|
from opc.core.models import normalize_role_runtime_status
|
||||||
|
from opc.core.transcript_visibility import (
|
||||||
|
FULL_DETAIL_ONLY_TRANSCRIPT_KINDS,
|
||||||
|
TranscriptDetailLevel,
|
||||||
|
normalize_transcript_detail_level,
|
||||||
|
transcript_metadata_visible,
|
||||||
|
)
|
||||||
from opc.layer2_organization.phase import (
|
from opc.layer2_organization.phase import (
|
||||||
DONE_PHASES,
|
DONE_PHASES,
|
||||||
IN_PROGRESS_PHASES,
|
IN_PROGRESS_PHASES,
|
||||||
@@ -118,14 +124,7 @@ def _session_message_ui_identity(message: Any) -> tuple[str, float, dict[str, An
|
|||||||
return canonical_id or str(getattr(message, "message_id", "") or ""), timestamp, ui_meta
|
return canonical_id or str(getattr(message, "message_id", "") or ""), timestamp, ui_meta
|
||||||
|
|
||||||
|
|
||||||
TranscriptDetailLevel = Literal["summary", "full"]
|
_FULL_DETAIL_ONLY_TRANSCRIPT_KINDS = FULL_DETAIL_ONLY_TRANSCRIPT_KINDS
|
||||||
|
|
||||||
_FULL_DETAIL_ONLY_TRANSCRIPT_KINDS: frozenset[str] = frozenset({
|
|
||||||
"runtime_v2_user_turn",
|
|
||||||
"runtime_v2_intermediate_assistant",
|
|
||||||
"runtime_v2_company_assistant",
|
|
||||||
"runtime_v2_tool_output",
|
|
||||||
})
|
|
||||||
|
|
||||||
_TRANSCRIPT_DUPLICATE_KIND_GROUPS: tuple[frozenset[str], ...] = (
|
_TRANSCRIPT_DUPLICATE_KIND_GROUPS: tuple[frozenset[str], ...] = (
|
||||||
frozenset({
|
frozenset({
|
||||||
@@ -208,10 +207,7 @@ def _strip_narrative_title_prefix(content: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_transcript_detail_level(value: Any) -> TranscriptDetailLevel:
|
def _normalize_transcript_detail_level(value: Any) -> TranscriptDetailLevel:
|
||||||
normalized = str(value or "").strip().lower()
|
return normalize_transcript_detail_level(value)
|
||||||
if normalized == "full":
|
|
||||||
return "full"
|
|
||||||
return "summary"
|
|
||||||
|
|
||||||
|
|
||||||
def _transcript_message_kind(message: Any) -> str:
|
def _transcript_message_kind(message: Any) -> str:
|
||||||
@@ -229,13 +225,7 @@ def _transcript_message_hidden_from_ui(
|
|||||||
detail_level: TranscriptDetailLevel = "summary",
|
detail_level: TranscriptDetailLevel = "summary",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
metadata = dict(getattr(message, "metadata", {}) or {})
|
metadata = dict(getattr(message, "metadata", {}) or {})
|
||||||
kind = str(metadata.get("kind", "") or "").strip()
|
return not transcript_metadata_visible(metadata, detail_level=detail_level)
|
||||||
if metadata.get("company_final_turn"):
|
|
||||||
# The role's final reply of a company turn is the user-visible result
|
|
||||||
# (intake/aggregate turns have no engine-recorded result surface, so
|
|
||||||
# hiding this would drop the reply from the chat entirely).
|
|
||||||
return False
|
|
||||||
return detail_level != "full" and kind in _FULL_DETAIL_ONLY_TRANSCRIPT_KINDS
|
|
||||||
|
|
||||||
|
|
||||||
def _render_text_parts(parts: list[Any]) -> str:
|
def _render_text_parts(parts: list[Any]) -> str:
|
||||||
@@ -1168,7 +1158,14 @@ def _prefer_duplicate_message(left: dict[str, Any], right: dict[str, Any]) -> tu
|
|||||||
return right, left
|
return right, left
|
||||||
|
|
||||||
|
|
||||||
def _collapse_adjacent_transcript_duplicates(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def collapse_adjacent_transcript_duplicates(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Collapse duplicate rendered result surfaces in chronological order.
|
||||||
|
|
||||||
|
This is public within the Office UI package because transcript pagination
|
||||||
|
formats raw database chunks incrementally. Reusing the renderer's exact
|
||||||
|
collapse rule at chunk boundaries keeps pagination and full snapshots
|
||||||
|
identical.
|
||||||
|
"""
|
||||||
collapsed: list[dict[str, Any]] = []
|
collapsed: list[dict[str, Any]] = []
|
||||||
for message in messages:
|
for message in messages:
|
||||||
if not collapsed:
|
if not collapsed:
|
||||||
@@ -1236,7 +1233,7 @@ def build_transcript_ui_messages(
|
|||||||
"metadata": dict(formatted.get("metadata", {}) or {}),
|
"metadata": dict(formatted.get("metadata", {}) or {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
collapsed_messages = _collapse_adjacent_transcript_duplicates(formatted_messages)
|
collapsed_messages = collapse_adjacent_transcript_duplicates(formatted_messages)
|
||||||
normalized_detail_level = _normalize_transcript_detail_level(detail_level)
|
normalized_detail_level = _normalize_transcript_detail_level(detail_level)
|
||||||
if normalized_detail_level == "full":
|
if normalized_detail_level == "full":
|
||||||
return collapsed_messages
|
return collapsed_messages
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from opc.core.org_config import (
|
|||||||
write_org_index,
|
write_org_index,
|
||||||
)
|
)
|
||||||
from opc.core.models import normalize_role_runtime_status
|
from opc.core.models import normalize_role_runtime_status
|
||||||
|
from opc.core.transcript_visibility import rendered_transcript_metadata_visible
|
||||||
from opc.presentation.kanban import build_company_board_columns
|
from opc.presentation.kanban import build_company_board_columns
|
||||||
from opc.layer2_organization.phase import (
|
from opc.layer2_organization.phase import (
|
||||||
kanban_column,
|
kanban_column,
|
||||||
@@ -77,6 +78,7 @@ from opc.plugins.office_ui.services import (
|
|||||||
from opc.plugins.office_ui.snapshot_builder import (
|
from opc.plugins.office_ui.snapshot_builder import (
|
||||||
STATUS_TO_COLUMN,
|
STATUS_TO_COLUMN,
|
||||||
_build_company_runtime_control_by_task,
|
_build_company_runtime_control_by_task,
|
||||||
|
collapse_adjacent_transcript_duplicates,
|
||||||
_build_session_context_preview,
|
_build_session_context_preview,
|
||||||
_extract_markdown_text,
|
_extract_markdown_text,
|
||||||
_sanitize_ui_message_dict,
|
_sanitize_ui_message_dict,
|
||||||
@@ -947,11 +949,8 @@ class WSHandler:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _message_visible_in_detail_level(message: dict[str, Any], detail_level: str) -> bool:
|
def _message_visible_in_detail_level(message: dict[str, Any], detail_level: str) -> bool:
|
||||||
normalized_detail_level = _normalize_transcript_detail_level(detail_level)
|
|
||||||
if normalized_detail_level == "full":
|
|
||||||
return True
|
|
||||||
metadata = dict(message.get("metadata", {}) or {})
|
metadata = dict(message.get("metadata", {}) or {})
|
||||||
return str(metadata.get("detail_visibility", "summary")).strip() != "full"
|
return rendered_transcript_metadata_visible(metadata, detail_level=detail_level)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _filter_ui_messages_for_detail_level(
|
def _filter_ui_messages_for_detail_level(
|
||||||
@@ -989,25 +988,86 @@ class WSHandler:
|
|||||||
channel_id = f"session:{task_id}"
|
channel_id = f"session:{task_id}"
|
||||||
page_loader = getattr(store, "get_session_transcript_page", None)
|
page_loader = getattr(store, "get_session_transcript_page", None)
|
||||||
if callable(page_loader):
|
if callable(page_loader):
|
||||||
before_dt = datetime.fromtimestamp(before_timestamp) if before_timestamp is not None else None
|
normalized_limit = max(1, min(int(limit), 500))
|
||||||
|
normalized_detail_level = _normalize_transcript_detail_level(detail_level)
|
||||||
|
# A database-visible transcript row can still disappear when the
|
||||||
|
# renderer finds no content, and adjacent result surfaces can
|
||||||
|
# collapse to one UI row. Page raw rows in bounded chunks until
|
||||||
|
# we have one *rendered* look-ahead row or exhaust the transcript.
|
||||||
|
# This makes has_more describe the UI timeline rather than the SQL
|
||||||
|
# row set and prevents an empty raw page from stalling history.
|
||||||
|
chunk_limit = normalized_limit
|
||||||
|
raw_before_dt = (
|
||||||
|
datetime.fromtimestamp(before_timestamp)
|
||||||
|
if before_timestamp is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
raw_before_id = before_message_id
|
||||||
|
seen_raw_cursors: set[tuple[datetime, str]] = set()
|
||||||
|
formatted_messages: list[dict[str, Any]] = []
|
||||||
|
total_count = 0
|
||||||
|
raw_has_more = False
|
||||||
|
|
||||||
|
while True:
|
||||||
raw_page = page_loader(
|
raw_page = page_loader(
|
||||||
session_id,
|
session_id,
|
||||||
limit=limit,
|
limit=chunk_limit,
|
||||||
before_created_at=before_dt,
|
before_created_at=raw_before_dt,
|
||||||
before_message_id=before_message_id,
|
before_message_id=raw_before_id,
|
||||||
detail_level=_normalize_transcript_detail_level(detail_level),
|
detail_level=normalized_detail_level,
|
||||||
)
|
)
|
||||||
page = await raw_page if inspect.isawaitable(raw_page) else raw_page
|
page = await raw_page if inspect.isawaitable(raw_page) else raw_page
|
||||||
transcript_page = list((page or {}).get("messages", []) or [])
|
transcript_chunk = list((page or {}).get("messages", []) or [])
|
||||||
formatted_page = build_transcript_ui_messages(
|
total_count = max(
|
||||||
transcript_page,
|
total_count,
|
||||||
|
int((page or {}).get("total_count", 0) or 0),
|
||||||
|
)
|
||||||
|
raw_has_more = bool((page or {}).get("has_more", False))
|
||||||
|
if not transcript_chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
formatted_chunk = build_transcript_ui_messages(
|
||||||
|
transcript_chunk,
|
||||||
channel_id=channel_id,
|
channel_id=channel_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
detail_level=_normalize_transcript_detail_level(detail_level),
|
detail_level=normalized_detail_level,
|
||||||
|
)
|
||||||
|
formatted_messages = collapse_adjacent_transcript_duplicates([
|
||||||
|
*formatted_chunk,
|
||||||
|
*formatted_messages,
|
||||||
|
])
|
||||||
|
if len(formatted_messages) > normalized_limit:
|
||||||
|
return (
|
||||||
|
formatted_messages[-normalized_limit:],
|
||||||
|
max(total_count, len(formatted_messages)),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
if not raw_has_more:
|
||||||
|
break
|
||||||
|
|
||||||
|
oldest_message = transcript_chunk[0].get("message")
|
||||||
|
oldest_created_at = getattr(oldest_message, "created_at", None)
|
||||||
|
oldest_message_id = str(
|
||||||
|
getattr(oldest_message, "message_id", "") or ""
|
||||||
|
).strip()
|
||||||
|
if not isinstance(oldest_created_at, datetime) or not oldest_message_id:
|
||||||
|
break
|
||||||
|
next_cursor = (oldest_created_at, oldest_message_id)
|
||||||
|
if next_cursor in seen_raw_cursors:
|
||||||
|
break
|
||||||
|
seen_raw_cursors.add(next_cursor)
|
||||||
|
raw_before_dt, raw_before_id = next_cursor
|
||||||
|
# Small client pages should not require one SQL round-trip per
|
||||||
|
# empty row. Start at the requested size so duplicate/empty
|
||||||
|
# boundaries are exact, then grow only while looking through
|
||||||
|
# rows which did not fill the rendered page.
|
||||||
|
chunk_limit = min(500, max(chunk_limit + 1, chunk_limit * 2))
|
||||||
|
|
||||||
|
return (
|
||||||
|
formatted_messages[-normalized_limit:],
|
||||||
|
max(total_count, len(formatted_messages)),
|
||||||
|
raw_has_more,
|
||||||
)
|
)
|
||||||
total_count = int((page or {}).get("total_count", len(formatted_page)) or 0)
|
|
||||||
has_more = bool((page or {}).get("has_more", False))
|
|
||||||
return formatted_page, max(total_count, len(formatted_page)), has_more
|
|
||||||
|
|
||||||
transcript_loader = getattr(store, "get_session_transcript", None)
|
transcript_loader = getattr(store, "get_session_transcript", None)
|
||||||
if not callable(transcript_loader):
|
if not callable(transcript_loader):
|
||||||
@@ -5497,38 +5557,28 @@ class WSHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
messages = await self.chat_store.get_channel_messages_page(
|
cache_page = await self.chat_store.get_channel_messages_page_info(
|
||||||
channel_id,
|
channel_id,
|
||||||
limit=request_limit,
|
limit=request_limit,
|
||||||
before_timestamp=before_timestamp,
|
before_timestamp=before_timestamp,
|
||||||
before_message_id=before_message_id,
|
before_message_id=before_message_id,
|
||||||
|
detail_level=detail_level,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
|
messages = list(cache_page.get("messages", []) or [])
|
||||||
|
visible_cache_count = int(cache_page.get("total_count", len(messages)) or 0)
|
||||||
|
cache_has_more = bool(cache_page.get("has_more", False))
|
||||||
messages = self._filter_ui_messages_for_detail_level(messages, detail_level)
|
messages = self._filter_ui_messages_for_detail_level(messages, detail_level)
|
||||||
messages = [_sanitize_ui_message_dict(message) for message in messages]
|
messages = [_sanitize_ui_message_dict(message) for message in messages]
|
||||||
except Exception:
|
|
||||||
messages = []
|
|
||||||
try:
|
|
||||||
if transcript_total_count:
|
|
||||||
visible_cache_count = transcript_total_count
|
|
||||||
else:
|
|
||||||
visible_cache_count = len(self._filter_ui_messages_for_detail_level(
|
|
||||||
await self.chat_store.get_channel_messages(
|
|
||||||
channel_id,
|
|
||||||
limit=max(request_limit * 8, 500),
|
|
||||||
project_id=project_id,
|
|
||||||
),
|
|
||||||
detail_level,
|
|
||||||
))
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if self._is_expected_shutdown_error(exc):
|
if self._is_expected_shutdown_error(exc):
|
||||||
logger.debug(f"session_detail: visible count skipped during shutdown for {task_id}")
|
logger.debug(f"session_detail: cache page skipped during shutdown for {task_id}")
|
||||||
return
|
return
|
||||||
|
messages = []
|
||||||
visible_cache_count = len(messages)
|
visible_cache_count = len(messages)
|
||||||
|
cache_has_more = False
|
||||||
total_message_count = max(transcript_total_count, visible_cache_count, len(messages))
|
total_message_count = max(transcript_total_count, visible_cache_count, len(messages))
|
||||||
has_more = transcript_has_more or (
|
has_more = transcript_has_more or cache_has_more
|
||||||
before_timestamp is None and total_message_count > len(messages)
|
|
||||||
)
|
|
||||||
|
|
||||||
task_meta = task.metadata if isinstance(getattr(task, "metadata", None), dict) else {}
|
task_meta = task.metadata if isinstance(getattr(task, "metadata", None), dict) else {}
|
||||||
handoff_context = _extract_markdown_text(task_meta.get("handoff_context"), max_chars=None)
|
handoff_context = _extract_markdown_text(task_meta.get("handoff_context"), max_chars=None)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from opc.plugins.office_ui.chat_store import ChatStore
|
||||||
|
|
||||||
|
|
||||||
|
class ChatStoreProgressFoldingTests(unittest.TestCase):
|
||||||
|
def test_stream_fold_preserves_first_timestamp(self) -> None:
|
||||||
|
first = {
|
||||||
|
"timestamp": 1_700_000_000.0,
|
||||||
|
"type": "thinking",
|
||||||
|
"summary": "Thinking",
|
||||||
|
"detail": "Need ",
|
||||||
|
"turn_id": "runtime-1:1",
|
||||||
|
"item_id": "runtime-1:1:thinking",
|
||||||
|
"seq": 1,
|
||||||
|
}
|
||||||
|
deltas = [
|
||||||
|
{
|
||||||
|
"timestamp": 1_700_000_000.1,
|
||||||
|
"type": "thinking",
|
||||||
|
"summary": "Thinking",
|
||||||
|
"detail": "more ",
|
||||||
|
"turn_id": "runtime-1:1",
|
||||||
|
"item_id": "runtime-1:1:thinking",
|
||||||
|
"seq": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1_700_000_000.2,
|
||||||
|
"type": "thinking",
|
||||||
|
"summary": "Thinking",
|
||||||
|
"detail": "context",
|
||||||
|
"turn_id": "runtime-1:1",
|
||||||
|
"item_id": "runtime-1:1:thinking",
|
||||||
|
"seq": 3,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
folded = ChatStore._fold_progress_entries([first], deltas)
|
||||||
|
|
||||||
|
self.assertEqual(len(folded), 1)
|
||||||
|
self.assertEqual(folded[0]["timestamp"], first["timestamp"])
|
||||||
|
self.assertEqual(folded[0]["detail"], "Need more context")
|
||||||
|
self.assertEqual(folded[0]["seq"], 3)
|
||||||
|
# Folding builds a replacement row and must not mutate the persisted
|
||||||
|
# value supplied by the caller.
|
||||||
|
self.assertEqual(first["detail"], "Need ")
|
||||||
|
|
||||||
|
def test_stream_fold_within_one_batch_keeps_creation_timestamp(self) -> None:
|
||||||
|
folded = ChatStore._fold_progress_entries(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": 10.0,
|
||||||
|
"type": "assistant",
|
||||||
|
"summary": "Part one",
|
||||||
|
"detail": "Part one ",
|
||||||
|
"turn_id": "runtime-2:1",
|
||||||
|
"stream_id": "runtime-2:1:assistant",
|
||||||
|
"seq": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 11.0,
|
||||||
|
"type": "assistant",
|
||||||
|
"summary": "part two",
|
||||||
|
"detail": "part two",
|
||||||
|
"turn_id": "runtime-2:1",
|
||||||
|
"stream_id": "runtime-2:1:assistant",
|
||||||
|
"seq": 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(folded), 1)
|
||||||
|
self.assertEqual(folded[0]["timestamp"], 10.0)
|
||||||
|
self.assertEqual(folded[0]["detail"], "Part one part two")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -679,7 +679,11 @@ async def test_session_detail_routes_by_request_project_id() -> None:
|
|||||||
chat_store = SimpleNamespace(
|
chat_store = SimpleNamespace(
|
||||||
create_session_channel=AsyncMock(return_value={"channel_id": "session:task-b"}),
|
create_session_channel=AsyncMock(return_value={"channel_id": "session:task-b"}),
|
||||||
backfill_messages=AsyncMock(return_value=[]),
|
backfill_messages=AsyncMock(return_value=[]),
|
||||||
get_channel_messages_page=AsyncMock(return_value=[]),
|
get_channel_messages_page_info=AsyncMock(return_value={
|
||||||
|
"messages": [],
|
||||||
|
"has_more": False,
|
||||||
|
"total_count": 0,
|
||||||
|
}),
|
||||||
get_channel_messages=AsyncMock(return_value=[]),
|
get_channel_messages=AsyncMock(return_value=[]),
|
||||||
)
|
)
|
||||||
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
|
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
|
||||||
@@ -708,7 +712,8 @@ async def test_session_detail_routes_by_request_project_id() -> None:
|
|||||||
"Project B Session",
|
"Project B Session",
|
||||||
project_id="project-b",
|
project_id="project-b",
|
||||||
)
|
)
|
||||||
assert chat_store.get_channel_messages_page.await_args.kwargs["project_id"] == "project-b"
|
assert chat_store.get_channel_messages_page_info.await_args.kwargs["project_id"] == "project-b"
|
||||||
|
assert chat_store.get_channel_messages_page_info.await_args.kwargs["detail_level"] == "summary"
|
||||||
|
|
||||||
|
|
||||||
@_async_test
|
@_async_test
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ from types import SimpleNamespace
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import aiosqlite
|
|
||||||
|
|
||||||
from opc.core.attachment_store import AttachmentRef, AttachmentStore
|
from opc.core.attachment_store import AttachmentRef, AttachmentStore
|
||||||
from opc.core.models import DelegationRun, ExecutionCheckpoint, Task, TaskStatus
|
from opc.core.models import DelegationRun, ExecutionCheckpoint, Task, TaskStatus
|
||||||
|
from opc.database.store import _SQLiteConnectionAdapter
|
||||||
from opc.layer2_organization import comms as file_comms
|
from opc.layer2_organization import comms as file_comms
|
||||||
from opc.plugins.office_ui.event_adapter import EventAdapter
|
from opc.plugins.office_ui.event_adapter import EventAdapter
|
||||||
from opc.plugins.office_ui.chat_store import ChatStore
|
from opc.plugins.office_ui.chat_store import ChatStore
|
||||||
@@ -207,8 +206,8 @@ def _make_engine(store: StubStore | None = None, memory: StubMemory | None = Non
|
|||||||
|
|
||||||
async def _make_chat_store() -> ChatStore:
|
async def _make_chat_store() -> ChatStore:
|
||||||
"""Create an in-memory ChatStore for testing."""
|
"""Create an in-memory ChatStore for testing."""
|
||||||
db = await aiosqlite.connect(":memory:")
|
db = _SQLiteConnectionAdapter(":memory:")
|
||||||
cs = ChatStore(db)
|
cs = ChatStore(db) # type: ignore[arg-type]
|
||||||
await cs.initialize()
|
await cs.initialize()
|
||||||
return cs
|
return cs
|
||||||
|
|
||||||
@@ -4444,6 +4443,97 @@ class TestWSHandlerSessionDetail(unittest.IsolatedAsyncioTestCase):
|
|||||||
["oldest"],
|
["oldest"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_session_detail_pages_transcript_and_ui_only_messages_together(self) -> None:
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.send_json = AsyncMock()
|
||||||
|
base_time = datetime.now()
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
id="mixed-page-task-1",
|
||||||
|
title="Mixed source page",
|
||||||
|
project_id="test-project",
|
||||||
|
session_id="mixed-page-session-1",
|
||||||
|
)
|
||||||
|
await self.store.save_task(task)
|
||||||
|
self.store._transcripts["mixed-page-session-1"] = [
|
||||||
|
{
|
||||||
|
"message": SimpleNamespace(
|
||||||
|
message_id="transcript-old",
|
||||||
|
role="assistant",
|
||||||
|
agent_id="agent-reviewer",
|
||||||
|
created_at=base_time,
|
||||||
|
summary_flag=False,
|
||||||
|
metadata={"kind": "top_level_reply"},
|
||||||
|
),
|
||||||
|
"parts": [SimpleNamespace(part_type="text", payload={"text": "Persisted reply"})],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
channel_id = "session:mixed-page-task-1"
|
||||||
|
await self.chat_store.create_session_channel(
|
||||||
|
task.id,
|
||||||
|
task.title,
|
||||||
|
project_id="test-project",
|
||||||
|
)
|
||||||
|
await self.chat_store.insert_message(
|
||||||
|
channel_id,
|
||||||
|
"system",
|
||||||
|
"OPC",
|
||||||
|
"Approval required",
|
||||||
|
metadata={
|
||||||
|
"source": "ui",
|
||||||
|
"detail_visibility": "summary",
|
||||||
|
"kind": "ui_only_notice",
|
||||||
|
},
|
||||||
|
message_id="ui-only-mid",
|
||||||
|
project_id="test-project",
|
||||||
|
created_at=base_time.timestamp() + 1,
|
||||||
|
)
|
||||||
|
await self.chat_store.insert_message(
|
||||||
|
channel_id,
|
||||||
|
"system",
|
||||||
|
"OPC",
|
||||||
|
"Legacy execution notice",
|
||||||
|
metadata={"source": "ui", "detail_visibility": "summary"},
|
||||||
|
message_id="ui-only-new",
|
||||||
|
project_id="test-project",
|
||||||
|
created_at=base_time.timestamp() + 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.handler._handle_session_detail(
|
||||||
|
ws,
|
||||||
|
{"project_id": "test-project", "task_id": task.id, "limit": 2},
|
||||||
|
)
|
||||||
|
|
||||||
|
first_payload = ws.send_json.await_args_list[0].args[0]["payload"]
|
||||||
|
self.assertEqual(first_payload["message_count"], 3)
|
||||||
|
self.assertEqual(first_payload["loaded_count"], 2)
|
||||||
|
self.assertTrue(first_payload["has_more"])
|
||||||
|
self.assertEqual(
|
||||||
|
[message["message_id"] for message in first_payload["messages"]],
|
||||||
|
["ui-only-mid", "ui-only-new"],
|
||||||
|
)
|
||||||
|
|
||||||
|
oldest_loaded = first_payload["messages"][0]
|
||||||
|
await self.handler._handle_session_detail(
|
||||||
|
ws,
|
||||||
|
{
|
||||||
|
"project_id": "test-project",
|
||||||
|
"task_id": task.id,
|
||||||
|
"limit": 2,
|
||||||
|
"before_created_at": oldest_loaded["created_at"],
|
||||||
|
"before_message_id": oldest_loaded["message_id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
second_payload = ws.send_json.await_args_list[1].args[0]["payload"]
|
||||||
|
self.assertEqual(second_payload["message_count"], 3)
|
||||||
|
self.assertEqual(second_payload["loaded_count"], 1)
|
||||||
|
self.assertFalse(second_payload["has_more"])
|
||||||
|
self.assertEqual(
|
||||||
|
[message["message_id"] for message in second_payload["messages"]],
|
||||||
|
["transcript-old"],
|
||||||
|
)
|
||||||
|
|
||||||
async def test_session_detail_returns_silently_when_shutdown_closes_chat_db(self) -> None:
|
async def test_session_detail_returns_silently_when_shutdown_closes_chat_db(self) -> None:
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
ws.send_json = AsyncMock()
|
ws.send_json = AsyncMock()
|
||||||
|
|||||||
@@ -0,0 +1,691 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from opc.core.models import SessionMessageRecord, SessionPartRecord, SessionRecord
|
||||||
|
from opc.core.transcript_visibility import transcript_metadata_visible
|
||||||
|
from opc.database.store import OPCStore, _SQLiteConnectionAdapter
|
||||||
|
from opc.plugins.office_ui.chat_store import (
|
||||||
|
ChatStore,
|
||||||
|
_MessageMatchIndex,
|
||||||
|
_MessageMatchState,
|
||||||
|
)
|
||||||
|
from opc.plugins.office_ui.snapshot_builder import build_transcript_ui_messages
|
||||||
|
from opc.plugins.office_ui.ws_handler import WSHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptStorePaginationTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_summary_page_filters_full_detail_rows_before_limit(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
store = OPCStore(Path(tmpdir) / "tasks.db")
|
||||||
|
await store.initialize()
|
||||||
|
try:
|
||||||
|
session_id = "summary-pagination-session"
|
||||||
|
task_id = "summary-pagination-task"
|
||||||
|
base = datetime(2026, 7, 13, 12, 0, 0)
|
||||||
|
await store.save_session(SessionRecord(
|
||||||
|
session_id=session_id,
|
||||||
|
project_id="test-project",
|
||||||
|
title="Summary pagination",
|
||||||
|
created_at=base,
|
||||||
|
updated_at=base,
|
||||||
|
))
|
||||||
|
|
||||||
|
async def save(
|
||||||
|
message_id: str,
|
||||||
|
offset: int,
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
company_final_turn: bool = False,
|
||||||
|
summary_flag: bool = False,
|
||||||
|
) -> None:
|
||||||
|
metadata = {"kind": kind}
|
||||||
|
if company_final_turn:
|
||||||
|
metadata["company_final_turn"] = True
|
||||||
|
created_at = base + timedelta(seconds=offset)
|
||||||
|
await store.save_session_message(SessionMessageRecord(
|
||||||
|
message_id=message_id,
|
||||||
|
session_id=session_id,
|
||||||
|
task_id=task_id,
|
||||||
|
role="assistant",
|
||||||
|
agent_id="agent-reviewer",
|
||||||
|
summary_flag=summary_flag,
|
||||||
|
metadata=metadata,
|
||||||
|
created_at=created_at,
|
||||||
|
))
|
||||||
|
await store.save_session_part(SessionPartRecord(
|
||||||
|
part_id=f"part-{message_id}",
|
||||||
|
message_id=message_id,
|
||||||
|
session_id=session_id,
|
||||||
|
part_type="text",
|
||||||
|
payload={"text": f"content:{message_id}"},
|
||||||
|
created_at=created_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
await save("summary-old", 0, "top_level_reply")
|
||||||
|
hidden_kinds = (
|
||||||
|
"runtime_v2_user_turn",
|
||||||
|
"runtime_v2_intermediate_assistant",
|
||||||
|
"runtime_v2_company_assistant",
|
||||||
|
"runtime_v2_tool_output",
|
||||||
|
)
|
||||||
|
# More than 8 * page size: post-LIMIT filtering used to return
|
||||||
|
# an empty page even though summary-old remained reachable.
|
||||||
|
for index in range(24):
|
||||||
|
await save(f"full-only-{index:02d}", index + 1, hidden_kinds[index % len(hidden_kinds)])
|
||||||
|
await save("assistant-final", 25, "runtime_v2_assistant")
|
||||||
|
await save(
|
||||||
|
"company-final",
|
||||||
|
26,
|
||||||
|
"runtime_v2_company_assistant",
|
||||||
|
company_final_turn=True,
|
||||||
|
)
|
||||||
|
await save("canonical-result", 27, "child_result")
|
||||||
|
await save("compaction-summary", 28, "top_level_reply", summary_flag=True)
|
||||||
|
|
||||||
|
latest = await store.get_session_transcript_page(
|
||||||
|
session_id,
|
||||||
|
limit=2,
|
||||||
|
detail_level="summary",
|
||||||
|
)
|
||||||
|
self.assertEqual(latest["total_count"], 4)
|
||||||
|
self.assertTrue(latest["has_more"])
|
||||||
|
self.assertEqual(
|
||||||
|
[item["message"].message_id for item in latest["messages"]],
|
||||||
|
["company-final", "canonical-result"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[message["message_id"] for message in build_transcript_ui_messages(
|
||||||
|
latest["messages"],
|
||||||
|
channel_id=f"session:{task_id}",
|
||||||
|
task_id=task_id,
|
||||||
|
detail_level="summary",
|
||||||
|
)],
|
||||||
|
["company-final", "canonical-result"],
|
||||||
|
)
|
||||||
|
|
||||||
|
older = await store.get_session_transcript_page(
|
||||||
|
session_id,
|
||||||
|
limit=2,
|
||||||
|
before_created_at=base + timedelta(seconds=26),
|
||||||
|
before_message_id="company-final",
|
||||||
|
detail_level="summary",
|
||||||
|
)
|
||||||
|
self.assertEqual(older["total_count"], 4)
|
||||||
|
self.assertFalse(older["has_more"])
|
||||||
|
self.assertEqual(
|
||||||
|
[item["message"].message_id for item in older["messages"]],
|
||||||
|
["summary-old", "assistant-final"],
|
||||||
|
)
|
||||||
|
|
||||||
|
full = await store.get_session_transcript_page(
|
||||||
|
session_id,
|
||||||
|
limit=2,
|
||||||
|
detail_level="full",
|
||||||
|
)
|
||||||
|
self.assertEqual(full["total_count"], 28)
|
||||||
|
self.assertTrue(full["has_more"])
|
||||||
|
finally:
|
||||||
|
await store.close()
|
||||||
|
|
||||||
|
async def test_rendered_page_reads_past_empty_and_collapsed_raw_rows(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
store = OPCStore(Path(tmpdir) / "tasks.db")
|
||||||
|
await store.initialize()
|
||||||
|
try:
|
||||||
|
session_id = "rendered-pagination-session"
|
||||||
|
task_id = "rendered-pagination-task"
|
||||||
|
base = datetime(2026, 7, 13, 13, 0, 0)
|
||||||
|
await store.save_session(SessionRecord(
|
||||||
|
session_id=session_id,
|
||||||
|
project_id="test-project",
|
||||||
|
title="Rendered pagination",
|
||||||
|
created_at=base,
|
||||||
|
updated_at=base,
|
||||||
|
))
|
||||||
|
|
||||||
|
async def save(
|
||||||
|
message_id: str,
|
||||||
|
offset: int,
|
||||||
|
kind: str,
|
||||||
|
content: str | None,
|
||||||
|
) -> None:
|
||||||
|
created_at = base + timedelta(seconds=offset)
|
||||||
|
await store.save_session_message(SessionMessageRecord(
|
||||||
|
message_id=message_id,
|
||||||
|
session_id=session_id,
|
||||||
|
task_id=task_id,
|
||||||
|
role="assistant",
|
||||||
|
agent_id="agent-reviewer",
|
||||||
|
metadata={"kind": kind},
|
||||||
|
created_at=created_at,
|
||||||
|
))
|
||||||
|
if content is not None:
|
||||||
|
await store.save_session_part(SessionPartRecord(
|
||||||
|
part_id=f"part-{message_id}",
|
||||||
|
message_id=message_id,
|
||||||
|
session_id=session_id,
|
||||||
|
part_type="text",
|
||||||
|
payload={"text": content},
|
||||||
|
created_at=created_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
await save("visible-old", 0, "top_level_reply", "older unique")
|
||||||
|
await save("duplicate-low", 1, "top_level_reply", "same result")
|
||||||
|
await save("duplicate-high", 2, "child_result", "same result")
|
||||||
|
await save("empty-latest", 3, "top_level_reply", None)
|
||||||
|
|
||||||
|
handler = WSHandler.__new__(WSHandler)
|
||||||
|
handler.engine = SimpleNamespace(store=store)
|
||||||
|
page, total_count, has_more = await handler._load_session_transcript_page(
|
||||||
|
SimpleNamespace(id=task_id, session_id=session_id),
|
||||||
|
limit=2,
|
||||||
|
detail_level="summary",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[message["message_id"] for message in page],
|
||||||
|
["visible-old", "duplicate-high"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[message["content"] for message in page],
|
||||||
|
["older unique", "same result"],
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(total_count, 2)
|
||||||
|
self.assertFalse(has_more)
|
||||||
|
finally:
|
||||||
|
await store.close()
|
||||||
|
|
||||||
|
def test_renderer_and_store_share_company_final_visibility(self) -> None:
|
||||||
|
self.assertFalse(transcript_metadata_visible(
|
||||||
|
{"kind": "runtime_v2_company_assistant"},
|
||||||
|
detail_level="summary",
|
||||||
|
))
|
||||||
|
self.assertTrue(transcript_metadata_visible(
|
||||||
|
{"kind": "runtime_v2_company_assistant", "company_final_turn": True},
|
||||||
|
detail_level="summary",
|
||||||
|
))
|
||||||
|
self.assertTrue(transcript_metadata_visible(
|
||||||
|
{"kind": "runtime_v2_assistant"},
|
||||||
|
detail_level="summary",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class ChatStorePaginationTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def _legacy_dedupe(
|
||||||
|
store: ChatStore,
|
||||||
|
messages: list[dict[str, object]],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
"""Reference implementation retained only for equivalence testing."""
|
||||||
|
deduped: list[dict[str, object]] = []
|
||||||
|
for message in sorted(messages, key=store._message_timestamp):
|
||||||
|
match_index = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index in range(len(deduped) - 1, -1, -1)
|
||||||
|
if store._messages_semantically_match(deduped[index], message)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if match_index is None:
|
||||||
|
deduped.append(message)
|
||||||
|
else:
|
||||||
|
deduped[match_index] = store._merge_duplicate_messages(
|
||||||
|
deduped[match_index],
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
def test_indexed_dedupe_matches_legacy_semantics(self) -> None:
|
||||||
|
randomizer = random.Random(20260713)
|
||||||
|
messages: list[dict[str, object]] = []
|
||||||
|
content_variants = (
|
||||||
|
"Repeated result",
|
||||||
|
"Repeated result\n\nVerification: passed",
|
||||||
|
"Unique body ",
|
||||||
|
"**Narrative heading**: " + ("long body " * 20),
|
||||||
|
)
|
||||||
|
result_kinds = (
|
||||||
|
"child_result",
|
||||||
|
"company_role_result",
|
||||||
|
"top_level_reply",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
for index in range(600):
|
||||||
|
metadata: dict[str, object] = {}
|
||||||
|
if randomizer.random() < 0.55:
|
||||||
|
metadata["source"] = "engine"
|
||||||
|
result_kind = randomizer.choice(result_kinds)
|
||||||
|
if result_kind:
|
||||||
|
metadata["transcript_kind"] = result_kind
|
||||||
|
# Exercise identity merges which can replace the semantic bucket of
|
||||||
|
# an already-indexed row.
|
||||||
|
if messages and randomizer.random() < 0.12:
|
||||||
|
identity_source = randomizer.choice(messages)
|
||||||
|
metadata["ui_message_id"] = identity_source["message_id"]
|
||||||
|
content = randomizer.choice(content_variants)
|
||||||
|
if content == "Unique body ":
|
||||||
|
content += str(index % 31)
|
||||||
|
messages.append({
|
||||||
|
"message_id": f"random-{index:04d}",
|
||||||
|
"channel_id": f"session:{randomizer.randrange(2)}",
|
||||||
|
"sender": "user" if randomizer.random() < 0.18 else "assistant",
|
||||||
|
"sender_name": "OPC",
|
||||||
|
"content": content,
|
||||||
|
# Include zero/negative sentinel values because the historical
|
||||||
|
# matcher deliberately treats a zero timestamp as unbounded.
|
||||||
|
"created_at": float(randomizer.randrange(-6, 45)) / 3.0,
|
||||||
|
"reply_to_id": f"reply-{randomizer.randrange(4)}",
|
||||||
|
"mentions": [],
|
||||||
|
"metadata": metadata,
|
||||||
|
})
|
||||||
|
randomizer.shuffle(messages)
|
||||||
|
|
||||||
|
store = ChatStore(None) # type: ignore[arg-type]
|
||||||
|
expected = self._legacy_dedupe(store, messages)
|
||||||
|
actual = store._dedupe_messages(messages)
|
||||||
|
self.assertEqual(actual, expected)
|
||||||
|
|
||||||
|
def test_indexed_dedupe_normalizes_long_content_once_per_row(self) -> None:
|
||||||
|
class CountingChatStore(ChatStore):
|
||||||
|
normalize_calls = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_duplicate_content(cls, content: object) -> str:
|
||||||
|
cls.normalize_calls += 1
|
||||||
|
return ChatStore._normalize_duplicate_content(content)
|
||||||
|
|
||||||
|
store = CountingChatStore(None) # type: ignore[arg-type]
|
||||||
|
long_content = "x" * 8192
|
||||||
|
message_count = 4000
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"message_id": f"scale-{index:05d}",
|
||||||
|
"channel_id": "session:scale",
|
||||||
|
"sender": "assistant",
|
||||||
|
"sender_name": "OPC",
|
||||||
|
"content": long_content,
|
||||||
|
"created_at": float(index),
|
||||||
|
"reply_to_id": None,
|
||||||
|
"mentions": [],
|
||||||
|
# Without an engine source these equal-content rows deliberately
|
||||||
|
# do not merge; the legacy reverse scan normalized O(n^2) pairs.
|
||||||
|
"metadata": {},
|
||||||
|
}
|
||||||
|
for index in range(message_count)
|
||||||
|
]
|
||||||
|
|
||||||
|
deduped = store._dedupe_messages(messages)
|
||||||
|
self.assertEqual(len(deduped), message_count)
|
||||||
|
self.assertEqual(CountingChatStore.normalize_calls, message_count)
|
||||||
|
|
||||||
|
def test_timed_backfill_index_does_not_scan_out_of_window_rows(self) -> None:
|
||||||
|
store = ChatStore(None) # type: ignore[arg-type]
|
||||||
|
row_count = 1000
|
||||||
|
|
||||||
|
def message(prefix: str, index: int, timestamp: float) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"message_id": f"{prefix}-{index:04d}",
|
||||||
|
"channel_id": "session:timed-backfill-scale",
|
||||||
|
"sender": "assistant",
|
||||||
|
"sender_name": "OPC",
|
||||||
|
"content": "Ordinary engine update",
|
||||||
|
"created_at": timestamp,
|
||||||
|
"reply_to_id": "same-turn",
|
||||||
|
"mentions": [],
|
||||||
|
# Intentionally not a result surface: only the exact 2-second
|
||||||
|
# ordinary-message window may match these rows.
|
||||||
|
"metadata": {"source": "engine"},
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = [
|
||||||
|
message("existing", index, 100_001.0 + index * 10.0)
|
||||||
|
for index in range(row_count)
|
||||||
|
]
|
||||||
|
incoming = [
|
||||||
|
message("incoming", index, 1.0 + index * 10.0)
|
||||||
|
for index in range(row_count)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Prepared-state reference of the former reversed scan. Every incoming
|
||||||
|
# row misses and is appended, causing 1000 + ... + 1999 comparisons.
|
||||||
|
legacy_rows = list(existing)
|
||||||
|
legacy_states = [
|
||||||
|
_MessageMatchState.from_message(store, item)
|
||||||
|
for item in legacy_rows
|
||||||
|
]
|
||||||
|
legacy_matches: list[int | None] = []
|
||||||
|
legacy_checks = 0
|
||||||
|
for item in incoming:
|
||||||
|
candidate = _MessageMatchState.from_message(store, item)
|
||||||
|
match_index: int | None = None
|
||||||
|
for index in range(len(legacy_states) - 1, -1, -1):
|
||||||
|
legacy_checks += 1
|
||||||
|
if legacy_states[index].matches(
|
||||||
|
candidate,
|
||||||
|
duplicate_window=store._DUPLICATE_WINDOW_SECONDS,
|
||||||
|
):
|
||||||
|
match_index = index
|
||||||
|
break
|
||||||
|
legacy_matches.append(match_index)
|
||||||
|
if match_index is None:
|
||||||
|
legacy_rows.append(item)
|
||||||
|
legacy_states.append(candidate)
|
||||||
|
|
||||||
|
indexed_rows = list(existing)
|
||||||
|
timed_index = _MessageMatchIndex(store, indexed_rows)
|
||||||
|
indexed_matches: list[int | None] = []
|
||||||
|
indexed_checks = 0
|
||||||
|
original_matches = _MessageMatchState.matches
|
||||||
|
|
||||||
|
def counted_matches(
|
||||||
|
existing_state: _MessageMatchState,
|
||||||
|
candidate_state: _MessageMatchState,
|
||||||
|
*,
|
||||||
|
duplicate_window: float,
|
||||||
|
) -> bool:
|
||||||
|
nonlocal indexed_checks
|
||||||
|
indexed_checks += 1
|
||||||
|
return original_matches(
|
||||||
|
existing_state,
|
||||||
|
candidate_state,
|
||||||
|
duplicate_window=duplicate_window,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MessageMatchState.matches = counted_matches
|
||||||
|
try:
|
||||||
|
for item in incoming:
|
||||||
|
candidate = timed_index.prepare(item)
|
||||||
|
match_index = timed_index.latest_match(
|
||||||
|
item,
|
||||||
|
prepared_state=candidate,
|
||||||
|
)
|
||||||
|
indexed_matches.append(match_index)
|
||||||
|
if match_index is None:
|
||||||
|
timed_index.append(item, prepared_state=candidate)
|
||||||
|
finally:
|
||||||
|
_MessageMatchState.matches = original_matches
|
||||||
|
|
||||||
|
self.assertEqual(indexed_matches, legacy_matches)
|
||||||
|
self.assertEqual(legacy_checks, 1_499_500)
|
||||||
|
self.assertEqual(indexed_checks, 0)
|
||||||
|
|
||||||
|
def test_timed_index_preserves_float_rounding_at_window_boundary(self) -> None:
|
||||||
|
store = ChatStore(None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def message(message_id: str, timestamp: float) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"message_id": message_id,
|
||||||
|
"channel_id": "session:float-window-boundary",
|
||||||
|
"sender": "assistant",
|
||||||
|
"sender_name": "OPC",
|
||||||
|
"content": "Boundary update",
|
||||||
|
"created_at": timestamp,
|
||||||
|
"reply_to_id": "same-turn",
|
||||||
|
"mentions": [],
|
||||||
|
"metadata": {"source": "engine"},
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = message("existing", -1e-300)
|
||||||
|
candidate = message("candidate", 2.0)
|
||||||
|
self.assertTrue(store._messages_semantically_match(existing, candidate))
|
||||||
|
|
||||||
|
rows = [existing]
|
||||||
|
index = _MessageMatchIndex(store, rows)
|
||||||
|
self.assertEqual(index.latest_match(candidate), 0)
|
||||||
|
|
||||||
|
def test_backfill_semantic_matches_remain_one_to_one(self) -> None:
|
||||||
|
asyncio.run(self._exercise_backfill_semantic_matches_one_to_one())
|
||||||
|
|
||||||
|
async def _exercise_backfill_semantic_matches_one_to_one(self) -> None:
|
||||||
|
tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
db = _SQLiteConnectionAdapter(str(Path(tmpdir.name) / "ui-state.db"))
|
||||||
|
store = ChatStore(db) # type: ignore[arg-type]
|
||||||
|
await store.initialize()
|
||||||
|
channel_id = "session:backfill-scale"
|
||||||
|
project_id = "test-project"
|
||||||
|
content = "Canonical result " + ("detail " * 1000)
|
||||||
|
try:
|
||||||
|
for index in range(200):
|
||||||
|
await store.insert_message(
|
||||||
|
channel_id,
|
||||||
|
"assistant",
|
||||||
|
"OPC",
|
||||||
|
content,
|
||||||
|
metadata={
|
||||||
|
"source": "engine",
|
||||||
|
"transcript_kind": "child_result",
|
||||||
|
},
|
||||||
|
message_id=f"existing-result-{index:03d}",
|
||||||
|
project_id=project_id,
|
||||||
|
created_at=float(index + 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
backfill = [
|
||||||
|
{
|
||||||
|
"message_id": f"backfill-result-{index:03d}",
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"sender": "assistant",
|
||||||
|
"sender_name": "OPC",
|
||||||
|
"content": content,
|
||||||
|
"created_at": float(index + 1000),
|
||||||
|
"metadata": {
|
||||||
|
"source": "engine",
|
||||||
|
"transcript_kind": "child_result",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for index in range(201)
|
||||||
|
]
|
||||||
|
inserted = await store.backfill_messages(
|
||||||
|
channel_id,
|
||||||
|
backfill,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[message["message_id"] for message in inserted],
|
||||||
|
["backfill-result-200"],
|
||||||
|
)
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT COUNT(*) FROM messages WHERE channel_id = ? AND project_id = ?",
|
||||||
|
(channel_id, project_id),
|
||||||
|
)
|
||||||
|
self.assertEqual((await cursor.fetchone())[0], 201)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
tmpdir.cleanup()
|
||||||
|
|
||||||
|
def test_summary_cache_page_filters_before_raw_fetch_limit(self) -> None:
|
||||||
|
asyncio.run(self._exercise_summary_cache_page())
|
||||||
|
|
||||||
|
async def _exercise_summary_cache_page(self) -> None:
|
||||||
|
tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
db = _SQLiteConnectionAdapter(str(Path(tmpdir.name) / "ui-state.db"))
|
||||||
|
store = ChatStore(db) # type: ignore[arg-type]
|
||||||
|
await store.initialize()
|
||||||
|
channel_id = "session:summary-cache-task"
|
||||||
|
project_id = "test-project"
|
||||||
|
|
||||||
|
async def insert(message_id: str, timestamp: float, visibility: str) -> None:
|
||||||
|
await store.insert_message(
|
||||||
|
channel_id,
|
||||||
|
"agent-reviewer",
|
||||||
|
"Reviewer",
|
||||||
|
f"content:{message_id}",
|
||||||
|
metadata={"detail_visibility": visibility},
|
||||||
|
message_id=message_id,
|
||||||
|
project_id=project_id,
|
||||||
|
created_at=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await insert("summary-old", 1.0, "summary")
|
||||||
|
for index in range(24):
|
||||||
|
await insert(f"full-only-{index:02d}", float(index + 2), "full")
|
||||||
|
await insert("summary-new", 26.0, "summary")
|
||||||
|
|
||||||
|
page = await store.get_channel_messages_page(
|
||||||
|
channel_id,
|
||||||
|
limit=2,
|
||||||
|
detail_level="summary",
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[message["message_id"] for message in page],
|
||||||
|
["summary-old", "summary-new"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
await store.get_channel_visible_message_count(
|
||||||
|
channel_id,
|
||||||
|
project_id=project_id,
|
||||||
|
detail_level="summary",
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
await store.get_channel_visible_message_count(
|
||||||
|
channel_id,
|
||||||
|
project_id=project_id,
|
||||||
|
detail_level="full",
|
||||||
|
),
|
||||||
|
26,
|
||||||
|
)
|
||||||
|
|
||||||
|
older = await store.get_channel_messages_page(
|
||||||
|
channel_id,
|
||||||
|
limit=2,
|
||||||
|
before_timestamp=26.0,
|
||||||
|
before_message_id="summary-new",
|
||||||
|
detail_level="summary",
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
self.assertEqual([message["message_id"] for message in older], ["summary-old"])
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
tmpdir.cleanup()
|
||||||
|
|
||||||
|
def test_cache_page_dedupes_before_paging_and_keeps_ui_only_rows(self) -> None:
|
||||||
|
asyncio.run(self._exercise_cache_page_with_ui_only_rows())
|
||||||
|
|
||||||
|
async def _exercise_cache_page_with_ui_only_rows(self) -> None:
|
||||||
|
tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
db = _SQLiteConnectionAdapter(str(Path(tmpdir.name) / "ui-state.db"))
|
||||||
|
store = ChatStore(db) # type: ignore[arg-type]
|
||||||
|
await store.initialize()
|
||||||
|
channel_id = "session:mixed-cache-task"
|
||||||
|
project_id = "test-project"
|
||||||
|
|
||||||
|
async def insert(
|
||||||
|
message_id: str,
|
||||||
|
timestamp: float,
|
||||||
|
content: str,
|
||||||
|
metadata: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
await store.insert_message(
|
||||||
|
channel_id,
|
||||||
|
"assistant",
|
||||||
|
"OPC",
|
||||||
|
content,
|
||||||
|
metadata=metadata,
|
||||||
|
message_id=message_id,
|
||||||
|
project_id=project_id,
|
||||||
|
created_at=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# These two UI-owned rows have no authoritative transcript row, but
|
||||||
|
# must still contribute to the page cursor, total, and has_more.
|
||||||
|
await insert(
|
||||||
|
"approval-card",
|
||||||
|
1.0,
|
||||||
|
"",
|
||||||
|
{
|
||||||
|
"checkpoint_id": "checkpoint-1",
|
||||||
|
"checkpoint_type": "tool_approval",
|
||||||
|
"checkpoint_status": "pending",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await insert(
|
||||||
|
"legacy-notice",
|
||||||
|
2.0,
|
||||||
|
"Legacy execution notice",
|
||||||
|
{"kind": "legacy_notice"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# More than the old ``limit * 8`` lookahead collapses to one final
|
||||||
|
# result surface. Raw-row pagination therefore used to hide both
|
||||||
|
# older UI-only rows and incorrectly report the end of history.
|
||||||
|
for index in range(24):
|
||||||
|
await insert(
|
||||||
|
f"result-surface-{index:02d}",
|
||||||
|
float(index + 3),
|
||||||
|
"Canonical child result",
|
||||||
|
{
|
||||||
|
"source": "engine",
|
||||||
|
"transcript_kind": "child_result",
|
||||||
|
"detail_visibility": "summary",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await insert(
|
||||||
|
"latest-message",
|
||||||
|
27.0,
|
||||||
|
"Latest committed reply",
|
||||||
|
{"detail_visibility": "summary"},
|
||||||
|
)
|
||||||
|
for index in range(20):
|
||||||
|
await insert(
|
||||||
|
f"full-only-{index:02d}",
|
||||||
|
float(index + 28),
|
||||||
|
f"Runtime row {index}",
|
||||||
|
{"detail_visibility": "full"},
|
||||||
|
)
|
||||||
|
|
||||||
|
page = await store.get_channel_messages_page_info(
|
||||||
|
channel_id,
|
||||||
|
limit=2,
|
||||||
|
detail_level="summary",
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(page["total_count"], 4)
|
||||||
|
self.assertTrue(page["has_more"])
|
||||||
|
self.assertEqual(
|
||||||
|
[message["message_id"] for message in page["messages"]],
|
||||||
|
["result-surface-00", "latest-message"],
|
||||||
|
)
|
||||||
|
|
||||||
|
result_message = page["messages"][0]
|
||||||
|
older = await store.get_channel_messages_page_info(
|
||||||
|
channel_id,
|
||||||
|
limit=2,
|
||||||
|
before_timestamp=result_message["created_at"],
|
||||||
|
before_message_id=result_message["message_id"],
|
||||||
|
detail_level="summary",
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
self.assertFalse(older["has_more"])
|
||||||
|
self.assertEqual(older["total_count"], 4)
|
||||||
|
self.assertEqual(
|
||||||
|
[message["message_id"] for message in older["messages"]],
|
||||||
|
["approval-card", "legacy-notice"],
|
||||||
|
)
|
||||||
|
|
||||||
|
compatible = await store.get_channel_messages_page(
|
||||||
|
channel_id,
|
||||||
|
limit=2,
|
||||||
|
detail_level="summary",
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(compatible, page["messages"])
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
tmpdir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user