diff --git a/src/core/cli/core_verbs.js b/src/core/cli/core_verbs.js index d3d1e9af..558db130 100644 --- a/src/core/cli/core_verbs.js +++ b/src/core/cli/core_verbs.js @@ -1,6 +1,7 @@ // @ts-check import { querySqlVerb } from '../query/verb.js' +import { queryGrepVerb } from '../search/grep_verb.js' /** * @import { VerbRegistration, VerbRegistry } from '../../../hypaware-plugin-kernel-types.js' @@ -12,9 +13,13 @@ import { querySqlVerb } from '../query/verb.js' * `hyp --help`), while the kernel verb registry registers them for the MCP * tool surface at boot. * + * A server host displaces `query grep` with its archive-backed twin via + * `unregister` (hypaware-server LLP 0178); the kernel ships the verb + * anyway so every client host greps its own cache with no plugin needed. + * * @type {VerbRegistration[]} */ -export const CORE_VERBS = [querySqlVerb] +export const CORE_VERBS = [querySqlVerb, queryGrepVerb] /** * Register the intrinsic core verbs onto the kernel verb registry. Run diff --git a/src/core/search/grep_verb.js b/src/core/search/grep_verb.js new file mode 100644 index 00000000..423dfd98 --- /dev/null +++ b/src/core/search/grep_verb.js @@ -0,0 +1,231 @@ +// @ts-check + +import { buildQuerySqlOutput } from '../query/format.js' +import { renderLocalOnlyNotice } from '../query/verb.js' +import { SEARCHABLE_COLUMNS } from './searchable_columns.js' + +/** + * @import { VerbRegistration } from '../../../hypaware-plugin-kernel-types.js' + * @import { ExtendedQueryStorageService } from '../../../src/core/cache/types.js' + * @import { GrepSearchHit, GrepSearchResult } from '../../../src/core/search/types.js' + * @import { LocalOnlyVisibilityReport } from '../../../src/core/query/types.js' + */ + +/** The server's own defaults, mirrored so local and remote page the same. */ +const DEFAULT_LIMIT = 50 +const MAX_LIMIT = 1000 + +/** + * The `hyp query grep` verb (LLP 0264 #verb): one declaration projects the + * CLI command and the `grep_search` MCP tool, and because the tool name and + * `inputSchema` match the server's own `grep_search`, `--remote ` + * reaches the server's archive-backed search with no server-side feature + * work. Locally the operation runs `executeGrepSearch` over this machine's + * cache; the two answers share the hit shape, the sort order, and the + * column allowlist through `hypaware/core/search`. + * + * `include-local-only` is the one local-only parameter, and it deliberately + * carries NO schema default: `argvToParams` sends every defaulted property + * over the wire on `--remote`, and the server's `grep_search` schema does + * not know this name, so a default here would make every remote call fail + * validation. Absent-unless-passed keeps the wire clean; passing it WITH + * `--remote` is rejected by the server, which is the honest outcome (the + * server enforces its own visibility, not the caller's). + * + * @type {VerbRegistration} + * @ref LLP 0264#verb [implements]: read-class core verb, tool grep_search, wire-compatible with the server so --remote works day one + */ +export const queryGrepVerb = { + name: 'query grep', + tool: 'grep_search', + // The coverage clause is not decoration. An MCP caller sees only this + // text, so without it zero hits are indistinguishable from "that text is + // not stored" - the exact wrong answer an agent would then report. + // @ref LLP 0264#shared [implements]: the allowlist stated at the one surface a machine caller reads, with the SQL escape hatch named + summary: + 'Grep stored ai_gateway_messages: case-insensitive substring or regex, served from ' + + 'hypgrep sidecar indexes where they exist plus a scan of the rest. Covers only these ' + + `columns: ${[...SEARCHABLE_COLUMNS].join(', ')}. Every other column, including system ` + + 'prompts (system_text), tool definitions (tools), attributes, and raw frames, is NOT ' + + 'searched, so zero hits is not evidence the text is absent from those columns - read ' + + 'them with query_sql instead', + authClass: 'read', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Substring to find (case-insensitive), or a regex pattern with regex: true', + greedy: true, + }, + regex: { + type: 'boolean', + description: + 'Treat query as a regular expression (compiled case-insensitive). ' + + 'Servers restrict regex mode to the operator; local search does not', + }, + session_id: { type: 'string', description: 'Only messages of this session' }, + chain_id: { type: 'string', description: 'Only this chain (matches agent_id or conversation_id)' }, + from: { type: 'string', description: 'Earliest day, YYYY-MM-DD (page older results by narrowing to)' }, + to: { type: 'string', description: 'Latest day, YYYY-MM-DD' }, + limit: { type: 'number', default: DEFAULT_LIMIT, description: `Max hits (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT})` }, + // @ref LLP 0105#override [implements]: the informed-consent override; the help text names the transcript-capture consequence and bundled skills never pass it + 'include-local-only': { + type: 'boolean', + description: + 'Include local-only rows even when this context is synced. If this session ' + + 'is itself captured, their content enters the transcript and can be forwarded. ' + + 'Local search only; a server enforces its own visibility', + }, + }, + required: ['query'], + positional: ['query'], + }, + async operation(params, ctx) { + const rawLimit = params.limit + // Above the ceiling clamps to the ceiling the flag's own help text + // advertises; only an unusable value (absent, fractional, zero) falls + // back to the default. Falling back for "too large" too would answer a + // request for MORE rows with FEWER than the default, and then print + // "raise --limit" at a caller who just did - advice that cannot be + // followed is worse than a silently capped answer. + const limit = + typeof rawLimit === 'number' && Number.isInteger(rawLimit) && rawLimit >= 1 + ? Math.min(rawLimit, MAX_LIMIT) + : DEFAULT_LIMIT + // Loaded on demand, not at module scope: `registerCoreCommands` + // projects every `CORE_VERBS` entry pre-boot so `hyp --help` can + // render, so a top-level import would pull hypgrep, hyparquet and the + // Iceberg store into the front door of every `hyp` invocation (measured + // at ~16ms on `hyp --help`, ~10%) for the one command that needs them. + // The remote stack in `verb_command.js` is deferred for the same reason. + const { executeGrepSearch } = await import('./grep_service.js') + const result = await executeGrepSearch({ + storage: /** @type {ExtendedQueryStorageService} */ (ctx.storage), + query: String(params.query ?? ''), + regex: params.regex === true, + sessionId: typeof params.session_id === 'string' ? params.session_id : undefined, + chainId: typeof params.chain_id === 'string' ? params.chain_id : undefined, + from: dayBound(params.from, 'from'), + to: dayBound(params.to, 'to'), + limit, + refresh: ctx.refresh, + // @ref LLP 0105 [constrained-by]: the caller's context rides every search; the service's shared predicate decides visibility, never this verb + callerCwd: ctx.callerCwd, + includeLocalOnly: params['include-local-only'] === true, + }) + // The clamp's own promise, carried through to the render: at the + // ceiling there is no larger `--limit` left to ask for, so the + // truncation notice must not send the caller back to a flag that + // cannot move. Local-only, like the freshness and visibility fields; a + // server result carries none of them and falls back to the general + // wording. + return { ...result, limitCeilingReached: limit >= MAX_LIMIT } + }, + render(result, controls) { + const r = /** @type {Partial & { localOnly?: LocalOnlyVisibilityReport, freshnessMessages?: string[], limitCeilingReached?: boolean, indexedFiles?: number, scannedFiles?: number }} */ (result) + const hits = Array.isArray(r.hits) ? r.hits : [] + // One row per matched column, rg-style: the locator columns lead, the + // snippet trails. Delegating to the query formatter gives grep the same + // LLP 0225 contract as sql for free: `table`/`markdown` escape every + // cell for a human reader, `json`/`jsonl` stay byte-exact for a + // pipeline, and the context budgets (`--max-cell`/`--max-bytes`) and + // `--output` spill behave identically across the two query surfaces. + const flattened = flattenHits(hits) + const format = controls.json && controls.format === 'table' ? 'json' : controls.format + const out = buildQuerySqlOutput(flattened, { + format, + output: controls.output, + maxCell: controls.maxCell, + maxBytes: controls.maxBytes, + }) + let stderr = (r.freshnessMessages ?? []).map((m) => `${m}\n`).join('') + stderr += renderLocalOnlyNotice(r.localOnly) + // The two completeness signals, on stderr so stdout stays a valid + // render: the limit cut the answer (narrow or raise --limit), or the + // walk stopped early (an abort or server deadline mid-search). + if (r.truncated === true) { + stderr += r.limitCeilingReached === true + ? `grep: more matches exist beyond the ${MAX_LIMIT}-hit ceiling - narrow with --from/--to or --session-id\n` + : 'grep: more matches exist beyond the limit - narrow with --from/--to or --session-id, or raise --limit\n' + } else if (r.exhausted === false) { + stderr += 'grep: the search stopped before covering every file; results may be incomplete\n' + } + // Zero hits over zero files is not the answer the summary's coverage + // clause promises to make honest: "searched everything, found nothing" + // and "searched nothing" render identically otherwise, and on the MCP + // surface an agent sees only the rows. The file counts are the local + // service's own; a server result carries none, so this stays quiet on + // `--remote`. + if (hits.length === 0 && r.indexedFiles === 0 && r.scannedFiles === 0) { + stderr += 'grep: no ai_gateway_messages data files were searched - nothing is recorded on this machine yet, ' + + 'or --from/--to excluded every file\n' + } + return { + stdout: out.stdout, + stderr: stderr + out.stderr, + ...(out.file ? { file: out.file } : {}), + } + }, +} + +/** + * A `from`/`to` day bound, refused unless it is shaped `YYYY-MM-DD`. + * The window is compared lexicographically against the row's own day + * (and prunes whole files the same way), so `2026-8-1` sorts below every + * real day and returns an empty answer with nothing on stderr. The + * summary works hard to make "zero hits" mean something; a mistyped flag + * must not be able to forge one. + * + * @param {unknown} value + * @param {'from' | 'to'} flag + * @returns {string | undefined} + */ +function dayBound(value, flag) { + if (typeof value !== 'string') return undefined + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new Error(`--${flag} expects a day as YYYY-MM-DD (got ${value})`) + } + return value +} + +/** + * Flatten hits to one row per matched column for the shared formatter. + * Locators ride every row so a reader can pivot any line straight into + * `hyp query sql` (`part_id`) or `hyp query grep --session-id`. + * + * The snippet is last on purpose: it is the one unbounded cell (up to the + * `--max-cell` budget), and `renderTable` pads a column to its widest + * value only up to 80 columns without truncating the cell, so a snippet + * anywhere but the final position shoves every locator after it out of + * its column on exactly the rows a reader most wants to scan. + * + * @param {GrepSearchHit[]} hits + * @returns {{ columns: string[], rows: Record[] }} + */ +function flattenHits(hits) { + const columns = ['date', 'session_id', 'column', 'message_id', 'part_id', 'snippet'] + /** @type {Record[]} */ + const rows = [] + for (const hit of hits) { + // Keys are inserted in `columns` order: `--format json` serializes the + // row objects themselves, so insertion order IS the key order a + // pipeline reads, and a table and a json render of one answer should + // not disagree about where the snippet sits. + /** @param {string | null} column @param {string | null} snippet */ + const row = (column, snippet) => ({ + date: hit.date, + session_id: hit.sessionId, + column, + message_id: hit.messageId, + part_id: hit.partId, + snippet, + }) + if (!Array.isArray(hit.matches) || hit.matches.length === 0) { + rows.push(row(null, null)) + continue + } + for (const match of hit.matches) rows.push(row(match.column, match.snippet)) + } + return { columns, rows } +} diff --git a/test/core/command-dispatch.test.js b/test/core/command-dispatch.test.js index 4e72df77..41b6c362 100644 --- a/test/core/command-dispatch.test.js +++ b/test/core/command-dispatch.test.js @@ -627,7 +627,7 @@ test('bare group command with an unknown subcommand reports the registry childre assert.equal(code, 2) assert.match(stderr.text(), /hyp query: unknown subcommand 'bogus'/) - assert.match(stderr.text(), /expected one of: overview, schema, sql/) + assert.match(stderr.text(), /expected one of: grep, overview, schema, sql/) }) test('a token that is neither a command nor a group prefix still errors', async () => { diff --git a/test/core/query-grep-verb.test.js b/test/core/query-grep-verb.test.js new file mode 100644 index 00000000..e986e0b6 --- /dev/null +++ b/test/core/query-grep-verb.test.js @@ -0,0 +1,317 @@ +// @ts-check + +/** + * @import { TestContext } from 'node:test' + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { verbToCommand } from '../../src/core/cli/verb_command.js' +import { argvToParams } from '../../src/core/cli/verb_codec.js' +import { CORE_VERBS } from '../../src/core/cli/core_verbs.js' +import { appendRowsToSourceTable } from '../../src/core/cache/partition.js' +import { createQueryStorageService } from '../../src/core/cache/storage.js' +import { queryGrepVerb } from '../../src/core/search/grep_verb.js' +import { aiGatewayDatasetRegistration } from '../../hypaware-core/plugins-workspace/ai-gateway/src/dataset.js' + +/** + * @import { ColumnSpec } from '../../hypaware-plugin-kernel-types.js' + */ + +const cmd = verbToCommand(queryGrepVerb) + +/** @type {ColumnSpec[]} */ +const COLUMNS = [ + { name: 'session_id', type: 'STRING', nullable: false }, + { name: 'conversation_id', type: 'STRING', nullable: true }, + { name: 'agent_id', type: 'STRING', nullable: true }, + { name: 'cwd', type: 'STRING', nullable: true }, + { name: 'content_text', type: 'STRING', nullable: true }, + { name: 'date', type: 'STRING', nullable: false }, + { name: 'part_id', type: 'STRING', nullable: false }, + { name: 'message_id', type: 'STRING', nullable: false }, + { name: 'message_created_at', type: 'TIMESTAMP', nullable: false }, + { name: 'client_name', type: 'STRING', nullable: true }, +] + +let rowSeq = 0 + +/** @param {Record} [over] */ +function mkRow(over = {}) { + rowSeq += 1 + const date = typeof over.date === 'string' ? over.date : '2026-08-10' + return { + session_id: 's1', + conversation_id: null, + agent_id: null, + cwd: '/home/open-proj', + content_text: null, + date, + part_id: `m${rowSeq}#0`, + message_id: `m${rowSeq}`, + message_created_at: new Date(`${date}T00:00:00Z`).getTime() + rowSeq * 1000, + client_name: 'test', + ...over, + } +} + +/** @param {Record[][]} batches */ +async function makeCtx(batches) { + const cacheRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-grep-verb-')) + const declaration = aiGatewayDatasetRegistration().cachePartitioning + for (const batch of batches) { + await appendRowsToSourceTable(cacheRoot, 'ai_gateway_messages', ['source=test'], COLUMNS, batch, { declaration }) + } + const storage = createQueryStorageService({ cacheRoot }) + /** @type {string[]} */ const out = [] + /** @type {string[]} */ const err = [] + const ctx = /** @type {any} */ ({ + env: {}, + config: { version: 2 }, + query: {}, + storage, + cwd: '/home/open-proj', + stdout: { write: (/** @type {string} */ s) => out.push(s) }, + stderr: { write: (/** @type {string} */ s) => err.push(s) }, + }) + return { ctx, out, err } +} + +test('the tool schema is wire-compatible with the server grep_search', () => { + assert.equal(queryGrepVerb.tool, 'grep_search') + assert.equal(queryGrepVerb.name, 'query grep') + assert.equal(queryGrepVerb.authClass, 'read') + const props = queryGrepVerb.inputSchema.properties ?? {} + assert.deepEqual( + Object.keys(props).sort(), + ['chain_id', 'from', 'include-local-only', 'limit', 'query', 'regex', 'session_id', 'to'], + ) + assert.deepEqual(queryGrepVerb.inputSchema.required, ['query']) + // The local-only override must never ride the wire uninvited: a schema + // default is auto-filled into params, and the server's grep_search schema + // does not know this name, so a default here breaks every --remote call. + assert.equal(props['include-local-only'].default, undefined) + // The coverage clause: zero hits must be explainable from the summary alone. + assert.match(queryGrepVerb.summary, /Covers only these columns: content_text/) + assert.match(queryGrepVerb.summary, /zero hits is not evidence/) +}) + +test('CORE_VERBS registers the grep verb beside sql', () => { + assert.ok(CORE_VERBS.some((v) => v.tool === 'grep_search')) + assert.ok(CORE_VERBS.some((v) => v.tool === 'query_sql')) +}) + +test('the codec maps dashed flags onto the snake_case wire names', () => { + const parsed = argvToParams(queryGrepVerb.inputSchema, [ + 'needle two', '--session-id', 's2', '--chain-id', 'a2', '--regex', + '--from', '2026-08-01', '--to', '2026-08-31', '--limit', '5', + ]) + assert.ok(parsed.ok) + assert.deepEqual(parsed.params, { + query: 'needle two', + session_id: 's2', + chain_id: 'a2', + regex: true, + from: '2026-08-01', + to: '2026-08-31', + limit: 5, + }) +}) + +test('hyp query grep finds a row and renders locators plus snippet', async () => { + const { ctx, out, err } = await makeCtx([ + [mkRow({ content_text: 'alpha needle one' })], + [mkRow({ date: '2026-08-12', session_id: 's2', content_text: 'the needle two' })], + ]) + const code = await cmd.run(['needle'], ctx) + assert.equal(code, 0) + const stdout = out.join('') + assert.match(stdout, /content_text/) + assert.match(stdout, /the needle two/) + assert.match(stdout, /s2/) + assert.equal(err.join(''), '', 'nothing truncated, nothing withheld, nothing on stderr') + // Newest first: the 2026-08-12 hit renders before the 2026-08-10 one. + assert.ok(stdout.indexOf('2026-08-12') < stdout.indexOf('2026-08-10')) +}) + +test('table render escapes captured control bytes; json stays byte-exact (LLP 0225)', async () => { + const { ctx, out } = await makeCtx([ + [mkRow({ content_text: 'evil needleline\ntwo' })], + ]) + const code = await cmd.run(['needle'], ctx) + assert.equal(code, 0) + const table = out.join('') + assert.ok(!table.includes(''), 'the ESC byte never reaches a human render') + assert.match(table, /\\u001b/) + out.length = 0 + const jsonCode = await cmd.run(['needle', '--format', 'json'], ctx) + assert.equal(jsonCode, 0) + const rows = JSON.parse(out.join('')) + assert.ok(rows[0].snippet.includes(''), 'the machine copy keeps the raw bytes') +}) + +test('the limit produces the truncation notice on stderr', async () => { + const { ctx, err } = await makeCtx([ + [mkRow({ content_text: 'needle a' })], + [mkRow({ date: '2026-08-12', session_id: 's2', content_text: 'needle b' })], + ]) + const code = await cmd.run(['needle', '--limit', '1'], ctx) + assert.equal(code, 0) + assert.match(err.join(''), /more matches exist beyond the limit/) +}) + +test('a limit above the ceiling clamps to it instead of dropping below the default', async () => { + // 60 rows: more than the default 50, so a fallback-to-default and a clamp + // to the ceiling are distinguishable. Falling back would answer a request + // for more rows with fewer, then advise raising a limit already at 9999. + const batch = [] + for (let i = 0; i < 60; i += 1) batch.push(mkRow({ content_text: `needle ${i}` })) + const { ctx, out, err } = await makeCtx([batch]) + const code = await cmd.run(['needle', '--limit', '9999'], ctx) + assert.equal(code, 0) + const rendered = out.join('') + assert.match(rendered, /needle 0\b/) + assert.equal(rendered.split('\n').filter((line) => /content_text/.test(line)).length, 60) + assert.equal(err.join(''), '', 'a clamped limit still covered every match, so nothing is truncated') +}) + +test('an unusable limit falls back to the default instead of failing', async () => { + const { ctx, out } = await makeCtx([[mkRow({ content_text: 'needle a' })]]) + const code = await cmd.run(['needle', '--limit', '0'], ctx) + assert.equal(code, 0) + assert.match(out.join(''), /needle a/) +}) + +test('at the ceiling the truncation notice stops advising a flag that cannot move', () => { + // The clamp exists so a caller is never told to raise a limit it already + // pinned; the notice has to keep that promise or the clamp only moves the + // unfollowable advice one flag along. + const atCeiling = queryGrepVerb.render( + { hits: [], truncated: true, exhausted: true, limitCeilingReached: true }, + /** @type {any} */ ({ format: 'table', json: false, maxCell: 200, maxBytes: 32768 }) + ) + assert.match(atCeiling.stderr ?? '', /beyond the 1000-hit ceiling/) + assert.doesNotMatch(atCeiling.stderr ?? '', /raise --limit/) + const belowCeiling = queryGrepVerb.render( + { hits: [], truncated: true, exhausted: true, limitCeilingReached: false }, + /** @type {any} */ ({ format: 'table', json: false, maxCell: 200, maxBytes: 32768 }) + ) + assert.match(belowCeiling.stderr ?? '', /raise --limit/) +}) + +test('zero hits over zero searched files says so instead of passing for a full search', async () => { + const { ctx, err } = await makeCtx([]) + const code = await cmd.run(['needle'], ctx) + assert.equal(code, 0) + assert.match(err.join(''), /no ai_gateway_messages data files were searched/) +}) + +test('zero hits over a searched cache stays quiet', async () => { + const { ctx, err } = await makeCtx([[mkRow({ content_text: 'nothing to see' })]]) + const code = await cmd.run(['needle'], ctx) + assert.equal(code, 0) + assert.equal(err.join(''), '', 'files were searched, so the empty answer is the honest one') +}) + +test('a malformed --from is refused rather than answering zero hits', async () => { + const { ctx, out, err } = await makeCtx([[mkRow({ content_text: 'needle a' })]]) + // The window is compared lexicographically, so `2026-8-1` would prune + // every real day and render an empty, unexplained answer. + const code = await cmd.run(['needle', '--from', '2026-8-1'], ctx) + assert.notEqual(code, 0) + assert.match(err.join(''), /--from expects a day as YYYY-MM-DD \(got 2026-8-1\)/) + assert.equal(out.join(''), '') +}) + +test('the snippet renders last so a long match cannot shove the locators out of column', async () => { + const { ctx, out } = await makeCtx([ + [mkRow({ content_text: `needle ${'x'.repeat(200)}` })], + [mkRow({ date: '2026-08-12', content_text: 'needle short' })], + ]) + const code = await cmd.run(['needle', '--format', 'jsonl'], ctx) + assert.equal(code, 0) + const first = JSON.parse(out.join('').split('\n')[0]) + assert.deepEqual(Object.keys(first), ['date', 'session_id', 'column', 'message_id', 'part_id', 'snippet']) +}) + +test('a render of a bare server-shaped result works without local fields', () => { + const rendered = queryGrepVerb.render( + { + hits: [{ + date: '2026-08-12', sessionId: 's2', agentId: null, conversationId: null, + partId: 'p1', messageId: 'm1', messageCreatedAt: '2026-08-12T00:00:00Z', + matches: [{ column: 'content_text', snippet: '...the needle two...' }], + }], + truncated: false, + exhausted: false, + }, + /** @type {any} */ ({ format: 'table', json: false, output: undefined, maxCell: 200, maxBytes: 32768 }) + ) + assert.match(rendered.stdout ?? '', /the needle two/) + assert.match(rendered.stderr ?? '', /stopped before covering every file/) +}) + +/** + * Install a fake MCP-over-HTTP server as `globalThis.fetch`, the + * verb-remote idiom, returning a server-shaped grep result. + * + * @param {TestContext} t + * @param {any} structuredContent + */ +function stubServer(t, structuredContent) { + const original = globalThis.fetch + t.after(() => { globalThis.fetch = original }) + globalThis.fetch = /** @type {any} */ (async (/** @type {string} */ _url, /** @type {any} */ init) => { + const req = JSON.parse(init.body) + const json = (/** @type {any} */ obj, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + headers: { get: (/** @type {string} */ k) => (k.toLowerCase() === 'content-type' ? 'application/json' : k.toLowerCase() === 'mcp-session-id' ? 'sess-1' : null) }, + text: async () => JSON.stringify(obj), + }) + if (req.method === 'initialize') return json({ jsonrpc: '2.0', id: req.id, result: { protocolVersion: '2025-06-18', serverInfo: { name: 'srv' } } }) + if (req.method === 'notifications/initialized') return { ok: true, status: 202, headers: { get: () => null }, text: async () => '' } + if (req.method === 'tools/call') { + stubServer.lastCall = req.params + return json({ jsonrpc: '2.0', id: req.id, result: { structuredContent, isError: false } }) + } + return json({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'no' } }) + }) +} +/** @type {any} */ +stubServer.lastCall = null + +test('--remote calls the server grep_search with the wire params and renders its result', async (t) => { + stubServer(t, { + hits: [{ + date: '2026-08-12', sessionId: 'remote-s', agentId: null, conversationId: null, + partId: 'p9', messageId: 'm9', messageCreatedAt: '2026-08-12T01:00:00Z', + matches: [{ column: 'content_text', snippet: '...remote needle...' }], + }], + truncated: true, + exhausted: false, + }) + /** @type {string[]} */ const out = [] + /** @type {string[]} */ const err = [] + const ctx = /** @type {any} */ ({ + env: { HYP_HOME: '/tmp/none', HYP_REMOTE_TOKEN_PROD: 'tok' }, + config: { version: 2, query: { remotes: { prod: { url: 'https://hyp.internal/mcp' } } } }, + query: {}, storage: {}, + stdout: { write: (/** @type {string} */ s) => out.push(s) }, + stderr: { write: (/** @type {string} */ s) => err.push(s) }, + }) + const code = await cmd.run(['remote needle', '--session-id', 'remote-s', '--remote', 'prod'], ctx) + assert.equal(code, 0) + assert.equal(stubServer.lastCall.name, 'grep_search') + assert.deepEqual(stubServer.lastCall.arguments, { + query: 'remote needle', + session_id: 'remote-s', + limit: 50, + }, 'only wire params travel; include-local-only never rides uninvited') + assert.match(out.join(''), /remote needle/) + assert.match(err.join(''), /more matches exist beyond the limit/) +})