Files
hapi/shared/src/peerParentStamp.test.ts
T
7f5d6b17f3 feat(peer): spawn-peer remit + rename-proof Parent UUID stamps (#1922)
* 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>
2026-11-02 13:17:22 +08:00

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)
})
})