mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
* feat(cli): spawn-peer with required remit and MCP spawn_peer Machine spawn creates an empty composer; stuffing message into that JSON is stripped. Add hapi spawn-peer and MCP spawn_peer that spawn, rename, deliver via the ping-peer path, and fail closed unless the remit is on the new session. Reject message/prompt/text on machine spawn with 400. Do not auto-approve spawn_peer in default/read-only. Fixes #1509 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): reject overlong spawn-peer names before spawn Hub rename max is 255; continuing past PATCH 400 left a live session under a different title. MCP/steer copy now says the hub 400s a spawn-body remit instead of stripping it. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): wait for fresh spawn-peer child instead of resuming it Machine spawn can return while the hub row is still inactive. ping-peer then POSTed /resume, which can launch a second agent because the original spawn has no existingSessionId for runner dedupe. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): reject flavor-illegal permissionMode before spawn-peer Codex + bypassPermissions (and Pi + any mode) used to reach the runner and fail after a child existed, or silently drop the mode. Check isPermissionModeAllowedForFlavor when both agent and mode are explicit. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): treat omitted spawn-peer agent as Claude for permissionMode Hub and runner default agent to claude when spawn omits it. Validate permissionMode against that effective flavor so yolo/read-only fail closed before a child starts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): resolve spawn-peer dir locally and archive failed children Relative --dir values were forwarded unchanged, so the runner resolved them against daemon cwd. Remit delivery failures also left the new session running, so a retry spawned a duplicate. Resolve in the caller and best-effort archive the child before rethrowing. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): verify remit before archiving a failed spawn-peer child A timed-out ping-peer POST can still land the remit. Check the transcript before archive, and only say the child was archived when the hub returns ok. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): swallow spawn-peer transcript GET errors and skip unapplied names A thrown messages GET after spawn skipped archive and leaked a live child. Treat transport errors as a miss so the verify loop can retry, then clean up. Also do not return a requested name the hub never applied. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(spawn-peer): hub peerSpawnDefaults + model/effort spawn surface Add hub settings peerSpawnDefaults (agent, permissionMode, per-flavor models) with stock claude/yolo/sonnet fallbacks. spawnPeer resolves explicit args → hub settings → stock, maps yolo to flavor equivalents, and forwards model/effort into machine spawn. Settings UI under General → Agents; MCP/CLI optional overrides. Co-authored-by: Cursor <cursoragent@cursor.com> * test(hub): use cursor-valid permission mode in peer spawn defaults test Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): teach spawn_peer that omit permissionMode means yolo MCP copy said "pass permissionMode only when the operator asked", so agents omitted it and got hub/stock whatever was stored — often approval-gated default. Clarify omit = yolo via peerSpawnDefaults/stock; pass only to tighten. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): apply peerSpawnDefaults to machine spawn and New Session POST /api/machines/:id/spawn now resolves omitted agent/permissionMode/model from hub Settings, and New Session seeds from the same peerSpawnDefaults so UI create and peer spawn share one authoritative default (stock yolo). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): NewSession hub seed respects sticky prefs + QueryClient in tests Sticky localStorage last-used agent wins over hub seed; machine spawn still applies peerSpawnDefaults server-side. Tests provide QueryClientProvider. Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): mock CREATABLE_AGENT_FLAVORS for settings general peerSpawn UI Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): stabilize NewSession tests after hub peerSpawnDefaults seed Wrap remaining mounts in QueryClientProvider, pre-seed hub settings so a late useQuery resolve cannot wipe in-test model picks, and use neutral stock permission defaults in the fixture. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(spawn-peer): address tip Codex Majors for peerSpawnDefaults Preserve explicit yolo booleans on machine spawn, fail closed when hub defaults cannot load, validate permission modes against the resolved agent, remap auto-approval across flavors, and stop late hub settings from wiping New Session edits. Reject retired agents in spawn defaults and restore the CLI executable build docs. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(spawn-peer): address follow-up tip Codex Majors Stop absent Yolo localStorage from beating hub defaults, track New Session edits before late hub seed, reject incompatible machine-spawn permissions, resolve MCP spawn_peer paths against the session working directory, and hide unsupported Codex safe-yolo from Settings spawn defaults. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(spawn-peer): clear tip-2 CI and follow-up Codex Majors Omit permissionMode for Pi/DSH, preserve explicit Yolo-off across delayed hub seed, fix settings test mocks for launch permission helpers, and restore unrelated CLI README lifecycle/ACP docs. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(spawn-peer): tip-3 Majors for seed edits and launchable modes Skip hub agent seed after permission edits, resolve inherited modes via the launch catalog (AGY request-review fallback), preserve hub Cursor models through preference restore, and restore Codex auth README nuance. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): tip Minors for Cursor/Grok hub permission seeding Give Cursor the native permission select so Plan/Ask survive machine spawn, and re-apply Grok hub permissions after agent-init resets. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(spawn-peer): tip Minors for Cursor select and hub seed remap Map hub permission through resolvePermissionModeForFlavor, keep native Default when agent is explicit without a model, and assert Cursor Plan/Ask Mode labels. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): tip Minors for delayed hub seed and Default model Apply hub permission/model to unset fields even with a sticky agent, preserve explicitly saved auto models, and seed OpenCode/AGY selectors from hub defaults. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(spawn-peer): tip Minors for model edit sticky and clear override Mark OpenCode/AGY model picks as edited so delayed hub seed cannot overwrite them, and treat empty hub model values as clear sentinels. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(spawn-peer): tip Majors for models patch typing and remit page scan Type the models patch as NonNullable and paginate transcript verification oldest-ward so a busy child is not archived when the remit leaves page 1. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): tip Minor — keep agent after explicit model pick Do not apply a late hub default agent when the operator already chose a model; otherwise preference restore drops that selection. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(spawn-peer): tip Cursor YOLO migrate and Codex effort map Bridge Cursor's saved YOLO toggle (incl. false) into native Default over hub yolo, and send Codex/OpenCode --effort as modelReasoningEffort. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): tip Majors — scoped YOLO bridge and catalog fallback Keep unsupported saved permission modes at Default instead of hub yolo, and apply delayed YOLO migration only when the sticky bridge agent matches. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): drop rebase conflict markers in startHappyServer Both sessionDisplayRename and spawnPeer imports are required after rebasing onto upstream rename work; markers had slipped into tip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): wrap Kimi NewSession tests in QueryClientProvider Hub settings useQuery requires a provider; upstream Kimi catalog tests rendered bare and broke after the spawn-peer rebase onto main. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(peer): rename-proof Parent UUID stamp on spawn remits Orchestrated spawn_peer / spawn-peer remits now auto-stamp a durable [title](/sessions/<uuid>) Parent chip (fail-closed in-session). MCP copy forbids inventing titles; UUID stays identity after parent rename. Refs heavygee/hapi#175 Co-authored-by: Cursor <cursoragent@cursor.com> * style(peer): plain hyphen in Parent-stamp warning and test comment Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): reject blank remit before Parent stamp Codex review on #1922: stamping first turned empty remits non-empty and bypassed the idle-session guard. Also only treat ## Parent blocks as already-present so bare /sessions citations still get the chip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): gate New Session Create on hub defaults; require Parent chip Codex tip review on #1922: block Create while getHubSettings is pending so stock mount defaults cannot bypass restrictive hub peerSpawnDefaults; skip Parent stamp only when ## Parent already has a markdown session chip; clarify codex resume takes a native thread id. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): resolve MCP spawn cwd; Agents settings section Codex Major on #1922: spawn_peer relative directories now prefer the launcher cwd (Codex --cd), then skillLookup, then session metadata.path. Move peerSpawnDefaults UI into the unused Agents settings section. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): pass session cwd to MCP spawn_peer; Agents settings section Codex tip review on #1922: resolve relative spawn_peer directories from the launcher working directory (Codex --cd), skillLookup, then metadata.path; move peerSpawnDefaults UI into the Agents settings section. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): skip archive on unread transcript; validate startingMode after hub agent Codex tip review: do not archive a child when remit verification cannot read the transcript; run AGY/DSH remote-only startingMode checks against the resolved hub agent; update MCP bridge test expectations for workingDirectory. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): treat exhausted remit pagination as unverifiable Hitting the messages page cap with hasMore still true must not count as an observed-empty transcript — that would archive a child whose remit may still exist on older pages. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(cli): list HAPI_API_URL as optional with localhost default Codex tip review: the README had moved HAPI_API_URL under Required even though configuration defaults it to http://localhost:3006 (and settings can supply a URL). Also drop trailing whitespace flagged by git diff --check. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): re-verify empty transcript before archiving failed spawn An earlier observed-absent page plus later unread GETs must not archive a child whose remit may have landed after the first empty read. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): never auto-archive on spawnPeer failure path Timed-out remit POSTs and empty-read/archive TOCTOU races can still land work on the child. Leave the session running with its id for inspection instead of terminating an in-flight peer. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): classify empty_session from final remit verify An earlier empty transcript read must not stick as empty_session once later message GETs become unavailable — report verify_failed instead. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): recognize escaped brackets in Parent chip labels Titles with ] were escaped in the chip label but the idempotency regex stopped at the first raw bracket and re-stamped a second Parent block. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): avoid Parent chip regex backtracking on backslashes Exclude \\ from the single-char label alternative so escaped pairs have one parse path — long nonmatching backslash runs no longer explode. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): MCP/CLI spawn_peer --machine for cross-host remits Wire optional machine (UUID or hostname via GET /api/machines) through MCP spawn_peer and hapi spawn-peer into existing spawnPeer({ machineId }). Skip local directory existence checks for cross-host targets to match the estate wrapper; keep fail-closed remit verify. Fixes tiann/hapi#1931 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): reject ambiguous spawn --machine label matches Tip bot Major on #1922: hostname/displayName selectors that match more than one online runner now fail closed with ambiguous + UUID guidance. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): preserve cross-OS absolute dirs for spawn --machine path.resolve on the caller OS was rewriting foreign absolute roots (C:\… on Linux, /srv/… on Windows) before the hub spawn POST. Detect POSIX or Windows absolute paths and only resolve relative directories. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(peer): resolve spawn dirs after local vs remote is known Defer directory resolution until the target machine is selected. Remote spawns still preserve foreign absolute roots; local spawns use native path.resolve so Windows drive-less paths like \repo bind to the caller drive. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): apply delayed DSH model defaults without replacing edits * fix(web): preserve hub permission defaults across form remounts --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: weishu <twsxtd@gmail.com>
186 lines
8.2 KiB
TypeScript
186 lines
8.2 KiB
TypeScript
import { describe, expect, it } from 'bun:test'
|
|
import {
|
|
SESSION_REFERENCE_STEER_SUFFIX,
|
|
SPAWN_PEER_TOOL_DESCRIPTION,
|
|
PEER_DELIVERY_MODE_DESCRIPTION,
|
|
PING_PEER_TOOL_DESCRIPTION,
|
|
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('guides collaboration toward auto and preserves explicit modes and retry safety', () => {
|
|
const text = buildSessionCitationSteerInstruction({ inspectTool: 'inspect_peer', pingTool: 'ping_peer' })
|
|
expect(text).toContain(PEER_DELIVERY_MODE_DESCRIPTION)
|
|
expect(PING_PEER_TOOL_DESCRIPTION).toContain(PEER_DELIVERY_MODE_DESCRIPTION)
|
|
expect(text).toContain('auto (default)')
|
|
expect(text).toContain('active Codex turn')
|
|
expect(text).toContain('Auto currently queues for other agent flavors')
|
|
expect(text).toContain('Prefer auto for routine collaboration when queueing is an acceptable fallback')
|
|
expect(text).toContain('queue: explicitly defer non-urgent updates')
|
|
expect(text).toContain('steer: use for important, time-sensitive findings the peer needs to know now')
|
|
expect(text).toContain('not after finishing its current turn')
|
|
expect(text).toContain('State why the finding matters now and what the peer should adjust')
|
|
expect(text).toContain('Steer requires insertion')
|
|
expect(text).toContain('does not abort the current turn or guarantee an immediate response')
|
|
expect(text).toContain('do not resend automatically')
|
|
expect(text).toContain('hapi ping-peer <id> <message> --delivery-mode auto')
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
it('teaches spawn_peer for new peers with work and forbids stuffing remit into machine spawn', () => {
|
|
const text = buildSessionCitationSteerInstruction({
|
|
inspectTool: 'mcp__hapi__inspect_peer',
|
|
pingTool: 'mcp__hapi__ping_peer',
|
|
listPeersTool: 'mcp__hapi__list_peers',
|
|
spawnTool: 'mcp__hapi__spawn_peer',
|
|
})
|
|
expect(text).toContain('mcp__hapi__spawn_peer')
|
|
expect(text).toMatch(/spawn-peer|spawn_peer/)
|
|
expect(text.toLowerCase()).toMatch(/400|reject/)
|
|
expect(text.toLowerCase()).not.toMatch(/strip/)
|
|
})
|
|
})
|
|
|
|
describe('SPAWN_PEER_TOOL_DESCRIPTION', () => {
|
|
it('tells agents the hub rejects a spawn-body remit instead of stripping it', () => {
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/reject/)
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).not.toMatch(/strip/)
|
|
})
|
|
|
|
it('teaches omit permissionMode = yolo default, not approval-gated default', () => {
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/yolo/)
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/omit/)
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).not.toMatch(
|
|
/pass permissionmode only when the operator asked/
|
|
)
|
|
})
|
|
|
|
it('forbids inventing peer titles and teaches Parent UUID stamp', () => {
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/never invent/)
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/parent/)
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/uuid/)
|
|
})
|
|
|
|
it('documents optional machine targeting beyond this host only', () => {
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/machine/)
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/hostname|uuid/)
|
|
expect(SPAWN_PEER_TOOL_DESCRIPTION.toLowerCase()).not.toMatch(
|
|
/spawn a new hapi session on this machine and/
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('PING_PEER_TOOL_DESCRIPTION identity rule', () => {
|
|
it('forbids inventing peer titles', async () => {
|
|
const { PING_PEER_TOOL_DESCRIPTION } = await import('./sessionCitation')
|
|
expect(PING_PEER_TOOL_DESCRIPTION.toLowerCase()).toMatch(/never invent/)
|
|
})
|
|
})
|