feat(web): enable default search and fetch
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: c36a75fc61fd7118f48c9b68be3144177df19534
|
||||
README.zh.md: e926fa99c4e483351f52ca4e76b668e26b34d02f
|
||||
README.md: 4437a9f980c689c1b11df09f00b3681a3d7af459
|
||||
README.zh.md: 46c447443b2d47ded685ebce7578aa712c43cf11
|
||||
|
||||
@@ -21,6 +21,8 @@ The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then
|
||||
|
||||
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
|
||||
|
||||
The Web/headless composition also registers `web_search` and `web_fetch`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. Fetch retrieves public HTTP(S) pages through the local provider so the model can follow a search result to its full content. The TUI composition does not mount these Web tools by default. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).
|
||||
|
||||
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
|
||||
|
||||
Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md).
|
||||
|
||||
@@ -21,6 +21,8 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用
|
||||
|
||||
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
|
||||
|
||||
Web/无头组合还会注册 `web_search` 和 `web_fetch`。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。抓取通过本地提供方获取公开 HTTP(S) 页面,使模型可以沿搜索结果读取完整内容。TUI 组合默认不挂载这些 Web 工具。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。
|
||||
|
||||
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
|
||||
|
||||
每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。
|
||||
|
||||
@@ -80,6 +80,30 @@
|
||||
- id: fs-local
|
||||
disabled: true
|
||||
|
||||
# The Web/headless product enables the stable web_search/web_fetch model
|
||||
# surface explicitly. DeepSeek search resolves the same DEEPSEEK_API_KEY
|
||||
# credential the Models page manages for chat, at each search; its Messages
|
||||
# endpoint is separate from the chat-completions endpoint. The local fetch
|
||||
# provider supplies full-page follow-up for search results.
|
||||
- insert:
|
||||
- id: web
|
||||
name: '@deepseek-ai/dsh-web'
|
||||
config:
|
||||
searchProvider: deepseek-official
|
||||
fetchProvider: local-http
|
||||
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
|
||||
- id: web-fetch-local
|
||||
name: '@deepseek-ai/dsh-web-fetch-local'
|
||||
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
|
||||
# ── web-only host rows, the transport layer, and the browser roster ─────────
|
||||
|
||||
# `dshClient` rows are the browser roster the modules node half scans into
|
||||
|
||||
@@ -112,11 +112,15 @@
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-fetch-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
|
||||
@@ -135,6 +135,17 @@ export interface LaunchOptions {
|
||||
* keyless first-run configuration lane; the default disables the adapter.
|
||||
*/
|
||||
deepSeekMissingCredential?: boolean
|
||||
/**
|
||||
* Patch the shipped DeepSeek search row to a deterministic endpoint and
|
||||
* credential reference. Browser search scenarios keep the real provider and
|
||||
* credentials seam while avoiding external search traffic and ambient keys.
|
||||
*/
|
||||
deepSeekSearch?: {
|
||||
/** Anthropic-compatible base URL; the provider appends `/messages`. */
|
||||
baseURL: string
|
||||
/** Credential reference resolved by the shipped search provider. */
|
||||
apiKeyEnv: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
|
||||
@@ -241,6 +252,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
...options.cordisTools === true
|
||||
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
|
||||
: [],
|
||||
...options.deepSeekSearch === undefined
|
||||
? []
|
||||
: [{
|
||||
id: 'web-search-deepseek',
|
||||
config: {
|
||||
apiKeyEnv: options.deepSeekSearch.apiKeyEnv,
|
||||
baseURL: options.deepSeekSearch.baseURL,
|
||||
},
|
||||
}],
|
||||
...mode === 'record' || options.deepSeekMissingCredential === true
|
||||
? []
|
||||
: [{ id: 'llm-deepseek', disabled: true }],
|
||||
|
||||
@@ -188,8 +188,12 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
mkdirSync(join(workspace, '.git'))
|
||||
writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
|
||||
|
||||
let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void
|
||||
const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => {
|
||||
interface NativeProviderRequest {
|
||||
messages?: { role?: string; content?: string }[]
|
||||
tools?: { function?: { name?: string } }[]
|
||||
}
|
||||
let resolveProviderRequest!: (request: NativeProviderRequest) => void
|
||||
const providerRequest = new Promise<NativeProviderRequest>((resolve) => {
|
||||
resolveProviderRequest = resolve
|
||||
})
|
||||
const provider = createServer((request, response) => {
|
||||
@@ -197,7 +201,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] })
|
||||
resolveProviderRequest(JSON.parse(body) as NativeProviderRequest)
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end([
|
||||
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
|
||||
@@ -256,6 +260,14 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
"role": "user",
|
||||
}
|
||||
`)
|
||||
expect(captured.tools?.map(tool => tool.function?.name)
|
||||
.filter(name => name === 'web_search' || name === 'web_fetch'))
|
||||
.toMatchInlineSnapshot(`
|
||||
[
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
]
|
||||
`)
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
|
||||
12
apps/web/tests/snapshots/web-search-round/session.jsonl
Normal file
12
apps/web/tests/snapshots/web-search-round/session.jsonl
Normal file
@@ -0,0 +1,12 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785456000000,"cwd":"{{cwd}}"}
|
||||
{"type":"user/message","seq":0,"time":1785456000001,"data":{"content":[{"type":"text","text":"Use web_search to search exactly \"DeepSeek Harness snapshot search\". Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":1,"time":1785456000002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1785456000003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_web_search","name":"web_search","argumentsDelta":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1785456000004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_web_search","name":"web_search","arguments":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":4,"time":1785456000005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785456000006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1785456000007,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1785456000008,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"SEARCH_DONE"}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1785456000009,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SEARCH_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1785456000010,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2}}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1785456000011,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
33
apps/web/tests/snapshots/web-search-round/ui.expected.md
Normal file
33
apps/web/tests/snapshots/web-search-round/ui.expected.md
Normal file
@@ -0,0 +1,33 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use web_search to search exactly" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- button "Search DeepSeek Harness snapshot search":
|
||||
- img
|
||||
- img
|
||||
- text: Search DeepSeek Harness snapshot search
|
||||
- paragraph: SEARCH_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 0% Input 22 tok · Output 7 tok
|
||||
195
apps/web/tests/web-search-round.e2e.ts
Normal file
195
apps/web/tests/web-search-round.e2e.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// Web e2e scenario for the shipped default search composition. A real browser
|
||||
// drives `web_search`; the model stream is replayed while the real DeepSeek
|
||||
// provider calls a deterministic local Anthropic-compatible endpoint through
|
||||
// the real credentials service.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/web-search-round', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/web-search-round/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/web-search-round/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const QUERY = 'DeepSeek Harness snapshot search'
|
||||
const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.`
|
||||
const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY')
|
||||
const SEARCH_CREDENTIAL = 'snapshot-search-key'
|
||||
const RESULT_URL = 'https://docs.example.test/search'
|
||||
|
||||
interface CapturedSearchRequest {
|
||||
path: string
|
||||
apiKey: string | undefined
|
||||
body: unknown
|
||||
}
|
||||
|
||||
/** Start the deterministic DeepSeek Messages double used by the real provider. */
|
||||
async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ server: Server; baseURL: string }> {
|
||||
const server = createServer((request, response) => {
|
||||
let body = ''
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
captured.push({
|
||||
path: request.url ?? '',
|
||||
apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined,
|
||||
body: JSON.parse(body) as unknown,
|
||||
})
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Found one source.',
|
||||
citations: [{
|
||||
type: 'web_search_result_location',
|
||||
url: RESULT_URL,
|
||||
cited_text: 'Snapshot search excerpt.',
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: 'web_search_tool_result',
|
||||
content: [{
|
||||
type: 'web_search_result',
|
||||
url: RESULT_URL,
|
||||
title: 'Snapshot Search Result',
|
||||
page_age: '2026-07-31',
|
||||
}],
|
||||
},
|
||||
],
|
||||
}))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const address = server.address() as AddressInfo
|
||||
return { server, baseURL: `http://127.0.0.1:${address.port}` }
|
||||
}
|
||||
|
||||
describe('web e2e: shipped default web search', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let searchServer: Server
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const searchRequests: CapturedSearchRequest[] = []
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const search = await startSearchServer(searchRequests)
|
||||
searchServer = search.server
|
||||
scaffold = await launchWebScaffold({
|
||||
deepSeekSearch: {
|
||||
baseURL: search.baseURL,
|
||||
apiKeyEnv: SEARCH_CREDENTIAL_REF,
|
||||
},
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
|
||||
})
|
||||
await scaffold.ctx.credentials.set(SEARCH_CREDENTIAL_REF, SEARCH_CREDENTIAL)
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (searchServer === undefined) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
searchServer.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('drives the recorded search to a settled turn (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-drive'))
|
||||
if (MODE !== 'record') {
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
}
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('uses the real provider and persists the structured result', () => {
|
||||
expect(searchRequests).toHaveLength(1)
|
||||
expect(searchRequests[0]).toMatchObject({
|
||||
path: '/messages',
|
||||
apiKey: SEARCH_CREDENTIAL,
|
||||
body: {
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Perform a web search for the query: ${QUERY}` }],
|
||||
}],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search' }],
|
||||
},
|
||||
})
|
||||
|
||||
const searchCall = sessionEvents.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
|
||||
event.type === 'tool/call' && event.data.name === 'web_search',
|
||||
)
|
||||
if (searchCall === undefined) throw new Error('the replayed turn did not call web_search')
|
||||
const searchResult = sessionEvents.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
|
||||
event.type === 'tool/result' && event.data.message.source.callId === searchCall.data.callId,
|
||||
)
|
||||
if (searchResult === undefined) throw new Error('web_search produced no durable result')
|
||||
const content = searchResult.data.message.content[0]
|
||||
expect(content.isError).toBe(false)
|
||||
expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
|
||||
.toContain(`[Snapshot Search Result](${RESULT_URL})`)
|
||||
expect(searchResult.data.meta).toMatchObject({
|
||||
sources: [{
|
||||
url: RESULT_URL,
|
||||
title: 'Snapshot Search Result',
|
||||
snippet: 'Snapshot search excerpt.',
|
||||
publishedAt: '2026-07-31',
|
||||
}],
|
||||
truncated: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the settled search card aria golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria'))
|
||||
await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -41,6 +41,7 @@
|
||||
"tests/sidebar-scrollbar.e2e.ts",
|
||||
"tests/code-mode-round.e2e.ts",
|
||||
"tests/cordis-tool-round.e2e.ts",
|
||||
"tests/web-search-round.e2e.ts",
|
||||
"tests/message-actions.e2e.ts",
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user