Ryker_Feng 6a4e5a3bb2
feat(frontend): add side conversations for quoted follow-ups (#3934)
* feat(frontend): add side conversations for quoted follow-ups

* style(frontend): apply prettier formatting to sidecar-chat files

* fix(frontend): surface sidecar cascade cleanup failures via console.warn

Previously deleteSidecarThreadsForParent silently swallowed both
lookup errors and per-thread deletion failures, so parent thread
deletions could succeed while orphaning sidecar threads with no
signal to the caller. Log a warning that includes the parent id
and the failed thread ids/reasons so the leak is discoverable in
telemetry, matching the existing console.warn/error pattern in
this file.

* fix(frontend): address all sidecar review feedback

Resolve every reviewer comment on PR #3934:

- input-box/hooks/sidecar-panel: clear quoted references only via an
  `onSent` callback that fires after the in-flight guard, so a dropped
  send no longer silently discards quotes (willem-bd #3550).
- message-list: flip the selection toolbar below the selection when it
  would clip above the viewport (willem-bd #3551).
- reference-metadata/thread/input-box: keep referenced ids, roles, and
  count arrays 1:1 parallel instead of deduping ids (willem-bd #3552).
- message-list: widen selection containment to the shared assistant-turn
  container and hint when a selection crosses messages (willem-bd #3553).
- sidecar/api: coalesce concurrent sidecar creates for one parent behind
  a single in-flight promise to prevent duplicates (willem-bd #3554).
- sidecar-trigger/context: force-restore on trigger click so a sidecar
  deleted elsewhere self-heals instead of opening a dead thread
  (willem-bd #3555).
- threads/hooks: surface sidecar cascade cleanup failures via
  console.warn for both lookup and per-thread deletes (Copilot).

Add unit + e2e coverage for parallel metadata, atomic create, and
trigger self-healing.
2026-07-05 00:12:16 +08:00

185 lines
4.9 KiB
TypeScript

import type { Message } from "@langchain/langgraph-sdk";
import { expect, test } from "@rstest/core";
import { accumulateUsage, selectHeaderTokenUsage } from "@/core/messages/usage";
import {
getAssistantTurnUsageMessages,
getMessageGroups,
} from "@/core/messages/utils";
test("accumulates each AI message usage only once by message id", () => {
const aiMessage = {
id: "ai-1",
type: "ai",
content: "Answer",
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
} as Message;
expect(accumulateUsage([aiMessage, aiMessage])).toEqual({
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
});
});
test("counts later usage-bearing snapshots for the same AI message id", () => {
const earlySnapshot = {
id: "ai-1",
type: "ai",
content: "Streaming...",
} as Message;
const completedSnapshot = {
id: "ai-1",
type: "ai",
content: "Complete answer",
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
} as Message;
expect(accumulateUsage([earlySnapshot, completedSnapshot])).toEqual({
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
});
});
test("reads usage metadata from additional kwargs when the SDK nests it there", () => {
const aiMessage = {
id: "ai-1",
type: "ai",
content: "Answer",
additional_kwargs: {
usage_metadata: {
input_tokens: 8,
output_tokens: 3,
total_tokens: 11,
},
},
} as unknown as Message;
expect(accumulateUsage([aiMessage])).toEqual({
inputTokens: 8,
outputTokens: 3,
totalTokens: 11,
});
});
test("keeps header and per-turn aggregation consistent for a reasoning+answer message", () => {
// A single AI message carrying both reasoning (here via inline <think>) and
// answer text now lands in exactly one assistant group (#3868), so its usage
// is counted once both in the per-turn aggregation and against the header
// total. The by-id dedupe (see "accumulates each AI message usage only once")
// remains the defence-in-depth guard if any future grouping reintroduces a
// duplicate.
const messages = [
{
id: "human-1",
type: "human",
content: "Explain this",
},
{
id: "ai-1",
type: "ai",
content: "<think>checking context</think>Final answer",
usage_metadata: { input_tokens: 20, output_tokens: 7, total_tokens: 27 },
},
] as Message[];
const groups = getMessageGroups(messages);
const usageMessagesByGroupIndex = getAssistantTurnUsageMessages(groups);
const turnUsageMessages = usageMessagesByGroupIndex.at(-1);
expect(groups.map((group) => group.type)).toEqual(["human", "assistant"]);
expect(turnUsageMessages?.map((message) => message.id)).toEqual(["ai-1"]);
expect(accumulateUsage(messages)).toEqual(
accumulateUsage(turnUsageMessages!),
);
expect(accumulateUsage(turnUsageMessages!)).toEqual({
inputTokens: 20,
outputTokens: 7,
totalTokens: 27,
});
});
test("prefers backend thread usage for header totals", () => {
const messages = [
{
id: "ai-visible",
type: "ai",
content: "Visible answer",
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
},
] as Message[];
expect(
selectHeaderTokenUsage({
backendUsage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
messages,
}),
).toEqual({
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
});
});
test("adds current in-flight message usage to backend header totals", () => {
const completedMessages = [
{
id: "ai-completed",
type: "ai",
content: "Completed answer",
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
},
{
id: "ai-pending",
type: "ai",
content: "Streaming answer",
usage_metadata: { input_tokens: 4, output_tokens: 6, total_tokens: 10 },
},
] as Message[];
expect(
selectHeaderTokenUsage({
backendUsage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
messages: completedMessages,
pendingMessages: [completedMessages[1]!],
}),
).toEqual({
inputTokens: 104,
outputTokens: 56,
totalTokens: 160,
});
});
test("falls back to visible messages when backend usage is unavailable or zero", () => {
const messages = [
{
id: "ai-visible",
type: "ai",
content: "Visible answer",
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
},
] as Message[];
expect(
selectHeaderTokenUsage({
backendUsage: null,
messages,
}),
).toEqual({
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
});
expect(
selectHeaderTokenUsage({
backendUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
messages,
}),
).toEqual({
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
});
});