Skip to content

Commit 953c3bd

Browse files
authored
fix(site): prevent spurious startup warning during pending status (#23805)
## Problem The `/agents` page frequently shows "Response startup is taking longer than expected" even while the agent is actively working and messages are appearing in the transcript. ## Root Cause There's an inconsistency between `isActiveChatStatus` and `shouldApplyMessagePart` during `"pending"` status (the state between agent tool-call turns): | Component | Treats `"pending"` as... | |---|---| | `isActiveChatStatus` | **active** — includes both `"running"` and `"pending"` | | `shouldApplyMessagePart` | **inactive** — drops all `message_part` events during `"pending"` | | Status handler | clears `streamState` to `null` on `"pending"` | This creates a dead state during multi-turn tool-call cycles: 1. Agent finishes a turn → status = `"pending"` → `streamState` cleared to `null` 2. `selectIsAwaitingFirstStreamChunk` returns `true` (status is "active", stream is null, latest message isn't assistant) 3. Phase = `"starting"` → 15s timer starts 4. Stream parts from the server are **silently dropped** (`shouldApplyMessagePart()` returns `false` for `"pending"`) 5. `streamState` stays `null` — phase is stuck at `"starting"` 6. Meanwhile, durable messages (tool calls, tool results) appear normally in the transcript 7. After 15s → "Response startup is taking longer than expected" fires ## Fix Narrow `selectIsAwaitingFirstStreamChunk` to only check `chatStatus === "running"` instead of `isActiveChatStatus(chatStatus)`. `"running"` is the only status where the transport actually accepts stream parts, so it's the only status where we should be showing the "starting" indicator. `isActiveChatStatus` is left unchanged since its other caller (`shouldSurfaceReconnectState`) correctly needs to include `"pending"`.
1 parent ca879ff commit 953c3bd

2 files changed

Lines changed: 79 additions & 2 deletions

File tree

site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import type * as TypesGen from "#/api/typesGenerated";
3-
import { createChatStore } from "./chatStore";
3+
import { createChatStore, selectIsAwaitingFirstStreamChunk } from "./chatStore";
44

55
// ---------------------------------------------------------------------------
66
// Helpers
@@ -584,3 +584,74 @@ describe("subscribe", () => {
584584
expect(countB).toBe(1);
585585
});
586586
});
587+
588+
// ---------------------------------------------------------------------------
589+
// selectIsAwaitingFirstStreamChunk
590+
// ---------------------------------------------------------------------------
591+
592+
describe("selectIsAwaitingFirstStreamChunk", () => {
593+
it("returns true when running with no stream state and no assistant message", () => {
594+
const store = createChatStore();
595+
store.setChatStatus("running");
596+
store.upsertDurableMessage(makeMessage(1, "user", "hello"));
597+
598+
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(true);
599+
});
600+
601+
it("returns false when the latest message is from the assistant", () => {
602+
const store = createChatStore();
603+
store.setChatStatus("running");
604+
store.upsertDurableMessage(makeMessage(1, "user", "hello"));
605+
store.upsertDurableMessage(makeMessage(2, "assistant", "hi there"));
606+
607+
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(false);
608+
});
609+
610+
it("returns false when stream state is present", () => {
611+
const store = createChatStore();
612+
store.setChatStatus("running");
613+
store.upsertDurableMessage(makeMessage(1, "user", "hello"));
614+
store.applyMessagePart({ type: "text", text: "response" });
615+
616+
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(false);
617+
});
618+
619+
it("returns false during pending status even when stream state is null", () => {
620+
const store = createChatStore();
621+
store.setChatStatus("pending");
622+
store.upsertDurableMessage(makeMessage(1, "user", "hello"));
623+
624+
// "pending" should NOT be treated as awaiting because the
625+
// transport drops message_part events during pending status.
626+
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(false);
627+
});
628+
629+
it("returns false when chat status is null", () => {
630+
const store = createChatStore();
631+
store.upsertDurableMessage(makeMessage(1, "user", "hello"));
632+
633+
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(false);
634+
});
635+
636+
it("returns true when latest message is a tool result during running", () => {
637+
const store = createChatStore();
638+
store.setChatStatus("running");
639+
store.upsertDurableMessage(makeMessage(1, "user", "hello"));
640+
store.upsertDurableMessage(makeMessage(2, "assistant", "calling tool"));
641+
store.upsertDurableMessage(makeMessage(3, "tool", "tool result"));
642+
643+
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(true);
644+
});
645+
646+
it("returns false when latest message is a tool result during pending", () => {
647+
const store = createChatStore();
648+
store.setChatStatus("pending");
649+
store.upsertDurableMessage(makeMessage(1, "user", "hello"));
650+
store.upsertDurableMessage(makeMessage(2, "assistant", "calling tool"));
651+
store.upsertDurableMessage(makeMessage(3, "tool", "tool result"));
652+
653+
// During "pending", the transport cannot deliver parts, so
654+
// we should not be in a "starting" state.
655+
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(false);
656+
});
657+
});

site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,9 +551,15 @@ export const selectIsAwaitingFirstStreamChunk = (
551551
const latestMessage = selectLatestDurableMessage(state);
552552
const latestMessageNeedsAssistantResponse =
553553
!latestMessage || latestMessage.role !== "assistant";
554+
// Only treat "running" as awaiting a first chunk. During "pending"
555+
// status the transport drops incoming message_part events
556+
// (shouldApplyMessagePart returns false), so streamState can never
557+
// transition away from null. Including "pending" here caused the
558+
// "Response startup is taking longer than expected" warning to
559+
// fire spuriously during multi-turn tool-call cycles.
554560
return (
555561
state.streamState === null &&
556-
isActiveChatStatus(state.chatStatus) &&
562+
state.chatStatus === "running" &&
557563
latestMessageNeedsAssistantResponse
558564
);
559565
};

0 commit comments

Comments
 (0)