Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ KNOWHERE_BASE_URL=https://api.knowhereto.ai
# Vercel AI Gateway key; AI SDK picks it up automatically
AI_GATEWAY_API_KEY=vck_your_key_here

# Optional override — defaults to deepseek/deepseek-v4-flash
# CHAT_MODEL=deepseek/deepseek-v4-flash
# Optional override — defaults to DeepSeek V4.1 Flash (native multimodal)
# CHAT_MODEL=deepseek/deepseek-v4.1-flash

# --- Auth (server-side only) ---
#
Expand Down
139 changes: 131 additions & 8 deletions src/agent-harness/answer-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { describe, expect, it, vi } from "vitest"

import { logger } from "@/lib/logger"

import { composeAnswerContext } from "./answer-context"
import type { EvidenceLedgerSnapshot } from "./types"

const revenuePng = new Uint8Array([1, 2, 3])
const marginPng = new Uint8Array([4, 5, 6])

describe("composeAnswerContext", () => {
it("composes retained chunks, full table HTML, images, memory, and the original question", async () => {
it("composes retained chunks, full table HTML, images at placeholders, memory, and the original question", async () => {
const readTableHtml = vi.fn().mockResolvedValue(
"<table><tr><td>Q4</td><td>24.9</td></tr></table>",
)
const readImage = vi.fn(async (assetUrl: string) =>
assetUrl.includes("revenue.png")
? { body: revenuePng, mediaType: "image/png" }
: { body: marginPng, mediaType: "image/png" },
)
const message = await composeAnswerContext({
ledger: makeLedger(),
memoryItems: [
Expand All @@ -20,11 +30,18 @@ describe("composeAnswerContext", () => {
],
userText: "Q4 的收入是多少?",
readTableHtml,
readImage,
})

expect(readTableHtml).toHaveBeenCalledWith(
"https://assets.example/tables/revenue.html?signature=valid",
)
expect(readImage).toHaveBeenCalledWith(
"https://assets.example/images/revenue.png?signature=valid",
)
expect(readImage).toHaveBeenCalledWith(
"https://assets.example/images/margin.png?signature=valid",
)
expect(message).toEqual({
role: "user",
content: [
Expand All @@ -38,24 +55,29 @@ describe("composeAnswerContext", () => {
"Revenue increased in Q4.",
"<table><tr><td>Q4</td><td>24.9</td></tr></table>",
"",
'[image ref="asset:r1:result:1:image_chunk_1"]',
"",
].join("\n"),
},
{
type: "image",
image: new URL("https://assets.example/images/revenue.png?signature=valid"),
image: revenuePng,
mediaType: "image/png",
},
{
type: "text",
text: '[image ref="asset:r1:result:1:image_chunk_2"]',
text: "\n\n\n",
},
{
type: "image",
image: new URL("https://assets.example/images/margin.png?signature=valid"),
image: marginPng,
mediaType: "image/png",
},
{
type: "text",
text: [
"",
"",
"",
"## Fluid Memory",
"",
'[memory ref="mem:1" kind="stance"]',
Expand All @@ -69,6 +91,90 @@ describe("composeAnswerContext", () => {
})
})

it("drops image placeholders and warns when the image URL cannot be read", async () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {})
const message = await composeAnswerContext({
ledger: makeLedger({
content:
"Before [images/revenue.png] after [images/margin.png] end.",
assets: makeLedger().assets.filter((asset) => asset.type === "image"),
}),
memoryItems: [],
userText: "图在哪?",
readImage: vi.fn().mockRejectedValue(new Error("Unable to read image: 403")),
})

const parts = userContentParts(message)
const text = parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
expect(text).toContain("Before after end.")
expect(text).not.toContain("[images/revenue.png]")
expect(text).not.toContain("[images/margin.png]")
expect(parts.some((part) => part.type === "image")).toBe(false)
expect(warn).toHaveBeenCalledWith(
"chat: skipped unreachable evidence asset",
expect.objectContaining({
type: "image",
reason: "Unable to read image: 403",
}),
)
warn.mockRestore()
})

it("drops table placeholders and warns when the table URL cannot be read", async () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {})
const message = await composeAnswerContext({
ledger: makeLedger({
content: "Revenue [Table: tables/revenue.html] rose.",
assets: makeLedger().assets.filter((asset) => asset.type === "table"),
}),
memoryItems: [],
userText: "收入多少?",
readTableHtml: vi.fn().mockRejectedValue(new Error("Unable to read table HTML: 404")),
})

const text = userContentParts(message)
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
expect(text).toContain("Revenue rose.")
expect(text).not.toContain("[Table: tables/revenue.html]")
expect(text).not.toContain("<table>")
expect(warn).toHaveBeenCalledWith(
"chat: skipped unreachable evidence asset",
expect.objectContaining({
type: "table",
reason: "Unable to read table HTML: 404",
}),
)
warn.mockRestore()
})

it("throws when a connected image placeholder is missing", async () => {
await expect(
composeAnswerContext({
ledger: {
...makeLedger(),
chunks: [
{
...makeLedger().chunks[0]!,
content: "Revenue increased in Q4.\n[Table: tables/revenue.html]",
},
],
},
memoryItems: [],
userText: "Q4 的收入是多少?",
readTableHtml: vi.fn().mockResolvedValue("<table></table>"),
readImage: vi.fn().mockResolvedValue({
body: revenuePng,
mediaType: "image/png",
}),
}),
).rejects.toThrow("Image placeholder")
})

it("omits knowledge and memory sections when neither search ran", async () => {
const message = await composeAnswerContext({
ledger: { ...makeLedger(), retrievalCount: 0, retainedPicks: [] },
Expand Down Expand Up @@ -132,14 +238,31 @@ describe("composeAnswerContext", () => {
})
})

function makeLedger(): EvidenceLedgerSnapshot {
function userContentParts(message: { readonly content: unknown }): readonly {
readonly type: string
readonly text?: string
}[] {
if (!Array.isArray(message.content)) {
throw new Error("expected composed message content to be an array")
}
return message.content
}

function makeLedger(
overrides: {
readonly content?: string
readonly assets?: EvidenceLedgerSnapshot["assets"]
} = {},
): EvidenceLedgerSnapshot {
return {
retrievalCount: 1,
chunks: [
{
ref: "r1:result:1",
kind: "result",
content: "Revenue increased in Q4.\n[Table: tables/revenue.html]",
content:
overrides.content ??
"Revenue increased in Q4.\n[Table: tables/revenue.html]\n[images/revenue.png]\n[images/margin.png]",
contentPreview: "Revenue increased in Q4. [Table: tables/revenue.html]",
chunkType: "text",
score: 0.9,
Expand All @@ -150,7 +273,7 @@ function makeLedger(): EvidenceLedgerSnapshot {
},
},
],
assets: [
assets: overrides.assets ?? [
{
ref: "asset:r1:result:1:table_chunk",
chunkRef: "r1:result:1",
Expand Down
Loading
Loading