Skip to content

Commit 6afb73c

Browse files
authored
Merge pull request #352 from stakwork/bugfix/CMQS8FYG5-fix-agent-streaming-envelope-markdown-citations-1782316262
Generated with Hive: Parse agent streaming envelope and render markdown with citations
2 parents 0a660a9 + 2e7372c commit 6afb73c

4 files changed

Lines changed: 277 additions & 50 deletions

File tree

src/components/agent/message-list.tsx

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
"use client"
22

3-
import { useRef, useEffect } from "react"
3+
import { useRef, useEffect, useState } from "react"
44
import { Bot, User } from "lucide-react"
55
import { ScrollArea } from "@/components/ui/scroll-area"
66
import { NodeRow } from "@/components/layout/node-row"
77
import { ToolCallRow } from "./tool-call-row"
88
import { unlockNode } from "@/lib/unlock-node"
9+
import { getNode } from "@/lib/graph-api"
910
import { useSchemaStore } from "@/stores/schema-store"
1011
import { cn } from "@/lib/utils"
1112
import { Skeleton } from "@/components/ui/skeleton"
1213
import type { AgentMessage } from "@/lib/agent-api"
1314
import type { GraphNode } from "@/lib/graph-api"
15+
import type { SchemaNode } from "@/app/ontology/page"
1416

