Files
hapi/shared/src/sessionCitation.test.ts
T
c0b30bf916 feat(cli): MCP list_peers + runner hub auth for peer discovery (#1372)
* feat(cli): MCP list_peers + runner hub auth inheritance

Runner-spawned agents could not discover same-hub peers without
sitting on the hub host or pasting a session id. Add MCP list_peers
(in-process credentials), export HAPI_API_URL/CLI_API_TOKEN after
auth init for shell fallbacks, and clearer auth failure hints.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): do not export default hub URL into HAPI_API_URL

exportHapiHubAuthEnv was writing the implicit localhost default into
process.env, which made maybeAutoStartServer skip starting the bundled
hub. Only export HAPI_API_URL when the URL came from env or settings;
always still export CLI_API_TOKEN. Also fill missing deliveryMode on
abort restore so web typecheck matches RawSendError (main tip unblock).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): widen initializeApiUrl mock return type in test

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): never export CLI_API_TOKEN; exclude self from list_peers

Keep settings/prompt-backed hub secrets out of wrapped agent env so
shell JWT+curl cannot bypass peer-tool approval. Fresh hapi re-reads
settings; env-backed tokens already inherit. list_peers omits the
calling session from the shortlist.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): resolve peer labels via summary/path like web titles

list_peers was showing (unnamed) for ordinary sessions because titles
live in metadata.summary.text. Match web getSessionTitle and collapse
whitespace so each peer stays one agent-readable line.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli,hub): emit full peer ids and honor GET /sessions?limit

Short 8-char prefixes collide across UUID namespaces; print full ids so
resolveSessionByPrefix stays unambiguous. Honor optional limit after sort
so listPeerSessions stops loading the whole namespace for scheduled counts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): type sessions limit test mock as Map<string, number>

CI tsc rejected Map<string, null> for getNextScheduledAtBySessionIds.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli,hub): unbounded ping resolve; peer list order=updatedAt

Keep GET /sessions?limit only for discovery callers. ping/inspect omit
limit so full UUIDs outside the first 500 stay resolvable. Peer lists
pass order=updatedAt so truncation matches newest-first. Basename
fallback splits Windows paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): auto-approve ACP title List Peer Sessions

Permission derivation prefers request.title; match the MCP tool title
form so default-mode ACP sessions do not prompt on discovery.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): pad list_peers fetch; split hub URL vs token hints

Fetch limit+2 when excluding the caller so overflow still surfaces at
limit=100. Clarify that auth login only saves the token, not HAPI_API_URL.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): use boolean overflow for ping-peer --list

Match MCP list_peers: fetch limit+1 and mark hasMore instead of claiming
an exact omitted count from a 200-row sample.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): tolerate mocked machineCache without expireInactive

CI flake: 5s inactivity tick hit test doubles that only stubbed
getOnlineMachinesByNamespace. Optional-call + stub the method.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:16:04 +08:00

116 lines
4.6 KiB
TypeScript

import { describe, expect, it } from 'bun:test'
import {
SESSION_REFERENCE_STEER_SUFFIX,
buildSessionCitationSteerInstruction,
extractSessionCitationIds,
normalizeSessionIdPrefix,
} from './sessionCitation'
const UUID = '7ee03698-0fe7-4f76-b8a8-d84f4eddbf5c'
describe('extractSessionCitationIds', () => {
it('extracts id from Copy-reference prose (tiann/hapi#1144)', () => {
const text = `See session "Coding" (/sessions/${UUID}) for context`
expect(extractSessionCitationIds(text)).toEqual([UUID])
})
it('extracts id from Copy-reference prose with steer suffix', () => {
const text =
`See session "Coding" (/sessions/${UUID}) for context.${SESSION_REFERENCE_STEER_SUFFIX}`
expect(extractSessionCitationIds(text)).toEqual([UUID])
})
it('extracts id from empty-title Copy-reference form', () => {
expect(extractSessionCitationIds(`See HAPI session /sessions/${UUID} for context`)).toEqual([
UUID,
])
})
it('extracts id from markdown composer chip form', () => {
expect(extractSessionCitationIds(`[Coding](/sessions/${UUID})`)).toEqual([UUID])
})
it('extracts id from bare /sessions/<id>', () => {
expect(extractSessionCitationIds(`please read /sessions/${UUID}`)).toEqual([UUID])
})
it('strips trailing prose punctuation from bare citations', () => {
expect(extractSessionCitationIds(`see /sessions/${UUID}.`)).toEqual([UUID])
expect(extractSessionCitationIds(`see /sessions/${UUID}, then continue`)).toEqual([UUID])
})
it('rejects dotted tails that look like source paths', () => {
expect(extractSessionCitationIds('see web/src/routes/sessions/chat.tsx')).toEqual([])
expect(extractSessionCitationIds('/sessions/chat.tsx')).toEqual([])
})
it('dedupes while preserving first-seen order', () => {
const other = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const text = `[A](/sessions/${UUID}) and See session "B" (/sessions/${other}) then /sessions/${UUID}`
expect(extractSessionCitationIds(text)).toEqual([UUID, other])
})
it('accepts BASE_URL-prefixed session paths', () => {
expect(extractSessionCitationIds(`(/hapi/sessions/${UUID})`)).toEqual([UUID])
})
})
describe('normalizeSessionIdPrefix', () => {
it('returns a bare prefix unchanged', () => {
expect(normalizeSessionIdPrefix('7ee03698')).toBe('7ee03698')
expect(normalizeSessionIdPrefix(` ${UUID} `)).toBe(UUID)
})
it('pulls the id out of a pasted Copy-reference blob', () => {
expect(
normalizeSessionIdPrefix(`See session "Coding" (/sessions/${UUID}) for context`)
).toBe(UUID)
})
it('prefers the parenthesized Copy-reference path over /sessions/ inside the title', () => {
const other = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const text =
`See session ${JSON.stringify(`Review /sessions/${other}`)} (/sessions/${UUID}) for context.${SESSION_REFERENCE_STEER_SUFFIX}`
expect(normalizeSessionIdPrefix(text)).toBe(UUID)
})
it('pulls the id out of a markdown citation', () => {
expect(normalizeSessionIdPrefix(`[Coding](/sessions/${UUID})`)).toBe(UUID)
})
it('fails closed on ambiguous multi-citation blobs', () => {
const other = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
expect(
normalizeSessionIdPrefix(
`[A](/sessions/${UUID}) and See session "B" (/sessions/${other}) for context`
)
).toBe('')
})
it('fails closed when a Copy-reference is followed by a second citation', () => {
const other = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
expect(
normalizeSessionIdPrefix(
`See session "A" (/sessions/${UUID}) for context and [B](/sessions/${other})`
)
).toBe('')
})
})
describe('buildSessionCitationSteerInstruction', () => {
it('mentions both citation forms and forbids local FS search', () => {
const text = buildSessionCitationSteerInstruction({
inspectTool: 'mcp__hapi__inspect_peer',
pingTool: 'mcp__hapi__ping_peer',
listPeersTool: 'mcp__hapi__list_peers',
})
expect(text).toContain('[title](/sessions/<id>)')
expect(text).toContain('See session')
expect(text).toContain('/sessions/<id>')
expect(text).toContain('mcp__hapi__inspect_peer')
expect(text).toContain('mcp__hapi__ping_peer')
expect(text).toContain('mcp__hapi__list_peers')
expect(text.toLowerCase()).toMatch(/not.*(grep|glob|filesystem|local file)/i)
})
})