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>
177 lines
7.5 KiB
TypeScript
177 lines
7.5 KiB
TypeScript
import { describe, expect, it } from 'bun:test'
|
|
import {
|
|
ParentStampError,
|
|
ensureParentStamp,
|
|
extractParentSessionIdFromRemit,
|
|
formatParentStampBlock,
|
|
formatSessionChip,
|
|
remitCitesSessionId,
|
|
} from './peerParentStamp'
|
|
import { extractSessionCitationIds, normalizeSessionIdPrefix } from './sessionCitation'
|
|
|
|
const PARENT_ID = '4bd4d2b9-e114-4e03-af4c-03e9e4f7439e'
|
|
const AGENT_SESSION_ID = 'cursor-agent-session-abc'
|
|
|
|
describe('formatSessionChip', () => {
|
|
it('builds a durable [title](/sessions/<uuid>) chip', () => {
|
|
expect(formatSessionChip({ sessionId: PARENT_ID, name: 'Producer to Infra' })).toBe(
|
|
`[Producer to Infra](/sessions/${PARENT_ID})`
|
|
)
|
|
})
|
|
|
|
it('falls back to a short id when name is missing (never invents a nickname)', () => {
|
|
expect(formatSessionChip({ sessionId: PARENT_ID })).toBe(
|
|
`[4bd4d2b9](/sessions/${PARENT_ID})`
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('formatParentStampBlock', () => {
|
|
it('stamps Parent with the full UUID chip and optional agentSessionId', () => {
|
|
const block = formatParentStampBlock({
|
|
sessionId: PARENT_ID,
|
|
name: 'cursor - tooling/meta bot',
|
|
agentSessionId: AGENT_SESSION_ID,
|
|
})
|
|
expect(block).toContain('## Parent')
|
|
expect(block).toContain(`[cursor - tooling/meta bot](/sessions/${PARENT_ID})`)
|
|
expect(block).toContain(`agentSessionId: \`${AGENT_SESSION_ID}\``)
|
|
expect(block.toLowerCase()).toMatch(/uuid|decoration|rename/)
|
|
})
|
|
})
|
|
|
|
describe('ensureParentStamp', () => {
|
|
it('prepends a Parent block when the remit lacks the parent UUID', () => {
|
|
const result = ensureParentStamp('## Your assignment\n- do the work', {
|
|
sessionId: PARENT_ID,
|
|
name: 'Orchestrator',
|
|
})
|
|
expect(result.stamped).toBe(true)
|
|
expect(result.alreadyPresent).toBe(false)
|
|
expect(result.message.startsWith('## Parent')).toBe(true)
|
|
expect(result.message).toContain(`/sessions/${PARENT_ID}`)
|
|
expect(result.message).toContain('## Your assignment')
|
|
})
|
|
|
|
it('is a no-op when the remit already has a ## Parent markdown chip for this UUID', () => {
|
|
const body = `## Parent\n- Orchestrator chip: [Old Title](/sessions/${PARENT_ID})\n\nGo.`
|
|
const result = ensureParentStamp(body, {
|
|
sessionId: PARENT_ID,
|
|
name: 'Renamed',
|
|
})
|
|
expect(result.stamped).toBe(false)
|
|
expect(result.alreadyPresent).toBe(true)
|
|
expect(result.message).toBe(body)
|
|
})
|
|
|
|
it('stays idempotent when the parent title contains brackets (escaped in the chip label)', () => {
|
|
const identity = { sessionId: PARENT_ID, name: 'Team [Infra]' }
|
|
const first = ensureParentStamp('Do the work.', identity)
|
|
expect(first.stamped).toBe(true)
|
|
expect(first.message).toContain(`[Team \\[Infra\\]](/sessions/${PARENT_ID})`)
|
|
const second = ensureParentStamp(first.message, {
|
|
sessionId: PARENT_ID,
|
|
name: 'Renamed [Again]',
|
|
})
|
|
expect(second.stamped).toBe(false)
|
|
expect(second.alreadyPresent).toBe(true)
|
|
expect(second.message).toBe(first.message)
|
|
expect(second.message.match(/## Parent/g)?.length).toBe(1)
|
|
})
|
|
|
|
it('does not hang on a long nonmatching backslash run under ## Parent', () => {
|
|
const body = `## Parent\n- [${'\\'.repeat(40)}\n\nDo the work.`
|
|
const started = Date.now()
|
|
const result = ensureParentStamp(body, {
|
|
sessionId: PARENT_ID,
|
|
name: 'Parent',
|
|
})
|
|
expect(Date.now() - started).toBeLessThan(200)
|
|
expect(result.stamped).toBe(true)
|
|
expect(result.message).toContain(`[Parent](/sessions/${PARENT_ID})`)
|
|
})
|
|
|
|
it('still stamps when the remit only has a bare parent citation (no ## Parent block)', () => {
|
|
// Bare /sessions/<uuid> must not suppress the Parent chip - chat UI only
|
|
// promotes citations under ## Parent into the sender-style chip.
|
|
const body = `See /sessions/${PARENT_ID} for context.\n\nDo the work.`
|
|
const result = ensureParentStamp(body, {
|
|
sessionId: PARENT_ID,
|
|
name: 'Parent',
|
|
})
|
|
expect(result.stamped).toBe(true)
|
|
expect(result.alreadyPresent).toBe(false)
|
|
expect(result.message.startsWith('## Parent')).toBe(true)
|
|
expect(result.message).toContain(body)
|
|
})
|
|
|
|
it('still stamps when ## Parent is empty and the UUID only appears under a later heading', () => {
|
|
const body = `## Parent\n\n## Context\nSee [other](/sessions/${PARENT_ID}) for prior work.\n\nDo the work.`
|
|
const result = ensureParentStamp(body, {
|
|
sessionId: PARENT_ID,
|
|
name: 'Parent',
|
|
})
|
|
expect(result.stamped).toBe(true)
|
|
expect(result.alreadyPresent).toBe(false)
|
|
expect(result.message.startsWith('## Parent')).toBe(true)
|
|
expect(result.message).toContain(`[Parent](/sessions/${PARENT_ID})`)
|
|
})
|
|
|
|
it('still stamps when ## Parent only has a bare /sessions path (no markdown chip)', () => {
|
|
const body = `## Parent\n- source: /sessions/${PARENT_ID}\n\nDo the work.`
|
|
const result = ensureParentStamp(body, {
|
|
sessionId: PARENT_ID,
|
|
name: 'Parent',
|
|
})
|
|
expect(result.stamped).toBe(true)
|
|
expect(result.alreadyPresent).toBe(false)
|
|
expect(result.message).toContain(`[Parent](/sessions/${PARENT_ID})`)
|
|
})
|
|
|
|
it('fail-closes when requireParent and parent id is missing', () => {
|
|
expect(() =>
|
|
ensureParentStamp('brief', null, { requireParent: true })
|
|
).toThrow(ParentStampError)
|
|
expect(() =>
|
|
ensureParentStamp('brief', { sessionId: ' ' }, { requireParent: true })
|
|
).toThrow(/parent session id/i)
|
|
})
|
|
|
|
it('allows missing parent when requireParent is false (outside-session CLI)', () => {
|
|
const result = ensureParentStamp('brief', undefined, { requireParent: false })
|
|
expect(result.message).toBe('brief')
|
|
expect(result.stamped).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('P2 falsification: spawn → rename → ping by handoff UUID only', () => {
|
|
it('keeps the handoff UUID usable after the parent display name changes', () => {
|
|
const originalName = 'Producer'
|
|
const renamedName = 'Producer to Infra'
|
|
const stamped = ensureParentStamp('Own steps: implement P0', {
|
|
sessionId: PARENT_ID,
|
|
name: originalName,
|
|
agentSessionId: AGENT_SESSION_ID,
|
|
})
|
|
|
|
// Parent is renamed mid-flight; remit text still has the old decorative title.
|
|
expect(stamped.message).toContain(originalName)
|
|
expect(stamped.message).not.toContain(renamedName)
|
|
|
|
// Child must address parent by UUID from the handoff chip - not invent titles.
|
|
const handoffIds = extractSessionCitationIds(stamped.message)
|
|
expect(handoffIds).toEqual([PARENT_ID])
|
|
expect(extractParentSessionIdFromRemit(stamped.message)).toBe(PARENT_ID)
|
|
expect(remitCitesSessionId(stamped.message, PARENT_ID)).toBe(true)
|
|
|
|
const chip = stamped.message.match(/\[[^\]]*\]\(\/sessions\/[^)]+\)/)?.[0] ?? ''
|
|
expect(normalizeSessionIdPrefix(chip)).toBe(PARENT_ID)
|
|
|
|
// Rename is a non-event for routing: UUID is identity; title is decoration.
|
|
const pingTarget = extractParentSessionIdFromRemit(stamped.message)
|
|
expect(pingTarget).toBe(PARENT_ID)
|
|
expect(pingTarget).not.toBe(renamedName)
|
|
expect(pingTarget).not.toBe(originalName)
|
|
})
|
|
})
|