1517
// Basic markdown renderer — bold, italic, inline code, line breaks, headings
1618
function MarkdownText({ text }: { text: string }) {
@@ -57,32 +59,73 @@ interface CitedNodesProps {
5759
refIds: string[]
5860
}
5961

62+
function CitedNodeChip({ refId, schemas }: { refId: string; schemas: SchemaNode[] }) {
63+
const [node, setNode] = useState<GraphNode | null>(null)
64+
const [loading, setLoading] = useState(true)
65+
66+
useEffect(() => {
67+
let cancelled = false
68+
getNode(refId)
69+
.then((n) => {
70+
if (!cancelled) setNode(n as GraphNode)
71+
})
72+
.catch(() => {
73+
// fall back to raw refId shown via stub node
74+
})
75+
.finally(() => {
76+
if (!cancelled) setLoading(false)
77+
})
78+
return () => {
79+
cancelled = true
80+
}
81+
}, [refId])
82+
83+
if (loading) {
84+
return <Skeleton data-testid="cited-node-skeleton" className="h-8 w-full rounded-md" />
85+
}
86+
87+
const displayNode: GraphNode = node ?? {
88+
ref_id: refId,
89+
node_type: refId,
90+
properties: { name: refId },
91+
}
92+
93+
// Derive human-readable label
94+
const label =
95+
(displayNode.properties?.name as string | undefined) ??
96+
(displayNode.properties?.title as string | undefined) ??
97+
displayNode.node_type ??
98+
refId
99+
100+
const nodeWithLabel: GraphNode = {
101+
...displayNode,
102+
node_type: displayNode.node_type,
103+
properties: { ...displayNode.properties, name: label },
104+
}
105+
106+
return (
107+
<NodeRow
108+
node={nodeWithLabel}
109+
schemas={schemas}
110+
onClick={() => unlockNode(refId).catch(() => {})}
111+
hideBoost
112+
/>
113+
)
114+
}
115+
60116
function CitedNodes({ refIds }: CitedNodesProps) {
61117
const schemas = useSchemaStore((s) => s.schemas)
62118

63119
if (refIds.length === 0) return null
64120

65-
// Create minimal GraphNode stubs for display — real data fetched on click via unlockNode
66-
const stubNodes: GraphNode[] = refIds.map((ref_id) => ({
67-
ref_id,
68-
node_type: "Unknown",
69-
properties: { ref_id },
70-
}))
71-
72121
return (
73122
<div className="mt-3 pt-3 border-t border-border/40">
74123
<p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground mb-2">
75124
Sources
76125
</p>
77126
<div className="flex flex-col gap-0.5">
78-
{stubNodes.map((node) => (
79-
<NodeRow
80-
key={node.ref_id}
81-
node={node}
82-
schemas={schemas}
83-
onClick={() => unlockNode(node.ref_id).catch(() => {})}
84-
hideBoost
85-
/>
127+
{refIds.map((refId) => (
128+
<CitedNodeChip key={refId} refId={refId} schemas={schemas} />
86129
))}
87130
</div>
88131
</div>

src/lib/__tests__/agent-api.test.ts

Lines changed: 78 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -129,28 +129,16 @@ describe("streamAgent – context in POST body", () => {
129129
// ─── SSE streaming behaviour ──────────────────────────────────────────────────
130130

131131
describe("streamAgent – SSE streaming", () => {
132-
it("text-delta chunk calls onChunk with the delta string", async () => {
132+
it("text-delta chunk does NOT call onChunk (answer suppressed until finish)", async () => {
133133
const onChunk = vi.fn()
134-
await streamAgent(
135-
"Q?",
136-
makeOpts({
137-
onChunk,
138-
...await makeSseResponse([
139-
{ type: "text-delta", textDelta: "Hello!" },
140-
{ type: "finish-message" },
141-
]).then(() => ({})),
142-
})
143-
)
144-
// Use a dedicated fetch mock for this test
145-
const onChunk2 = vi.fn()
146134
mockFetch.mockImplementationOnce(() =>
147135
makeSseResponse([
148136
{ type: "text-delta", textDelta: "Hello!" },
149137
{ type: "finish-message" },
150138
])
151139
)
152-
await streamAgent("Q?", makeOpts({ onChunk: onChunk2 }))
153-
expect(onChunk2).toHaveBeenCalledWith("Hello!")
140+
await streamAgent("Q?", makeOpts({ onChunk }))
141+
expect(onChunk).not.toHaveBeenCalled()
154142
})
155143

156144
it("tool-input-available calls onToolCall with status in-flight", async () => {
@@ -200,23 +188,92 @@ describe("streamAgent – SSE streaming", () => {
200188
expect(done).toBeDefined()
201189
})
202190

203-
it("finish-message calls onDone with accumulated text and empty cited_ref_ids", async () => {
191+
it("finish-message parses JSON envelope: extracts answer and cited_ref_ids", async () => {
204192
const onDone = vi.fn()
193+
const envelope = JSON.stringify({
194+
answer: "The answer is 42.",
195+
cited_ref_ids: ["node-abc", "node-xyz"],
196+
usage: {},
197+
})
205198
mockFetch.mockImplementationOnce(() =>
206199
makeSseResponse([
207-
{ type: "text-delta", textDelta: "Foo " },
208-
{ type: "text-delta", textDelta: "bar." },
200+
{ type: "text-delta", textDelta: envelope },
209201
{ type: "finish-message" },
210202
])
211203
)
212204
await streamAgent("Q?", makeOpts({ onDone }))
213-
expect(onDone).toHaveBeenCalledWith({ answer: "Foo bar.", cited_ref_ids: [] })
205+
expect(onDone).toHaveBeenCalledWith({
206+
answer: "The answer is 42.",
207+
cited_ref_ids: ["node-abc", "node-xyz"],
208+
})
209+
})
210+
211+
it("finish-message: envelope missing cited_ref_ids defaults to []", async () => {
212+
const onDone = vi.fn()
213+
const envelope = JSON.stringify({ answer: "Short answer." })
214+
mockFetch.mockImplementationOnce(() =>
215+
makeSseResponse([
216+
{ type: "text-delta", textDelta: envelope },
217+
{ type: "finish-message" },
218+
])
219+
)
220+
await streamAgent("Q?", makeOpts({ onDone }))
221+
expect(onDone).toHaveBeenCalledWith({ answer: "Short answer.", cited_ref_ids: [] })
222+
})
223+
224+
it("finish-message: strips end-of-answer marker before parsing", async () => {
225+
const onDone = vi.fn()
226+
const envelope =
227+
JSON.stringify({ answer: "Marked answer.", cited_ref_ids: ["ref-1"] }) +
228+
"[END_OF_ANSWER]"
229+
mockFetch.mockImplementationOnce(() =>
230+
makeSseResponse([
231+
{ type: "text-delta", textDelta: envelope },
232+
{ type: "finish-message" },
233+
])
234+
)
235+
await streamAgent("Q?", makeOpts({ onDone }))
236+
expect(onDone).toHaveBeenCalledWith({ answer: "Marked answer.", cited_ref_ids: ["ref-1"] })
237+
})
238+
239+
it("finish-message: JSON inside a ```json fence is still extracted", async () => {
240+
const onDone = vi.fn()
241+
const fenced =
242+
"```json\n" +
243+
JSON.stringify({ answer: "Fenced answer.", cited_ref_ids: ["ref-2"] }) +
244+
"\n```"
245+
mockFetch.mockImplementationOnce(() =>
246+
makeSseResponse([
247+
{ type: "text-delta", textDelta: fenced },
248+
{ type: "finish-message" },
249+
])
250+
)
251+
await streamAgent("Q?", makeOpts({ onDone }))
252+
expect(onDone).toHaveBeenCalledWith({ answer: "Fenced answer.", cited_ref_ids: ["ref-2"] })
253+
})
254+
255+
it("finish-message: falls back to raw text and warns on invalid JSON", async () => {
256+
const onDone = vi.fn()
257+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
258+
mockFetch.mockImplementationOnce(() =>
259+
makeSseResponse([
260+
{ type: "text-delta", textDelta: "{ bad json }" },
261+
{ type: "finish-message" },
262+
])
263+
)
264+
await streamAgent("Q?", makeOpts({ onDone }))
265+
expect(warnSpy).toHaveBeenCalledWith(
266+
expect.stringContaining("[agent-api] envelope parse failed")
267+
)
268+
expect(onDone).toHaveBeenCalledWith({ answer: "{ bad json }", cited_ref_ids: [] })
269+
warnSpy.mockRestore()
214270
})
215271

216-
it("fallback onDone called when stream ends without finish-message", async () => {
272+
it("fallback onDone called when stream ends without finish-message (parses envelope)", async () => {
217273
const onDone = vi.fn()
274+
const envelope = JSON.stringify({ answer: "Partial.", cited_ref_ids: [] })
218275
mockFetch.mockImplementationOnce(() =>
219-
makeSseResponse([{ type: "text-delta", textDelta: "Partial." }])
276+
makeSseResponse([{ type: "text-delta", textDelta: envelope }])
220277
)
221278
await streamAgent("Q?", makeOpts({ onDone }))
222279
expect(onDone).toHaveBeenCalledWith({ answer: "Partial.", cited_ref_ids: [] })

src/lib/__tests__/message-list.test.tsx

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, it, expect, vi } from "vitest"
2-
import { render, screen } from "@testing-library/react"
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { render, screen, waitFor } from "@testing-library/react"
33
import React from "react"
44

55
// ── Mock heavy dependencies ───────────────────────────────────────────────────
@@ -8,13 +8,18 @@ vi.mock("@/components/ui/scroll-area", () => ({
88
}))
99

1010
vi.mock("@/components/ui/skeleton", () => ({
11-
Skeleton: ({ className }: { className?: string }) => (
12-
<div data-testid="skeleton" className={className} />
11+
Skeleton: ({ className, ...props }: { className?: string; [key: string]: unknown }) => (
12+
<div data-testid="skeleton" className={className} {...props} />
1313
),
1414
}))
1515

1616
vi.mock("@/components/layout/node-row", () => ({
17-
NodeRow: () => <div data-testid="node-row" />,
17+
NodeRow: ({ node }: { node: { properties?: { name?: string }; node_type?: string } }) => (
18+
<div data-testid="node-row">
19+
<span data-testid="node-label">{node.properties?.name}</span>
20+
<span data-testid="node-type">{node.node_type}</span>
21+
</div>
22+
),
1823
}))
1924

2025
vi.mock("@/lib/unlock-node", () => ({
@@ -25,8 +30,24 @@ vi.mock("@/stores/schema-store", () => ({
2530
useSchemaStore: (sel: (s: { schemas: [] }) => unknown) => sel({ schemas: [] }),
2631
}))
2732

33+
// Mock graph-api getNode
34+
const mockGetNode = vi.fn()
35+
vi.mock("@/lib/graph-api", () => ({
36+
getNode: (...args: unknown[]) => mockGetNode(...args),
37+
}))
38+
2839
import { MessageList } from "@/components/agent/message-list"
2940

41+
beforeEach(() => {
42+
vi.clearAllMocks()
43+
// Default: getNode resolves with a real node
44+
mockGetNode.mockResolvedValue({
45+
ref_id: "abc",
46+
node_type: "Episode",
47+
properties: { name: "Test Ep" },
48+
})
49+
})
50+
3051
describe("MessageList", () => {
3152
it("shows Thinking… placeholder when isStreaming=true and content is empty", () => {
3253
render(
@@ -88,3 +109,92 @@ describe("MessageList", () => {
88109
expect(screen.getAllByTestId("skeleton")).toHaveLength(3)
89110
})
90111
})
112+
113+
describe("CitedNodes — label resolution", () => {
114+
it("shows a loading skeleton per chip while getNode is pending", async () => {
115+
// Never resolves during this test
116+
mockGetNode.mockReturnValue(new Promise(() => {}))
117+
render(
118+
<MessageList
119+
messages={[
120+
{
121+
role: "agent",
122+
content: "Hello",
123+
isStreaming: false,
124+
citedRefIds: ["abc"],
125+
},
126+
]}
127+
/>
128+
)
129+
// The "cited-node-skeleton" should appear while fetch is in-flight
130+
expect(screen.getByTestId("cited-node-skeleton")).toBeInTheDocument()
131+
})
132+
133+
it("renders the resolved node label and type badge", async () => {
134+
mockGetNode.mockResolvedValue({
135+
ref_id: "abc",
136+
node_type: "Episode",
137+
properties: { name: "Test Ep" },
138+
})
139+
render(
140+
<MessageList
141+
messages={[
142+
{
143+
role: "agent",
144+
content: "Hello",
145+
isStreaming: false,
146+
citedRefIds: ["abc"],
147+
},
148+
]}
149+
/>
150+
)
151+
await waitFor(() => {
152+
expect(screen.getByTestId("node-label")).toHaveTextContent("Test Ep")
153+
expect(screen.getByTestId("node-type")).toHaveTextContent("Episode")
154+
})
155+
})
156+
157+
it("falls back to ref_id when getNode rejects", async () => {
158+
mockGetNode.mockRejectedValue(new Error("not found"))
159+
render(
160+
<MessageList
161+
messages={[
162+
{
163+
role: "agent",
164+
content: "Hello",
165+
isStreaming: false,
166+
citedRefIds: ["fallback-id"],
167+
},
168+
]}
169+
/>
170+
)
171+
await waitFor(() => {
172+
expect(screen.getByTestId("node-row")).toBeInTheDocument()
173+
// Label falls back to refId when node not found
174+
expect(screen.getByTestId("node-label")).toHaveTextContent("fallback-id")
175+
})
176+
})
177+
178+
it("uses title property when name is absent", async () => {
179+
mockGetNode.mockResolvedValue({
180+
ref_id: "ep-99",
181+
node_type: "Article",
182+
properties: { title: "My Article Title" },
183+
})
184+
render(
185+
<MessageList
186+
messages={[
187+
{
188+
role: "agent",
189+
content: "Answer",
190+
isStreaming: false,
191+
citedRefIds: ["ep-99"],
192+
},
193+
]}
194+
/>
195+
)
196+
await waitFor(() => {
197+
expect(screen.getByTestId("node-label")).toHaveTextContent("My Article Title")
198+
})
199+
})
200+
})

0 commit comments

Comments
 (0)