Files
PaperClipAI/ui/src/lib/issue-chat-messages.test.ts
T
Devin Foley adfbe2d4b9 feat(environments): refer to the managed default environment by name, not the sandbox driver key (#11838)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Managed deployments provision a platform-managed default environment
for agent runs; the UI shows this environment in selectors, the agent
form, run details, and the environments page
> - Those surfaces append the raw driver key to the environment name, so
users see labels like "Paperclip Computer (sandbox)", "Paperclip
Computer · sandbox", and fallback copy such as "Managed sandbox" and
"The sandbox has no ready authentication"
> - "sandbox" is infrastructure vocabulary, not the product name of the
environment; showing it next to the managed environment's name is
confusing and off-brand
> - This pull request renders platform-managed environments by name
alone and rewords the sandbox-phrased copy, while user-created
environments keep the driver suffix so mixed lists stay distinguishable
> - The benefit is that the default environment reads as one clear
product name everywhere, and self-hosted users lose nothing: their own
environments still show the driver

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Display of the platform-managed default environment across the UI.

**Subsystem affected**

UI (environment selectors, agent config form, environments page, agents
page, run details) and the claude-local/codex-local adapter auth checks.

**Current behavior**

The agent form labels the inherited default environment as "Name
(sandbox)". Environment selectors and the environments list render "Name
· sandbox". The agents page describes the environment as "<provider>
sandbox provider". The agent form's fallback label is "Managed sandbox".
Adapter auth checks say "The sandbox has no ready authentication for
this adapter."

**Proposed behavior**

Platform-managed environment rows (`metadata.managedByPaperclip`) render
their name alone. The fallback label is "Paperclip Computer". The agents
page describes managed environments as "Managed by Paperclip". Run
details omit the driver suffix for sandbox-driver environments (the
adjacent Provider entry already identifies the mechanism). Adapter auth
checks say "This environment has no ready authentication for this
adapter."

**Reason and benefit**

The managed environment carries a product name. Appending the raw driver
key ("sandbox") to it is noise and contradicts the product naming.
User-created environments keep the driver suffix, so mixed lists stay
distinguishable.

**Breaking changes**

None. Message text of the auth check is not read programmatically; the
UI keys off `ADAPTER_AUTH_MISSING_CHECK_CODE`. Rows without the managed
marker render exactly as before.

## What Changed

- New `environmentDisplayLabel` helper in
`ui/src/lib/managed-sandbox-environment.ts`: managed rows → name alone;
other rows → "Name · driver".
- `AgentConfigForm`: inherited-default label uses the helper; fallback
copy "Managed sandbox" → "Paperclip Computer"; environment options use
the helper.
- `ProjectProperties`, `CompanyEnvironments`: environment selector
options use the helper; the environments-list row hides the driver
suffix on managed rows; the managed detail page's fallback description
no longer says "sandbox".
- `Agents` page: managed environments are described as "Managed by
Paperclip" instead of "<provider> sandbox provider".
- `CommentThread` run details: the driver suffix is omitted for
sandbox-driver environments.
- claude-local and codex-local adapters: auth-missing check message/hint
reworded from "sandbox" to "environment" (ACP and environment-test
paths); claude-local probe/effort/login hints reworded the same way.
- Run status lines: "Syncing workspace to sandbox", "Exporting git
changes from sandbox", "Starting adapter in sandbox", and friends now
say "environment"; "Finalizing sandbox workspace" → "Finalizing
workspace". Templated transfer-progress lines map the `sandbox`
transport key to "environment" for display (`runtime-progress.ts`).
- Agent form sign-in panel: "Sign in to the sandbox" → "Sign in to the
environment"; "Authenticated. The sandbox has credentials now." → "…The
environment has credentials now."
- Feature catalog + instance settings card: "Managed Sandbox Only" →
"Managed Environment Only" (setting key unchanged; the card keeps its
alphabetical slot).
- Server agents routes: execution-target failure and test-identity copy
no longer say "sandbox"; workspace-mode label "Cloud sandbox" → "Cloud
environment".
- Tests: new `environmentDisplayLabel` unit cases; new `AgentConfigForm`
render case asserting the managed default renders without "(sandbox)" or
"· sandbox"; status-line assertions updated across adapter-utils, server
heartbeat/live-run, and UI chat suites.

## Verification

- `pnpm --filter @paperclipai/ui typecheck` — clean.
- `pnpm --filter @paperclipai/adapter-claude-local typecheck` and
`--filter @paperclipai/adapter-codex-local typecheck` — clean.
- `vitest run` for `managed-sandbox-environment.test.ts`,
`AgentConfigForm.render.test.tsx`, `CompanyEnvironments.test.tsx`,
`Agents.test.tsx`, `CommentThread.test.tsx`, `NewAgent.test.tsx` — all
green (118 tests across the two runs).

## Risks

Low risk. Cosmetic label changes only; no data or API changes. Rows
without `metadata.managedByPaperclip` render exactly as before, so
self-hosted deployments with their own environments see no change. The
only self-hosted-visible wording changes are the adapter auth-check
message and the driver suffix omission on sandbox-driver rows in run
details.

## Model Used

- Claude (Anthropic) — claude-fable-5 (Claude Fable 5), Claude Code CLI,
extended thinking, tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (no
docs reference these labels)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-21 12:51:22 -07:00

1492 lines
47 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Agent } from "@paperclipai/shared";
import {
buildAssistantPartsFromTranscript,
buildIssueChatMessages,
isCoTSegmentActive,
preserveReadableStreamingRetraction,
stabilizeThreadMessages,
type IssueChatComment,
type IssueChatLinkedRun,
} from "./issue-chat-messages";
import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "./issue-thread-interactions";
import type { IssueTimelineEvent } from "./issue-timeline-events";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { registerUIAdapter, unregisterUIAdapter } from "../adapters/registry";
function createAgent(id: string, name: string): Agent {
return {
id,
companyId: "company-1",
name,
role: "engineer",
title: null,
icon: "code",
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
pauseReason: null,
pausedAt: null,
urlKey: "codexcoder",
permissions: { canCreateAgents: false },
} as Agent;
}
function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComment {
const authorAgentId = overrides.authorAgentId ?? null;
return {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "user-1",
body: "Hello",
authorType: authorAgentId ? "agent" : "user",
presentation: null,
metadata: null,
sourceTrust: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
}
function createInteraction(
overrides: Partial<SuggestTasksInteraction> = {},
): SuggestTasksInteraction {
return {
id: "interaction-1",
companyId: "company-1",
issueId: "issue-1",
kind: "suggest_tasks",
title: "Suggested follow-up work",
summary: "Preview the next issue tree before accepting it.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
tasks: [
{
clientKey: "task-1",
title: "Prototype the card",
},
],
},
result: null,
...overrides,
resolverPolicy: overrides.resolverPolicy ?? "anyone",
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
?? { requested: "board_or_agents", effective: "board_or_agents" },
};
}
function createRequestConfirmation(
overrides: Partial<RequestConfirmationInteraction> = {},
): RequestConfirmationInteraction {
return {
id: "confirmation-1",
companyId: "company-1",
issueId: "issue-1",
kind: "request_confirmation",
title: "Approve the plan",
summary: "Review and approve the latest plan.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
prompt: "Approve the plan?",
},
result: null,
...overrides,
resolverPolicy: overrides.resolverPolicy ?? "anyone",
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
?? { requested: "board_or_agents", effective: "board_or_agents" },
};
}
describe("buildAssistantPartsFromTranscript", () => {
it("maps assistant text, reasoning, and tool activity while omitting noisy stderr", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Working on it. " },
{ kind: "assistant", ts: "2026-04-06T12:00:01.000Z", text: "Done." },
{ kind: "thinking", ts: "2026-04-06T12:00:02.000Z", text: "Need to inspect files." },
{
kind: "tool_call",
ts: "2026-04-06T12:00:03.000Z",
name: "read_file",
toolUseId: "tool-1",
input: { path: "ui/src/pages/IssueDetail.tsx" },
},
{
kind: "tool_result",
ts: "2026-04-06T12:00:04.000Z",
toolUseId: "tool-1",
content: "file contents",
isError: false,
},
{ kind: "stderr", ts: "2026-04-06T12:00:05.000Z", text: "warn: noisy setup output" },
]);
expect(result.parts).toHaveLength(3);
expect(result.parts[0]).toMatchObject({ type: "text", text: "Working on it. Done." });
expect(result.parts[1]).toMatchObject({ type: "reasoning", text: "Need to inspect files." });
expect(result.parts[2]).toMatchObject({
type: "tool-call",
toolCallId: "tool-1",
toolName: "read_file",
result: "file contents",
isError: false,
});
expect(result.notices).toEqual([]);
});
it("preserves transcript ordering when text and tool activity are interleaved", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "First." },
{
kind: "tool_call",
ts: "2026-04-06T12:00:01.000Z",
name: "read_file",
toolUseId: "tool-1",
input: { path: "ui/src/components/IssueChatThread.tsx" },
},
{ kind: "assistant", ts: "2026-04-06T12:00:02.000Z", text: "Second." },
{
kind: "tool_result",
ts: "2026-04-06T12:00:03.000Z",
toolUseId: "tool-1",
content: "ok",
isError: false,
},
{ kind: "thinking", ts: "2026-04-06T12:00:04.000Z", text: "Need one more check." },
{
kind: "tool_call",
ts: "2026-04-06T12:00:05.000Z",
name: "write_file",
toolUseId: "tool-2",
input: { path: "ui/src/lib/issue-chat-messages.ts" },
},
{
kind: "tool_result",
ts: "2026-04-06T12:00:06.000Z",
toolUseId: "tool-2",
content: "saved",
isError: false,
},
]);
expect(result.parts).toMatchObject([
{ type: "text", text: "First." },
{ type: "tool-call", toolCallId: "tool-1", toolName: "read_file", result: "ok" },
{ type: "text", text: "Second." },
{ type: "reasoning", text: "Need one more check." },
{ type: "tool-call", toolCallId: "tool-2", toolName: "write_file", result: "saved" },
]);
});
it("treats a completed tool-only segment as resolved once a tool_result arrives", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "thinking", ts: "2026-04-06T12:00:00.000Z", text: "Checking the task." },
{
kind: "tool_call",
ts: "2026-04-06T12:00:01.000Z",
name: "search",
toolUseId: "tool-1",
input: { query: "paperclip" },
},
{
kind: "tool_result",
ts: "2026-04-06T12:00:02.000Z",
toolUseId: "tool-1",
content: "search completed",
isError: false,
},
{ kind: "assistant", ts: "2026-04-06T12:00:03.000Z", text: "Found the relevant code." },
]);
expect(result.parts).toMatchObject([
{ type: "reasoning", text: "Checking the task." },
{
type: "tool-call",
toolCallId: "tool-1",
toolName: "search",
result: "search completed",
isError: false,
},
{ type: "text", text: "Found the relevant code." },
]);
expect(result.segments).toEqual([{
startMs: new Date("2026-04-06T12:00:00.000Z").getTime(),
endMs: new Date("2026-04-06T12:00:02.000Z").getTime(),
}]);
});
it("marks only the latest chain-of-thought segment active while a run is live", () => {
expect(isCoTSegmentActive({
isMessageRunning: true,
segmentIndex: 0,
segmentCount: 2,
})).toBe(false);
expect(isCoTSegmentActive({
isMessageRunning: true,
segmentIndex: 1,
segmentCount: 2,
})).toBe(true);
expect(isCoTSegmentActive({
isMessageRunning: false,
segmentIndex: 1,
segmentCount: 2,
})).toBe(false);
});
it("keeps run errors while suppressing init and system transcript noise", () => {
const result = buildAssistantPartsFromTranscript([
{
kind: "init",
ts: "2026-04-06T12:00:00.000Z",
model: "gpt-5.4",
sessionId: "session-123",
},
{
kind: "system",
ts: "2026-04-06T12:00:01.000Z",
text: "item started: planning_step (id=step-1)",
},
{
kind: "system",
ts: "2026-04-06T12:00:02.000Z",
text: "item completed: planning_step (id=step-1)",
},
{
kind: "result",
ts: "2026-04-06T12:00:03.000Z",
text: "Tool crashed during execution",
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
costUsd: 0,
subtype: "error",
isError: true,
errors: ["ENOENT: missing file"],
},
]);
expect(result.parts).toMatchObject([
{
type: "reasoning",
text: "Run error: ENOENT: missing file",
},
]);
expect(result.notices).toEqual([]);
});
it("preserves diff transcript output as a fenced diff block", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Applied the patch." },
{ kind: "diff", ts: "2026-04-06T12:00:01.000Z", changeType: "file_header", text: "ui/src/lib/issue-chat-messages.ts" },
{ kind: "diff", ts: "2026-04-06T12:00:02.000Z", changeType: "add", text: "+function formatDiffBlock(lines: string[]) {" },
{ kind: "diff", ts: "2026-04-06T12:00:03.000Z", changeType: "add", text: "+ return ````diff`;" },
]);
expect(result.parts).toMatchObject([
{ type: "text", text: "Applied the patch." },
{
type: "text",
text: [
"```diff",
"ui/src/lib/issue-chat-messages.ts",
"+function formatDiffBlock(lines: string[]) {",
"+ return ````diff`;",
"```",
].join("\n"),
},
]);
});
});
describe("buildIssueChatMessages", () => {
it("uses the company user label for current-user comments instead of collapsing to You", () => {
const messages = buildIssueChatMessages({
comments: [createComment({ authorUserId: "user-1" })],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
userLabelMap: new Map([["user-1", "Dotta"]]),
});
expect(messages[0]).toMatchObject({
role: "user",
metadata: {
custom: {
authorName: "Dotta",
authorUserId: "user-1",
},
},
});
});
it("flags an operator-interrupted historical run so the timeline can read 'interrupted'", () => {
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-int",
status: "cancelled",
agentId: "agent-1",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:02:00.000Z"),
resultJson: { operatorInterrupted: true, interruptionSource: "issue_comment_interrupt" },
},
{
runId: "run-plain",
status: "cancelled",
agentId: "agent-1",
createdAt: new Date("2026-04-06T12:03:00.000Z"),
startedAt: new Date("2026-04-06T12:03:00.000Z"),
finishedAt: new Date("2026-04-06T12:04:00.000Z"),
resultJson: null,
},
],
liveRuns: [],
});
const interrupted = messages.find((message) => message.id === "run-assistant:run-int");
const plain = messages.find((message) => message.id === "run-assistant:run-plain");
expect(interrupted?.metadata?.custom).toMatchObject({ runOperatorInterrupted: true });
expect(plain?.metadata?.custom).toMatchObject({ runOperatorInterrupted: false });
});
it("redacts deleted comment bodies while preserving tombstone metadata", () => {
const messages = buildIssueChatMessages({
comments: [
createComment({
body: "Sensitive deleted body",
deletedAt: new Date("2026-04-06T12:05:00.000Z"),
deletedByType: "user",
deletedByUserId: "user-1",
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
userLabelMap: new Map([["user-1", "Dotta"]]),
});
expect(messages[0]?.content).toEqual([{ type: "text", text: "" }]);
expect(messages[0]?.metadata.custom).toMatchObject({
deletedAt: "2026-04-06T12:05:00.000Z",
deletedByType: "user",
deletedByUserId: "user-1",
});
expect(JSON.stringify(messages[0])).not.toContain("Sensitive deleted body");
});
it("preserves low-trust source metadata on comment messages", () => {
const messages = buildIssueChatMessages({
comments: [
createComment({
authorAgentId: "agent-1",
authorUserId: null,
sourceTrust: {
preset: "low_trust_review",
disposition: "quarantined",
sourceAgentId: "agent-1",
},
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
agentMap: new Map([["agent-1", createAgent("agent-1", "Low Trust Reviewer")]]),
});
expect(messages[0]?.metadata.custom.sourceTrust).toMatchObject({
preset: "low_trust_review",
disposition: "quarantined",
sourceAgentId: "agent-1",
});
});
it("prefers derived agent attribution when a board-authored comment is proven to come from a run", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
const messages = buildIssueChatMessages({
comments: [
createComment({
authorUserId: "user-1",
derivedAuthorAgentId: "agent-1",
derivedCreatedByRunId: "run-1",
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
agentMap,
currentUserId: "user-1",
userLabelMap: new Map([["user-1", "Dotta"]]),
});
expect(messages[0]).toMatchObject({
role: "assistant",
metadata: {
custom: {
authorName: "Claude",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: "user-1",
runId: "run-1",
runAgentId: "agent-1",
},
},
});
});
it("does not reattribute a genuine board/user comment that has no derived agent", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
const messages = buildIssueChatMessages({
comments: [
createComment({
authorUserId: "local-board",
authorType: "user",
// No agent ever resolved for this comment — a real board action.
derivedAuthorAgentId: null,
derivedCreatedByRunId: null,
runId: null,
runAgentId: null,
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
agentMap,
currentUserId: "user-1",
userLabelMap: new Map([["local-board", "Board"]]),
});
expect(messages[0]).toMatchObject({
role: "user",
metadata: {
custom: {
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
},
},
});
});
it("renders a comment as agent-authored when runAgentId is set from activity log", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
const messages = buildIssueChatMessages({
comments: [
createComment({
authorUserId: "user-1",
runId: "run-1",
runAgentId: "agent-1",
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
agentMap,
currentUserId: "user-1",
userLabelMap: new Map([["user-1", "Dotta"]]),
});
expect(messages[0]).toMatchObject({
role: "assistant",
metadata: {
custom: {
authorName: "Claude",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: "user-1",
runId: "run-1",
runAgentId: "agent-1",
},
},
});
});
it("orders events before comments and appends active live runs as running assistant messages", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
const comments = [
createComment(),
createComment({
id: "comment-2",
authorAgentId: "agent-1",
authorUserId: null,
body: "I made the change.",
createdAt: new Date("2026-04-06T12:03:00.000Z"),
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
runId: "run-1",
runAgentId: "agent-1",
}),
];
const timelineEvents: IssueTimelineEvent[] = [
{
id: "event-1",
createdAt: new Date("2026-04-06T11:59:00.000Z"),
actorType: "user",
actorId: "user-1",
statusChange: {
from: "done",
to: "todo",
},
},
];
const linkedRuns: IssueChatLinkedRun[] = [
{
runId: "run-history-1",
status: "succeeded",
agentId: "agent-1",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:02:00.000Z"),
},
];
const liveRuns: LiveRunForIssue[] = [
{
id: "run-live-1",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:04:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:04:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
},
];
const messages = buildIssueChatMessages({
comments,
timelineEvents,
linkedRuns,
liveRuns,
transcriptsByRunId: new Map([
[
"run-live-1",
[{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text: "Streaming reply" }],
],
]),
hasOutputForRun: (runId) => runId === "run-live-1",
companyId: "company-1",
projectId: "project-1",
agentMap,
currentUserId: "user-1",
});
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"system:activity:event-1",
"user:comment-1",
"assistant:comment-2",
"assistant:run-assistant:run-live-1",
]);
const liveRunMessage = messages.at(-1);
expect(liveRunMessage).toMatchObject({
role: "assistant",
status: { type: "running" },
});
expect(liveRunMessage?.content[0]).toMatchObject({
type: "text",
text: "Streaming reply",
});
});
it("suppresses live-run Working messages for terminal issues", () => {
const liveRun: LiveRunForIssue = {
id: "run-live-terminal",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:04:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:04:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
};
const terminalMessages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [],
liveRuns: [liveRun],
issueStatus: "done",
currentUserId: "user-1",
});
const liveMessages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [],
liveRuns: [liveRun],
issueStatus: "in_progress",
currentUserId: "user-1",
});
expect(terminalMessages.find((message) => message.id === "run-assistant:run-live-terminal")).toBeUndefined();
expect(liveMessages.find((message) => message.id === "run-assistant:run-live-terminal")).toMatchObject({
status: { type: "running" },
metadata: { custom: { waitingText: "Working..." } },
});
});
it("merges thread interactions into the same chronological feed as comments and runs", () => {
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "comment-1",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
}),
],
interactions: [
createInteraction({
id: "interaction-2",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [
{
id: "run-live-1",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:03:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:03:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
},
],
transcriptsByRunId: new Map([
[
"run-live-1",
[{ kind: "assistant", ts: "2026-04-06T12:03:01.000Z", text: "Working on it." }],
],
]),
hasOutputForRun: (runId) => runId === "run-live-1",
currentUserId: "user-1",
});
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"user:comment-1",
"system:interaction:interaction-2",
"assistant:run-assistant:run-live-1",
]);
expect(messages[1]).toMatchObject({
metadata: {
custom: {
kind: "interaction",
anchorId: "interaction-interaction-2",
},
},
});
});
it("drops degenerate ask_user_questions interactions so they leave no empty slot (PAP-424)", () => {
function askInteraction(
id: string,
questions: AskUserQuestionsQuestion[],
): AskUserQuestionsInteraction {
return {
id,
companyId: "company-1",
issueId: "issue-1",
kind: "ask_user_questions",
title: null,
summary: null,
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
resolvedAt: null,
resolverPolicy: "anyone",
requestedResolverPolicy: "anyone",
effectiveResolverPolicy: "anyone",
resolverPolicyProvenance: "inherited",
effectiveResolverPolicySource: "requested",
legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents" },
payload: { version: 1, questions },
result: null,
} as AskUserQuestionsInteraction;
}
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "comment-1",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
}),
],
interactions: [
// A truly unanswerable card (no options, no free-text) — must be
// filtered out entirely.
askInteraction("interaction-degenerate", [
{ id: "q1", prompt: "Anything?", selectionMode: "single", options: [] },
]),
// A legitimate yes/no question survives.
askInteraction("interaction-legit", [
{
id: "q1",
prompt: "Ship it?",
selectionMode: "single",
options: [
{ id: "yes", label: "Yes" },
{ id: "no", label: "No" },
],
},
]),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
const ids = messages.map((message) => `${message.role}:${message.id}`);
// The legit card is present; the degenerate one leaves no message at all.
expect(ids).toEqual(["user:comment-1", "system:interaction:interaction-legit"]);
expect(ids).not.toContain("system:interaction:interaction-degenerate");
});
it("preserves ephemeral active-run status metadata for rendering", () => {
const activeRun: ActiveRunForIssue = {
id: "run-active-1",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:03:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:03:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
currentStatusMessage: "Syncing git worktree to environment",
currentStatusUpdatedAt: "2026-04-06T12:03:05.000Z",
currentToolName: "bash",
lastAssistantSnippet: "Checking repository status",
lastEventAt: "2026-04-06T12:03:08.000Z",
};
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
activeRun,
currentUserId: "user-1",
});
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
role: "assistant",
status: { type: "running" },
metadata: {
custom: {
kind: "live-run",
runId: "run-active-1",
currentStatusMessage: "Syncing git worktree to environment",
currentStatusUpdatedAt: "2026-04-06T12:03:05.000Z",
currentToolName: "bash",
lastAssistantSnippet: "Checking repository status",
lastEventAt: "2026-04-06T12:03:08.000Z",
},
},
});
});
it("places request confirmations after later same-run handoff status and comment", () => {
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "comment-handoff",
authorAgentId: "agent-1",
authorUserId: null,
body: "Ready for approval.",
createdAt: new Date("2026-04-06T12:03:00.000Z"),
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
runId: "run-1",
runAgentId: "agent-1",
}),
createComment({
id: "comment-user-reply",
body: "Approved.",
createdAt: new Date("2026-04-06T12:04:00.000Z"),
updatedAt: new Date("2026-04-06T12:04:00.000Z"),
}),
],
interactions: [
createRequestConfirmation({
id: "confirmation-1",
sourceRunId: "run-1",
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: "comment-user-reply",
},
}),
],
timelineEvents: [
{
id: "event-in-review",
actorType: "agent",
actorId: "agent-1",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
runId: "run-1",
statusChange: {
from: "in_progress",
to: "in_review",
},
},
],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"system:activity:event-in-review",
"assistant:comment-handoff",
"system:interaction:confirmation-1",
"user:comment-user-reply",
]);
});
it("keeps request confirmations chronological without later same-run handoff evidence", () => {
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "comment-later",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
}),
],
interactions: [
createRequestConfirmation({
id: "confirmation-1",
sourceRunId: "run-1",
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"system:interaction:confirmation-1",
"user:comment-later",
]);
});
it("does not move request confirmations past unrelated comments before same-run handoff", () => {
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "comment-user-reply",
body: "I have a question first.",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
}),
createComment({
id: "comment-handoff",
authorAgentId: "agent-1",
authorUserId: null,
body: "Ready for approval.",
createdAt: new Date("2026-04-06T12:03:00.000Z"),
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
runId: "run-1",
runAgentId: "agent-1",
}),
],
interactions: [
createRequestConfirmation({
id: "confirmation-1",
sourceRunId: "run-1",
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"system:interaction:confirmation-1",
"user:comment-user-reply",
"assistant:comment-handoff",
]);
});
it("keeps succeeded runs as assistant messages when transcript output exists", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-history-1",
status: "succeeded",
agentId: "agent-1",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:03:00.000Z"),
},
],
liveRuns: [],
transcriptsByRunId: new Map([
[
"run-history-1",
[
{ kind: "thinking", ts: "2026-04-06T12:01:10.000Z", text: "Checking the current issue thread." },
{ kind: "assistant", ts: "2026-04-06T12:02:30.000Z", text: "Updated the thread renderer." },
],
],
]),
hasOutputForRun: (runId) => runId === "run-history-1",
agentMap,
currentUserId: "user-1",
});
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
id: "run-assistant:run-history-1",
role: "assistant",
status: { type: "complete", reason: "stop" },
metadata: {
custom: {
kind: "historical-run",
runId: "run-history-1",
chainOfThoughtLabel: "Worked for 2 minutes",
},
},
});
expect(messages[0]?.content).toMatchObject([
{ type: "reasoning", text: "Checking the current issue thread." },
{ type: "text", text: "Updated the thread renderer." },
]);
});
it("compacts long run transcripts in issue chat while preserving matching tool context", () => {
const isoAt = (baseMs: number, offsetSeconds: number) =>
new Date(baseMs + offsetSeconds * 1000).toISOString();
const baseMs = Date.parse("2026-04-06T12:00:00.000Z");
const transcript = [
...Array.from({ length: 9 }, (_, index) => ({
kind: "assistant" as const,
ts: isoAt(baseMs, index),
text: `Older update ${index + 1}`,
})),
{
kind: "tool_call" as const,
ts: isoAt(baseMs, 9),
name: "search",
toolUseId: "tool-keep",
input: { query: "issue chat virtualization" },
},
...Array.from({ length: 79 }, (_, index) => ({
kind: "assistant" as const,
ts: isoAt(baseMs, 10 + index),
text: `Recent update ${index + 1}`,
})),
{
kind: "tool_result" as const,
ts: isoAt(baseMs, 89),
toolUseId: "tool-keep",
content: "search completed",
isError: false,
},
];
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-history-3",
status: "succeeded",
agentId: "agent-1",
agentName: "CodexCoder",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
startedAt: new Date("2026-04-06T12:00:00.000Z"),
finishedAt: new Date("2026-04-06T12:03:00.000Z"),
},
],
liveRuns: [],
transcriptsByRunId: new Map([["run-history-3", transcript]]),
hasOutputForRun: (runId) => runId === "run-history-3",
currentUserId: "user-1",
});
expect(messages).toHaveLength(1);
const textParts = messages[0]?.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text) ?? [];
expect(textParts.join("\n")).not.toContain("Older update 1");
expect(messages[0]?.content).toContainEqual(expect.objectContaining({
type: "tool-call",
toolCallId: "tool-keep",
toolName: "search",
result: "search completed",
}));
});
it("honors a wider transcript window declared by an adapter's UI module", () => {
// Capability path: an adapter (built-in or plugin) declares
// transcriptPresentation on its UI module; shared code resolves it via the
// registry with no adapter identities. The test registers a synthetic
// verbose adapter — the test right above pins the default 30-entry window
// for adapters that declare nothing.
registerUIAdapter({
type: "verbose_test_local",
label: "Verbose Test",
parseStdoutLine: () => [],
ConfigFields: () => null,
buildAdapterConfig: () => ({}),
transcriptPresentation: { maxVisibleEntries: 400, liveReasoningView: "scrollLog" },
});
try {
const isoAt = (baseMs: number, offsetSeconds: number) =>
new Date(baseMs + offsetSeconds * 1000).toISOString();
const baseMs = Date.parse("2026-04-06T12:00:00.000Z");
// 90 renderable entries: over the default 30-entry window, under the
// declared 400-entry window, so nothing is trimmed.
const transcript = [
...Array.from({ length: 9 }, (_, index) => ({
kind: "assistant" as const,
ts: isoAt(baseMs, index),
text: `Older update ${index + 1}`,
})),
{
kind: "tool_call" as const,
ts: isoAt(baseMs, 9),
name: "search",
toolUseId: "tool-keep",
input: { query: "issue chat virtualization" },
},
...Array.from({ length: 79 }, (_, index) => ({
kind: "assistant" as const,
ts: isoAt(baseMs, 10 + index),
text: `Recent update ${index + 1}`,
})),
{
kind: "tool_result" as const,
ts: isoAt(baseMs, 89),
toolUseId: "tool-keep",
content: "search completed",
isError: false,
},
];
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-history-verbose",
status: "succeeded",
agentId: "agent-1",
agentName: "VerboseCoder",
adapterType: "verbose_test_local",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
startedAt: new Date("2026-04-06T12:00:00.000Z"),
finishedAt: new Date("2026-04-06T12:03:00.000Z"),
},
],
liveRuns: [],
transcriptsByRunId: new Map([["run-history-verbose", transcript]]),
hasOutputForRun: (runId) => runId === "run-history-verbose",
currentUserId: "user-1",
});
expect(messages).toHaveLength(1);
const textParts = messages[0]?.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text) ?? [];
expect(textParts.join("\n")).toContain("Older update 1");
expect(textParts.join("\n")).toContain("Recent update 79");
} finally {
unregisterUIAdapter("verbose_test_local");
}
});
it("keeps the same assistant message id when a live run becomes a cancelled historical run", () => {
const liveMessages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [],
liveRuns: [
{
id: "run-1",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:01:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:01:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
},
],
transcriptsByRunId: new Map([
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
]),
hasOutputForRun: (runId) => runId === "run-1",
currentUserId: "user-1",
});
const cancelledMessages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-1",
status: "cancelled",
agentId: "agent-1",
agentName: "CodexCoder",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:01:08.000Z"),
},
],
liveRuns: [],
transcriptsByRunId: new Map([
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
]),
hasOutputForRun: (runId) => runId === "run-1",
currentUserId: "user-1",
});
expect(liveMessages).toHaveLength(1);
expect(cancelledMessages).toHaveLength(1);
expect(liveMessages[0]).toMatchObject({ id: "run-assistant:run-1", status: { type: "running" } });
expect(cancelledMessages[0]).toMatchObject({
id: "run-assistant:run-1",
status: { type: "complete", reason: "stop" },
metadata: { custom: { runStatus: "cancelled" } },
});
});
it("labels pause-caused cancelled runs as paused by board", () => {
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-paused",
status: "cancelled",
agentId: "agent-1",
agentName: "CodexCoder",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:02:00.000Z"),
resultJson: { stopReason: "paused" },
},
],
liveRuns: [],
transcriptsByRunId: new Map([
["run-paused", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
]),
hasOutputForRun: (runId) => runId === "run-paused",
currentUserId: "user-1",
});
expect(messages).toHaveLength(1);
expect(messages[0]?.metadata.custom).toMatchObject({
chainOfThoughtLabel: "Paused by board after 1 minute",
runStatus: "cancelled",
});
});
it("labels error-code-only operator interruptions as interrupted by board", () => {
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-interrupted",
status: "cancelled",
agentId: "agent-1",
agentName: "CodexCoder",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:02:00.000Z"),
errorCode: "operator_interrupted",
resultJson: null,
},
],
liveRuns: [],
transcriptsByRunId: new Map([
["run-interrupted", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
]),
hasOutputForRun: (runId) => runId === "run-interrupted",
currentUserId: "user-1",
});
expect(messages).toHaveLength(1);
expect(messages[0]?.metadata.custom).toMatchObject({
chainOfThoughtLabel: "Interrupted by board after 1 minute",
runOperatorInterrupted: true,
runStatus: "cancelled",
});
});
it("can keep succeeded runs without transcript output for embedded run feeds", () => {
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-history-2",
status: "succeeded",
agentId: "agent-1",
agentName: "CodexCoder",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:03:00.000Z"),
},
],
liveRuns: [],
includeSucceededRunsWithoutOutput: true,
currentUserId: "user-1",
});
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
id: "run:run-history-2",
role: "system",
metadata: {
custom: {
kind: "run",
runId: "run-history-2",
runAgentName: "CodexCoder",
runStatus: "succeeded",
},
},
});
});
});
describe("stabilizeThreadMessages", () => {
it("reveals live streamed additions at word boundaries instead of character boundaries", () => {
expect(preserveReadableStreamingRetraction(
"Writing ",
"Writing the pla",
)).toBe("Writing the ");
expect(preserveReadableStreamingRetraction(
"Writing ",
"Writing the plan ",
)).toBe("Writing the plan ");
expect(preserveReadableStreamingRetraction(
"Writing ",
"Writing the plan.",
)).toBe("Writing the plan.");
expect(preserveReadableStreamingRetraction(
"Writing ",
"Writing draft",
)).toBe("Writing draft");
});
it("holds sliding-window removals until an older paragraph or group boundary drops", () => {
expect(preserveReadableStreamingRetraction(
"First sentence. Second sentence is visible",
"irst sentence. Second sentence is visible now ",
)).toBe("irst sentence. Second sentence is visible now ");
expect(preserveReadableStreamingRetraction(
"First sentence. Second sentence is visible",
"Second sentence is visible now ",
)).toBe("Second sentence is visible now ");
expect(preserveReadableStreamingRetraction(
"Paragraph one.\n\nParagraph two is visible",
"Paragraph two is visible now ",
)).toBe("Paragraph two is visible now ");
expect(preserveReadableStreamingRetraction(
"The answer is 42",
"42 is the answer",
)).toBe("42 is the answer");
expect(preserveReadableStreamingRetraction(
"The quick brown fox jumps over the lazy dog",
"quick brown fox jumps over the lazy dog near the river",
)).toBe("quick brown fox jumps over the lazy dog near the river");
});
it("keeps live streamed retractions readable until a whole line disappears", () => {
expect(preserveReadableStreamingRetraction(
"First line\nSecond line\nThird line is complete",
"First line\nSecond line\nThird line",
)).toBe("First line\nSecond line\nThird line is complete");
expect(preserveReadableStreamingRetraction(
"First line\nSecond line\nThird line is complete",
"First line\nSecond line",
)).toBe("First line\nSecond line");
const liveRun: LiveRunForIssue = {
id: "run-live-retract",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:04:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:04:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
};
const buildLiveMessages = (text: string) => buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [],
liveRuns: [liveRun],
transcriptsByRunId: new Map([
["run-live-retract", [{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text }]],
]),
hasOutputForRun: (runId) => runId === "run-live-retract",
currentUserId: "user-1",
});
const fullText = "First line\nSecond line\nThird line is complete";
const firstStable = stabilizeThreadMessages(buildLiveMessages(fullText), [], new Map());
const partialRetractionStable = stabilizeThreadMessages(
buildLiveMessages("First line\nSecond line\nThird line"),
firstStable.messages,
firstStable.cache,
);
expect(partialRetractionStable.messages).toBe(firstStable.messages);
expect(partialRetractionStable.messages[0]?.content[0]).toMatchObject({
type: "text",
text: fullText,
});
const wholeLineRetractionStable = stabilizeThreadMessages(
buildLiveMessages("First line\nSecond line"),
partialRetractionStable.messages,
partialRetractionStable.cache,
);
expect(wholeLineRetractionStable.messages[0]?.content[0]).toMatchObject({
type: "text",
text: "First line\nSecond line",
});
});
it("reuses unchanged message objects across rebuilds", () => {
const firstPass = buildIssueChatMessages({
comments: [createComment()],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
const firstStable = stabilizeThreadMessages(firstPass, [], new Map());
const secondPass = buildIssueChatMessages({
comments: [
createComment(),
createComment({
id: "comment-2",
body: "New message",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
const secondStable = stabilizeThreadMessages(
secondPass,
firstStable.messages,
firstStable.cache,
);
expect(secondStable.messages).toHaveLength(2);
expect(secondStable.messages[0]).toBe(firstStable.messages[0]);
expect(secondStable.messages[1]?.id).toBe("comment-2");
});
it("reuses the previous array when nothing semantically changed", () => {
const firstPass = buildIssueChatMessages({
comments: [createComment()],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
const firstStable = stabilizeThreadMessages(firstPass, [], new Map());
const secondPass = buildIssueChatMessages({
comments: [createComment()],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
const secondStable = stabilizeThreadMessages(
secondPass,
firstStable.messages,
firstStable.cache,
);
expect(secondStable.messages).toBe(firstStable.messages);
});
});