Skip to content

Commit 6481d9b

Browse files
committed
fix(agent): drop placeholder assistant bubble in chat
1 parent deaa23d commit 6481d9b

7 files changed

Lines changed: 98 additions & 14 deletions

File tree

frontend/src/components/chat/chat-session-pane.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ export function ChatSessionPane({
288288
currRunId,
289289
prompt: activePrompt,
290290
} = useSessionStatus({
291-
chatId: chat?.id,
291+
chatId: isReadonly ? undefined : chat?.id,
292292
workspaceId,
293293
})
294294
const { sendMessage, messages, status, regenerate, lastError, clearError } =

frontend/tests/chat-session-pane.test.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import {
77
MessagePart,
88
} from "@/components/chat/chat-session-pane"
99
import { TooltipProvider } from "@/components/ui/tooltip"
10-
import { useUpdateChat, useVercelChat } from "@/hooks/use-chat"
10+
import {
11+
useSessionStatus,
12+
useUpdateChat,
13+
useVercelChat,
14+
} from "@/hooks/use-chat"
1115
import { useBuilderRegistryActions } from "@/lib/hooks"
1216

1317
jest.mock("@/hooks/use-chat", () => ({
@@ -85,6 +89,9 @@ const mockUseVercelChat = useVercelChat as jest.MockedFunction<
8589
const mockUseUpdateChat = useUpdateChat as jest.MockedFunction<
8690
typeof useUpdateChat
8791
>
92+
const mockUseSessionStatus = useSessionStatus as jest.MockedFunction<
93+
typeof useSessionStatus
94+
>
8895
const mockUseBuilderRegistryActions =
8996
useBuilderRegistryActions as jest.MockedFunction<
9097
typeof useBuilderRegistryActions
@@ -241,6 +248,30 @@ describe("ChatSessionPane", () => {
241248
})
242249
})
243250

251+
it("does not poll session status for read-only legacy chats", () => {
252+
mockUseVercelChatStatus("ready")
253+
const readonlyChat = { ...createChatFixture(), is_readonly: true }
254+
255+
render(
256+
<QueryClientProvider client={queryClient}>
257+
<TooltipProvider>
258+
<ChatSessionPane
259+
chat={readonlyChat}
260+
workspaceId="workspace-1"
261+
entityType="case"
262+
entityId="case-1"
263+
modelInfo={{ name: "gpt-4o-mini", provider: "openai" }}
264+
/>
265+
</TooltipProvider>
266+
</QueryClientProvider>
267+
)
268+
269+
expect(mockUseSessionStatus).toHaveBeenCalledWith({
270+
chatId: undefined,
271+
workspaceId: "workspace-1",
272+
})
273+
})
274+
244275
it("renders dots indicator while status is submitted", () => {
245276
mockUseVercelChat.mockReturnValue({
246277
sendMessage: jest.fn(),

tests/unit/test_agent_session_router.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -579,16 +579,20 @@ async def test_stream_session_events_returns_204_when_no_turn_started() -> None:
579579
async def test_stream_session_events_attaches_when_turn_in_progress_no_events_yet() -> (
580580
None
581581
):
582+
session_id = uuid.uuid4()
583+
curr_run_id = uuid.uuid4()
582584
stream = _fake_stream()
583585
response = await _run_stream_endpoint(
584-
session=_fake_stream_session(status_value="running"),
586+
session=_fake_stream_session(status_value="running", curr_run_id=curr_run_id),
585587
stream=stream,
586588
headers={},
589+
session_id=session_id,
587590
)
588591

589592
assert isinstance(response, StreamingResponse)
590593
stream.sse.assert_called_once()
591594
assert stream.sse.call_args.kwargs["last_id"] == "0-0"
595+
assert stream.sse.call_args.kwargs["message_id"] == f"{session_id}:{curr_run_id}"
592596

593597

594598
@pytest.mark.anyio
@@ -609,9 +613,10 @@ async def test_stream_session_events_waiting_without_cursor_uses_db_history() ->
609613
@pytest.mark.anyio
610614
async def test_stream_session_events_resumes_after_last_event_id() -> None:
611615
"""A live cursor newer than the buffer min resumes after it (composite id)."""
616+
curr_run_id = uuid.uuid4()
612617
stream = _fake_stream(min_id="1000-0")
613618
response = await _run_stream_endpoint(
614-
session=_fake_stream_session(status_value="running"),
619+
session=_fake_stream_session(status_value="running", curr_run_id=curr_run_id),
615620
stream=stream,
616621
headers={"Last-Event-ID": "1234-0:2"},
617622
)
@@ -647,9 +652,10 @@ async def test_stream_session_events_terminal_reconnect_uses_latest_run_id() ->
647652
@pytest.mark.anyio
648653
async def test_stream_session_events_stale_cursor_running_replays_from_start() -> None:
649654
"""Cursor older than the buffer min, still running -> replay from 0-0."""
655+
curr_run_id = uuid.uuid4()
650656
stream = _fake_stream(min_id="2000-0")
651657
response = await _run_stream_endpoint(
652-
session=_fake_stream_session(status_value="running"),
658+
session=_fake_stream_session(status_value="running", curr_run_id=curr_run_id),
653659
stream=stream,
654660
headers={"Last-Event-ID": "1000-0:0"},
655661
)
@@ -672,4 +678,22 @@ async def test_stream_session_events_stale_cursor_terminal_finishes() -> None:
672678

673679
assert isinstance(response, StreamingResponse)
674680
stream.finished_sse.assert_called_once()
681+
assert stream.finished_sse.call_args.kwargs["message_id"] is None
682+
stream.sse.assert_not_called()
683+
684+
685+
@pytest.mark.anyio
686+
async def test_stream_session_events_terminal_without_run_id_finishes() -> None:
687+
"""Terminal reconnects without a run id do not synthesize a new bubble id."""
688+
stream = _fake_stream(min_id="1000-0")
689+
response = await _run_stream_endpoint(
690+
session=_fake_stream_session(status_value="stopped"),
691+
stream=stream,
692+
headers={"Last-Event-ID": "1234-0:2"},
693+
latest_run_id=None,
694+
)
695+
696+
assert isinstance(response, StreamingResponse)
697+
stream.finished_sse.assert_called_once()
698+
assert stream.finished_sse.call_args.kwargs["message_id"] is None
675699
stream.sse.assert_not_called()

tests/unit/test_vercel_stream_context.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,22 @@ async def events():
10181018
assert id_frames[1].startswith("id: 1000-0:1\n")
10191019

10201020

1021+
@pytest.mark.anyio
1022+
async def test_sse_vercel_omits_start_frame_when_message_id_unknown():
1023+
"""Terminal reconnect finish streams should not create a synthetic bubble."""
1024+
1025+
async def events():
1026+
return
1027+
yield # pragma: no cover - establishes async generator
1028+
1029+
frames: list[str] = []
1030+
async for frame in sse_vercel(events(), message_id=None):
1031+
frames.append(frame)
1032+
1033+
assert not any('"type":"start"' in frame for frame in frames)
1034+
assert any('"type":"finish"' in frame for frame in frames)
1035+
1036+
10211037
@pytest.mark.anyio
10221038
async def test_sse_vercel_skips_frames_at_composite_resume_cursor():
10231039
"""Reconnect inside a Redis entry replays that entry but drops seen frames."""

tracecat/agent/adapter/vercel.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@ class VercelStreamContext:
766766
consistent start/delta/end sequences required by the Vercel protocol.
767767
"""
768768

769-
message_id: str
769+
message_id: str | None
770770
# Active parts keyed by event index -> maintains per-part lifecycle state.
771771
part_states: dict[int, _PartState] = dataclasses.field(default_factory=dict)
772772
tool_finished: dict[str, bool] = dataclasses.field(default_factory=dict)
@@ -1805,14 +1805,15 @@ def convert_chat_messages_to_ui(
18051805
async def sse_vercel(
18061806
events: AsyncIterable[StreamEvent],
18071807
*,
1808-
message_id: str,
1808+
message_id: str | None,
18091809
resume_from: str | None = None,
18101810
) -> AsyncIterable[str]:
18111811
"""Stream Redis events as Vercel AI SDK frames without persisting adapter output.
18121812
18131813
``message_id`` is the stable assistant-bubble id (``session_id:curr_run_id``)
18141814
so reconnects within a turn resume the same bubble instead of spawning a new
1815-
one. Each Redis entry can fan out to many Vercel frames, so frames carry a
1815+
one. It is omitted for terminal reconnects where no run id can be resolved.
1816+
Each Redis entry can fan out to many Vercel frames, so frames carry a
18161817
composite ``id: {redis_id}:{frame_index}`` that the browser replays from.
18171818
"""
18181819

@@ -1839,7 +1840,8 @@ def emit(payload: VercelSSEPayload) -> str | None:
18391840

18401841
try:
18411842
# 1. Start of the message stream
1842-
yield format_sse(StartEventPayload(messageId=message_id))
1843+
if message_id is not None:
1844+
yield format_sse(StartEventPayload(messageId=message_id))
18431845

18441846
# 2. Process events from Redis stream
18451847
async for stream_event in events:

tracecat/agent/session/router.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@
4242
router = APIRouter(prefix="/agent/sessions", tags=["agent-sessions"])
4343

4444

45-
def _bubble_id(session_id: uuid.UUID, curr_run_id: uuid.UUID | None) -> str:
46-
"""Stable assistant-bubble id for a turn (falls back to session if no run)."""
47-
return f"{session_id}:{curr_run_id}" if curr_run_id else str(session_id)
45+
def _bubble_id(session_id: uuid.UUID, curr_run_id: uuid.UUID | None) -> str | None:
46+
"""Stable assistant-bubble id for a turn, if the turn is known."""
47+
return f"{session_id}:{curr_run_id}" if curr_run_id else None
4848

4949

5050
def _redis_id_lt(a: str, b: str) -> bool:
@@ -567,6 +567,17 @@ async def stream_session_events(
567567
start_id = requested
568568
resume_from = last_event_id if cursor else None
569569

570+
# Terminal reconnects can outlive curr_run_id and, in edge cases, fail to
571+
# resolve a persisted run id. Do not invent a session-only assistant id:
572+
# finish cleanly so the client refetches DB history without rendering an
573+
# empty synthetic bubble.
574+
if not is_stream_attachable and message_id is None:
575+
return StreamingResponse(
576+
stream.finished_sse(format=format, message_id=None),
577+
media_type="text/event-stream",
578+
headers=headers,
579+
)
580+
570581
logger.info(
571582
"Starting session stream",
572583
last_id=start_id,

tracecat/agent/stream/connector.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ def sse(
254254
last_id: str,
255255
format: StreamFormat,
256256
*,
257-
message_id: str,
257+
message_id: str | None,
258258
resume_from: str | None = None,
259259
) -> AsyncIterable[str]:
260260
cursor = parse_vercel_frame_cursor(resume_from)
@@ -277,7 +277,7 @@ def sse(
277277
raise ValueError(f"Invalid format: {format}")
278278

279279
def finished_sse(
280-
self, format: StreamFormat, *, message_id: str
280+
self, format: StreamFormat, *, message_id: str | None
281281
) -> AsyncIterable[str]:
282282
"""Emit an immediately-finishing stream (no live content).
283283

0 commit comments

Comments
 (0)