FN-040: fix chat source-link integrity and contrast
Harden chat source links across streaming providers while improving new-tab behavior and visual contrast. - Preserve source-link metadata through streamed delta and event bridges. - Open chat source links safely in new tabs across desktop and mobile surfaces. - Improve source-link contrast and add regression coverage plus user-facing guidance. Files changed: .changeset/fn-040-chat-source-links.md | 7 +++ docs/dashboard-guide.md | 3 + .../ui-bugs/chat-streamed-source-link-integrity.md | 53 ++++++++++++++++ packages/dashboard/app/components/ChatView.css | 24 ++++++++ .../app/components/StandardChatSurface.tsx | 8 ++- .../components/__tests__/ChatView.core.test.tsx | 38 ++++++++++++ .../components/__tests__/ChatView.mobile.test.tsx | 70 ++++++++++++++++++++-- .../__tests__/ChatView.sessions-rooms.test.tsx | 49 +++++++++++++++ ...andardChatSurface.nativeStructureEmbed.test.tsx | 52 +++++++++++++++- .../__tests__/TaskPlannerChatTab.test.tsx | 48 +++++++++++++++ .../engine/src/__tests__/streaming-delta.test.ts | 36 +++++++++++ packages/engine/src/execution/streaming-delta.ts | 17 +++++- .../src/__tests__/event-bridge.test.ts | 30 +++++++++- packages/pi-claude-cli/src/event-bridge.ts | 14 +++-- .../src/__tests__/event-bridge.test.ts | 42 +++++++++++++ .../fusion-plugin-acp-runtime/src/event-bridge.ts | 8 ++- .../src/__tests__/provider.test.ts | 34 +++++++++++ .../src/acp/event-bridge.ts | 8 ++- .../src/__tests__/event-bridge.test.ts | 28 +++++++++ .../src/event-bridge.ts | 14 +++-- .../src/__tests__/provider.test.ts | 34 +++++++++++ .../src/acp/event-bridge.ts | 8 ++- .../src/__tests__/runtime-adapter.test.ts | 34 +++++++++++ .../src/acp/event-bridge.ts | 8 ++- 24 files changed, 642 insertions(+), 25 deletions(-) Fusion-Task-Id: FN-040 Fusion-Task-Lineage: 364ad678-df3e-4fb2-b19c-29eb44d8a429 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-040-chat-source-links.md
Normal file
7
.changeset/fn-040-chat-source-links.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep Chat source links complete, readable, and safely opened in a new tab.
|
||||
category: fix
|
||||
dev: Numeric dotted tokens now survive streaming bridge normalization; shared Chat Markdown links use tokenized contrast and noopener noreferrer.
|
||||
@@ -736,6 +736,9 @@ Chat view provides project-scoped conversations with agents. The default convers
|
||||
|
||||
Use **Settings → Appearance → Conversation layout** to choose the project-scoped message presentation for every dashboard chat surface. **Bubbles** is the default and keeps the bounded, left/right-aligned message bubbles; **Full width** lets each message use the available transcript width. The choice applies immediately to normal Chat, Quick Chat, and dock/overflow Chat hosts, as well as task-detail **Activity** and task-aware **Chat**. Missing or invalid values safely use **Bubbles**.
|
||||
|
||||
<!-- FNXC:ChatStreamingDocs 2026-08-19-13:52: Ordinary Markdown links in the shared Chat renderer open safely in a new tab and use the assistant-bubble text token so source destinations remain readable on desktop and narrow hosts. -->
|
||||
Ordinary Markdown links in Direct Chat, Chat Rooms, Quick Chat, floating/dock Chat, and task-detail Planner Chat open in a new browser tab and include the safe `noopener noreferrer` relationship. They retain the complete sanitized destination and use the shared readable, always-underlined Chat treatment on desktop and mobile. Native `fusion://` structure references continue to open their preview cards, file-path controls keep their in-app navigation, and terminal-only CLI output plus separate non-Chat Markdown surfaces are outside this link contract.
|
||||
|
||||
<!-- FNXC:ChatComposerDocs 2026-08-19-03:02: Primary conversation drafts must stop displacing the transcript while retaining an intentional desktop/tablet escape hatch. -->
|
||||
Primary Chat, Rooms, Activity, and task Chat composers grow automatically through five rendered lines, then scroll excess text inside the input. On desktop and tablet, drag the native vertical resize affordance to enlarge the current draft; that manual height is in-memory only, is not saved, and resets when the draft is cleared, sent, or the conversation/task target changes. Mobile keeps the composer compact, capped at five lines, and internally scrolling without advertising a mouse-only resize affordance.
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: "Chat streamed source-link integrity"
|
||||
date: 2026-08-19
|
||||
category: ui-bugs
|
||||
module: packages/engine/src/execution/streaming-delta.ts and packages/dashboard/app/components/StandardChatSurface.tsx
|
||||
problem_type: data_integrity
|
||||
component: chat_streaming
|
||||
symptoms:
|
||||
- "Streaming Chat source labels changed numeric dotted tokens such as GPT-5.6 into GPT-5. 6"
|
||||
- "The inserted space also corrupted dotted URL path segments and source destinations"
|
||||
- "Chat Markdown source links opened in the current tab and had weak/default contrast"
|
||||
root_cause: overly_broad_stream_boundary_repair
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
- runtime_bridge
|
||||
- markdown_renderer
|
||||
- url_sanitization
|
||||
- chat_persistence
|
||||
- responsive_ui
|
||||
tags:
|
||||
- chat
|
||||
- streaming
|
||||
- source-links
|
||||
- markdown
|
||||
- new-tab
|
||||
- contrast
|
||||
- runtime-bridges
|
||||
---
|
||||
|
||||
# Chat streamed source-link integrity
|
||||
|
||||
## Problem
|
||||
|
||||
Some providers split a response immediately after a numeric period. The shared sentence-boundary repair treated every punctuation-plus-digit boundary as a missing sentence space, so chunks such as `GPT-5.` + `6 Luna` and `/gpt-5.` + `6-luna` were persisted as `GPT-5. 6` and `/gpt-5. 6-luna`. The Chat renderer then produced incomplete destinations, opened ordinary links in the current tab, and inherited a low-contrast browser link color.
|
||||
|
||||
## Solution
|
||||
|
||||
Classify a digit-period-digit boundary before applying the existing sentence repair. This narrow exception covers versions, decimals, IP addresses, and dotted URL path segments while preserving legitimate repairs such as `Done.` + `Next` and `Phase complete.` + `2 more items`. The engine normalizer and every runtime bridge apply the rule before their accumulators or callbacks emit deltas, so live SSE, in-flight recovery, final PostgreSQL messages, and resumed history share one corrected value. Text and thinking accumulators remain independent.
|
||||
|
||||
The shared `standardChatMarkdownComponents` anchor preserves ReactMarkdown's sanitized `href`, adds semantic `target="_blank"` and `rel="noopener noreferrer"`, and removes an unsafe sanitized anchor shell without bypassing `defaultUrlTransform`. Native structure references still become preview cards and file-path controls retain their in-app behavior. Chat-scoped CSS uses `var(--text)` against assistant `var(--surface-1)`, keeps normal and visited links underlined, and uses existing transition and focus tokens; no global anchor rule or render-time content rewrite was added.
|
||||
|
||||
## Regression matrix
|
||||
|
||||
- Engine normalizer: exact three-link Markdown split after every numeric period, decimals/IPs, sentence-plus-number repair, whitespace idempotence, lowercase/property continuation, cross-block fallback, and independent text/thinking tails.
|
||||
- pi-Claude, generic ACP, Droid, Claude ACP, Grok ACP, and OMP ACP bridges: exact source links at the accumulator/callback boundary plus thinking-token coverage where the bridge owns a separate accumulator.
|
||||
- Shared Markdown renderer: persisted and streaming links, exact href/target/rel, repeated links, unsafe schemes, native structure references, file paths, force-plain/empty content, and no nested or empty interactive shell.
|
||||
- Production hosts: desktop Direct Chat persisted/live bubbles, Chat Room transcript, task-detail Planner Chat history and reattached stream, and narrow/mobile Direct Chat.
|
||||
- Verification: targeted runtime/dashboard Vitest suites, changed-only `pnpm test`, `pnpm lint`, `pnpm typecheck`, and `pnpm build`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Historical persisted messages are not rewritten. Terminal-only CLI output, Task Activity/AgentLog, Mailbox, Documents, Workflow Results, and other separate Markdown renderers retain their existing contracts unless they independently adopt the shared Chat renderer.
|
||||
@@ -1216,6 +1216,30 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Shared Chat Markdown links need page-text contrast against assistant bubbles and a persistent affordance in desktop, compact, and mobile hosts. Scope the treatment here so terminal-only and unrelated Markdown renderers keep their own contracts.
|
||||
*/
|
||||
.chat-message-content--markdown a,
|
||||
.chat-message-content--markdown a:visited {
|
||||
color: var(--text);
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--text);
|
||||
transition: color var(--transition-fast), text-decoration-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-message-content--markdown a:hover,
|
||||
.chat-message-content--markdown a:focus-visible {
|
||||
color: var(--text);
|
||||
text-decoration-color: var(--text);
|
||||
}
|
||||
|
||||
.chat-message-content--markdown a:focus-visible {
|
||||
outline: none;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.chat-message-content--failure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -451,10 +451,16 @@ function renderMarkdownBlockWithNativeStructurePreviews(
|
||||
return blocks.length === 1 ? blocks[0] : <>{blocks}</>;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Ordinary Chat Markdown links must preserve ReactMarkdown's sanitized href while opening in a separate tab with an explicit reverse-tabnabbing policy. Native structure references remain previews instead of becoming ordinary anchors.
|
||||
*/
|
||||
function NativeStructureMarkdownAnchor({ children, href, ...props }: React.ComponentProps<"a">) {
|
||||
const structureRef = href ? parseNativeStructureChatRef(href) : null;
|
||||
if (structureRef) return <NativeStructurePreview ref={structureRef} onOpen={openNativeStructure} />;
|
||||
return <a href={href} {...props}>{children}</a>;
|
||||
/* FNXC:ChatStreaming 2026-08-19-13:52: ReactMarkdown clears unsafe hrefs; do not leave an empty interactive shell. */
|
||||
if (!href) return <span>{children}</span>;
|
||||
return <a {...props} href={href} target="_blank" rel="noopener noreferrer">{children}</a>;
|
||||
}
|
||||
|
||||
function NativeStructureMarkdownCode({ children, ...props }: React.ComponentProps<"code">) {
|
||||
|
||||
@@ -469,6 +469,44 @@ describe("ChatView", () => {
|
||||
expect(screen.getByText("Hi there!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Exercise Direct Chat's persisted and live bubbles with the exact reported source response. The caller test proves the shared renderer receives complete numeric tokens and applies the secure new-tab policy on the production ChatView surface.
|
||||
*/
|
||||
it("renders complete source links in persisted and streaming Direct Chat", async () => {
|
||||
const sourceMarkdown = [
|
||||
"Sources officielles:",
|
||||
"",
|
||||
"[GPT‑5.6 Luna](https://developers.openai.com/api/docs/models/gpt-5.6-luna)",
|
||||
"[GPT‑5.6 Sol](https://developers.openai.com/api/docs/models/gpt-5.6-sol)",
|
||||
"[GPT‑5.6 Terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra)",
|
||||
].join("\\n");
|
||||
const expectedHrefs = [
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-luna",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-sol",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-terra",
|
||||
];
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [{ id: "msg-source", sessionId: "session-001", role: "assistant", content: sourceMarkdown, createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
isStreaming: true,
|
||||
streamingText: sourceMarkdown,
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const persisted = screen.getByTestId("chat-message-msg-source");
|
||||
const streaming = document.querySelector(".chat-message--streaming") as HTMLElement;
|
||||
for (const bubble of [persisted, streaming]) {
|
||||
const links = Array.from(bubble.querySelectorAll(".chat-message-content--markdown a"));
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual(expectedHrefs);
|
||||
expect(links.every((link) => link.getAttribute("target") === "_blank")).toBe(true);
|
||||
expect(links.every((link) => link.getAttribute("rel") === "noopener noreferrer")).toBe(true);
|
||||
expect(bubble.textContent).toContain("GPT‑5.6");
|
||||
expect(bubble.textContent).not.toContain("5. 6");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders file paths in assistant inline code as clickable links while preserving the code wrapper", async () => {
|
||||
const openFile = vi.fn();
|
||||
setupMockChat({
|
||||
|
||||
@@ -1137,6 +1137,44 @@ describe("ChatView mobile behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
A narrow Direct Chat host must render the same complete source destinations and security attributes as desktop; responsive containment must not replace the shared Chat Markdown treatment.
|
||||
*/
|
||||
it("mobile mode: keeps streamed source links complete and safely target a new tab", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
try {
|
||||
const sourceMarkdown = [
|
||||
"Sources officielles:",
|
||||
"",
|
||||
"[GPT‑5.6 Luna](https://developers.openai.com/api/docs/models/gpt-5.6-luna)",
|
||||
"[GPT‑5.6 Sol](https://developers.openai.com/api/docs/models/gpt-5.6-sol)",
|
||||
"[GPT‑5.6 Terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra)",
|
||||
].join("\\n");
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [{ id: "mobile-source", sessionId: activeSessionFixture.id, role: "assistant", content: sourceMarkdown, createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
isStreaming: true,
|
||||
streamingText: sourceMarkdown,
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const persistedBubble = screen.getByTestId("chat-message-mobile-source");
|
||||
const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement;
|
||||
for (const bubble of [persistedBubble, streamingBubble]) {
|
||||
const links = Array.from(bubble.querySelectorAll(".chat-message-content--markdown a"));
|
||||
expect(links).toHaveLength(3);
|
||||
expect(links.every((link) => link.getAttribute("href")?.includes("gpt-5.6-"))).toBe(true);
|
||||
expect(links.every((link) => link.getAttribute("target") === "_blank")).toBe(true);
|
||||
expect(links.every((link) => link.getAttribute("rel") === "noopener noreferrer")).toBe(true);
|
||||
expect(bubble.textContent).not.toContain("5. 6");
|
||||
}
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: keeps populated streaming direct composer above iOS accessory chrome", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true);
|
||||
@@ -1987,15 +2025,19 @@ describe("ChatView mobile behavior", () => {
|
||||
const restoreMatchMedia = mockViewportMode("mobile");
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
localStorage.setItem("fusion:chat-scope", "rooms");
|
||||
const resizeObserverCtor = vi.fn();
|
||||
const observedElements: Element[] = [];
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Chat input autosizing legitimately owns a ResizeObserver in Rooms scope. The regression invariant is narrower: no observer may follow the `.chat-messages` direct-thread viewport while the Room transcript is active.
|
||||
*/
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeObserverCtor(callback);
|
||||
}
|
||||
observe = vi.fn();
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
observe = vi.fn((element: Element) => {
|
||||
observedElements.push(element);
|
||||
});
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
},
|
||||
@@ -2026,7 +2068,7 @@ describe("ChatView mobile behavior", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("chat-sidebar-rooms")).toBeInTheDocument();
|
||||
});
|
||||
expect(resizeObserverCtor).not.toHaveBeenCalled();
|
||||
expect(observedElements.some((element) => element.classList.contains("chat-messages"))).toBe(false);
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
if (originalResizeObserver) {
|
||||
@@ -2452,6 +2494,22 @@ describe("ChatView mobile CSS contract", () => {
|
||||
expect(css).not.toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message-copy-action\s*\{[^}]*min-width:\s*calc\(var\(--space-lg\)\s*\*\s*2\.25\)/);
|
||||
expect(css).not.toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message-copy-action\s*\{[^}]*min-height:\s*calc\(var\(--space-lg\)\s*\*\s*2\.25\)/);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Keep Chat links readable and visibly interactive in narrow hosts without allowing a mobile rule or unrelated global anchor rule to replace the shared tokenized treatment.
|
||||
*/
|
||||
it("keeps Chat Markdown anchors contrast-safe and underlined on every host width", () => {
|
||||
const normalRule = css.match(/\.chat-message-content--markdown a,\s*\.chat-message-content--markdown a:visited\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
const interactiveRule = css.match(/\.chat-message-content--markdown a:hover,\s*\.chat-message-content--markdown a:focus-visible\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
expect(normalRule).toContain("color: var(--text);");
|
||||
expect(normalRule).toContain("text-decoration-line: underline;");
|
||||
expect(normalRule).toContain("text-decoration-color: var(--text);");
|
||||
expect(normalRule).toContain("transition: color var(--transition-fast)");
|
||||
expect(interactiveRule).toContain("color: var(--text);");
|
||||
expect(css).toMatch(/\.chat-message-content--markdown a:focus-visible\s*\{[^}]*box-shadow:\s*var\(--focus-ring-strong\);[^}]*\}/s);
|
||||
expect(css).not.toMatch(/@media[^{}]*max-width:\s*768px[^{}]*\{[^}]*chat-message-content--markdown a[^}]*color:\s*(?!var\(--text\))/s);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView empty-state token guards", () => {
|
||||
|
||||
@@ -339,6 +339,55 @@ describe("Direct/Rooms scope toggle", () => {
|
||||
expect(screen.getByTestId("chat-sidebar-rooms")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Room transcripts reuse StandardChatSurface rather than a second Markdown renderer. Assert the same complete source destinations and new-tab security policy after selecting a populated room.
|
||||
*/
|
||||
it("renders complete source links in a Chat Room transcript", async () => {
|
||||
const room = createRoomFixture("sources");
|
||||
const sourceMarkdown = [
|
||||
"Sources officielles:",
|
||||
"",
|
||||
"[GPT‑5.6 Luna](https://developers.openai.com/api/docs/models/gpt-5.6-luna)",
|
||||
"[GPT‑5.6 Sol](https://developers.openai.com/api/docs/models/gpt-5.6-sol)",
|
||||
"[GPT‑5.6 Terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra)",
|
||||
].join("\\n");
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
setupMockRooms({
|
||||
rooms: [room],
|
||||
activeRoom: room,
|
||||
messages: [{
|
||||
id: "room-source-message",
|
||||
roomId: room.id,
|
||||
role: "assistant",
|
||||
content: sourceMarkdown,
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
senderAgentId: null,
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
mentions: [],
|
||||
}],
|
||||
});
|
||||
localStorage.setItem("fusion:chat-scope", "rooms");
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const bubble = await waitFor(() => {
|
||||
const node = screen.getByTestId("chat-message-room-source-message");
|
||||
expect(node).toBeInTheDocument();
|
||||
return node;
|
||||
});
|
||||
const links = Array.from(bubble.querySelectorAll(".chat-message-content--markdown a"));
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-luna",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-sol",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-terra",
|
||||
]);
|
||||
expect(links.every((link) => link.getAttribute("target") === "_blank")).toBe(true);
|
||||
expect(links.every((link) => link.getAttribute("rel") === "noopener noreferrer")).toBe(true);
|
||||
expect(bubble.textContent).not.toContain("5. 6");
|
||||
});
|
||||
|
||||
it("shows rooms placeholder and hides direct search/list in Rooms scope", async () => {
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
|
||||
@@ -192,7 +192,55 @@ describe("StandardChatSurface native structure embeds", () => {
|
||||
renderMessage({ content: "fusion://mission/M-001?query [unsafe](javascript:alert(1))" });
|
||||
expect(screen.queryByTestId("native-structure-preview")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("fusion://mission/M-001?query")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(Array.from(document.querySelectorAll("a")).map((link) => ({ href: link.getAttribute("href"), text: link.textContent }))).toEqual([]);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Test persisted and in-flight assistant Markdown through the shared renderer. Exact hrefs and security attributes belong to the rendered anchor, while unsafe schemes remain rejected by ReactMarkdown's URL transform.
|
||||
*/
|
||||
it("renders complete source links safely for persisted and streaming assistant content", () => {
|
||||
const content = [
|
||||
"Sources officielles:",
|
||||
"",
|
||||
"[GPT‑5.6 Luna](https://developers.openai.com/api/docs/models/gpt-5.6-luna)",
|
||||
"[GPT‑5.6 Sol](https://developers.openai.com/api/docs/models/gpt-5.6-sol)",
|
||||
"[GPT‑5.6 Terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra)",
|
||||
"[unsafe](javascript:alert(1))",
|
||||
].join("\\n");
|
||||
const expected = [
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-luna",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-sol",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-terra",
|
||||
];
|
||||
|
||||
renderMessage({ content });
|
||||
const persistedLinks = Array.from(document.querySelectorAll(".chat-message-content--markdown a"));
|
||||
expect(persistedLinks).toHaveLength(expected.length);
|
||||
persistedLinks.forEach((link, index) => {
|
||||
expect(link).toHaveAttribute("href", expected[index]);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(link.closest("a a")).toBeNull();
|
||||
});
|
||||
expect(screen.queryByRole("link", { name: "unsafe" })).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
render(
|
||||
<StandardStreamingMessage
|
||||
streamingText={content}
|
||||
forcePlain={false}
|
||||
agentName="Assistant"
|
||||
hideAssistantIdentity={false}
|
||||
showAssistantModelTag={false}
|
||||
activeModelTag={null}
|
||||
activeModelProvider={null}
|
||||
/>,
|
||||
);
|
||||
const streamingLinks = Array.from(document.querySelectorAll(".chat-message-content--markdown a"));
|
||||
expect(streamingLinks.map((link) => link.getAttribute("href"))).toEqual(expected);
|
||||
expect(streamingLinks.every((link) => link.getAttribute("target") === "_blank")).toBe(true);
|
||||
expect(streamingLinks.every((link) => link.getAttribute("rel") === "noopener noreferrer")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not transform native-looking Markdown link labels or inline code", () => {
|
||||
@@ -218,7 +266,7 @@ describe("StandardChatSurface native structure embeds", () => {
|
||||
}
|
||||
|
||||
function renderPlannerChat() {
|
||||
return render(<TaskPlannerChatTab task={{ id: "FN-1", description: "Task", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "2026-07-19T00:00:00.000Z", updatedAt: "2026-07-19T00:00:00.000Z" } as never} active planningModel={{ provider: "anthropic", modelId: "claude" }} projectId="project-1" addToast={vi.fn()} />);
|
||||
return render(<TaskPlannerChatTab task={{ id: "FN-1", description: "Task", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "2026-07-19T00:00:00.000Z", updatedAt: "2026-07-19T00:00:00.000Z" } as never} active taskChatModel={{ provider: "anthropic", modelId: "claude" }} projectId="project-1" addToast={vi.fn()} />);
|
||||
}
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -248,6 +248,54 @@ describe("TaskPlannerChatTab", () => {
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Task-detail Planner Chat must use the same shared Markdown anchor contract for both loaded history and an in-flight reattached response; this catches a renderer fork that would regress only task-bound Chat.
|
||||
*/
|
||||
it("renders complete source links in persisted and streaming Planner Chat", async () => {
|
||||
const sourceMarkdown = [
|
||||
"Sources officielles:",
|
||||
"",
|
||||
"[GPT‑5.6 Luna](https://developers.openai.com/api/docs/models/gpt-5.6-luna)",
|
||||
"[GPT‑5.6 Sol](https://developers.openai.com/api/docs/models/gpt-5.6-sol)",
|
||||
"[GPT‑5.6 Terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra)",
|
||||
].join("\\n");
|
||||
const inFlightGeneration = {
|
||||
status: "generating",
|
||||
streamingText: sourceMarkdown,
|
||||
streamingThinking: "",
|
||||
toolCalls: [],
|
||||
replayFromEventId: 1,
|
||||
updatedAt: "2026-07-01T14:00:00.000Z",
|
||||
};
|
||||
const session = makePlannerSession({ isGenerating: true, inFlightGeneration });
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValue({ session });
|
||||
mockFetchChatSession.mockResolvedValue({ session });
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
messages: [{ id: "planner-source", sessionId: "chat-planner", role: "assistant", content: sourceMarkdown, thinkingOutput: null, metadata: null, createdAt: "2026-07-01T13:59:00.000Z" }],
|
||||
});
|
||||
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
|
||||
renderPlannerChat();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll(".chat-message-content--markdown a")).toHaveLength(6);
|
||||
});
|
||||
const links = Array.from(document.querySelectorAll(".chat-message-content--markdown a"));
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-luna",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-sol",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-terra",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-luna",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-sol",
|
||||
"https://developers.openai.com/api/docs/models/gpt-5.6-terra",
|
||||
]);
|
||||
expect(links.every((link) => link.getAttribute("target") === "_blank")).toBe(true);
|
||||
expect(links.every((link) => link.getAttribute("rel") === "noopener noreferrer")).toBe(true);
|
||||
expect(document.body.textContent).toContain("GPT‑5.6");
|
||||
expect(document.body.textContent).not.toContain("5. 6");
|
||||
});
|
||||
|
||||
it("caps the loaded planner composer, preserves deliberate expansion, and resets on clear", async () => {
|
||||
mockFetchChatMessages.mockResolvedValueOnce({
|
||||
messages: [{ id: "planner-history", sessionId: "chat-planner", role: "assistant", content: "Loaded planner history", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" }],
|
||||
|
||||
@@ -24,6 +24,36 @@ describe("normalizeStreamingDelta", () => {
|
||||
expect(normalizeStreamingDelta("obj", ".prop")).toBe(".prop");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Reconstruct the reported source response through the production normalizer seam. A digit-period-digit split is a token continuation in both labels and URL paths, while a sentence followed by a numeric list still receives its missing space.
|
||||
*/
|
||||
it("preserves numeric dotted versions and URL paths across reported source chunks", () => {
|
||||
const chunks = [
|
||||
"Sources officielles :\\n\\n[GPT‑5.",
|
||||
"6 Luna](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-luna)\\n[GPT‑5.",
|
||||
"6 Sol](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-sol)\\n[GPT‑5.",
|
||||
"6 Terra](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-terra)",
|
||||
];
|
||||
let accumulated = "";
|
||||
for (const chunk of chunks) {
|
||||
const delta = normalizeStreamingDelta(accumulated, chunk);
|
||||
accumulated += delta;
|
||||
}
|
||||
|
||||
expect(accumulated).toContain("GPT‑5.6 Luna");
|
||||
expect(accumulated).not.toContain("5. 6");
|
||||
expect(accumulated).toContain("/gpt-5.6-luna");
|
||||
expect(accumulated).toContain("/gpt-5.6-sol");
|
||||
expect(accumulated).toContain("/gpt-5.6-terra");
|
||||
expect(normalizeStreamingDelta("Done.", "2 more items")).toBe(" 2 more items");
|
||||
expect(normalizeStreamingDelta("Version 1.", "2.3 and 192.")).toBe("2.3 and 192.");
|
||||
expect(normalizeStreamingDelta("192.", "168.0.1")).toBe("168.0.1");
|
||||
});
|
||||
|
||||
it("is idempotent when whitespace already exists", () => {
|
||||
expect(normalizeStreamingDelta("...task.", " Foundation")).toBe(" Foundation");
|
||||
});
|
||||
@@ -154,6 +184,12 @@ describe("createStreamingDeltaNormalizer", () => {
|
||||
expect(normalizer.normalize(undefined, 0, "Foundation", "text")).toBe("Foundation");
|
||||
});
|
||||
|
||||
it("preserves numeric dotted tokens through the partial-free fallback tail", () => {
|
||||
const normalizer = createStreamingDeltaNormalizer();
|
||||
expect(normalizer.normalize(undefined, 0, "GPT-5.", "text")).toBe("GPT-5.");
|
||||
expect(normalizer.normalize(undefined, 0, "6", "text")).toBe("6");
|
||||
});
|
||||
|
||||
it("is defensive for invalid partial/content index and wrong block type", () => {
|
||||
const normalizer = createStreamingDeltaNormalizer();
|
||||
expect(normalizer.normalize(undefined, 0, "Foundation", "text")).toBe("Foundation");
|
||||
|
||||
@@ -20,11 +20,18 @@ export function normalizeStreamingDelta(previousText: string, nextDelta: string)
|
||||
return nextDelta;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Sentence-boundary repair must leave digit-period-digit continuations intact. Stream chunks can split model versions, decimals, IP addresses, and URL path segments at the period, so inserting a space here corrupts persisted Chat Markdown and its destination.
|
||||
*/
|
||||
const isNumericTokenContinuation =
|
||||
previousChar === "." && /\d/.test(previousText.slice(-2, -1)) && /\d/.test(nextChar);
|
||||
|
||||
// Claude sometimes splits adjacent sentences across separate deltas or text
|
||||
// blocks without preserving the separating space. Only repair the specific
|
||||
// "sentence punctuation + uppercase/quoted sentence start" case so code,
|
||||
// domains, and lowercase continuations remain untouched.
|
||||
if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
// domains, lowercase continuations, and numeric tokens remain untouched.
|
||||
if (!isNumericTokenContinuation && /[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
return ` ${nextDelta}`;
|
||||
}
|
||||
|
||||
@@ -108,7 +115,11 @@ export function createStreamingDeltaNormalizer(): {
|
||||
const result = normalizeStreamingDelta(previousText, delta);
|
||||
|
||||
if (result) {
|
||||
const tail = result.slice(-1);
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-14:34:
|
||||
Fallback normalization has no partial message to inspect. Retain two characters per stream so its numeric-boundary classifier can still see the digit before a trailing period.
|
||||
*/
|
||||
const tail = result.slice(-2);
|
||||
if (kind === "text") {
|
||||
lastTextTail = tail;
|
||||
} else {
|
||||
|
||||
@@ -179,7 +179,35 @@ describe("createEventBridge", () => {
|
||||
expect(textEnd1.content).toBe("Second");
|
||||
});
|
||||
|
||||
it("repairs a missing sentence boundary between consecutive text blocks", () => {
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
The Claude CLI bridge must classify numeric period boundaries before mutating its output accumulator, otherwise source labels and URL paths reach Chat already corrupted.
|
||||
*/
|
||||
it("preserves the reported dotted model links across text deltas", () => {
|
||||
const bridge = createBridgeWithStart();
|
||||
bridge.handleEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } as any);
|
||||
for (const text of [
|
||||
"[GPT‑5.",
|
||||
"6 Luna](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-luna) [GPT‑5.",
|
||||
"6 Sol](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-sol) [GPT‑5.",
|
||||
"6 Terra](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-terra)",
|
||||
]) {
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } } as any);
|
||||
}
|
||||
|
||||
const output = bridge.getOutput();
|
||||
const text = (output.content[0] as any).text as string;
|
||||
expect(text).toContain("GPT‑5.6 Luna");
|
||||
expect(text).not.toContain("5. 6");
|
||||
expect(text).toContain("/gpt-5.6-luna");
|
||||
expect(text).toContain("/gpt-5.6-sol");
|
||||
expect(text).toContain("/gpt-5.6-terra");
|
||||
});
|
||||
|
||||
it("repairs a missing sentence boundary between consecutive text blocks", () => {
|
||||
const bridge = createBridgeWithStart();
|
||||
|
||||
bridge.handleEvent({
|
||||
|
||||
@@ -72,11 +72,17 @@ function normalizeStreamingDelta(previousText: string, nextDelta: string): strin
|
||||
return nextDelta;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Numeric dotted tokens and URL path segments are one token even when the provider splits them across deltas; do not insert a sentence space between their digits before Chat persistence.
|
||||
*/
|
||||
const isNumericTokenContinuation =
|
||||
previousChar === "." && /\d/.test(previousText.slice(-2, -1)) && /\d/.test(nextChar);
|
||||
|
||||
// Claude sometimes splits adjacent sentences across separate deltas or text
|
||||
// blocks without preserving the separating space. Only repair the specific
|
||||
// "sentence punctuation + uppercase/quoted sentence start" case so code,
|
||||
// domains, and lowercase continuations remain untouched.
|
||||
if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
// blocks without preserving the separating space. Keep the repair narrow so
|
||||
// code, domains, lowercase continuations, and numeric tokens remain intact.
|
||||
if (!isNumericTokenContinuation && /[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
return ` ${nextDelta}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,48 @@ describe("event bridge: text/thinking", () => {
|
||||
expect(onText.mock.calls.map((c) => c[0]).join("")).toBe("Done. Next step.");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Normalize numeric boundaries before ACP output is emitted so the shared Chat stream and persisted message receive complete model labels and URL paths.
|
||||
*/
|
||||
it("preserves the reported dotted model links across text chunks", () => {
|
||||
const { callbacks, onText } = makeCallbacks();
|
||||
const bridge = createEventBridge(callbacks);
|
||||
for (const text of [
|
||||
"[GPT‑5.",
|
||||
"6 Luna](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-luna) [GPT‑5.",
|
||||
"6 Sol](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-sol) [GPT‑5.",
|
||||
"6 Terra](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-terra)",
|
||||
]) {
|
||||
bridge.handleSessionUpdate({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text },
|
||||
} as SessionUpdate);
|
||||
}
|
||||
|
||||
const output = onText.mock.calls.map(([text]) => text).join("");
|
||||
expect(output).toContain("GPT‑5.6 Luna");
|
||||
expect(output).not.toContain("5. 6");
|
||||
expect(output).toContain("/gpt-5.6-luna");
|
||||
expect(output).toContain("/gpt-5.6-sol");
|
||||
expect(output).toContain("/gpt-5.6-terra");
|
||||
});
|
||||
|
||||
it("preserves numeric token boundaries in the independent thinking accumulator", () => {
|
||||
const { callbacks, onThinking } = makeCallbacks();
|
||||
const bridge = createEventBridge(callbacks);
|
||||
for (const text of ["Version 5.", "6 and decimal 2.", "5"]) {
|
||||
bridge.handleSessionUpdate({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text },
|
||||
} as SessionUpdate);
|
||||
}
|
||||
expect(onThinking.mock.calls.map(([text]) => text).join("")).toBe("Version 5.6 and decimal 2.5");
|
||||
});
|
||||
|
||||
it("agent_thought_chunk routes to onThinking, not onText", () => {
|
||||
const { callbacks, onText, onThinking } = makeCallbacks();
|
||||
const bridge = createEventBridge(callbacks);
|
||||
|
||||
@@ -89,7 +89,13 @@ function normalizeStreamingDelta(previousText: string, nextDelta: string): strin
|
||||
const previousChar = previousText.slice(-1);
|
||||
const nextChar = nextDelta[0] ?? "";
|
||||
if (/\s/.test(previousChar) || /\s/.test(nextChar)) return nextDelta;
|
||||
if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Numeric dotted tokens and URL path segments are one token even when the provider splits them across deltas; do not insert a sentence space between their digits before Chat persistence.
|
||||
*/
|
||||
const isNumericTokenContinuation =
|
||||
previousChar === "." && /\d/.test(previousText.slice(-2, -1)) && /\d/.test(nextChar);
|
||||
if (!isNumericTokenContinuation && /[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
return ` ${nextDelta}`;
|
||||
}
|
||||
return nextDelta;
|
||||
|
||||
@@ -2,7 +2,41 @@ import { describe, expect, it, vi } from "vitest";
|
||||
vi.mock("../probe.js", () => ({ probeClaudeBinary: vi.fn() }));
|
||||
import { probeClaudeBinary } from "../probe.js";
|
||||
import { discoverClaudeProviderModels } from "../provider.js";
|
||||
import { createEventBridge } from "../acp/event-bridge.js";
|
||||
describe("discoverClaudeProviderModels", () => {
|
||||
it("returns qualified provider-safe Claude ids when bridge is available", async () => { vi.mocked(probeClaudeBinary).mockResolvedValue({ available:true, probeDurationMs:1 }); const result=await discoverClaudeProviderModels(); expect(result.models.map((m)=>m.id)).toContain("claude-sonnet-4-20250514"); expect(result.fallbackUsed).toBe(false); });
|
||||
it("degrades to empty fallback when the bridge is unavailable", async () => { vi.mocked(probeClaudeBinary).mockResolvedValue({ available:false, reason:"missing", probeDurationMs:1 }); await expect(discoverClaudeProviderModels()).resolves.toMatchObject({models:[],fallbackUsed:true,reason:"missing"}); });
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
The Claude ACP bridge normalizes before callbacks append output, preserving numeric model versions and URL path segments in both Chat text and independent thinking streams.
|
||||
*/
|
||||
describe("Claude ACP stream numeric token preservation", () => {
|
||||
it("keeps dotted source links and thinking versions intact", () => {
|
||||
const onText = vi.fn<(text: string) => void>();
|
||||
const onThinking = vi.fn<(text: string) => void>();
|
||||
const bridge = createEventBridge({ onText, onThinking });
|
||||
for (const text of [
|
||||
"[GPT‑5.",
|
||||
"6 Luna](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-luna) [GPT‑5.",
|
||||
"6 Sol](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-sol) [GPT‑5.",
|
||||
"6 Terra](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-terra)",
|
||||
]) {
|
||||
bridge.handleSessionUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } } as any);
|
||||
}
|
||||
bridge.handleSessionUpdate({ sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "Version 5." } } as any);
|
||||
bridge.handleSessionUpdate({ sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "6" } } as any);
|
||||
|
||||
const output = onText.mock.calls.map(([text]) => text).join("");
|
||||
expect(output).not.toContain("5. 6");
|
||||
expect(output).toContain("GPT‑5.6 Luna");
|
||||
expect(output).toContain("/gpt-5.6-luna");
|
||||
expect(output).toContain("/gpt-5.6-sol");
|
||||
expect(output).toContain("/gpt-5.6-terra");
|
||||
expect(onThinking.mock.calls.map(([text]) => text).join("")).toBe("Version 5.6");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,13 @@ function normalizeStreamingDelta(previousText: string, nextDelta: string): strin
|
||||
const previousChar = previousText.slice(-1);
|
||||
const nextChar = nextDelta[0] ?? "";
|
||||
if (/\s/.test(previousChar) || /\s/.test(nextChar)) return nextDelta;
|
||||
if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Numeric dotted tokens and URL path segments are one token even when the provider splits them across deltas; do not insert a sentence space between their digits before Chat persistence.
|
||||
*/
|
||||
const isNumericTokenContinuation =
|
||||
previousChar === "." && /\d/.test(previousText.slice(-2, -1)) && /\d/.test(nextChar);
|
||||
if (!isNumericTokenContinuation && /[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
return ` ${nextDelta}`;
|
||||
}
|
||||
return nextDelta;
|
||||
|
||||
@@ -57,6 +57,34 @@ describe("droid event bridge streaming delta normalization", () => {
|
||||
expect(stream.events[2]).toEqual(expect.objectContaining({ type: "text_delta", delta: " Good overview." }));
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Classify numeric period boundaries before the Droid bridge appends or emits deltas so source labels and URL paths stay valid in Chat.
|
||||
*/
|
||||
it("preserves the reported dotted model links across text deltas", () => {
|
||||
const bridge = createBridgeWithStart();
|
||||
bridge.handleEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } as any);
|
||||
for (const text of [
|
||||
"[GPT‑5.",
|
||||
"6 Luna](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-luna) [GPT‑5.",
|
||||
"6 Sol](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-sol) [GPT‑5.",
|
||||
"6 Terra](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-terra)",
|
||||
]) {
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } } as any);
|
||||
}
|
||||
|
||||
const output = bridge.getOutput();
|
||||
const text = (output.content[0] as any).text as string;
|
||||
expect(text).toContain("GPT‑5.6 Luna");
|
||||
expect(text).not.toContain("5. 6");
|
||||
expect(text).toContain("/gpt-5.6-luna");
|
||||
expect(text).toContain("/gpt-5.6-sol");
|
||||
expect(text).toContain("/gpt-5.6-terra");
|
||||
});
|
||||
|
||||
it("repairs a missing sentence boundary between consecutive text blocks", () => {
|
||||
const bridge = createBridgeWithStart();
|
||||
|
||||
|
||||
@@ -72,11 +72,17 @@ function normalizeStreamingDelta(previousText: string, nextDelta: string): strin
|
||||
return nextDelta;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Numeric dotted tokens and URL path segments are one token even when the provider splits them across deltas; do not insert a sentence space between their digits before Chat persistence.
|
||||
*/
|
||||
const isNumericTokenContinuation =
|
||||
previousChar === "." && /\d/.test(previousText.slice(-2, -1)) && /\d/.test(nextChar);
|
||||
|
||||
// Claude sometimes splits adjacent sentences across separate deltas or text
|
||||
// blocks without preserving the separating space. Only repair the specific
|
||||
// "sentence punctuation + uppercase/quoted sentence start" case so code,
|
||||
// domains, and lowercase continuations remain untouched.
|
||||
if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
// blocks without preserving the separating space. Keep the repair narrow so
|
||||
// code, domains, lowercase continuations, and numeric tokens remain intact.
|
||||
if (!isNumericTokenContinuation && /[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
return ` ${nextDelta}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.mock("../process-manager.js", () => ({ discoverGrokModels: vi.fn() }));
|
||||
import { discoverGrokModels } from "../process-manager.js";
|
||||
import { probeGrokBinary } from "../probe.js";
|
||||
import { discoverGrokProviderModels } from "../provider.js";
|
||||
import { createEventBridge } from "../acp/event-bridge.js";
|
||||
|
||||
describe("discoverGrokProviderModels", () => {
|
||||
beforeEach(() => {
|
||||
@@ -55,3 +56,36 @@ describe("discoverGrokProviderModels", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
The Grok ACP bridge normalizes before callbacks append output, preserving numeric model versions and URL path segments in both Chat text and independent thinking streams.
|
||||
*/
|
||||
describe("Grok ACP stream numeric token preservation", () => {
|
||||
it("keeps dotted source links and thinking versions intact", () => {
|
||||
const onText = vi.fn<(text: string) => void>();
|
||||
const onThinking = vi.fn<(text: string) => void>();
|
||||
const bridge = createEventBridge({ onText, onThinking });
|
||||
for (const text of [
|
||||
"[GPT‑5.",
|
||||
"6 Luna](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-luna) [GPT‑5.",
|
||||
"6 Sol](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-sol) [GPT‑5.",
|
||||
"6 Terra](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-terra)",
|
||||
]) {
|
||||
bridge.handleSessionUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } } as any);
|
||||
}
|
||||
bridge.handleSessionUpdate({ sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "Version 5." } } as any);
|
||||
bridge.handleSessionUpdate({ sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "6" } } as any);
|
||||
|
||||
const output = onText.mock.calls.map(([text]) => text).join("");
|
||||
expect(output).not.toContain("5. 6");
|
||||
expect(output).toContain("GPT‑5.6 Luna");
|
||||
expect(output).toContain("/gpt-5.6-luna");
|
||||
expect(output).toContain("/gpt-5.6-sol");
|
||||
expect(output).toContain("/gpt-5.6-terra");
|
||||
expect(onThinking.mock.calls.map(([text]) => text).join("")).toBe("Version 5.6");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,13 @@ function normalizeStreamingDelta(previousText: string, nextDelta: string): strin
|
||||
const previousChar = previousText.slice(-1);
|
||||
const nextChar = nextDelta[0] ?? "";
|
||||
if (/\s/.test(previousChar) || /\s/.test(nextChar)) return nextDelta;
|
||||
if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Numeric dotted tokens and URL path segments are one token even when the provider splits them across deltas; do not insert a sentence space between their digits before Chat persistence.
|
||||
*/
|
||||
const isNumericTokenContinuation =
|
||||
previousChar === "." && /\d/.test(previousText.slice(-2, -1)) && /\d/.test(nextChar);
|
||||
if (!isNumericTokenContinuation && /[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
return ` ${nextDelta}`;
|
||||
}
|
||||
return nextDelta;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OmpRuntimeAdapter } from "../runtime-adapter.js";
|
||||
import { createEventBridge } from "../acp/event-bridge.js";
|
||||
import type { AgentRuntimeOptions, AgentSession, AgentSessionResult } from "../types.js";
|
||||
|
||||
function makeFakeSession(partial?: Partial<AgentSession>): AgentSession {
|
||||
@@ -189,3 +190,36 @@ describe("OmpRuntimeAdapter", () => {
|
||||
expect(adapter.describeModel(session)).toMatch(/^omp\//);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
The OMP ACP bridge normalizes before callbacks append output, preserving numeric model versions and URL path segments in both Chat text and independent thinking streams.
|
||||
*/
|
||||
describe("OMP ACP stream numeric token preservation", () => {
|
||||
it("keeps dotted source links and thinking versions intact", () => {
|
||||
const onText = vi.fn<(text: string) => void>();
|
||||
const onThinking = vi.fn<(text: string) => void>();
|
||||
const bridge = createEventBridge({ onText, onThinking });
|
||||
for (const text of [
|
||||
"[GPT‑5.",
|
||||
"6 Luna](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-luna) [GPT‑5.",
|
||||
"6 Sol](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-sol) [GPT‑5.",
|
||||
"6 Terra](https://developers.openai.com/api/docs/models/gpt-5.",
|
||||
"6-terra)",
|
||||
]) {
|
||||
bridge.handleSessionUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } } as any);
|
||||
}
|
||||
bridge.handleSessionUpdate({ sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "Version 5." } } as any);
|
||||
bridge.handleSessionUpdate({ sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "6" } } as any);
|
||||
|
||||
const output = onText.mock.calls.map(([text]) => text).join("");
|
||||
expect(output).not.toContain("5. 6");
|
||||
expect(output).toContain("GPT‑5.6 Luna");
|
||||
expect(output).toContain("/gpt-5.6-luna");
|
||||
expect(output).toContain("/gpt-5.6-sol");
|
||||
expect(output).toContain("/gpt-5.6-terra");
|
||||
expect(onThinking.mock.calls.map(([text]) => text).join("")).toBe("Version 5.6");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,13 @@ function normalizeStreamingDelta(previousText: string, nextDelta: string): strin
|
||||
const previousChar = previousText.slice(-1);
|
||||
const nextChar = nextDelta[0] ?? "";
|
||||
if (/\s/.test(previousChar) || /\s/.test(nextChar)) return nextDelta;
|
||||
if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-08-19-13:52:
|
||||
Numeric dotted tokens and URL path segments are one token even when the provider splits them across deltas; do not insert a sentence space between their digits before Chat persistence.
|
||||
*/
|
||||
const isNumericTokenContinuation =
|
||||
previousChar === "." && /\d/.test(previousText.slice(-2, -1)) && /\d/.test(nextChar);
|
||||
if (!isNumericTokenContinuation && /[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||
return ` ${nextDelta}`;
|
||||
}
|
||||
return nextDelta;
|
||||
|
||||
Reference in New Issue
Block a user