Zeren Wang a58ab484a6
feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash (#5443)
* feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash

Implements docs/superpowers/specs/2026-09-12-projects-mvp-phase2-design.md
(issue #5160, tracker #5129) in the slice order of the spec's §16.

Slices:
- A: ProjectsConfig + write-time 422 UTF-8 byte cap; PROJECT_CONTEXT_KEY
  admission pinning (both server-owned sets + worker hoist); latest-only
  request-scoped <project> block via DynamicContextMiddleware
  wrap_model_call/awrap_model_call (idempotent reassembly, reserved ID
  prefix + marker + provenance, never persisted); journal audit
  fingerprints; Instructions tab.
- B: ProjectDocumentRow + migration 0023; ProjectDocumentRepository with
  locked check-and-set; hash-qualified immutable shelf storage with
  Paths helpers; upload/list/content/delete-to-trash routes; project
  delete trashes the shelf in-transaction; request-scoped bounded
  <documents> index with honest count/shown + actionable overflow note;
  list_project_documents/read_project_document tools registered only on
  pinned runs; PAT allowlist + drift guards; blocking-IO anchors.
- C: shared thread-upload ingestion service (uploads router refactored to
  parity); POST from-thread with provenance; attach-to-thread with
  lock-staged copy (archived source allowed); read-only thread-files
  view with per-group truncation reporting.
- D: restore (restored/merged/not_found/no_target/content_missing; no
  file moves), purge (continuous row lock across unlink/delete/commit,
  retryable on FS errors), retention sweep (lazy + startup, 24h orphan
  guard, row-side reconciliation never deletes).
- E: Documents tab (shelf + conversation-files browser, provenance,
  archived banner, content-missing rows), /workspace/trash route,
  sidebar entry, composer attach handoff, i18n (en-US/zh-CN), e2e mocks
  + specs.

Review hardening folded in (10 rounds, all with tests):
- force active shelf content (HTML/XML family) to download; nosniff on
  artifact + content responses; unified unsandboxed-iframe PDF preview
  (fixes the pre-existing Chromium sandbox blank in the artifact viewer)
- scope document trash to the URL project under the document lock
- atomic no-overwrite filename reservation for ALL ingestion (seeded
  claims + os.link commit with suffix retry; same-name re-upload now
  unique-names instead of replacing); hidden staging only, no visible
  placeholders; lease cleanup on setup failure
- serialize conversion under the document lock with post-lock active
  revalidation; drain locked filesystem work on cancellation; preserve
  bytes when an insert's commit state is uncertain (including trashed
  rows)
- original-integrity checks before serving text or cached conversions;
  content_missing surfaced in list responses (UI reads the flag, no
  409-probe); downloads always serve original bytes
- bounded streaming document reads with cached char counts; shelf limits
  declared in middleware release identity
- thread-root confinement for from-thread sources; config fallback
  rejects fractional/infinite values; composer counts staged
  attachments; pending attachments persist until submission or removal;
  in-flight instruction/rename edits survive save refetches; shelf and
  trash pagination; conversation-file and thread-files pages stay
  subscribed to refetches

Docs: README/README_zh, backend API.md/ARCHITECTURE.md, AGENTS.md
contracts, config.example.yaml projects block.

Review follow-ups (head b4807477 → this revision):
- The trash retention sweep is split so repeated lazy triggers stay
  bounded: the indexed expiry purge still runs on every trigger
  (GET /api/trash/documents, POST /api/trash/purge) while the
  O(all rows + all files) reconciliation is throttled to one run per
  user per 15 minutes (process-local, per-user window). The startup
  sweep now runs as a background task instead of blocking gateway
  readiness, and shutdown awaits it (bounded).
- The export scrub (stripInternalMarkers) is fence- and indentation-aware
  like the render path, so a pasted, fenced <project>/<documents> snippet
  survives markdown export while real injected blocks (never fenced) are
  still removed. Fence regexes moved to a dependency-free leaf module to
  avoid the messages↔streamdown import cycle.
- The artifact viewer's PDF iframe no longer carries an added title
  attribute (the upstream e2e contract locates it via :not([title])), and
  the upstream artifact-preview spec now pins the new contract: PDFs
  render unsandboxed, images keep sandbox="".

* fix(projects): round-2 review — cancel an overrun trash sweep, restore the PDF frame title

- Shutdown cancelled only the shield around the background startup sweep,
  so an all-users reconciliation that outlived the 5s budget kept walking
  rows and files while the document repo and DB engine were disposed
  underneath it. The wait now lives in `_shutdown_startup_trash_sweep`,
  which cancels the task and drains it before worker exit: the shield
  keeps the wait bounded, the cancel makes it final (CancelledError lands
  at the sweep's next await, and `_run_startup_trash_sweep` only catches
  `Exception`, so nothing swallows it).
- The browser-preview iframe lost `title={getFileName(filepath)}` in the
  previous fix round, leaving the PDF frame without an accessible name
  while its siblings keep theirs. Restore it (WCAG frame titles), assert
  it in the DOM test, and anchor the e2e on `iframe[title="report.pdf"]`
  instead of `iframe:not([title])`.

* fix(projects): round-3 review — report the sweep's late finish, not a phantom cancel

`Task.cancel()` returns False when the sweep already finished inside the
window between the deadline firing and the cancel, so the shutdown log
claimed a cancellation that never happened. Branch on that outcome: the
warning stays for a real cancel, a late finish is logged at info, and both
paths still reap the task before worker exit.

* fix(projects): round-4 review — make Empty trash delete what it confirms

`POST /api/trash/purge` only ran the retention sweep, and the sweep's
candidate selection is age-gated, so a freshly trashed document survived
"Empty trash" even though the confirmation promises that every listed
document is permanently deleted. With one trashed row the route answered
`{"purged": 0}` and left it in place; `GET /api/trash/documents` sweeps
expired rows before listing, so the visible rows were normally ineligible
for the action by construction.

Empty trash now drives `purge_all_trashed`: the caller's trashed rows
(`list_all_trashed`, no age filter) each go through the same guarded,
row-locked `purge` as the single-document delete — bytes first, then the
row, in one transaction — so a row restored mid-flight is skipped instead of
force-deleted, and an unlink failure rolls that row back and answers 500 with
a retryable message. Retention expiry stays where it was: the sweep's
`purge_candidates` is now the only age-gated selection, and the lazy
retention sweep still runs on the listing and at startup.

Tests: the router suite replaces the retention-gated expectation with the
reviewer's repro (fresh row purged, bytes unlinked, shelf and other users'
trash untouched, a failing unlink stays retryable and 500); a blocking-I/O
anchor drives the new entry point through the offload; the mocked e2e covers
the action end to end; a new real-backend spec performs it against the real
gateway and re-reads `GET /api/trash/documents`. README, API, ARCHITECTURE
and the phase-2 design docs (en+zh) state the age-independent contract.
2026-09-16 18:46:18 +08:00

