mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-11 19:50:05 +00:00
* 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>
116 lines
4.6 KiB
TypeScript
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)
|
|
})
|
|
})
|