diff --git a/cli/src/commands/pingPeer.ts b/cli/src/commands/pingPeer.ts index 092774fd8c..c93b1ef1f0 100644 --- a/cli/src/commands/pingPeer.ts +++ b/cli/src/commands/pingPeer.ts @@ -32,7 +32,8 @@ ${chalk.bold('Usage:')} ${chalk.bold('Notes:')} Do not reinvent JWT + curl for peer handoffs. Prefer this command or MCP ping_peer / list_peers. - Resolves by id prefix (8 chars OK). Same hub token/namespace as this CLI. + Resolves by hub session id prefix (8 chars OK) or durable metadata.agentSessionId. + POST always targets the current hub row after resolve (hub ids rotate). Inactive sessions are resumed via POST /api/sessions/:id/resume, then messaged. When a user cites [title](/sessions/) or Copy-reference See session "…" (/sessions/) for context, pass that here. diff --git a/cli/src/modules/pingPeer/pingPeer.test.ts b/cli/src/modules/pingPeer/pingPeer.test.ts index a442b0cddb..6c44178e01 100644 --- a/cli/src/modules/pingPeer/pingPeer.test.ts +++ b/cli/src/modules/pingPeer/pingPeer.test.ts @@ -54,11 +54,80 @@ describe('resolveSessionByPrefix', () => { } catch (error) { expect(error).toBeInstanceOf(PingPeerError) expect((error as PingPeerError).code).toBe('ambiguous') + expect((error as PingPeerError).message).toMatch(/hub id prefix/) + expect((error as PingPeerError).message).toMatch(/name=/) } }) - it('refuses unknown prefixes', () => { + it('refuses unknown prefixes with actionable hints', () => { expect(() => resolveSessionByPrefix(sessions, 'zzzz')).toThrowError(/no session matching/) + try { + resolveSessionByPrefix(sessions, 'zzzz') + } catch (error) { + expect((error as PingPeerError).message).toMatch(/agentSessionId prefix/) + expect((error as PingPeerError).message).toMatch(/Sample:/) + } + }) + + it('resolves by exact agentSessionId (case insensitive)', () => { + const withAgent: PingPeerSessionSummary[] = [ + { + id: '11111111-1111-1111-1111-111111111111', + active: false, + updatedAt: 100, + metadata: { name: 'Stale row', agentSessionId: '8e1f4fd4-durable-agent' } + }, + { + id: '22222222-2222-2222-2222-222222222222', + active: true, + updatedAt: 200, + metadata: { name: 'Current row', agentSessionId: '8e1f4fd4-durable-agent' } + } + ] + expect(resolveSessionByPrefix(withAgent, '8e1f4fd4-durable-agent').id).toBe(withAgent[1]!.id) + expect(resolveSessionByPrefix(withAgent, '8E1F4FD4-DURABLE-AGENT').id).toBe(withAgent[1]!.id) + }) + + it('resolves by agentSessionId prefix and prefers active + newest', () => { + const withAgent: PingPeerSessionSummary[] = [ + { + id: 'aaaaaaaa-1111-1111-1111-111111111111', + active: false, + updatedAt: 50, + metadata: { name: 'Old', agentSessionId: 'peer-agent-aaaa-old' } + }, + { + id: 'bbbbbbbb-2222-2222-2222-222222222222', + active: true, + updatedAt: 100, + metadata: { name: 'Live', agentSessionId: 'peer-agent-bbbb-live' } + } + ] + expect(resolveSessionByPrefix(withAgent, 'peer-agent-bbbb').id).toBe(withAgent[1]!.id) + }) + + it('refuses ambiguous agentSessionId prefix ties at the same active/updatedAt tier', () => { + const withAgent: PingPeerSessionSummary[] = [ + { + id: 'aaaaaaaa-1111-1111-1111-111111111111', + active: true, + updatedAt: 100, + metadata: { name: 'A', agentSessionId: 'shared-prefix-1111' } + }, + { + id: 'bbbbbbbb-2222-2222-2222-222222222222', + active: true, + updatedAt: 100, + metadata: { name: 'B', agentSessionId: 'shared-prefix-2222' } + } + ] + expect(() => resolveSessionByPrefix(withAgent, 'shared-prefix')).toThrow(PingPeerError) + try { + resolveSessionByPrefix(withAgent, 'shared-prefix') + } catch (error) { + expect((error as PingPeerError).code).toBe('ambiguous') + expect((error as PingPeerError).message).toMatch(/agentSessionId prefix/) + } }) }) @@ -671,4 +740,67 @@ describe('listSessions query params', () => { expect(result.sessionId).toBe(sessionId) expect(pingParams[0]).toBeUndefined() }) + + it('resolves by agentSessionId prefix and posts to the current hub id', async () => { + const hubId = 'cccccccc-3333-3333-3333-333333333333' + const agentSessionId = '05d9f0f2-durable-cursor-thread' + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/sessions/${hubId}/messages`)) { + expect(body).toEqual({ text: 'via agentSessionId' }) + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions') && !url.includes(hubId)) { + return { + status: 200, + data: { + sessions: [{ + id: hubId, + active: true, + metadata: { + name: 'Orchestrator', + flavor: 'cursor', + agentSessionId + } + }] + } + } + } + if (url.endsWith(`/api/sessions/${hubId}`)) { + return { + status: 200, + data: { + session: { + id: hubId, + active: true, + metadata: { + name: 'Orchestrator', + flavor: 'cursor', + agentSessionId + } + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await pingPeer({ + sessionIdPrefix: '05d9f0f2', + message: 'via agentSessionId', + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(result.sessionId).toBe(hubId) + expect(result.name).toBe('Orchestrator') + }) }) diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index 1a554d917e..fabd1fa85b 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -45,6 +45,7 @@ export type PingPeerSessionSummary = { path?: string | null lifecycleState?: string | null piSessionId?: string + agentSessionId?: string summary?: { text?: string } | null } | null } @@ -163,6 +164,82 @@ function authHeaders(jwt: string): Record { }) } +function readAgentSessionId(session: PingPeerSessionSummary): string { + const raw = session.metadata?.agentSessionId + return typeof raw === 'string' ? raw.trim() : '' +} + +function sortPeersActiveThenUpdated(sessions: PingPeerSessionSummary[]): PingPeerSessionSummary[] { + return [...sessions].sort((a, b) => { + const aActive = a.active ? 0 : 1 + const bActive = b.active ? 0 : 1 + if (aActive !== bActive) { + return aActive - bActive + } + return (b.updatedAt ?? 0) - (a.updatedAt ?? 0) + }) +} + +function formatPeerResolveHint(session: PingPeerSessionSummary): string { + const name = session.metadata?.name?.trim() || resolvePeerSessionLabel(session) + const agentSessionId = readAgentSessionId(session) + const hubShort = session.id.slice(0, 8) + const agentPart = agentSessionId + ? ` agentSessionId=${agentSessionId.length > 12 ? `${agentSessionId.slice(0, 12)}…` : agentSessionId}` + : '' + return `name="${name}" hubId=${hubShort}…${agentPart}` +} + +function throwPeerResolveNotFound(trimmed: string, sessions: PingPeerSessionSummary[]): never { + const sample = sessions.slice(0, 3).map(formatPeerResolveHint).join('; ') + const hint = sample ? ` Sample: ${sample}.` : '' + throw new PingPeerError( + 'not_found', + `no session matching '${trimmed}'. Try a longer hub id prefix or agentSessionId prefix.${hint}` + ) +} + +function throwPeerResolveAmbiguous( + trimmed: string, + matches: PingPeerSessionSummary[], + kind: string +): never { + const sample = matches.slice(0, 5).map(formatPeerResolveHint).join('; ') + throw new PingPeerError( + 'ambiguous', + `'${trimmed}' matches ${matches.length} sessions by ${kind} (${sample}${matches.length > 5 ? '; …' : ''}); use a longer prefix` + ) +} + +function pickBestPeerMatch( + matches: PingPeerSessionSummary[], + trimmed: string, + kind: string +): PingPeerSessionSummary { + if (matches.length === 0) { + throw new PingPeerError('not_found', `no session matching '${trimmed}'`) + } + if (matches.length === 1) { + return matches[0]! + } + const sorted = sortPeersActiveThenUpdated(matches) + const top = sorted[0]! + const tied = sorted.filter( + (session) => + session.active === top.active + && (session.updatedAt ?? 0) === (top.updatedAt ?? 0) + ) + if (tied.length > 1) { + throwPeerResolveAmbiguous(trimmed, tied, kind) + } + return top +} + +/** + * Resolve a peer session by hub id prefix or durable `metadata.agentSessionId`. + * Hub ids are ephemeral; agentSessionId survives hub-row churn (#1203). + * POST/resume always use the resolved hub `id` (current row). + */ export function resolveSessionByPrefix( sessions: PingPeerSessionSummary[], prefix: string @@ -172,23 +249,43 @@ export function resolveSessionByPrefix( throw new PingPeerError('bad_args', 'session id prefix is required') } - const exact = sessions.filter((session) => session.id === trimmed) - if (exact.length === 1) { - return exact[0]! + const exactHub = sessions.filter((session) => session.id === trimmed) + if (exactHub.length === 1) { + return exactHub[0]! } - const matches = sessions.filter((session) => session.id.startsWith(trimmed)) - if (matches.length === 0) { - throw new PingPeerError('not_found', `no session matching prefix '${trimmed}'`) + const hubPrefixMatches = sessions.filter((session) => session.id.startsWith(trimmed)) + if (hubPrefixMatches.length === 1) { + return hubPrefixMatches[0]! } - if (matches.length > 1) { - const sample = matches.slice(0, 5).map((session) => session.id.slice(0, 8)).join(', ') - throw new PingPeerError( - 'ambiguous', - `prefix '${trimmed}' matches ${matches.length} sessions (${sample}${matches.length > 5 ? ', ...' : ''}); use a longer prefix` - ) + if (hubPrefixMatches.length > 1) { + throwPeerResolveAmbiguous(trimmed, hubPrefixMatches, 'hub id prefix') } - return matches[0]! + + const needleLower = trimmed.toLowerCase() + const agentExact = sessions.filter( + (session) => readAgentSessionId(session).toLowerCase() === needleLower + ) + if (agentExact.length > 0) { + return pickBestPeerMatch(agentExact, trimmed, 'agentSessionId') + } + + const agentPrefixMatches = sessions.filter((session) => + readAgentSessionId(session).toLowerCase().startsWith(needleLower) + ) + if (agentPrefixMatches.length > 0) { + return pickBestPeerMatch(agentPrefixMatches, trimmed, 'agentSessionId prefix') + } + + // Overseer `resolve` substring parity for agentSessionId when prefix is too short. + const agentContainsMatches = sessions.filter((session) => + readAgentSessionId(session).toLowerCase().includes(needleLower) + ) + if (agentContainsMatches.length > 0) { + return pickBestPeerMatch(agentContainsMatches, trimmed, 'agentSessionId') + } + + throwPeerResolveNotFound(trimmed, sessions) } async function listSessions( diff --git a/shared/src/sessionCitation.ts b/shared/src/sessionCitation.ts index bca4efe5e1..02a10f5d3d 100644 --- a/shared/src/sessionCitation.ts +++ b/shared/src/sessionCitation.ts @@ -26,15 +26,16 @@ export const INSPECT_PEER_TOOL_DESCRIPTION = /** MCP `ping_peer` tool description (same citation forms as inspect_peer). */ export const PING_PEER_TOOL_DESCRIPTION = - 'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs on the same hub/namespace. ' + + 'Send a message to another HAPI session (peer handoff / nudge). Resolves by hub session id prefix or durable metadata.agentSessionId, resumes if inactive, then POSTs on the same hub/namespace. ' + 'When the user cites a peer via [title](/sessions/), Copy-reference prose See session "…" (/sessions/) for context, or a bare /sessions/, ' + 'extract and pass it as sessionIdPrefix. /sessions/ is a hub path - do NOT search the local filesystem for it. ' + 'Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat.' /** Zod `.describe` for sessionIdPrefix on inspect_peer / ping_peer. */ export const SESSION_ID_PREFIX_PARAM_DESCRIPTION = - 'Target HAPI session id or unique id prefix (another session - not this chat). ' + - 'Prefer the full UUID from [title](/sessions/) or Copy-reference See session "…" (/sessions/) for context.' + 'Target HAPI session id, hub id prefix, or durable agentSessionId prefix (another session - not this chat). ' + + 'Prefer the full UUID from [title](/sessions/) or Copy-reference See session "…" (/sessions/) for context; ' + + 'agentSessionId survives hub-row churn when the cited hub id is stale.' /** * Hub session ids have no dots. Reject dotted tails so source paths like