Skip to content
Merged
2 changes: 2 additions & 0 deletions web/oss/src/components/AgentChatSlice/AgentConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ const AgentConversation = ({
markLiveGate,
resumeOrphaned,
isSeen,
runningElsewhere,
} = useAgentChatSession({entityId, sessionId, initialMessages, intent: scrollIntent})

// Turn Inspector: open state, the focused turn, and the assistant → turn-number mapping.
Expand Down Expand Up @@ -652,6 +653,7 @@ const AgentConversation = ({
entityId={entityId}
messages={messages}
busy={busy}
runningElsewhere={runningElsewhere}
hitlPending={hitlPending}
queue={{queued, removeQueued, clearQueue}}
modelKey={modelKey}
Expand Down
21 changes: 17 additions & 4 deletions web/oss/src/components/AgentChatSlice/assets/loadSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,20 @@ import {transcriptToMessages} from "./transcriptToMessages"
* authoritative). Because this return is a one-shot copy, `onRefreshed` re-delivers the
* transcript when that revalidation lands — callers apply it behind their own adoption guards.
*/
export interface SessionTranscript {
messages: UIMessage[]
/**
* How many durable records this transcript was built from. The log is append-only and ordered,
* so this is an EXACT "has the server moved on?" watermark — unlike a message count, which
* `transcriptToMessages` deliberately holds flat while a turn grows (issue #5530).
*/
recordCount: number
}

export const loadSessionMessages = async (
sessionId: string,
onRefreshed?: (messages: UIMessage[]) => void,
): Promise<UIMessage[] | null> => {
onRefreshed?: (transcript: SessionTranscript) => void,
): Promise<SessionTranscript | null> => {
// Fetch through the shared records query cache (same key as `sessionRecordsQueryFamily`) so
// hydration, revalidation, and the Inspector's atom subscribers share ONE network flight per
// stale window instead of each issuing a raw duplicate request. A failure resolves to `null`
Expand All @@ -36,11 +46,14 @@ export const loadSessionMessages = async (
void refreshed.then((fresh) => {
if (!fresh || fresh.length === 0) return
const freshMsgs = transcriptToMessages(fresh)
if (freshMsgs && freshMsgs.length > 0) onRefreshed(freshMsgs)
if (freshMsgs && freshMsgs.length > 0) {
onRefreshed({messages: freshMsgs, recordCount: fresh.length})
}
})
}
if (!records || records.length === 0) return null
return transcriptToMessages(records)
const messages = transcriptToMessages(records)
return messages ? {messages, recordCount: records.length} : null
} catch (err) {
console.warn("[loadSessionMessages] hydration fetch failed:", err)
return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,50 @@ describe("transcriptToMessages paused end-marker", () => {
expect((messages?.[0].metadata as {paused?: boolean} | undefined)?.paused).toBeUndefined()
})
})

/**
* Regression guard for issue #5530. The adoption guard must not go back to comparing message
* counts: a turn grows IN PLACE here, so the count is identical before and after it completes.
* If a mapper change ever makes these counts differ, revisit `shouldAdoptServerTranscript` —
* do not "simplify" it back to a count comparison on the strength of that change.
*/
describe("transcriptToMessages turn growth is invisible to a message count", () => {
const midTurn = (): SessionRecord[] => [
...approvalRecords(),
record("record-done-paused", {type: "done", stopReason: "paused"}),
]

const completed = (): SessionRecord[] => [
...midTurn(),
record("record-response", {
type: "interaction_response",
id: "approval-1",
kind: "user_approval",
payload: {toolCallId: "tool-1", approved: true},
}),
record("record-result", {
type: "tool_result",
id: "tool-1",
output: {stdout: "a.txt\nb.txt"},
}),
record("record-answer", {type: "message", text: "There are two files."}),
record("record-done", {type: "done"}),
]

it("renders the same number of messages before and after the turn finishes", () => {
const partial = transcriptToMessages(midTurn())
const full = transcriptToMessages(completed())

expect(partial).toHaveLength(1)
expect(full).toHaveLength(1)
// Same messages, far more content — and far more records behind it.
expect(completed().length).toBeGreaterThan(midTurn().length)
})

it("carries strictly more content in the completed turn", () => {
const partial = transcriptToMessages(midTurn())
const full = transcriptToMessages(completed())

expect(full?.[0].parts.length).toBeGreaterThan(partial?.[0].parts.length ?? 0)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import MicPermissionNotice from "./MicPermissionNotice"
import QueuedMessages from "./QueuedMessages"
import RecordingBar from "./RecordingBar"
import RevealCollapse from "./RevealCollapse"
import RunningElsewhereStrip from "./RunningElsewhereStrip"
import VoiceInputButton from "./VoiceInputButton"

// The composer carries Lexical — the heaviest dependency of this chunk — out of the
Expand All @@ -51,6 +52,7 @@ const AgentComposerDock = ({
entityId,
messages,
busy,
runningElsewhere,
hitlPending,
queue,
modelKey,
Expand Down Expand Up @@ -78,6 +80,8 @@ const AgentComposerDock = ({
entityId: string
messages: UIMessage[]
busy: boolean
/** The backend reports a live run for this session that this browser is not driving. */
runningElsewhere: boolean
hitlPending: boolean
queue: {
queued: QueuedMessage[]
Expand Down Expand Up @@ -201,6 +205,11 @@ const AgentComposerDock = ({
<div className={CHAT_COLUMN}>
<ConnectModelBanner {...modelKey} suppressed={chromeHidden} />
</div>
{/* Sits with the other docked strips so a session running in another browser reads
as busy instead of frozen (#5530). */}
{runningElsewhere && !chromeHidden ? (
<RunningElsewhereStrip className={CHAT_COLUMN} />
) : null}
<ApprovalDock
className={CHAT_COLUMN}
approvals={pendingApprovals}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import clsx from "clsx"

/**
* "This session is running somewhere else" — shown when the backend reports a live run for the
* session while THIS browser isn't the one streaming it (another tab, another device).
*
* Issue #5530: a second browser gave no sign at all that anything was happening, so a session that
* was mid-turn looked identical to an idle one. There is no push channel to browsers today, so the
* transcript catches up by polling the durable record log — this strip is what makes that
* legible instead of looking frozen.
*
* Matches the `running` dot in the session bar (`bg-colorInfo`, pulsing) so the two read as one
* signal.
*/
const RunningElsewhereStrip = ({className}: {className?: string}) => (
<div
className={clsx(
"mb-2 box-border flex items-center gap-2 rounded-lg border border-solid px-3 py-2",
"border-colorBorder bg-colorFillQuaternary",
className,
)}
role="status"
>
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-colorInfo opacity-60" />
<span className="relative inline-flex size-2 rounded-full bg-colorInfo" />
</span>
<span className="text-xs text-colorTextSecondary">
This session is running somewhere else — the transcript updates as the turn progresses.
</span>
</div>
)

export default RunningElsewhereStrip
26 changes: 24 additions & 2 deletions web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {expandedKeysForMessages, pruneExpandedAtom} from "../state/expandState"
import {
persistSessionMessagesAtom,
sessionMessagesAtom,
sessionRecordCountsReadAtom,
stampMessagesCreatedAtAtom,
} from "../state/sessions"
import {captureTurnRequestAtom} from "../state/turnCaptures"
Expand Down Expand Up @@ -74,6 +75,15 @@ export const useAgentChatSession = ({
// Immutable snapshot of the restored ids (seenIdsRef grows) — the first-seen stamping
// effect below skips these so a reload can't masquerade as the turns' send time.
const restoredIdsRef = useRef<Set<string>>(new Set(initialMessages.map((m) => m.id)))
// How many durable records the transcript we're RENDERING was built from — the exact test for
// "has the server moved on?" (issue #5530). Message counts can't see a turn growing in place:
// `transcriptToMessages` folds a paused turn into its resume and only closes a message on
// `done`, so a mid-turn snapshot and the finished turn have the SAME count and a count-based
// guard rejects the finished server copy forever. Cleared the moment a live turn starts, since
// we can't know what the server logged for it — the next open then re-syncs from the log.
const recordWatermarkRef = useRef<number | undefined>(
store.get(sessionRecordCountsReadAtom)[sessionId],
)
// Whether the LAST assistant turn was user-stopped. You can only cancel the in-flight (last) turn,
// so this is a single boolean gated on position at render time — independent of message ids (which
// can be missing/duplicated in restore/error paths and would otherwise smear the tag onto every
Expand Down Expand Up @@ -177,13 +187,15 @@ export const useAgentChatSession = ({
// throttle-revalidate the drives) as the turn streams, not just at onFinish.
useFileActivityDetector({sessionId, messages})

const {isHydrating, hydratedEmpty} = useSessionHydration({
const {isHydrating, hydratedEmpty, runningElsewhere} = useSessionHydration({
sessionId,
initialMessages,
messagesRef,
busyRef,
seenIdsRef,
restoredIdsRef,
recordWatermarkRef,
busy,
setMessages,
persistMessages,
intent,
Expand Down Expand Up @@ -266,10 +278,19 @@ export const useAgentChatSession = ({
})
}, [error, setMessages])

// A live turn makes the transcript no longer a copy of the server's, and we can't know how many
// records the runner logged for it — so drop the watermark and let the next open re-sync from
// the durable log. MUST stay declared above the persist effect: on the commit where `status`
// flips to "submitted", effects run in declaration order, so clearing here is what stops the
// persist below from filing a locally-extended transcript under a server watermark.
useEffect(() => {
if (status === "submitted" || status === "streaming") recordWatermarkRef.current = undefined
}, [status])

// Persist the conversation whenever its stream settles (skip mid-stream).
useEffect(() => {
if (status === "streaming") return
persistMessages({id: sessionId, messages})
persistMessages({id: sessionId, messages, recordCount: recordWatermarkRef.current})
}, [messages, status, sessionId, persistMessages])

// Bound the in-message expand-state store: on settle, drop entries whose owning message is gone
Expand Down Expand Up @@ -398,6 +419,7 @@ export const useAgentChatSession = ({
busyRef,
isHydrating,
hydratedEmpty,
runningElsewhere,
stopped,
setStopped,
handleStop,
Expand Down
Loading
Loading