1486 lines
46 KiB
TypeScript

import type { Message } from "@langchain/langgraph-sdk";
import { describe, expect, test } from "@rstest/core";
import {
areStreamMetadataSnapshotsEqual,
extractContentFromMessage,
extractTextFromMessage,
extractReasoningContentFromMessage,
getBranchableAssistantGroupIds,
getLatestEditableTurn,
getMessageCopyData,
getAssistantTurnCopyData,
getAssistantTurnUsageMessages,
getMessageGroups,
getStreamMetadataSnapshot,
getStreamingMessageLookup,
hasContent,
hasReasoning,
isAssistantMessageGroupStreaming,
isHiddenFromUIMessage,
parseUploadedFiles,
stripInternalMarkers,
stripUploadedFilesTag,
} from "@/core/messages/utils";
function aiMessage(content: string): Message {
return {
id: "ai-1",
type: "ai",
content,
} as Message;
}
test("aggregates token usage messages once per assistant turn", () => {
const messages = [
{
id: "human-1",
type: "human",
content: "Plan a trip",
},
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [{ id: "tool-1", name: "web_search", args: {} }],
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
},
{
id: "tool-1-result",
type: "tool",
name: "web_search",
tool_call_id: "tool-1",
content: "[]",
},
{
id: "ai-2",
type: "ai",
content: "Here is the itinerary",
usage_metadata: { input_tokens: 2, output_tokens: 8, total_tokens: 10 },
},
{
id: "human-2",
type: "human",
content: "Make it shorter",
},
{
id: "ai-3",
type: "ai",
content: "Short version",
usage_metadata: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
},
] as Message[];
const groups = getMessageGroups(messages);
const usageMessagesByGroupIndex = getAssistantTurnUsageMessages(groups);
expect(groups.map((group) => group.type)).toEqual([
"human",
"assistant:processing",
"assistant",
"human",
"assistant",
]);
expect(
usageMessagesByGroupIndex.map(
(groupMessages) => groupMessages?.map((message) => message.id) ?? null,
),
).toEqual([null, null, ["ai-1", "ai-2"], null, ["ai-3"]]);
});
describe("branchable assistant groups", () => {
const messages = [
{ id: "human-1", type: "human", content: "First question" },
{ id: "ai-history", type: "ai", content: "Historical final answer" },
{ id: "human-2", type: "human", content: "Complex question" },
{ id: "ai-intermediate", type: "ai", content: "Intermediate answer" },
{
id: "ai-tool",
type: "ai",
content: "",
tool_calls: [{ id: "tool-1", name: "write_todos", args: {} }],
},
{
id: "tool-result",
type: "tool",
name: "write_todos",
tool_call_id: "tool-1",
content: "Todos updated",
},
{ id: "ai-final", type: "ai", content: "Final answer" },
] as Message[];
test("keeps historical turns branchable and selects only the final AI group in the current completed turn", () => {
const groups = getMessageGroups(messages);
expect([...getBranchableAssistantGroupIds(groups, false)]).toEqual([
"ai-history",
"ai-final",
]);
});
test("does not expose the current turn while it is still loading", () => {
const groups = getMessageGroups(messages);
expect([...getBranchableAssistantGroupIds(groups, true)]).toEqual([
"ai-history",
]);
});
test("does not expose a completed turn that ends in processing", () => {
const groups = getMessageGroups(messages.slice(0, -1));
expect([...getBranchableAssistantGroupIds(groups, false)]).toEqual([
"ai-history",
]);
});
});
describe("latest editable user turn", () => {
test("returns the latest completed human turn", () => {
const messages = [
{ id: "human-1", type: "human", content: "First question" },
{ id: "ai-history", type: "ai", content: "Historical answer" },
{ id: "human-2", type: "human", content: "Use tools" },
{
id: "ai-tool",
type: "ai",
content: "",
tool_calls: [{ id: "tool-1", name: "web_search", args: {} }],
},
{
id: "tool-result",
type: "tool",
name: "web_search",
tool_call_id: "tool-1",
content: "result",
},
{ id: "ai-final", type: "ai", content: "Final answer" },
] as Message[];
const turn = getLatestEditableTurn(getMessageGroups(messages), false);
expect(turn?.humanMessage.id).toBe("human-2");
});
test("returns null while the current turn is loading", () => {
const messages = [
{ id: "human-1", type: "human", content: "Question" },
{ id: "ai-1", type: "ai", content: "Answer" },
] as Message[];
expect(getLatestEditableTurn(getMessageGroups(messages), true)).toBeNull();
});
test("returns null when the latest turn has no final assistant text", () => {
const messages = [
{ id: "human-1", type: "human", content: "Question" },
{
id: "ai-tool",
type: "ai",
content: "",
tool_calls: [{ id: "tool-1", name: "web_search", args: {} }],
},
{
id: "tool-result",
type: "tool",
name: "web_search",
tool_call_id: "tool-1",
content: "result",
},
] as Message[];
expect(getLatestEditableTurn(getMessageGroups(messages), false)).toBeNull();
});
test("returns null when the latest assistant text still has tool calls", () => {
const messages = [
{ id: "human-1", type: "human", content: "Question" },
{
id: "ai-tool",
type: "ai",
content: "Let me search first.",
tool_calls: [{ id: "tool-1", name: "web_search", args: {} }],
},
] as Message[];
expect(getLatestEditableTurn(getMessageGroups(messages), false)).toBeNull();
});
test("returns null when a later human turn is incomplete", () => {
const messages = [
{ id: "human-1", type: "human", content: "First question" },
{ id: "ai-1", type: "ai", content: "First answer" },
{ id: "human-2", type: "human", content: "Follow up" },
] as Message[];
expect(getLatestEditableTurn(getMessageGroups(messages), false)).toBeNull();
});
});
test("reasoning + content (no tool calls) yields a single assistant bubble, not a duplicate processing group", () => {
// Regression for #3868: in thinking/pro/ultra modes the final assistant
// message carries both reasoning_content and answer text. It must surface its
// reasoning exactly once — inside the assistant bubble's <Reasoning>
// collapsible. Routing the same message into a processing group as well makes
// the ChainOfThought panel above the bubble paint the identical reasoning a
// second time.
const messages = [
{ id: "human-1", type: "human", content: "Why is the sky blue?" },
{
id: "ai-1",
type: "ai",
content: "Rayleigh scattering makes the sky blue.",
additional_kwargs: { reasoning_content: "Recall Rayleigh scattering." },
},
] as Message[];
const groups = getMessageGroups(messages);
expect(groups.map((group) => group.type)).toEqual(["human", "assistant"]);
// The reasoning-bearing message lands in exactly one group, so turn-usage
// aggregation never double-counts it (see #2770).
const turnUsage = getAssistantTurnUsageMessages(groups);
expect(turnUsage.at(-1)?.map((message) => message.id)).toEqual(["ai-1"]);
});
test("keeps unresolved streaming text in the processing group when tool calls arrive later", () => {
const textOnlyMessages = [
{ id: "human-1", type: "human", content: "Create a presentation" },
{
id: "ai-1",
type: "ai",
content: "I will inspect the source material first.",
},
] as Message[];
const textOnlyGroups = getMessageGroups(textOnlyMessages, {
isCurrentTurnLoading: true,
});
expect(textOnlyGroups.map((group) => group.type)).toEqual([
"human",
"assistant:processing",
]);
const withToolCall = [
textOnlyMessages[0],
{
...textOnlyMessages[1],
tool_calls: [
{ id: "call-1", name: "read_file", args: { path: "slides.md" } },
],
},
] as Message[];
const toolCallGroups = getMessageGroups(withToolCall, {
isCurrentTurnLoading: true,
});
expect(toolCallGroups.map((group) => group.type)).toEqual([
"human",
"assistant:processing",
]);
expect(toolCallGroups[1]?.id).toBe(textOnlyGroups[1]?.id);
expect(getMessageGroups(textOnlyMessages).map((group) => group.type)).toEqual(
["human", "assistant"],
);
});
test("keeps post-tool streaming text in the processing group until the turn settles", () => {
const messages = [
{ id: "human-1", type: "human", content: "Inspect and summarize" },
{
id: "ai-1",
type: "ai",
content: "I will inspect the current implementation.",
tool_calls: [
{ id: "call-1", name: "read_file", args: { path: "source.ts" } },
],
},
{
id: "tool-1",
type: "tool",
name: "read_file",
tool_call_id: "call-1",
content: "file contents",
},
{
id: "ai-2",
type: "ai",
content: "Here is the final streamed answer.",
},
] as Message[];
const loadingGroups = getMessageGroups(messages, {
isCurrentTurnLoading: true,
});
expect(loadingGroups.map((group) => group.type)).toEqual([
"human",
"assistant:processing",
]);
expect(loadingGroups[1]?.messages.map((message) => message.id)).toEqual([
"ai-1",
"tool-1",
"ai-2",
]);
expect(getMessageGroups(messages).map((group) => group.type)).toEqual([
"human",
"assistant:processing",
"assistant",
]);
});
test("keeps tool-call reasoning in the processing group while the final answer's reasoning rides its own bubble", () => {
// Companion to #3868: only the message that also becomes an assistant bubble
// (content, no tool calls) is pulled out of the processing group. Reasoning
// attached to an intermediate tool-calling step still belongs above, with its
// tool steps.
const messages = [
{ id: "human-1", type: "human", content: "Search and summarize" },
{
id: "ai-1",
type: "ai",
content: "",
additional_kwargs: { reasoning_content: "I should search first." },
tool_calls: [{ id: "tool-1", name: "web_search", args: { query: "x" } }],
},
{
id: "tool-1-result",
type: "tool",
name: "web_search",
tool_call_id: "tool-1",
content: "[]",
},
{
id: "ai-2",
type: "ai",
content: "Here is the summary.",
additional_kwargs: { reasoning_content: "Synthesize the findings." },
},
] as Message[];
const groups = getMessageGroups(messages);
expect(groups.map((group) => group.type)).toEqual([
"human",
"assistant:processing",
"assistant",
]);
expect(groups[1]?.messages.map((message) => message.id)).toEqual([
"ai-1",
"tool-1-result",
]);
expect(groups[2]?.messages.map((message) => message.id)).toEqual(["ai-2"]);
});
describe("inline <think> tag splitting", () => {
test("strips a fully closed <think> block from AI content", () => {
const message = aiMessage("<think>internal reasoning</think>final answer");
expect(extractContentFromMessage(message)).toBe("final answer");
expect(extractReasoningContentFromMessage(message)).toBe(
"internal reasoning",
);
});
test("strips multiple closed <think> blocks and joins their reasoning", () => {
const message = aiMessage(
"<think>step one</think>between<think>step two</think>after",
);
expect(extractContentFromMessage(message)).toBe("betweenafter");
expect(extractReasoningContentFromMessage(message)).toBe(
"step one\n\nstep two",
);
});
test("during streaming, an unclosed <think> tag does not leak its tail into content", () => {
// Simulates accumulated content mid-stream, before </think> arrives.
const message = aiMessage(
"<think>I need to analyze the user's question step by",
);
expect(extractContentFromMessage(message)).toBe("");
expect(extractContentFromMessage(message)).not.toContain("<think>");
expect(extractReasoningContentFromMessage(message)).toBe(
"I need to analyze the user's question step by",
);
});
test("preamble before an unclosed <think> stays in content", () => {
const message = aiMessage(
"Here is part of the answer.<think>but wait, let me reconsider",
);
expect(extractContentFromMessage(message)).toBe(
"Here is part of the answer.",
);
expect(extractReasoningContentFromMessage(message)).toBe(
"but wait, let me reconsider",
);
});
test("closed <think> followed by a trailing unclosed <think> merges both into reasoning", () => {
const message = aiMessage(
"<think>first step</think>partial answer<think>second step still streaming",
);
expect(extractContentFromMessage(message)).toBe("partial answer");
expect(extractReasoningContentFromMessage(message)).toBe(
"first step\n\nsecond step still streaming",
);
});
test("hasReasoning recognises an unclosed <think> tag mid-stream", () => {
expect(hasReasoning(aiMessage("<think>thinking in progress"))).toBe(true);
});
test("hasContent excludes an unclosed <think> tail when no preamble exists", () => {
expect(hasContent(aiMessage("<think>thinking in progress"))).toBe(false);
});
test("hasContent stays true when preamble precedes an unclosed <think>", () => {
expect(hasContent(aiMessage("preamble<think>still thinking"))).toBe(true);
});
test("a lone <think> open tag with no body yields no reasoning and no content", () => {
const message = aiMessage("<think>");
expect(extractContentFromMessage(message)).toBe("");
expect(extractReasoningContentFromMessage(message)).toBeNull();
expect(hasReasoning(message)).toBe(false);
});
test("a literal <think> inside markdown inline code is not treated as reasoning", () => {
const message = aiMessage(
"Use `<think>` markers to delimit reasoning sections.",
);
expect(extractContentFromMessage(message)).toBe(
"Use `<think>` markers to delimit reasoning sections.",
);
expect(extractReasoningContentFromMessage(message)).toBeNull();
expect(hasReasoning(message)).toBe(false);
});
test("a closed <think> pair inside markdown inline code stays in content", () => {
// The model talking about the tag literally, e.g. when explaining prompt
// engineering. Hollowing the code span out into the reasoning panel
// corrupts the visible answer.
const message = aiMessage(
"Wrap your reasoning in `<think>your reasoning</think>` before answering.",
);
expect(extractContentFromMessage(message)).toBe(
"Wrap your reasoning in `<think>your reasoning</think>` before answering.",
);
expect(extractReasoningContentFromMessage(message)).toBeNull();
expect(hasReasoning(message)).toBe(false);
});
test("a closed <think> pair outside code is still stripped when another sits in inline code", () => {
const message = aiMessage(
"<think>real reasoning</think>Use `<think>text</think>` markers.",
);
expect(extractContentFromMessage(message)).toBe(
"Use `<think>text</think>` markers.",
);
expect(extractReasoningContentFromMessage(message)).toBe("real reasoning");
});
test("a backtick-prefixed <think> mid-stream is not split into reasoning", () => {
// Simulates the moment the model has emitted the opening backtick and
// `<think>` for a literal documentation reference, before the closing
// backtick arrives. The pre-fix behaviour would have permanently
// truncated the content here.
const message = aiMessage("Documentation: `<think>");
expect(extractContentFromMessage(message)).toBe("Documentation: `<think>");
expect(extractReasoningContentFromMessage(message)).toBeNull();
});
test("re-splits when the same message object gets new content", () => {
// Streaming replaces `content` on the accumulating message, so a split
// cached against the message object must be keyed by the content it was
// derived from.
const message = aiMessage("<think>first</think>one");
expect(extractContentFromMessage(message)).toBe("one");
expect(extractReasoningContentFromMessage(message)).toBe("first");
(message as { content: string }).content = "<think>second</think>two";
expect(extractContentFromMessage(message)).toBe("two");
expect(extractReasoningContentFromMessage(message)).toBe("second");
expect(hasReasoning(message)).toBe(true);
});
});
describe("isHiddenFromUIMessage", () => {
function contentReadCounter(
message: Omit<Message, "content">,
content: string,
) {
const counter = { reads: 0 };
const probe = {
...message,
get content() {
counter.reads++;
return content;
},
} as unknown as Message;
return { probe, counter };
}
test("does not read AI content to decide visibility", () => {
// Only the human branch consults the text, but this predicate runs over
// every message on every stream chunk (grouping, dedup, human-input
// state), so reading AI content here costs a full content scan per
// message per chunk.
const { probe, counter } = contentReadCounter(
{ id: "ai-visible", type: "ai" } as Message,
"<think>long reasoning</think>a long streamed answer",
);
expect(isHiddenFromUIMessage(probe)).toBe(false);
expect(counter.reads).toBe(0);
});
test("does not read content when a control message name already hides it", () => {
const { probe, counter } = contentReadCounter(
{ id: "summary-1", type: "ai", name: "summary" } as Message,
"compressed context",
);
expect(isHiddenFromUIMessage(probe)).toBe(true);
expect(counter.reads).toBe(0);
});
test("still hides a human message that is only slash skill activation", () => {
const message = {
id: "slash-activation",
type: "human",
content:
"<slash_skill_activation>\n<skill_content># SKILL.md</skill_content>\n</slash_skill_activation>",
} as Message;
expect(isHiddenFromUIMessage(message)).toBe(true);
});
test("keeps a human message that carries real text alongside the activation", () => {
const message = {
id: "slash-activation-with-task",
type: "human",
content:
"<slash_skill_activation>\n<skill_content># SKILL.md</skill_content>\n</slash_skill_activation>\nreal user task",
} as Message;
expect(isHiddenFromUIMessage(message)).toBe(false);
});
test("hides any message flagged with hide_from_ui", () => {
const message = {
id: "hidden-1",
type: "human",
content: "internal reply",
additional_kwargs: { hide_from_ui: true },
} as Message;
expect(isHiddenFromUIMessage(message)).toBe(true);
});
});
describe("human message internal context stripping", () => {
test("strips legacy uploaded_files context from copy data", () => {
// Display-only backward compatibility (#4212): pre-#4174 history still
// carries <uploaded_files> blocks, which copy data must strip rather
// than leak as raw XML with server-side paths.
const message = {
id: "human-with-legacy-upload",
type: "human",
content:
"<uploaded_files>\nThe following files were uploaded in this message:\n\n- paper.pdf (1.0 MB)\n Path: /mnt/user-data/uploads/paper.pdf\n</uploaded_files>\n\nSummarize this paper",
} as Message;
expect(getMessageCopyData(message)).toBe("Summarize this paper");
});
test("strips current_uploads context from copy data", () => {
// Mirrors the block UploadsMiddleware emits since #4174, including the
// trailing usage-guidance lines.
const message = {
id: "human-with-current-uploads",
type: "human",
content:
"<current_uploads>\nThe following files were uploaded in this message:\n\n- paper.docx (177.6 KB)\n Path: /mnt/user-data/uploads/paper.docx\n\nTo work with these files:\n- Use `grep` to search for keywords\n (e.g. `grep(pattern='revenue', path='/mnt/user-data/uploads/')`).\n</current_uploads>\n\nMake a slide deck from this",
} as Message;
expect(getMessageCopyData(message)).toBe("Make a slide deck from this");
});
test("parses uploaded files from a current_uploads block", () => {
const content =
"<current_uploads>\nThe following files were uploaded in this message:\n\n- paper.docx (177.6 KB)\n Path: /mnt/user-data/uploads/paper.docx\n Document outline (use `read_file` with line ranges to read sections):\n L1: Introduction\n- data.xlsx (12.0 KB)\n Path: /mnt/user-data/uploads/data.xlsx\n</current_uploads>\n\nSummarize";
// size is bytes (FileInMessage contract): the block's "177.6 KB" /
// "12.0 KB" are converted back from the human-readable form the backend
// emits, so formatBytes re-renders them at the original magnitude.
expect(parseUploadedFiles(content)).toEqual([
{
filename: "paper.docx",
size: Math.round(177.6 * 1024), // 181862
path: "/mnt/user-data/uploads/paper.docx",
},
{
filename: "data.xlsx",
size: 12 * 1024, // 12288
path: "/mnt/user-data/uploads/data.xlsx",
},
]);
});
test("parses uploaded filenames that contain parentheses", () => {
// Browsers name duplicate downloads "photo (1).png"; the backend emits the
// filename verbatim, so the parser must not stop the name at the first "(".
const content =
"<current_uploads>\nThe following files were uploaded in this message:\n\n- photo (1).png (12.3 KB)\n Path: /mnt/user-data/uploads/photo (1).png\n- report (final) (2).docx (1.5 MB)\n Path: /mnt/user-data/uploads/report (final) (2).docx\n- normal.pdf (3.0 KB)\n Path: /mnt/user-data/uploads/normal.pdf\n</current_uploads>\n\nSummarize";
expect(parseUploadedFiles(content)).toEqual([
{
filename: "photo (1).png",
size: Math.round(12.3 * 1024),
path: "/mnt/user-data/uploads/photo (1).png",
},
{
filename: "report (final) (2).docx",
size: Math.round(1.5 * 1024 * 1024),
path: "/mnt/user-data/uploads/report (final) (2).docx",
},
{
filename: "normal.pdf",
size: 3 * 1024,
path: "/mnt/user-data/uploads/normal.pdf",
},
]);
});
test("stripInternalMarkers removes current_uploads blocks on export", () => {
const content =
"<current_uploads>\n- paper.docx (177.6 KB)\n Path: /mnt/user-data/uploads/paper.docx\n</current_uploads>\n\nExport me";
expect(stripInternalMarkers(content)).toBe("Export me");
});
test("stripInternalMarkers removes attributed project context blocks on export", () => {
const content =
'<project name="Roadmap">\nsecret instructions\n</project>\n\nExport me';
expect(stripInternalMarkers(content)).toBe("Export me");
});
test("stripInternalMarkers removes documents blocks on export", () => {
const content =
'<documents count="2" shown="2">\n- id=abc | q3.pdf (2.1 MB, modified 2026-09-10)\n</documents>\n\nExport me';
expect(stripInternalMarkers(content)).toBe("Export me");
});
test("stripInternalMarkers preserves fenced code that uses marker tag names", () => {
const content = [
"Here is my pom:",
"```xml",
"<project>",
" <artifactId>demo</artifactId>",
"</project>",
"```",
"Export me",
].join("\n");
expect(stripInternalMarkers(content)).toBe(content);
});
test("stripInternalMarkers preserves tilde-fenced and indented code spans", () => {
const tilde = ["~~~", '<documents count="1">', "</documents>", "~~~"].join(
"\n",
);
expect(stripInternalMarkers(tilde)).toBe(tilde);
// The leading text keeps ``trim()`` from eating the code's indentation.
const indented = [
"Pasted snippet:",
"",
" <project>",
" </project>",
].join("\n");
expect(stripInternalMarkers(indented)).toBe(indented);
});
test("stripInternalMarkers still removes an injected block whose content contains a fence", () => {
const content = [
"<memory>",
"```",
"not a real fence owner",
"```",
"</memory>",
"Export me",
].join("\n");
expect(stripInternalMarkers(content)).toBe("Export me");
});
test("strips slash skill activation context from display content", () => {
const content =
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>\nreal user task";
expect(stripUploadedFilesTag(content)).toBe("real user task");
});
test("hides leaked slash skill activation messages with no user text", () => {
const messages = [
{
id: "slash-activation",
type: "human",
content:
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>",
},
{
id: "ai-1",
type: "ai",
content: "Public answer",
},
] as Message[];
const groups = getMessageGroups(messages);
expect(groups.map((group) => group.type)).toEqual(["assistant"]);
expect(
groups.flatMap((group) => group.messages).map((message) => message.id),
).toEqual(["ai-1"]);
});
});
test("hides internal todo reminder messages from message groups", () => {
const messages = [
{
id: "human-1",
type: "human",
content: "Audit the middleware",
},
{
id: "todo-reminder-1",
type: "human",
name: "todo_completion_reminder",
content: "<system_reminder>finish todos</system_reminder>",
},
{
id: "todo-reminder-2",
type: "human",
name: "todo_reminder",
content: "<system_reminder>remember todos</system_reminder>",
},
{
id: "ai-1",
type: "ai",
content: "Done",
},
] as Message[];
const groups = getMessageGroups(messages);
expect(groups.map((group) => group.type)).toEqual(["human", "assistant"]);
expect(
groups.flatMap((group) => group.messages).map((message) => message.id),
).toEqual(["human-1", "ai-1"]);
});
test("hides assistant copy data while that turn is streaming", () => {
const messages = [
{
id: "ai-1",
type: "ai",
content: "Partial answer",
},
] as Message[];
expect(getAssistantTurnCopyData(messages)).toBe("Partial answer");
expect(getAssistantTurnCopyData(messages, { isStreaming: true })).toBeNull();
});
test("falls back to reasoning for a reasoning-only assistant turn's copy data", () => {
// A turn can end with reasoning but no answer text (e.g. stopped during
// thinking). getMessageCopyData already copies the reasoning in that case;
// the turn-level copy button must not disappear instead.
const messages = [
{
id: "ai-1",
type: "ai",
content: "",
additional_kwargs: { reasoning_content: "the actual reasoning" },
},
] as Message[];
expect(getAssistantTurnCopyData(messages)).toBe("the actual reasoning");
});
test("settled copy data is derived once per messages array reference (#5094)", () => {
// Settled group arrays keep their identity across streaming chunks, and the
// copy button re-renders per chunk. Reading `content` through a getter
// proves the second settled call is served from the array-reference cache
// instead of re-running the O(turn bytes) extraction.
let contentReads = 0;
const message = {
id: "ai-1",
type: "ai",
get content() {
contentReads += 1;
return "Final answer";
},
} as unknown as Message;
const messages = [message];
expect(getAssistantTurnCopyData(messages)).toBe("Final answer");
const readsAfterFirstCall = contentReads;
expect(readsAfterFirstCall).toBeGreaterThan(0);
expect(getAssistantTurnCopyData(messages)).toBe("Final answer");
expect(contentReads).toBe(readsAfterFirstCall);
});
test("copy-data cache does not leak across array references", () => {
const first = [
{ id: "ai-1", type: "ai", content: "first answer" },
] as Message[];
const second = [
{ id: "ai-2", type: "ai", content: "second answer" },
] as Message[];
expect(getAssistantTurnCopyData(first)).toBe("first answer");
expect(getAssistantTurnCopyData(second)).toBe("second answer");
// The streaming short-circuit stays ahead of the cache.
expect(getAssistantTurnCopyData(second, { isStreaming: true })).toBeNull();
expect(getAssistantTurnCopyData(second)).toBe("second answer");
});
test("null copy data is not cached for a reference", () => {
// A turn with no copyable AI text must keep recomputing (and stay null)
// rather than a cached null hiding a later value — the same array can be
// re-used once messages are appended to a rebuilt group.
const messages = [
{ id: "human-1", type: "human", content: "hi" },
] as Message[];
expect(getAssistantTurnCopyData(messages)).toBeNull();
expect(getAssistantTurnCopyData(messages)).toBeNull();
});
test("marks the latest assistant message as streaming", () => {
const messages = [
{
id: "human-1",
type: "human",
content: "Hello",
},
{
id: "ai-1",
type: "ai",
content: "Still generating",
},
] as Message[];
const groups = getMessageGroups(messages);
const assistantGroupIndex = groups.findIndex(
(group) => group.type === "assistant",
);
expect(
isAssistantMessageGroupStreaming(
groups[assistantGroupIndex]?.messages ?? [],
getStreamingMessageLookup(messages, true, () => ({
streamMetadata: { langgraph_node: "agent" },
})),
),
).toBe(true);
expect(
isAssistantMessageGroupStreaming(
groups[assistantGroupIndex]?.messages ?? [],
getStreamingMessageLookup(messages, false, () => ({
streamMetadata: { langgraph_node: "agent" },
})),
),
).toBe(false);
});
test("compares stream metadata snapshots by keys and metadata identity", () => {
const identifiedMessage = {
id: "ai-1",
type: "ai",
content: "Completed answer",
} as Message;
const anonymousMessage = {
type: "ai",
content: "Anonymous answer",
} as Message;
const identifiedMetadata = { langgraph_node: "agent" };
const anonymousMetadata = { langgraph_node: "agent" };
const messages = [identifiedMessage, anonymousMessage];
const snapshot = getStreamMetadataSnapshot(messages, (message) => ({
streamMetadata:
message === identifiedMessage ? identifiedMetadata : anonymousMetadata,
}));
const equivalentSnapshot = getStreamMetadataSnapshot(messages, (message) => ({
streamMetadata:
message === identifiedMessage ? identifiedMetadata : anonymousMetadata,
}));
const changedSnapshot = getStreamMetadataSnapshot(messages, (message) => ({
streamMetadata:
message === identifiedMessage
? { ...identifiedMetadata }
: anonymousMetadata,
}));
const missingSnapshot = getStreamMetadataSnapshot(
[identifiedMessage],
() => ({ streamMetadata: identifiedMetadata }),
);
expect(areStreamMetadataSnapshotsEqual(snapshot, equivalentSnapshot)).toBe(
true,
);
expect(areStreamMetadataSnapshotsEqual(snapshot, changedSnapshot)).toBe(
false,
);
expect(areStreamMetadataSnapshotsEqual(snapshot, missingSnapshot)).toBe(
false,
);
});
test("ignores stream metadata retained from a completed turn", () => {
const completedMetadata = { langgraph_node: "agent", langgraph_step: 1 };
const activeMetadata = { langgraph_node: "agent", langgraph_step: 2 };
const completedMessages = [
{
id: "human-1",
type: "human",
content: "Hello",
},
{
id: "ai-1",
type: "ai",
content: "Completed answer",
},
] as Message[];
const settledMetadata = getStreamMetadataSnapshot(
completedMessages,
(message) =>
message.id === "ai-1" ? { streamMetadata: completedMetadata } : undefined,
);
const messages = [
...completedMessages,
{
id: "human-2",
type: "human",
content: "Continue",
},
{
id: "ai-2",
type: "ai",
content: "Still generating",
},
] as Message[];
const groups = getMessageGroups(messages).filter(
(group) => group.type === "assistant",
);
const streamingMessages = getStreamingMessageLookup(
messages,
true,
(message) => {
if (message.id === "ai-1") {
return { streamMetadata: completedMetadata };
}
if (message.id === "ai-2") {
return { streamMetadata: activeMetadata };
}
return undefined;
},
settledMetadata,
);
expect(
isAssistantMessageGroupStreaming(
groups[0]?.messages ?? [],
streamingMessages,
),
).toBe(false);
expect(
isAssistantMessageGroupStreaming(
groups[1]?.messages ?? [],
streamingMessages,
),
).toBe(true);
});
test("treats updated metadata for the same message id as active", () => {
const message = {
id: "ai-1",
type: "ai",
content: "Partial answer",
} as Message;
const completedMetadata = { langgraph_node: "agent", langgraph_step: 1 };
const activeMetadata = { langgraph_node: "agent", langgraph_step: 2 };
const settledMetadata = getStreamMetadataSnapshot([message], () => ({
streamMetadata: completedMetadata,
}));
expect(
isAssistantMessageGroupStreaming(
[message],
getStreamingMessageLookup(
[message],
true,
() => ({ streamMetadata: activeMetadata }),
settledMetadata,
),
),
).toBe(true);
});
test("keeps previous assistant copyable while waiting for a new visible answer", () => {
const messages = [
{
id: "human-1",
type: "human",
content: "Hello",
},
{
id: "ai-1",
type: "ai",
content: "Completed answer",
},
{
id: "opt-human-1",
type: "human",
content: "Continue",
},
] as Message[];
const groups = getMessageGroups(messages);
const assistantGroupIndex = groups.findIndex(
(group) => group.type === "assistant",
);
expect(
isAssistantMessageGroupStreaming(
groups[assistantGroupIndex]?.messages ?? [],
getStreamingMessageLookup(messages, true),
),
).toBe(false);
});
test("keeps previous assistant copyable while a hidden send is starting", () => {
const messages = [
{
id: "human-1",
type: "human",
content: "Hello",
},
{
id: "ai-1",
type: "ai",
content: "Completed answer",
},
] as Message[];
const groups = getMessageGroups(messages);
const assistantGroupIndex = groups.findIndex(
(group) => group.type === "assistant",
);
expect(
isAssistantMessageGroupStreaming(
groups[assistantGroupIndex]?.messages ?? [],
getStreamingMessageLookup(messages, true),
),
).toBe(false);
});
test("keeps previous assistant copyable after a hidden send is appended", () => {
const messages = [
{
id: "human-1",
type: "human",
content: "Hello",
},
{
id: "ai-1",
type: "ai",
content: "Completed answer",
},
{
id: "human-hidden",
type: "human",
content: "Save this agent",
additional_kwargs: { hide_from_ui: true },
},
] as Message[];
const groups = getMessageGroups(messages);
const assistantGroupIndex = groups.findIndex(
(group) => group.type === "assistant",
);
expect(
isAssistantMessageGroupStreaming(
groups[assistantGroupIndex]?.messages ?? [],
getStreamingMessageLookup(messages, true),
),
).toBe(false);
});
test("uses stream metadata to identify an assistant before optimistic input", () => {
const messages = [
{
id: "human-1",
type: "human",
content: "Hello",
},
{
id: "ai-1",
type: "ai",
content: "Completed answer",
},
{
id: "ai-2",
type: "ai",
content: "Still generating",
},
{
id: "opt-human-1",
type: "human",
content: "Continue",
},
] as Message[];
const assistantGroups = getMessageGroups(messages).filter(
(group) => group.type === "assistant",
);
const groups = getMessageGroups(messages);
const assistantGroupIndexes = groups
.map((group, index) => (group.type === "assistant" ? index : -1))
.filter((index) => index >= 0);
expect(
isAssistantMessageGroupStreaming(
groups[assistantGroupIndexes[0] ?? -1]?.messages ?? [],
getStreamingMessageLookup(messages, true, (message) =>
message.id === "ai-2"
? { streamMetadata: { langgraph_node: "agent" } }
: undefined,
),
),
).toBe(false);
expect(
isAssistantMessageGroupStreaming(
groups[assistantGroupIndexes[1] ?? -1]?.messages ?? [],
getStreamingMessageLookup(messages, true, (message) =>
message.id === "ai-2"
? { streamMetadata: { langgraph_node: "agent" } }
: undefined,
),
),
).toBe(true);
expect(assistantGroups.map((group) => group.id)).toEqual(["ai-1", "ai-2"]);
});
test("does not mark a completed assistant group streaming from a later processing group", () => {
const messages = [
{
id: "human-1",
type: "human",
content: "Hello",
},
{
id: "ai-1",
type: "ai",
content: "Visible answer",
},
{
id: "ai-2",
type: "ai",
content: "",
tool_calls: [{ id: "tool-1", name: "web_search", args: {} }],
},
] as Message[];
const groups = getMessageGroups(messages);
const assistantGroupIndex = groups.findIndex(
(group) => group.type === "assistant",
);
expect(groups.map((group) => group.type)).toEqual([
"human",
"assistant",
"assistant:processing",
]);
expect(
isAssistantMessageGroupStreaming(
groups[assistantGroupIndex]?.messages ?? [],
getStreamingMessageLookup(messages, true, (message) =>
message.id === "ai-2"
? { streamMetadata: { langgraph_node: "agent" } }
: undefined,
),
),
).toBe(false);
});
test("keeps streaming assistant hidden when a hidden control message follows it", () => {
const messages = [
{
id: "human-1",
type: "human",
content: "Hello",
},
{
id: "ai-1",
type: "ai",
content: "Still generating",
},
{
id: "human-hidden",
type: "human",
content: "Save this agent",
additional_kwargs: { hide_from_ui: true },
},
] as Message[];
const groups = getMessageGroups(messages);
const assistantGroupIndex = groups.findIndex(
(group) => group.type === "assistant",
);
expect(
isAssistantMessageGroupStreaming(
groups[assistantGroupIndex]?.messages ?? [],
getStreamingMessageLookup(messages, true, (message) =>
message.id === "ai-1"
? { streamMetadata: { langgraph_node: "agent" } }
: undefined,
),
),
).toBe(true);
});
describe("multi-part content with bare-string continuations", () => {
// Gemini streams the first content block as a {type:"text"} object carrying
// the thinking signature, then emits continuation deltas as plain strings.
// LangChain's Python merge_content preserves these as bare-string elements,
// so the finalized message content is [{type:"text", ...}, "...rest..."].
const geminiMessage = {
id: "ai-1",
type: "ai",
content: [
{
type: "text",
text: "First block carrying the signature.",
extras: { signature: "abc123" },
index: 0,
},
"Continuation streamed as a bare string.",
],
} as unknown as Message;
test("extractContentFromMessage includes the bare-string parts", () => {
expect(extractContentFromMessage(geminiMessage)).toBe(
"First block carrying the signature.\nContinuation streamed as a bare string.",
);
});
test("extractTextFromMessage includes the bare-string parts", () => {
expect(extractTextFromMessage(geminiMessage)).toBe(
"First block carrying the signature.\nContinuation streamed as a bare string.",
);
});
});
describe("orphan tool messages", () => {
// LangGraph stream-mode "messages-tuple" can emit tool-result events out of order or
// replayed from subagent state (e.g. bash subagent under LocalSandboxProvider with
// allow_host_bash). When that happens, the tool message arrives after a terminal
// assistant/human group, so getMessageGroups' lastOpenGroup() returns null.
//
// The previous behaviour was console.error + drop, which silently hid the tool
// result from the UI. The fix falls back to attaching the orphan tool to the most
// recent group so the user can still see what the agent did.
test("attaches orphan tool message to the most recent group instead of dropping it", () => {
const messages = [
{ id: "h-1", type: "human", content: "Run something" },
{
id: "ai-1",
type: "ai",
content: "ok",
tool_calls: [{ id: "call-1", name: "bash", args: {} }],
},
{
id: "t-1",
type: "tool",
name: "bash",
tool_call_id: "call-1",
content: "output-1",
},
{ id: "ai-2", type: "ai", content: "Done." }, // terminal assistant group
// Orphan tool: arrives after a terminal group, no preceding processing group
{
id: "t-2",
type: "tool",
name: "bash",
tool_call_id: "call-2",
content: "output-2",
},
] as Message[];
const groups = getMessageGroups(messages);
// Expect groups: human, assistant:processing (ai-1 + t-1), assistant (ai-2), and
// t-2 should be attached to the last group (assistant), not dropped.
const types = groups.map((g) => g.type);
expect(types).toEqual(["human", "assistant:processing", "assistant"]);
// t-2 must be retrievable from one of the groups — must NOT be silently dropped
const allMessages = groups.flatMap((g) => g.messages);
const t2 = allMessages.find((m) => m.id === "t-2");
expect(t2).toBeDefined();
expect(t2?.type).toBe("tool");
});
test("opens a processing group for a leading orphan tool message", () => {
// History pagination cuts by event seq, not turn boundaries, so the first
// loaded page can begin mid-turn with tool results whose AI tool-call
// message sits on an unloaded older page (#4399). They must stay visible
// in a processing group, not be dropped with a console error per render.
const messages = [
{
id: "t-lead-1",
type: "tool",
name: "bash",
tool_call_id: "call-old-1",
content: "output from unloaded turn",
},
{
id: "t-lead-2",
type: "tool",
name: "bash",
tool_call_id: "call-old-2",
content: "second output",
},
{ id: "ai-1", type: "ai", content: "Done." },
] as Message[];
const groups = getMessageGroups(messages);
expect(groups.map((g) => g.type)).toEqual([
"assistant:processing",
"assistant",
]);
// Both leading orphans cluster into the same processing group.
expect(groups[0]?.messages.map((m) => m.id)).toEqual([
"t-lead-1",
"t-lead-2",
]);
});
test("leading orphan absorbs the following real AI turn into one processing group", () => {
// A leading orphan followed by a real tool-call turn must accumulate into
// a single processing group via the assistant:processing branch, not
// strand the orphan as a separate empty group beside the real turn.
const messages = [
{
id: "t-lead",
type: "tool",
name: "bash",
tool_call_id: "call-old",
content: "output from unloaded turn",
},
{
id: "ai-1",
type: "ai",
content: "running",
tool_calls: [{ id: "call-1", name: "bash", args: {} }],
},
{
id: "t-1",
type: "tool",
name: "bash",
tool_call_id: "call-1",
content: "output-1",
},
] as Message[];
const groups = getMessageGroups(messages);
// One processing group holds the orphan, the AI turn, and its tool result.
expect(groups.map((g) => g.type)).toEqual(["assistant:processing"]);
expect(groups[0]?.messages.map((m) => m.id)).toEqual([
"t-lead",
"ai-1",
"t-1",
]);
});
test("tool message preceded only by hidden messages gets a group", () => {
// messages is non-empty, but every earlier message is hidden from the UI —
// groups is still empty when the tool message arrives.
const messages = [
{
id: "hidden-1",
type: "human",
content:
"<slash_skill_activation>\n<skill_content># SKILL.md</skill_content>\n</slash_skill_activation>",
},
{
id: "t-1",
type: "tool",
name: "bash",
tool_call_id: "call-1",
content: "output",
},
] as Message[];
const groups = getMessageGroups(messages);
expect(groups.map((g) => g.type)).toEqual(["assistant:processing"]);
expect(groups[0]?.messages.map((m) => m.id)).toEqual(["t-1"]);
});
test("replayed tool with same tool_call_id is not lost (duplicate stream events)", () => {
// LangGraph subagent state restoration can replay tool-result events. The
// frontend log shows the same tool_call_id arriving twice. Both occurrences
// should be visible in the UI, not just the first.
const messages = [
{ id: "h-1", type: "human", content: "q" },
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [{ id: "call-x", name: "bash", args: {} }],
},
{
id: "t-1a",
type: "tool",
name: "bash",
tool_call_id: "call-x",
content: "first delivery",
},
// Terminal assistant group ends the turn and closes the processing group.
// Without this interleave the replayed t-1b would still take the
// unchanged happy path; with it, t-1b arrives when lastOpenGroup()
// returns null and must take the new fallback branch to be visible.
{ id: "ai-2", type: "ai", content: "Done." },
// Replayed tool-result for the original tool_call — must reach the new
// else-if (groups.length > 0) branch instead of being dropped.
{
id: "t-1b",
type: "tool",
name: "bash",
tool_call_id: "call-x",
content: "first delivery",
},
] as Message[];
const groups = getMessageGroups(messages);
const allMessages = groups.flatMap((g) => g.messages);
// Strict assertion: the replayed tool message must be reachable from a
// group (i.e. attached via the new fallback). Before the fix this was
// silently dropped by console.error.
const t1b = allMessages.find((m) => m.id === "t-1b");
expect(t1b).toBeDefined();
expect(t1b?.type).toBe("tool");
});
});