Skip to content

Commit 43ced7b

Browse files
committed
fix(ui): preserve startup chat sends during history load
1 parent 49b6207 commit 43ced7b

4 files changed

Lines changed: 200 additions & 12 deletions

File tree

ui/src/ui/app-gateway.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -602,21 +602,25 @@ function canRefreshActiveTabBeforeAgents(host: GatewayHost): boolean {
602602
return normalizeAgentId(parsed.agentId) === resolveFreshDefaultAgentId(host);
603603
}
604604

605+
function normalizeStartupRefreshError(error: unknown): Error {
606+
return error instanceof Error ? error : new Error(String(error));
607+
}
608+
605609
async function loadAgentsThenRefreshActiveTab(host: GatewayHost) {
606-
let initialRefreshError: unknown;
610+
let initialRefreshError: Error | undefined;
607611
const refreshBeforeAgents = canRefreshActiveTabBeforeAgents(host);
608612
const initialRefresh = refreshBeforeAgents
609-
? refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]).catch((err) => {
610-
initialRefreshError = err;
613+
? refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]).catch((err: unknown) => {
614+
initialRefreshError = normalizeStartupRefreshError(err);
611615
})
612616
: Promise.resolve();
613617
let refreshAfterAgents = !refreshBeforeAgents;
614-
let agentsError: unknown;
618+
let agentsError: Error | undefined;
615619
try {
616620
await loadAgents(host as unknown as AgentsState);
617621
refreshAfterAgents = fallbackUnconfiguredSessionSelection(host) || refreshAfterAgents;
618-
} catch (err) {
619-
agentsError = err;
622+
} catch (err: unknown) {
623+
agentsError = normalizeStartupRefreshError(err);
620624
}
621625
await initialRefresh;
622626
if (refreshAfterAgents) {

ui/src/ui/controllers/chat.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1962,6 +1962,111 @@ describe("loadChatHistory retry handling", () => {
19621962
expect(state.chatLoading).toBe(false);
19631963
});
19641964

1965+
it("preserves a first send appended while the startup history request is in flight", async () => {
1966+
const history = createDeferred<{ messages: Array<unknown>; thinkingLevel?: string }>();
1967+
const request = vi.fn(() => history.promise);
1968+
const state = createState({
1969+
connected: true,
1970+
client: { request } as unknown as ChatState["client"],
1971+
});
1972+
1973+
const load = loadChatHistory(state);
1974+
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
1975+
1976+
const optimisticMessage = {
1977+
role: "user",
1978+
content: [{ type: "text", text: "send before history settles" }],
1979+
timestamp: 123,
1980+
};
1981+
state.chatMessages = [optimisticMessage];
1982+
state.chatRunId = "run-after-history-start";
1983+
state.chatStream = "";
1984+
state.chatStreamStartedAt = 456;
1985+
1986+
history.resolve({ messages: [], thinkingLevel: "low" });
1987+
await load;
1988+
1989+
expect(state.chatMessages).toEqual([optimisticMessage]);
1990+
expect(state.chatRunId).toBe("run-after-history-start");
1991+
expect(state.chatStream).toBe("");
1992+
expect(state.chatStreamStartedAt).toBe(456);
1993+
expect(state.chatThinkingLevel).toBe("low");
1994+
expect(state.chatLoading).toBe(false);
1995+
});
1996+
1997+
it("preserves late assistant messages when startup history only catches up to the user turn", async () => {
1998+
const history = createDeferred<{ messages: Array<unknown>; thinkingLevel?: string }>();
1999+
const request = vi.fn(() => history.promise);
2000+
const state = createState({
2001+
connected: true,
2002+
client: { request } as unknown as ChatState["client"],
2003+
});
2004+
2005+
const load = loadChatHistory(state);
2006+
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
2007+
2008+
const userMessage = {
2009+
role: "user",
2010+
content: [{ type: "text", text: "send before history settles" }],
2011+
timestamp: 123,
2012+
};
2013+
const assistantMessage = {
2014+
role: "assistant",
2015+
content: [{ type: "text", text: "answer before history catches up" }],
2016+
timestamp: 456,
2017+
};
2018+
state.chatMessages = [userMessage, assistantMessage];
2019+
2020+
history.resolve({ messages: [userMessage], thinkingLevel: "low" });
2021+
await load;
2022+
2023+
expect(state.chatMessages).toEqual([userMessage, assistantMessage]);
2024+
expect(state.chatThinkingLevel).toBe("low");
2025+
expect(state.chatLoading).toBe(false);
2026+
});
2027+
2028+
it("keeps repeated late prompts when startup history only has an older matching prompt", async () => {
2029+
const history = createDeferred<{ messages: Array<unknown>; thinkingLevel?: string }>();
2030+
const request = vi.fn(() => history.promise);
2031+
const state = createState({
2032+
connected: true,
2033+
client: { request } as unknown as ChatState["client"],
2034+
});
2035+
2036+
const load = loadChatHistory(state);
2037+
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
2038+
2039+
const repeatedPrompt = {
2040+
role: "user",
2041+
content: [{ type: "text", text: "continue" }],
2042+
timestamp: 200,
2043+
};
2044+
state.chatMessages = [repeatedPrompt];
2045+
2046+
history.resolve({
2047+
messages: [
2048+
{
2049+
role: "user",
2050+
content: [{ type: "text", text: "continue" }],
2051+
timestamp: 100,
2052+
},
2053+
],
2054+
thinkingLevel: "low",
2055+
});
2056+
await load;
2057+
2058+
expect(state.chatMessages).toEqual([
2059+
{
2060+
role: "user",
2061+
content: [{ type: "text", text: "continue" }],
2062+
timestamp: 100,
2063+
},
2064+
repeatedPrompt,
2065+
]);
2066+
expect(state.chatThinkingLevel).toBe("low");
2067+
expect(state.chatLoading).toBe(false);
2068+
});
2069+
19652070
it("starts a fresh same-session history load after local messages change", async () => {
19662071
const staleRequest = createDeferred<{ messages: Array<unknown>; thinkingLevel?: string }>();
19672072
const freshRequest = createDeferred<{ messages: Array<unknown>; thinkingLevel?: string }>();

ui/src/ui/controllers/chat.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,36 @@ function messageDisplaySignature(message: unknown): string | null {
194194
}
195195
}
196196

197+
function messageTimestampMs(message: unknown): number | null {
198+
if (!message || typeof message !== "object") {
199+
return null;
200+
}
201+
const timestamp = (message as { timestamp?: unknown; ts?: unknown }).timestamp;
202+
if (typeof timestamp === "number" && Number.isFinite(timestamp)) {
203+
return timestamp;
204+
}
205+
const ts = (message as { timestamp?: unknown; ts?: unknown }).ts;
206+
return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
207+
}
208+
209+
function historyHasSameOrNewerDisplayMessage(
210+
historyMessages: unknown[],
211+
signature: string,
212+
message: unknown,
213+
): boolean {
214+
const timestamp = messageTimestampMs(message);
215+
if (timestamp == null) {
216+
return false;
217+
}
218+
return historyMessages.some((historyMessage) => {
219+
if (messageDisplaySignature(historyMessage) !== signature) {
220+
return false;
221+
}
222+
const historyTimestamp = messageTimestampMs(historyMessage);
223+
return historyTimestamp != null && historyTimestamp >= timestamp;
224+
});
225+
}
226+
197227
export function preserveOptimisticTailMessages(
198228
historyMessages: unknown[],
199229
previousMessages: unknown[],
@@ -247,6 +277,34 @@ export function preserveOptimisticTailMessages(
247277
return optimisticTail.length > 0 ? [...historyMessages, ...optimisticTail] : historyMessages;
248278
}
249279

280+
function collectLateOptimisticTailMessages(
281+
previousMessages: unknown[],
282+
currentMessages: unknown[],
283+
historyMessages: unknown[],
284+
): unknown[] {
285+
if (currentMessages === previousMessages || currentMessages.length <= previousMessages.length) {
286+
return [];
287+
}
288+
if (previousMessages.some((message, index) => currentMessages[index] !== message)) {
289+
return [];
290+
}
291+
const lateTail: unknown[] = [];
292+
for (const message of currentMessages.slice(previousMessages.length)) {
293+
if (!isLocallyOptimisticHistoryMessage(message) || shouldHideHistoryMessage(message)) {
294+
return [];
295+
}
296+
const signature = messageDisplaySignature(message);
297+
if (!signature) {
298+
return [];
299+
}
300+
if (historyHasSameOrNewerDisplayMessage(historyMessages, signature, message)) {
301+
continue;
302+
}
303+
lateTail.push(message);
304+
}
305+
return lateTail;
306+
}
307+
250308
function isRetryableStartupUnavailable(err: unknown, method: string): err is GatewayRequestError {
251309
if (!(err instanceof GatewayRequestError)) {
252310
return false;
@@ -489,6 +547,7 @@ async function loadChatHistoryUncached(
489547
const requestVersion = beginChatHistoryRequest(state);
490548
const startedAt = Date.now();
491549
const previousMessages = state.chatMessages;
550+
const previousRunId = state.chatRunId;
492551
// Any pending input-history snapshot becomes invalid once we start reloading transcript state.
493552
state.resetChatInputHistoryNavigation?.();
494553
state.chatLoading = true;
@@ -524,19 +583,29 @@ async function loadChatHistoryUncached(
524583
}
525584
const messages = Array.isArray(res.messages) ? res.messages : [];
526585
const visibleMessages = messages.filter((message) => !shouldHideHistoryMessage(message));
586+
const lateOptimisticTail = collectLateOptimisticTailMessages(
587+
previousMessages,
588+
state.chatMessages,
589+
visibleMessages,
590+
);
527591
state.chatMessages = preserveOptimisticTailMessages(visibleMessages, previousMessages);
592+
if (lateOptimisticTail.length > 0) {
593+
state.chatMessages = [...state.chatMessages, ...lateOptimisticTail];
594+
}
528595
state.currentSessionId =
529596
typeof res.sessionInfo?.sessionId === "string" && res.sessionInfo.sessionId.trim()
530597
? res.sessionInfo.sessionId
531598
: typeof res.sessionId === "string" && res.sessionId.trim()
532599
? res.sessionId
533600
: null;
534601
state.chatThinkingLevel = res.sessionInfo?.thinkingLevel ?? res.thinkingLevel ?? null;
535-
// Clear all streaming state — history includes tool results and text
536-
// inline, so keeping streaming artifacts would cause duplicates.
537-
maybeResetToolStream(state);
538-
state.chatStream = null;
539-
state.chatStreamStartedAt = null;
602+
if (!state.chatRunId || state.chatRunId === previousRunId) {
603+
// Clear all streaming state — history includes tool results and text
604+
// inline, so keeping streaming artifacts would cause duplicates.
605+
maybeResetToolStream(state);
606+
state.chatStream = null;
607+
state.chatStreamStartedAt = null;
608+
}
540609
return res;
541610
} catch (err) {
542611
if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey, requestAgentId)) {

ui/src/ui/e2e/chat-flow.e2e.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
187187
});
188188
const page = await context.newPage();
189189
const gateway = await installMockGateway(page, {
190-
deferredMethods: ["agents.list"],
190+
deferredMethods: ["agents.list", "chat.history"],
191191
historyMessages: [],
192192
});
193193

@@ -207,6 +207,16 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
207207
expect(params.message).toBe(prompt);
208208
expect(params.sessionKey).toBe("main");
209209

210+
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
211+
await gateway.resolveDeferred("chat.history", {
212+
messages: [],
213+
sessionId: "control-ui-e2e-session",
214+
thinkingLevel: null,
215+
});
216+
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
217+
await gateway.emitChatFinal({ runId, text: "History race stayed visible." });
218+
await page.getByText("History race stayed visible.").waitFor({ timeout: 10_000 });
219+
210220
await gateway.resolveDeferred("agents.list");
211221
} finally {
212222
await context.close();

0 commit comments

Comments
 (0)