From de3d95ff7a6e3238931de5cedf9d9a0fef714347 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:53:50 +0000 Subject: [PATCH 001/142] feat(peer): attribute ping_peer deliveries with trusted provenance Stop ghost user messages from peer nudges (#1203 / A2A Layer 0.1): CLI stamps X-Hapi-Peer-Delivery from HAPI_SESSION_ID, hub stores sentFrom=peer with store-validated source session, and web badges the source link. Co-authored-by: Cursor --- cli/src/api/apiSession.test.ts | 4 +- cli/src/api/types.ts | 6 + cli/src/modules/pingPeer/pingPeer.test.ts | 100 ++++++++++- cli/src/modules/pingPeer/pingPeer.ts | 40 ++++- hub/src/sync/messageService.test.ts | 72 ++++++++ hub/src/sync/messageService.ts | 19 ++- hub/src/sync/syncEngine.ts | 6 +- hub/src/web/routes/messages.test.ts | 155 +++++++++++++++++- hub/src/web/routes/messages.ts | 49 +++++- shared/src/apiTypes.test.ts | 33 ++++ shared/src/apiTypes.ts | 27 ++- web/src/chat/peerDelivery.test.ts | 23 +++ web/src/chat/peerDelivery.ts | 26 +++ .../AssistantChat/messages/UserMessage.tsx | 46 ++++++ web/src/lib/assistant-runtime.ts | 21 ++- web/src/lib/locales/en.ts | 3 + web/src/lib/locales/zh-CN.ts | 3 + 17 files changed, 609 insertions(+), 24 deletions(-) create mode 100644 web/src/chat/peerDelivery.test.ts create mode 100644 web/src/chat/peerDelivery.ts diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts index 4a510ffdea..35c129a78a 100644 --- a/cli/src/api/apiSession.test.ts +++ b/cli/src/api/apiSession.test.ts @@ -138,7 +138,7 @@ function triggerIncomingUserMessage( id?: string seq: number text: string - sentFrom: 'cli' | 'webapp' | 'telegram-bot' + sentFrom: 'cli' | 'webapp' | 'telegram-bot' | 'peer' } ): void { socket.trigger('update', { @@ -546,7 +546,7 @@ describe('ApiSessionClient incoming user messages', () => { client.close() }) - it.each(['webapp', 'telegram-bot'] as const)( + it.each(['webapp', 'telegram-bot', 'peer'] as const)( 'delivers %s-originated user messages', (sentFrom) => { socketHarness.sockets.length = 0 diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 4a5af91e97..b573c7ec00 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -74,6 +74,12 @@ export const MessageMetaSchema = z.object({ // Queue remains the default for existing clients. Pi-aware callers may // explicitly request native steering while a turn is streaming. deliveryMode: z.enum(['queue', 'steer']).optional(), + // Peer delivery provenance (#1203). Additive; agents may reply via ping_peer + // targeting peer.sourceSessionId. Not a Layer 1 work-contract. + peer: z.object({ + sourceSessionId: z.string().optional(), + sourceName: z.string().optional() + }).optional(), fallbackModel: z.string().nullable().optional(), customSystemPrompt: z.string().nullable().optional(), appendSystemPrompt: z.string().nullable().optional(), diff --git a/cli/src/modules/pingPeer/pingPeer.test.ts b/cli/src/modules/pingPeer/pingPeer.test.ts index a442b0cddb..24cddc0fd1 100644 --- a/cli/src/modules/pingPeer/pingPeer.test.ts +++ b/cli/src/modules/pingPeer/pingPeer.test.ts @@ -1,8 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + HAPI_PEER_DELIVERY_HEADER, + HAPI_PEER_DELIVERY_HEADER_VALUE +} from '@hapi/protocol' +import { HAPI_SESSION_ID_ENV } from '@/agent/hapiSessionEnv' import { PingPeerError, exitCodeForPingPeerError, pingPeer, + resolvePeerDeliveryProvenance, resolveSessionByPrefix, type PingPeerSessionSummary } from './pingPeer' @@ -13,15 +19,23 @@ type MockResponse = { } function createHttpMock(handlers: { - post?: (url: string, body?: unknown) => MockResponse | Promise + post?: ( + url: string, + body?: unknown, + config?: { headers?: Record } + ) => MockResponse | Promise get?: (url: string, config?: { params?: Record }) => MockResponse | Promise }) { return { - post: vi.fn(async (url: string, body?: unknown) => { + post: vi.fn(async ( + url: string, + body?: unknown, + config?: { headers?: Record } + ) => { if (!handlers.post) { throw new Error(`unexpected POST ${url}`) } - return handlers.post(url, body) + return handlers.post(url, body, config) }), get: vi.fn(async (url: string, config?: { params?: Record }) => { if (!handlers.get) { @@ -62,6 +76,20 @@ describe('resolveSessionByPrefix', () => { }) }) +describe('resolvePeerDeliveryProvenance', () => { + it('reads trusted env only (never free-form args)', () => { + expect(resolvePeerDeliveryProvenance({})).toEqual({}) + expect(resolvePeerDeliveryProvenance({ + [HAPI_SESSION_ID_ENV]: '6212dae5-8a60-4284-b7a5-c09aa3571ce4' + })).toEqual({ + sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4' + }) + expect(resolvePeerDeliveryProvenance({ + [HAPI_SESSION_ID_ENV]: 'not-a-uuid' + })).toEqual({}) + }) +}) + describe('pingPeer', () => { let nowMs: number let sleepCalls: number[] @@ -69,18 +97,21 @@ describe('pingPeer', () => { beforeEach(() => { nowMs = 1_000_000 sleepCalls = [] + delete process.env[HAPI_SESSION_ID_ENV] }) it('sends to an already-active session without resume', async () => { const sessionId = '05d9f0f2-9273-4137-933c-07459a1146a2' const http = createHttpMock({ - post: (url, body) => { + post: (url, body, config) => { if (url.endsWith('/api/auth')) { expect(body).toEqual({ accessToken: 'tok' }) return { status: 200, data: { token: 'jwt' } } } if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { - expect(body).toEqual({ text: 'hello peer' }) + expect(body).toEqual({ text: 'hello peer', peer: {} }) + expect(config?.headers?.[HAPI_PEER_DELIVERY_HEADER]) + .toBe(HAPI_PEER_DELIVERY_HEADER_VALUE) return { status: 200, data: { ok: true } } } throw new Error(`unexpected POST ${url}`) @@ -671,4 +702,63 @@ describe('listSessions query params', () => { expect(result.sessionId).toBe(sessionId) expect(pingParams[0]).toBeUndefined() }) + + it('stamps peer provenance from HAPI_SESSION_ID when inside a wrapped session', async () => { + const targetId = '05d9f0f2-9273-4137-933c-07459a1146a2' + const sourceId = '6212dae5-8a60-4284-b7a5-c09aa3571ce4' + process.env[HAPI_SESSION_ID_ENV] = sourceId + + const http = createHttpMock({ + post: (url, body, config) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/sessions/${targetId}/messages`)) { + expect(body).toEqual({ + text: 'handoff', + peer: { sourceSessionId: sourceId } + }) + expect(config?.headers?.[HAPI_PEER_DELIVERY_HEADER]) + .toBe(HAPI_PEER_DELIVERY_HEADER_VALUE) + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions')) { + return { + status: 200, + data: { + sessions: [{ + id: targetId, + active: true, + metadata: { name: 'Target' } + }] + } + } + } + if (url.endsWith(`/api/sessions/${targetId}`)) { + return { + status: 200, + data: { + session: { + id: targetId, + active: true, + metadata: { name: 'Target' } + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await pingPeer({ + sessionIdPrefix: '05d9f0f2', + message: 'handoff', + accessToken: 'tok', + apiUrl: 'http://127.0.0.1:3006', + http: http as never + }) + }) }) diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index 1a554d917e..f0a8e03512 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -9,11 +9,36 @@ */ import axios, { type AxiosInstance } from 'axios' -import { extractAssistantPlainText, isObject } from '@hapi/protocol' +import { + extractAssistantPlainText, + HAPI_PEER_DELIVERY_HEADER, + HAPI_PEER_DELIVERY_HEADER_VALUE, + isObject, + type PeerDeliveryMeta +} from '@hapi/protocol' import { normalizeSessionIdPrefix } from '@hapi/protocol/sessionCitation' import { configuration } from '@/configuration' import { getAuthToken } from '@/api/auth' import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' +import { HAPI_SESSION_ID_ENV } from '@/agent/hapiSessionEnv' + +const SESSION_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * Trusted peer provenance for delivery. Source id comes only from process env + * (`HAPI_SESSION_ID`) - never from MCP/CLI free-form args (#1203 kill criterion). + * Display name is filled hub-side from the session store when the id is valid. + */ +export function resolvePeerDeliveryProvenance( + env: NodeJS.ProcessEnv = process.env +): PeerDeliveryMeta { + const rawId = env[HAPI_SESSION_ID_ENV]?.trim() ?? '' + if (rawId && SESSION_ID_RE.test(rawId)) { + return { sourceSessionId: rawId } + } + return {} +} export type PingPeerErrorCode = | 'bad_args' @@ -345,13 +370,17 @@ async function sendMessage( jwt: string, sessionId: string, message: string, - http: AxiosInstance + http: AxiosInstance, + peer: PeerDeliveryMeta = {} ): Promise { const response = await http.post( `${apiUrl}/api/sessions/${encodeURIComponent(sessionId)}/messages`, - { text: message }, + { text: message, peer }, { - headers: authHeaders(jwt), + headers: { + ...authHeaders(jwt), + [HAPI_PEER_DELIVERY_HEADER]: HAPI_PEER_DELIVERY_HEADER_VALUE + }, timeout: 30_000, validateStatus: () => true } @@ -515,8 +544,9 @@ export async function pingPeer(options: PingPeerOptions): Promise { }) }) +describe('MessageService.sendMessage peer provenance', () => { + it('persists sentFrom peer and optional source session meta', async () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'peer-provenance', + { path: '/tmp/peer-provenance', host: 'localhost', flavor: 'cursor' }, + null, + 'default' + ) + const service = new MessageService(store, { + of: () => ({ + to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }), + adapter: { rooms: { get: () => undefined } } + }) + } as unknown as Server, makePublisher() as any) + + await service.sendMessage(session.id, { + text: 'peer nudge', + localId: 'peer-local', + sentFrom: 'peer', + peer: { + sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4', + sourceName: 'Orchestrator' + } + }) + + const stored = store.messages.getUninvokedLocalMessages(session.id) + expect(stored).toHaveLength(1) + expect(stored[0]?.content).toMatchObject({ + role: 'user', + meta: { + sentFrom: 'peer', + peer: { + sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4', + sourceName: 'Orchestrator' + } + } + }) + }) + + it('never stores peer meta when sentFrom is webapp', async () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'peer-forge-guard', + { path: '/tmp/peer-forge-guard', host: 'localhost', flavor: 'cursor' }, + null, + 'default' + ) + const service = new MessageService(store, { + of: () => ({ + to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }), + adapter: { rooms: { get: () => undefined } } + }) + } as unknown as Server, makePublisher() as any) + + await service.sendMessage(session.id, { + text: 'web typed', + localId: 'web-local', + sentFrom: 'webapp', + peer: { + sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4' + } + }) + + const stored = store.messages.getUninvokedLocalMessages(session.id) + expect(stored[0]?.content).toMatchObject({ + meta: { sentFrom: 'webapp' } + }) + expect((stored[0]?.content as { meta?: { peer?: unknown } }).meta?.peer).toBeUndefined() + }) +}) + describe('MessageService.sendMessage deliveryMode', () => { function makeTrackingIo(): { io: Server; cliEmitted: unknown[] } { const cliEmitted: unknown[] = [] diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index b7ff690c4c..1b5672282c 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -605,7 +605,11 @@ export class MessageService { text: string localId?: string | null attachments?: AttachmentMetadata[] - sentFrom?: 'telegram-bot' | 'webapp' + sentFrom?: 'telegram-bot' | 'webapp' | 'peer' + peer?: { + sourceSessionId?: string + sourceName?: string + } scheduledAt?: number | null deliveryMode?: MessageDeliveryMode } @@ -628,6 +632,16 @@ export class MessageService { payload.deliveryMode, payload.scheduledAt ) + const peer = sentFrom === 'peer' && payload.peer + ? { + ...(payload.peer.sourceSessionId + ? { sourceSessionId: payload.peer.sourceSessionId } + : {}), + ...(payload.peer.sourceName + ? { sourceName: payload.peer.sourceName } + : {}) + } + : undefined const content = { role: 'user', @@ -638,7 +652,8 @@ export class MessageService { }, meta: { sentFrom, - deliveryMode + deliveryMode, + ...(peer ? { peer } : {}) } } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 92f38fb91d..8e275eceb1 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -960,7 +960,11 @@ async uploadScratchlistAttachment( path: string previewUrl?: string }> - sentFrom?: 'telegram-bot' | 'webapp' + sentFrom?: 'telegram-bot' | 'webapp' | 'peer' + peer?: { + sourceSessionId?: string + sourceName?: string + } scheduledAt?: number | null deliveryMode?: MessageDeliveryMode } diff --git a/hub/src/web/routes/messages.test.ts b/hub/src/web/routes/messages.test.ts index 3876d3f855..4a6389bc0c 100644 --- a/hub/src/web/routes/messages.test.ts +++ b/hub/src/web/routes/messages.test.ts @@ -28,6 +28,8 @@ function createApp(opts: { queuedLocalIds: string[] invokedLocalMessages: Array<{ localId: string; invokedAt: number }> } + /** Optional peer source sessions visible to resolveTrustedPeerMeta. */ + peerSessions?: Record }) { const sentMessages: Array<{ sessionId: string; payload: unknown }> = [] const queuedStateCalls: Array<{ sessionId: string; localIds: string[] }> = [] @@ -59,13 +61,27 @@ function createApp(opts: { hasMore: false } })) + const peerSessions = opts.peerSessions ?? {} const engine = { - resolveSessionAccess: () => ({ - ok: true, - sessionId: 'session-1', - session: { id: 'session-1', active: opts.active !== false } - }), + resolveSessionAccess: (sessionId: string, _namespace: string) => { + if (sessionId === 'session-1') { + return { + ok: true as const, + sessionId: 'session-1', + session: { id: 'session-1', active: opts.active !== false, metadata: { name: 'Target' } } + } + } + const peer = peerSessions[sessionId] + if (peer) { + return { + ok: true as const, + sessionId, + session: { id: sessionId, active: true, metadata: { name: peer.name } } + } + } + return { ok: false as const, reason: 'not-found' as const } + }, sendMessage, getQueuedState, cancelQueuedMessage: async () => ({ status: 'cancelled' }), @@ -259,6 +275,134 @@ describe('POST /api/sessions/:id/messages — #2 scheduledAt upper bound', () => }) }) +describe('POST /api/sessions/:id/messages — peer provenance (#1203)', () => { + it('marks delivery as peer when X-Hapi-Peer-Delivery is set', async () => { + const sourceId = '6212dae5-8a60-4284-b7a5-c09aa3571ce4' + const { app, sentMessages } = createApp({ + peerSessions: { [sourceId]: { name: 'Orchestrator' } } + }) + + const response = await app.request('/api/sessions/session-1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-hapi-peer-delivery': '1' + }, + body: JSON.stringify({ + text: 'from peer', + peer: { sourceSessionId: sourceId, sourceName: 'client-forged-name' } + }) + }) + + expect(response.status).toBe(200) + expect(sentMessages).toEqual([{ + sessionId: 'session-1', + payload: { + text: 'from peer', + localId: undefined, + attachments: undefined, + sentFrom: 'peer', + // Hub fills name from store; client sourceName is ignored. + peer: { sourceSessionId: sourceId, sourceName: 'Orchestrator' }, + scheduledAt: undefined, + deliveryMode: undefined + } + }]) + }) + + it('ignores forged peer body fields without the delivery header (stays webapp)', async () => { + const sourceId = '6212dae5-8a60-4284-b7a5-c09aa3571ce4' + const { app, sentMessages } = createApp({ + peerSessions: { [sourceId]: { name: 'Orchestrator' } } + }) + + const response = await app.request('/api/sessions/session-1/messages', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + text: 'ghost', + peer: { + sourceSessionId: sourceId, + sourceName: 'forged' + }, + sourceSessionId: sourceId, + sentFrom: 'peer' + }) + }) + + expect(response.status).toBe(200) + expect(sentMessages).toEqual([{ + sessionId: 'session-1', + payload: { + text: 'ghost', + localId: undefined, + attachments: undefined, + sentFrom: 'webapp', + peer: undefined, + scheduledAt: undefined, + deliveryMode: undefined + } + }]) + }) + + it('marks outside-session peer delivery without inventing a source id', async () => { + const { app, sentMessages } = createApp({}) + + const response = await app.request('/api/sessions/session-1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-hapi-peer-delivery': '1' + }, + body: JSON.stringify({ text: 'cli ping', peer: {} }) + }) + + expect(response.status).toBe(200) + expect(sentMessages).toEqual([{ + sessionId: 'session-1', + payload: { + text: 'cli ping', + localId: undefined, + attachments: undefined, + sentFrom: 'peer', + peer: {}, + scheduledAt: undefined, + deliveryMode: undefined + } + }]) + }) + + it('drops sourceSessionId that is not in the caller namespace', async () => { + const { app, sentMessages } = createApp({}) + + const response = await app.request('/api/sessions/session-1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-hapi-peer-delivery': '1' + }, + body: JSON.stringify({ + text: 'forged source', + peer: { sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4' } + }) + }) + + expect(response.status).toBe(200) + expect(sentMessages).toEqual([{ + sessionId: 'session-1', + payload: { + text: 'forged source', + localId: undefined, + attachments: undefined, + sentFrom: 'peer', + peer: {}, + scheduledAt: undefined, + deliveryMode: undefined + } + }]) + }) +}) + describe('POST /api/sessions/:id/messages — deliveryMode', () => { it('forwards an immediate steer intent to the hub', async () => { const { app, sentMessages } = createApp({}) @@ -277,6 +421,7 @@ describe('POST /api/sessions/:id/messages — deliveryMode', () => { localId: 'local-steer', attachments: undefined, sentFrom: 'webapp', + peer: undefined, scheduledAt: undefined, deliveryMode: 'steer' } diff --git a/hub/src/web/routes/messages.ts b/hub/src/web/routes/messages.ts index b4eb79b378..39e3d549b7 100644 --- a/hub/src/web/routes/messages.ts +++ b/hub/src/web/routes/messages.ts @@ -1,9 +1,46 @@ import { Hono } from 'hono' -import { MessagesQuerySchema, QueuedStateRequestSchema, SendMessageRequestSchema } from '@hapi/protocol' +import { + HAPI_PEER_DELIVERY_HEADER, + HAPI_PEER_DELIVERY_HEADER_VALUE, + MessagesQuerySchema, + QueuedStateRequestSchema, + SendMessageRequestSchema, + type PeerDeliveryMeta +} from '@hapi/protocol' import type { SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireSessionFromParam, requireSyncEngine } from './guards' +function isPeerDeliveryRequest(c: { req: { header: (name: string) => string | undefined } }): boolean { + const raw = c.req.header(HAPI_PEER_DELIVERY_HEADER) + return (raw?.trim().toLowerCase() ?? '') === HAPI_PEER_DELIVERY_HEADER_VALUE +} + +/** + * Keep sentFrom=peer, but only persist a sourceSessionId that exists in this + * namespace. Fill sourceName from hub metadata (ignore client-supplied name). + */ +export function resolveTrustedPeerMeta( + engine: SyncEngine, + namespace: string, + claimed: PeerDeliveryMeta | undefined +): PeerDeliveryMeta { + const claimedId = claimed?.sourceSessionId?.trim() + if (!claimedId) { + return {} + } + const access = engine.resolveSessionAccess(claimedId, namespace) + if (!access.ok) { + return {} + } + const meta = access.session.metadata as { name?: unknown } | null | undefined + const sourceName = typeof meta?.name === 'string' ? meta.name.trim() : '' + return { + sourceSessionId: access.sessionId, + ...(sourceName ? { sourceName: sourceName.slice(0, 255) } : {}) + } +} + export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -108,11 +145,19 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Message requires text or attachments' }, 400) } + // Peer provenance is header-gated (#1203). Body `peer` without the + // delivery header is ignored so the normal web send path cannot label + // operator keystrokes as peer. + const peerDelivery = isPeerDeliveryRequest(c) + const peer = peerDelivery + ? resolveTrustedPeerMeta(engine, c.get('namespace'), parsed.data.peer) + : undefined await engine.sendMessage(sessionId, { text: parsed.data.text, localId: parsed.data.localId, attachments: parsed.data.attachments, - sentFrom: 'webapp', + sentFrom: peerDelivery ? 'peer' : 'webapp', + peer, scheduledAt: parsed.data.scheduledAt, deliveryMode: parsed.data.deliveryMode }) diff --git a/shared/src/apiTypes.test.ts b/shared/src/apiTypes.test.ts index 95eaca2e56..5eb4e8366b 100644 --- a/shared/src/apiTypes.test.ts +++ b/shared/src/apiTypes.test.ts @@ -145,3 +145,36 @@ describe('SendMessageRequestSchema deliveryMode', () => { } }) }) + +describe('SendMessageRequestSchema peer provenance', () => { + it('accepts empty peer object and optional source fields', () => { + expect(SendMessageRequestSchema.parse({ text: 'nudge', peer: {} }).peer).toEqual({}) + expect(SendMessageRequestSchema.parse({ + text: 'nudge', + peer: { + sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4', + sourceName: 'meta - PR watcher' + } + }).peer).toEqual({ + sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4', + sourceName: 'meta - PR watcher' + }) + }) + + it('strips top-level forge fields and keeps peer delivery fail-open on wire', () => { + // Invalid UUID-shaped ids are accepted on the wire; hub drops unknown ids. + expect(SendMessageRequestSchema.safeParse({ + text: 'nudge', + peer: { sourceSessionId: 'not-a-uuid-but-nonzero' } + }).success).toBe(true) + + const forgedInput = { + text: 'nudge', + sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4', + sentFrom: 'peer' + } + const forged = SendMessageRequestSchema.parse(forgedInput) + expect(forged).toEqual({ text: 'nudge' }) + expect('peer' in forged).toBe(false) + }) +}) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index ca90e63527..f624ca962f 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -496,12 +496,37 @@ export type MessagesQuery = z.infer export const MessageDeliveryModeSchema = z.enum(['queue', 'steer']) export type MessageDeliveryMode = z.infer +/** + * Peer-delivery provenance for `ping_peer` / `hapi ping-peer` (A2A Layer 0.1 / #1203). + * + * Wire shape (request): optional `sourceSessionId` hint from the CLI env. + * Hub only honors this when {@link HAPI_PEER_DELIVERY_HEADER} is present; without + * the header, `peer` is ignored and the row stays `sentFrom: webapp` (stops the + * normal web composer path from accidentally labeling operator keystrokes as peer). + * + * Trust note: the header is not a cryptographic authenticity bound - any holder of + * the namespace JWT can set it. Authoritative source id is still never an MCP/tool + * argument; the hub additionally drops ids that are not in the caller's namespace + * and fills `sourceName` from its own session store (client-supplied names ignored). + */ +export const PeerDeliveryMetaSchema = z.object({ + sourceSessionId: z.string().trim().min(1).max(128).optional(), + // Accepted for forward-compat but ignored by the hub (name is store-derived). + sourceName: z.string().trim().min(1).max(255).optional() +}) +export type PeerDeliveryMeta = z.infer + +/** Lower-case header name; HTTP headers are case-insensitive. */ +export const HAPI_PEER_DELIVERY_HEADER = 'x-hapi-peer-delivery' +export const HAPI_PEER_DELIVERY_HEADER_VALUE = '1' + export const SendMessageRequestSchema = z.object({ text: z.string(), localId: z.string().min(1).optional(), attachments: z.array(AttachmentMetadataSchema).optional(), scheduledAt: z.number().int().positive().nullable().optional(), - deliveryMode: MessageDeliveryModeSchema.optional() + deliveryMode: MessageDeliveryModeSchema.optional(), + peer: PeerDeliveryMetaSchema.optional() }).refine( (data) => data.scheduledAt == null || typeof data.localId === 'string', { message: 'scheduledAt requires localId', path: ['localId'] } diff --git a/web/src/chat/peerDelivery.test.ts b/web/src/chat/peerDelivery.test.ts new file mode 100644 index 0000000000..96208b56d0 --- /dev/null +++ b/web/src/chat/peerDelivery.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { getPeerDeliveryInfo, isPeerDeliveryMeta } from './peerDelivery' + +describe('peerDelivery', () => { + it('detects peer sentFrom and extracts optional source fields', () => { + expect(isPeerDeliveryMeta({ sentFrom: 'webapp' })).toBe(false) + expect(isPeerDeliveryMeta({ sentFrom: 'peer' })).toBe(true) + expect(getPeerDeliveryInfo({ + sentFrom: 'peer', + peer: { + sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4', + sourceName: 'Orchestrator' + } + })).toEqual({ + sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4', + sourceName: 'Orchestrator' + }) + expect(getPeerDeliveryInfo({ sentFrom: 'peer', peer: {} })).toEqual({ + sourceSessionId: undefined, + sourceName: undefined + }) + }) +}) diff --git a/web/src/chat/peerDelivery.ts b/web/src/chat/peerDelivery.ts new file mode 100644 index 0000000000..b127372930 --- /dev/null +++ b/web/src/chat/peerDelivery.ts @@ -0,0 +1,26 @@ +export type PeerDeliveryInfo = { + sourceSessionId?: string + sourceName?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** True when message meta marks peer/CLI delivery (#1203). */ +export function isPeerDeliveryMeta(meta: unknown): boolean { + if (!isRecord(meta)) return false + return meta.sentFrom === 'peer' +} + +export function getPeerDeliveryInfo(meta: unknown): PeerDeliveryInfo | null { + if (!isPeerDeliveryMeta(meta) || !isRecord(meta)) return null + const peer = isRecord(meta.peer) ? meta.peer : null + const sourceSessionId = typeof peer?.sourceSessionId === 'string' && peer.sourceSessionId.trim() + ? peer.sourceSessionId.trim() + : undefined + const sourceName = typeof peer?.sourceName === 'string' && peer.sourceName.trim() + ? peer.sourceName.trim() + : undefined + return { sourceSessionId, sourceName } +} diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index aa3df25a02..0cdfa07039 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -1,4 +1,5 @@ import { MessagePrimitive, useAuiState, type TextMessagePart } from '@assistant-ui/react' +import { useNavigate } from '@tanstack/react-router' import { useHappyChatContext } from '@/components/AssistantChat/context' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator' @@ -7,9 +8,12 @@ import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } fr import { CliOutputBlock } from '@/components/CliOutputBlock' import { getConversationMessageAnchorId } from '@/chat/outline' import { MessageActions } from '@/components/AssistantChat/messages/MessageActions' +import { useTranslation } from '@/lib/use-translation' export function HappyUserMessage() { const ctx = useHappyChatContext() + const navigate = useNavigate() + const { t } = useTranslation() const role = useAuiState((s) => s.message.role) const messageId = useAuiState((s) => s.message.id) const elementId = getConversationMessageAnchorId(messageId) @@ -32,6 +36,23 @@ export function HappyUserMessage() { const custom = s.message.metadata.custom as Partial | undefined return custom?.attachments }) + const isPeerDelivery = useAuiState((s) => { + if (s.message.role !== 'user') return false + const custom = s.message.metadata.custom as Partial | undefined + return custom?.sentFrom === 'peer' + }) + const peerSourceId = useAuiState((s) => { + if (s.message.role !== 'user') return null + const custom = s.message.metadata.custom as Partial | undefined + const id = custom?.peer?.sourceSessionId + return typeof id === 'string' && id.trim() ? id.trim() : null + }) + const peerSourceName = useAuiState((s) => { + if (s.message.role !== 'user') return null + const custom = s.message.metadata.custom as Partial | undefined + const name = custom?.peer?.sourceName + return typeof name === 'string' && name.trim() ? name.trim() : null + }) const isCliOutput = useAuiState((s) => { const custom = s.message.metadata.custom as Partial | undefined return custom?.kind === 'cli-output' @@ -96,6 +117,31 @@ export function HappyUserMessage() { data-hapi-message-role="user" className="happy-message flex flex-col items-end scroll-mt-4" > + {isPeerDelivery ? ( +
+ {peerSourceId ? ( + + ) : ( + {t('message.peerFromUnknown')} + )} +
+ ) : null}
diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index 98c29e60a1..aba3ec46b9 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -16,6 +16,7 @@ import type { ToolGroupBlock, VisibleChatBlock } from '@/chat/toolGroups' import { visibleBlockRole } from '@/chat/toolGroups' import type { AttachmentMetadata, MessageStatus as HappyMessageStatus, Session } from '@/types/api' import { buildShareHiddenByMessageId } from '@/lib/shareTurnAvailability' +import { getPeerDeliveryInfo, isPeerDeliveryMeta } from '@/chat/peerDelivery' /** * Aggregated metadata for a multi-turn response group, surfaced on the @@ -44,6 +45,12 @@ export type HappyChatMessageMetadata = { usage?: UsageData model?: string | null review?: CodexReview + /** Peer delivery provenance from hub message meta (#1203). */ + sentFrom?: string + peer?: { + sourceSessionId?: string + sourceName?: string + } /** * Distinct turn count when this block carries an aggregated response * group footer. Single-turn blocks omit this field so the existing @@ -426,6 +433,16 @@ function toThreadMessageLike( timestamp: number ): ThreadMessageLike { if (block.kind === 'user-text') { + const peerInfo = getPeerDeliveryInfo(block.meta) + const sentFrom = isPeerDeliveryMeta(block.meta) + ? 'peer' + : undefined + const peer = peerInfo && (peerInfo.sourceSessionId || peerInfo.sourceName) + ? { + ...(peerInfo.sourceSessionId ? { sourceSessionId: peerInfo.sourceSessionId } : {}), + ...(peerInfo.sourceName ? { sourceName: peerInfo.sourceName } : {}) + } + : undefined return { role: 'user', id: threadMessageId, @@ -438,7 +455,9 @@ function toThreadMessageLike( localId: block.localId, originalText: block.originalText, attachments: block.attachments, - invokedAt: block.invokedAt + invokedAt: block.invokedAt, + ...(sentFrom ? { sentFrom } : {}), + ...(peer ? { peer } : {}) } satisfies HappyChatMessageMetadata } } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7f7117191c..98608889dd 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -11,6 +11,9 @@ export default { 'message.copy': 'Copy', 'message.copied': 'Copied', 'message.info': 'Message details', + 'message.peerFromSession': 'From peer session', + 'message.peerFromNamed': 'From peer session: {name}', + 'message.peerFromUnknown': 'From peer (unknown session)', 'message.fork': 'Fork', 'message.rewind': 'Rewind', 'message.fork.confirmTitle': 'Fork conversation', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 0b95581b11..30c019b0b5 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -11,6 +11,9 @@ export default { 'message.copy': '复制', 'message.copied': '已复制', 'message.info': '消息详情', + 'message.peerFromSession': '来自对等会话', + 'message.peerFromNamed': '来自对等会话:{name}', + 'message.peerFromUnknown': '来自对等会话(未知来源)', 'message.fork': 'Fork', 'message.rewind': 'Rewind', 'message.fork.confirmTitle': '分叉对话', From 979800d51c3e1ae1b5e70ca3ab05bd95b64c3bd1 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:58:57 +0000 Subject: [PATCH 002/142] feat(web): show peer delivery sender as @session mention chip Match rich-composer @mention chrome for who sent a peer nudge so provenance reads like an @ reference, not a separate prose badge. Co-authored-by: Cursor --- .../AssistantChat/RichComposerInput.tsx | 9 ++- .../messages/PeerSenderChip.test.tsx | 36 +++++++++++ .../AssistantChat/messages/PeerSenderChip.tsx | 60 +++++++++++++++++++ .../AssistantChat/messages/UserMessage.tsx | 38 +++--------- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/lib/sessionMentionChip.test.ts | 18 ++++++ web/src/lib/sessionMentionChip.ts | 11 ++++ 8 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 web/src/components/AssistantChat/messages/PeerSenderChip.test.tsx create mode 100644 web/src/components/AssistantChat/messages/PeerSenderChip.tsx create mode 100644 web/src/lib/sessionMentionChip.test.ts create mode 100644 web/src/lib/sessionMentionChip.ts diff --git a/web/src/components/AssistantChat/RichComposerInput.tsx b/web/src/components/AssistantChat/RichComposerInput.tsx index cc15aa83d4..044f2057a6 100644 --- a/web/src/components/AssistantChat/RichComposerInput.tsx +++ b/web/src/components/AssistantChat/RichComposerInput.tsx @@ -31,6 +31,10 @@ import { formatSessionMentionTooltip, type SessionMentionTooltipModel, } from '@/lib/sessionReference' +import { + SESSION_MENTION_CHIP_CLASSNAME, + formatSessionMentionChipLabel, +} from '@/lib/sessionMentionChip' import { SessionRowSummary } from '@/components/SessionRowSummary' import type { SessionSummary } from '@/types/api' @@ -96,9 +100,8 @@ function createMentionSpan( span.dataset.sessionId = id span.dataset.sessionTitle = title span.dataset.composerMention = 'session' - span.className = - 'mx-0.5 inline-flex max-w-[12rem] items-center truncate rounded-md bg-[var(--app-subtle-bg)] px-1.5 py-0.5 align-baseline text-[0.95em] font-medium text-[var(--app-link)]' - span.textContent = `@${title || id.slice(0, 8)}` + span.className = SESSION_MENTION_CHIP_CLASSNAME + span.textContent = formatSessionMentionChipLabel(title, id) const tip = resolveTooltip?.(id, title)?.model ?? formatSessionMentionTooltip(null, title, id) span.setAttribute('aria-label', tip.ariaLabel) diff --git a/web/src/components/AssistantChat/messages/PeerSenderChip.test.tsx b/web/src/components/AssistantChat/messages/PeerSenderChip.test.tsx new file mode 100644 index 0000000000..e09b79fab2 --- /dev/null +++ b/web/src/components/AssistantChat/messages/PeerSenderChip.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { PeerSenderChip } from './PeerSenderChip' + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), +})) + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +describe('PeerSenderChip', () => { + it('renders the same @title chip label as rich-composer mentions', () => { + render( + + ) + const chip = screen.getByRole('button', { name: /hapi-inline ownership/i }) + expect(chip).toHaveTextContent('@hapi-inline ownership') + expect(chip).toHaveAttribute('data-session-id', '3e387783-d48e-4a73-932a-90acebe91702') + expect(chip).toHaveAttribute('data-hapi-peer-delivery', 'true') + }) + + it('renders a non-link @peer chip when source is unknown', () => { + render() + expect(screen.getByText('message.peerUnknownChip')).toHaveAttribute( + 'data-hapi-peer-unknown', + 'true' + ) + }) +}) diff --git a/web/src/components/AssistantChat/messages/PeerSenderChip.tsx b/web/src/components/AssistantChat/messages/PeerSenderChip.tsx new file mode 100644 index 0000000000..53ce2c0c04 --- /dev/null +++ b/web/src/components/AssistantChat/messages/PeerSenderChip.tsx @@ -0,0 +1,60 @@ +import { useNavigate } from '@tanstack/react-router' +import { + SESSION_MENTION_CHIP_CLASSNAME, + formatSessionMentionChipLabel, +} from '@/lib/sessionMentionChip' +import { formatSessionMentionTooltip } from '@/lib/sessionReference' +import { useTranslation } from '@/lib/use-translation' +import { cn } from '@/lib/utils' + +export type PeerSenderChipProps = { + sourceSessionId?: string | null + sourceName?: string | null +} + +/** + * Peer-delivery sender identity — same `@title` chip chrome as rich-composer + * session mentions so "who sent this" matches @ referencing (#1203). + */ +export function PeerSenderChip({ sourceSessionId, sourceName }: PeerSenderChipProps) { + const navigate = useNavigate() + const { t } = useTranslation() + const id = sourceSessionId?.trim() || '' + const title = sourceName?.trim() || '' + + if (!id) { + return ( + + {t('message.peerUnknownChip')} + + ) + } + + const label = formatSessionMentionChipLabel(title, id) + const tip = formatSessionMentionTooltip(null, title, id) + + return ( + + ) +} diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index 0cdfa07039..7be1bfea86 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -1,19 +1,16 @@ import { MessagePrimitive, useAuiState, type TextMessagePart } from '@assistant-ui/react' -import { useNavigate } from '@tanstack/react-router' import { useHappyChatContext } from '@/components/AssistantChat/context' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator' import { MessageAttachments } from '@/components/AssistantChat/messages/MessageAttachments' import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble' +import { PeerSenderChip } from '@/components/AssistantChat/messages/PeerSenderChip' import { CliOutputBlock } from '@/components/CliOutputBlock' import { getConversationMessageAnchorId } from '@/chat/outline' import { MessageActions } from '@/components/AssistantChat/messages/MessageActions' -import { useTranslation } from '@/lib/use-translation' export function HappyUserMessage() { const ctx = useHappyChatContext() - const navigate = useNavigate() - const { t } = useTranslation() const role = useAuiState((s) => s.message.role) const messageId = useAuiState((s) => s.message.id) const elementId = getConversationMessageAnchorId(messageId) @@ -117,34 +114,17 @@ export function HappyUserMessage() { data-hapi-message-role="user" className="happy-message flex flex-col items-end scroll-mt-4" > - {isPeerDelivery ? ( -
- {peerSourceId ? ( - - ) : ( - {t('message.peerFromUnknown')} - )} -
- ) : null}
+ {isPeerDelivery ? ( +
+ +
+ ) : null} {hasText ? : null} {hasAttachments ? : null}
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 98608889dd..5e285fbd68 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -14,6 +14,7 @@ export default { 'message.peerFromSession': 'From peer session', 'message.peerFromNamed': 'From peer session: {name}', 'message.peerFromUnknown': 'From peer (unknown session)', + 'message.peerUnknownChip': '@peer', 'message.fork': 'Fork', 'message.rewind': 'Rewind', 'message.fork.confirmTitle': 'Fork conversation', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 30c019b0b5..f2dd764270 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -14,6 +14,7 @@ export default { 'message.peerFromSession': '来自对等会话', 'message.peerFromNamed': '来自对等会话:{name}', 'message.peerFromUnknown': '来自对等会话(未知来源)', + 'message.peerUnknownChip': '@peer', 'message.fork': 'Fork', 'message.rewind': 'Rewind', 'message.fork.confirmTitle': '分叉对话', diff --git a/web/src/lib/sessionMentionChip.test.ts b/web/src/lib/sessionMentionChip.test.ts new file mode 100644 index 0000000000..293c6efe45 --- /dev/null +++ b/web/src/lib/sessionMentionChip.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { formatSessionMentionChipLabel } from './sessionMentionChip' + +describe('formatSessionMentionChipLabel', () => { + it('matches rich-composer @title chip text', () => { + expect(formatSessionMentionChipLabel( + 'hapi-inline ownership', + '3e387783-d48e-4a73-932a-90acebe91702' + )).toBe('@hapi-inline ownership') + }) + + it('falls back to id prefix when title is empty', () => { + expect(formatSessionMentionChipLabel( + ' ', + '3e387783-d48e-4a73-932a-90acebe91702' + )).toBe('@3e387783') + }) +}) diff --git a/web/src/lib/sessionMentionChip.ts b/web/src/lib/sessionMentionChip.ts new file mode 100644 index 0000000000..4077402eec --- /dev/null +++ b/web/src/lib/sessionMentionChip.ts @@ -0,0 +1,11 @@ +/** + * Shared visual for rich-composer `@session` chips and peer-delivery sender + * identity (#1203). Keep these identical so "who sent this" matches @mention. + */ +export const SESSION_MENTION_CHIP_CLASSNAME = + 'mx-0.5 inline-flex max-w-[12rem] items-center truncate rounded-md bg-[var(--app-subtle-bg)] px-1.5 py-0.5 align-baseline text-[0.95em] font-medium text-[var(--app-link)]' + +export function formatSessionMentionChipLabel(title: string | null | undefined, sessionId: string): string { + const trimmed = title?.replace(/\s+/g, ' ').trim() + return `@${trimmed || sessionId.slice(0, 8)}` +} From 3c948eb5012e8cdd817ebbeb7e816360ed943658 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:12:29 +0000 Subject: [PATCH 003/142] fix(peer): bind provenance to CLI path; surface source to agents Close cold-pass-1 B1/M1: web JWT sends never trust body sourceSessionId; attributed delivery goes through POST /cli/sessions/:source/peer-messages. Receiving agents get a From: /sessions/ prefix for reply targeting. Co-authored-by: Cursor --- cli/src/agent/runners/runAgentSession.test.ts | 3 +- cli/src/agent/runners/runAgentSession.ts | 8 +- cli/src/agy/runAgy.ts | 8 +- cli/src/claude/runClaude.ts | 22 ++++- cli/src/claude/utils/startHappyServer.ts | 2 + cli/src/codex/runCodex.test.ts | 3 +- cli/src/codex/runCodex.ts | 14 ++- cli/src/copilot/runCopilot.ts | 14 ++- cli/src/cursor/runCursor.test.ts | 3 +- cli/src/cursor/runCursor.ts | 8 +- cli/src/grok/runGrok.ts | 8 +- cli/src/kimi/runKimi.ts | 8 +- cli/src/modules/pingPeer/pingPeer.test.ts | 34 ++----- cli/src/modules/pingPeer/pingPeer.ts | 94 +++++++++++++------ cli/src/opencode/runOpencode.test.ts | 3 +- cli/src/opencode/runOpencode.ts | 20 +++- cli/src/utils/attachmentFormatter.test.ts | 53 +++++++++++ cli/src/utils/attachmentFormatter.ts | 40 +++++++- hub/src/sync/messageService.test.ts | 29 ++++++ hub/src/sync/messageService.ts | 8 +- hub/src/web/routes/cli.test.ts | 93 ++++++++++++++++++ hub/src/web/routes/cli.ts | 43 +++++++++ hub/src/web/routes/messages.test.ts | 42 ++------- hub/src/web/routes/messages.ts | 31 +++--- shared/src/apiTypes.test.ts | 11 ++- shared/src/apiTypes.ts | 36 +++++-- .../messages/PeerSenderChip.test.tsx | 8 ++ .../AssistantChat/messages/PeerSenderChip.tsx | 26 +++++ .../AssistantChat/messages/UserMessage.tsx | 32 ++++--- web/src/lib/assistant-runtime.test.ts | 30 +++++- web/src/lib/assistant-runtime.ts | 3 +- web/src/lib/locales/en.ts | 2 - web/src/lib/locales/zh-CN.ts | 4 +- 33 files changed, 569 insertions(+), 174 deletions(-) create mode 100644 cli/src/utils/attachmentFormatter.test.ts diff --git a/cli/src/agent/runners/runAgentSession.test.ts b/cli/src/agent/runners/runAgentSession.test.ts index fe1886578a..edbf735ca2 100644 --- a/cli/src/agent/runners/runAgentSession.test.ts +++ b/cli/src/agent/runners/runAgentSession.test.ts @@ -105,7 +105,8 @@ vi.mock('@/ui/logger', () => ({ })) vi.mock('@/utils/attachmentFormatter', () => ({ - formatMessageWithAttachments: vi.fn((text: string) => text) + formatMessageWithAttachments: vi.fn((text: string) => text), + formatUserMessageForAgent: vi.fn((text: string) => text) })) import { runAgentSession } from './runAgentSession' diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index efd33c28b3..4e7e25666c 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -10,7 +10,7 @@ import { startHappyServer } from '@/claude/utils/startHappyServer'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; import { bootstrapSession } from '@/agent/sessionFactory'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { formatUserMessageForAgent } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; @@ -52,7 +52,11 @@ export async function runAgentSession(opts: { const messageQueue = new MessageQueue2>(() => hashObject({})); session.onUserMessage((message, localId) => { - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + const formattedText = formatUserMessageForAgent( + message.content.text, + message.content.attachments, + message.meta + ); messageQueue.push(formattedText, {}, localId); }); diff --git a/cli/src/agy/runAgy.ts b/cli/src/agy/runAgy.ts index 79480afc1e..b5088e17e7 100644 --- a/cli/src/agy/runAgy.ts +++ b/cli/src/agy/runAgy.ts @@ -10,7 +10,7 @@ import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFacto import { registerLocalHandoffHandler } from '@/agent/localHandoff'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { formatUserMessageForAgent } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import type { SessionEffort, SessionModel } from '@/api/types'; import { startHookServer } from '@/claude/utils/startHookServer'; @@ -288,7 +288,11 @@ export async function runAgy(opts: { }; session.onUserMessage((message, localId) => { - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + const formattedText = formatUserMessageForAgent( + message.content.text, + message.content.attachments, + message.meta + ); const mode: AgyMode = { permissionMode: currentPermissionMode, }; diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 8d51680cde..f87660e1ac 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -18,7 +18,12 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; -import { formatAttachmentsForClaude, formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { + annotatePeerDeliveryForAgent, + formatAttachmentsForClaude, + formatMessageWithAttachments, + formatUserMessageForAgent +} from '@/utils/attachmentFormatter'; import { normalizeClaudeSessionModel } from './model'; import { normalizeClaudeSessionEffort } from './effort'; import { normalizeHookPermissionMode } from './utils/hookPermissionMode'; @@ -380,9 +385,12 @@ export async function runClaude(options: StartOptions = {}): Promise { const attachmentText = formatAttachmentsForClaude(message.content.attachments); const expandedText = currentSessionRef.current?.expandSkillReference(message.content.text, attachmentText) ?? message.content.text; - const formattedText = expandedText !== message.content.text - ? expandedText - : formatMessageWithAttachments(message.content.text, message.content.attachments); + const formattedText = annotatePeerDeliveryForAgent( + expandedText !== message.content.text + ? expandedText + : formatMessageWithAttachments(message.content.text, message.content.attachments), + message.meta + ); if (specialCommand.type === 'compact') { logger.debug('[start] Detected /compact command'); @@ -451,7 +459,11 @@ export async function runClaude(options: StartOptions = {}): Promise { return; } - const planPrompt = formatMessageWithAttachments(specialCommand.prompt, message.content.attachments); + const planPrompt = formatUserMessageForAgent( + specialCommand.prompt, + message.content.attachments, + message.meta + ); messageQueue.push(planPrompt, enhancedMode, localId); logger.debugLargeJson('[start] /plan command prompt pushed to queue:', message); return; diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 6060bdc222..c109e77a4d 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -301,6 +301,8 @@ function createHapiMcpServer( const result = await pingPeer({ sessionIdPrefix: args.sessionIdPrefix, message: args.message, + // Hub binds provenance to this CLI session id via /cli/.../peer-messages. + authenticatedSourceSessionId: client.sessionId, }); return { content: [ diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts index 6449f27c61..adbb33e7cc 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -89,7 +89,8 @@ vi.mock('@/ui/logger', () => ({ })) vi.mock('@/utils/attachmentFormatter', () => ({ - formatMessageWithAttachments: vi.fn((text: string) => text) + formatMessageWithAttachments: vi.fn((text: string) => text), + formatUserMessageForAgent: vi.fn((text: string) => text) })) vi.mock('@/modules/common/slashCommands', () => ({ diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index aa3da7cbe9..c0cad5f564 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -13,7 +13,7 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { formatUserMessageForAgent } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import type { ReasoningEffort } from './appServerTypes'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; @@ -261,7 +261,7 @@ export async function runCodex(opts: { isolatedCommandText = message.content.text.trim(); } } - text = formatMessageWithAttachments(text, message.content.attachments); + text = formatUserMessageForAgent(text, message.content.attachments, message.meta); const messagePermissionMode = currentPermissionMode; logger.debug( @@ -295,7 +295,15 @@ export async function runCodex(opts: { serviceTier: currentServiceTier, personality: currentPersonality }; - messageQueue.push(formatMessageWithAttachments(message.content.text, message.content.attachments), enhancedMode, localId); + messageQueue.push( + formatUserMessageForAgent( + message.content.text, + message.content.attachments, + message.meta + ), + enhancedMode, + localId + ); } }).catch((error) => { logger.debug('[Codex] User message handler chain failed', error); diff --git a/cli/src/copilot/runCopilot.ts b/cli/src/copilot/runCopilot.ts index 947f8a2018..80bf1eec56 100644 --- a/cli/src/copilot/runCopilot.ts +++ b/cli/src/copilot/runCopilot.ts @@ -12,7 +12,7 @@ import { registerLocalHandoffHandler } from '@/agent/localHandoff'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; import { isCopilotAgentMode, isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { formatUserMessageForAgent } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import { resolveCopilotRuntimeConfig } from './utils/config'; import { listSlashCommands } from '@/modules/common/slashCommands'; @@ -140,7 +140,11 @@ export async function runCopilot(opts: { return cancelledBeforeEnqueue.delete(localId); }; const pushPlain = () => { - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + const formattedText = formatUserMessageForAgent( + message.content.text, + message.content.attachments, + message.meta + ); messageQueue.push(formattedText, buildMode(), localId); }; let recognizedSlash = false; @@ -214,7 +218,11 @@ export async function runCopilot(opts: { text = slash.text; } - const formattedText = formatMessageWithAttachments(text, message.content.attachments); + const formattedText = formatUserMessageForAgent( + text, + message.content.attachments, + message.meta + ); messageQueue.push(formattedText, buildMode(), localId); } catch (error) { logger.debug('[copilot] Failed to handle user message', error); diff --git a/cli/src/cursor/runCursor.test.ts b/cli/src/cursor/runCursor.test.ts index d45808870a..fab6518e84 100644 --- a/cli/src/cursor/runCursor.test.ts +++ b/cli/src/cursor/runCursor.test.ts @@ -84,7 +84,8 @@ vi.mock('@/ui/logger', () => ({ })); vi.mock('@/utils/attachmentFormatter', () => ({ - formatMessageWithAttachments: vi.fn((text: string) => text) + formatMessageWithAttachments: vi.fn((text: string) => text), + formatUserMessageForAgent: vi.fn((text: string) => text) })); vi.mock('./cursorUserMessageQueue', () => ({ diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index fec43e8091..df4f9d7105 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -12,7 +12,7 @@ import { resolveNullableSessionModel, resolveSessionConfigPermissionMode } from '@/agent/sessionConfigRpc'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { formatUserMessageForAgent } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import { enqueueCursorUserMessage } from './cursorUserMessageQueue'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; @@ -103,7 +103,11 @@ export async function runCursor(opts: { permissionMode: currentPermissionMode ?? 'default', model: queuedModel }; - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + const formattedText = formatUserMessageForAgent( + message.content.text, + message.content.attachments, + message.meta + ); enqueueCursorUserMessage(messageQueue, formattedText, enhancedMode, localId); }); diff --git a/cli/src/grok/runGrok.ts b/cli/src/grok/runGrok.ts index 881002d778..ab833e1d56 100644 --- a/cli/src/grok/runGrok.ts +++ b/cli/src/grok/runGrok.ts @@ -14,7 +14,7 @@ import { setControlledByUser } from '@/agent/runnerLifecycle' import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc' -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter' +import { formatUserMessageForAgent } from '@/utils/attachmentFormatter' import { getInvokedCwd } from '@/utils/invokedCwd' export async function runGrok(opts: { @@ -79,7 +79,11 @@ export async function runGrok(opts: { session.onUserMessage((message, localId) => { queue.push( - formatMessageWithAttachments(message.content.text, message.content.attachments), + formatUserMessageForAgent( + message.content.text, + message.content.attachments, + message.meta + ), { permissionMode: currentPermissionMode, model: currentModel ?? undefined, diff --git a/cli/src/kimi/runKimi.ts b/cli/src/kimi/runKimi.ts index f148b880de..15ec28cadf 100644 --- a/cli/src/kimi/runKimi.ts +++ b/cli/src/kimi/runKimi.ts @@ -11,7 +11,7 @@ import { registerLocalHandoffHandler } from '@/agent/localHandoff'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { formatUserMessageForAgent } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import { resolveKimiRuntimeConfig } from './utils/config'; @@ -98,7 +98,11 @@ export async function runKimi(opts: { }; session.onUserMessage((message, localId) => { - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + const formattedText = formatUserMessageForAgent( + message.content.text, + message.content.attachments, + message.meta + ); const mode: KimiMode = { permissionMode: currentPermissionMode, model: resolvedModel diff --git a/cli/src/modules/pingPeer/pingPeer.test.ts b/cli/src/modules/pingPeer/pingPeer.test.ts index 24cddc0fd1..317f765929 100644 --- a/cli/src/modules/pingPeer/pingPeer.test.ts +++ b/cli/src/modules/pingPeer/pingPeer.test.ts @@ -3,12 +3,10 @@ import { HAPI_PEER_DELIVERY_HEADER, HAPI_PEER_DELIVERY_HEADER_VALUE } from '@hapi/protocol' -import { HAPI_SESSION_ID_ENV } from '@/agent/hapiSessionEnv' import { PingPeerError, exitCodeForPingPeerError, pingPeer, - resolvePeerDeliveryProvenance, resolveSessionByPrefix, type PingPeerSessionSummary } from './pingPeer' @@ -76,20 +74,6 @@ describe('resolveSessionByPrefix', () => { }) }) -describe('resolvePeerDeliveryProvenance', () => { - it('reads trusted env only (never free-form args)', () => { - expect(resolvePeerDeliveryProvenance({})).toEqual({}) - expect(resolvePeerDeliveryProvenance({ - [HAPI_SESSION_ID_ENV]: '6212dae5-8a60-4284-b7a5-c09aa3571ce4' - })).toEqual({ - sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4' - }) - expect(resolvePeerDeliveryProvenance({ - [HAPI_SESSION_ID_ENV]: 'not-a-uuid' - })).toEqual({}) - }) -}) - describe('pingPeer', () => { let nowMs: number let sleepCalls: number[] @@ -97,7 +81,6 @@ describe('pingPeer', () => { beforeEach(() => { nowMs = 1_000_000 sleepCalls = [] - delete process.env[HAPI_SESSION_ID_ENV] }) it('sends to an already-active session without resume', async () => { @@ -109,7 +92,8 @@ describe('pingPeer', () => { return { status: 200, data: { token: 'jwt' } } } if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { - expect(body).toEqual({ text: 'hello peer', peer: {} }) + // Bare CLI: unattributed peer header, no body source claim. + expect(body).toEqual({ text: 'hello peer' }) expect(config?.headers?.[HAPI_PEER_DELIVERY_HEADER]) .toBe(HAPI_PEER_DELIVERY_HEADER_VALUE) return { status: 200, data: { ok: true } } @@ -703,23 +687,22 @@ describe('listSessions query params', () => { expect(pingParams[0]).toBeUndefined() }) - it('stamps peer provenance from HAPI_SESSION_ID when inside a wrapped session', async () => { + it('attributes via CLI peer-messages when authenticatedSourceSessionId is set', async () => { const targetId = '05d9f0f2-9273-4137-933c-07459a1146a2' const sourceId = '6212dae5-8a60-4284-b7a5-c09aa3571ce4' - process.env[HAPI_SESSION_ID_ENV] = sourceId const http = createHttpMock({ post: (url, body, config) => { if (url.endsWith('/api/auth')) { return { status: 200, data: { token: 'jwt' } } } - if (url.endsWith(`/api/sessions/${targetId}/messages`)) { + if (url.endsWith(`/cli/sessions/${sourceId}/peer-messages`)) { expect(body).toEqual({ - text: 'handoff', - peer: { sourceSessionId: sourceId } + targetSessionId: targetId, + text: 'handoff' }) - expect(config?.headers?.[HAPI_PEER_DELIVERY_HEADER]) - .toBe(HAPI_PEER_DELIVERY_HEADER_VALUE) + expect(config?.headers?.Authorization).toBe('Bearer tok') + expect(config?.headers?.[HAPI_PEER_DELIVERY_HEADER]).toBeUndefined() return { status: 200, data: { ok: true } } } throw new Error(`unexpected POST ${url}`) @@ -757,6 +740,7 @@ describe('listSessions query params', () => { sessionIdPrefix: '05d9f0f2', message: 'handoff', accessToken: 'tok', + authenticatedSourceSessionId: sourceId, apiUrl: 'http://127.0.0.1:3006', http: http as never }) diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index f0a8e03512..bf1516e706 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -14,31 +14,12 @@ import { HAPI_PEER_DELIVERY_HEADER, HAPI_PEER_DELIVERY_HEADER_VALUE, isObject, - type PeerDeliveryMeta + isSessionId } from '@hapi/protocol' import { normalizeSessionIdPrefix } from '@hapi/protocol/sessionCitation' import { configuration } from '@/configuration' import { getAuthToken } from '@/api/auth' import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' -import { HAPI_SESSION_ID_ENV } from '@/agent/hapiSessionEnv' - -const SESSION_ID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - -/** - * Trusted peer provenance for delivery. Source id comes only from process env - * (`HAPI_SESSION_ID`) - never from MCP/CLI free-form args (#1203 kill criterion). - * Display name is filled hub-side from the session store when the id is valid. - */ -export function resolvePeerDeliveryProvenance( - env: NodeJS.ProcessEnv = process.env -): PeerDeliveryMeta { - const rawId = env[HAPI_SESSION_ID_ENV]?.trim() ?? '' - if (rawId && SESSION_ID_RE.test(rawId)) { - return { sourceSessionId: rawId } - } - return {} -} export type PingPeerErrorCode = | 'bad_args' @@ -80,6 +61,13 @@ export type PingPeerOptions = { waitActiveSecs?: number apiUrl?: string accessToken?: string + /** + * Calling session id from ApiSessionClient (MCP inside a wrapped session). + * When set, delivery uses `POST /cli/sessions/:source/peer-messages` so the + * hub binds provenance to the CLI path — never a web JWT body field (#1203). + * Bare `hapi ping-peer` omits this and sends unattributed peer rows. + */ + authenticatedSourceSessionId?: string http?: AxiosInstance sleep?: (ms: number) => Promise now?: () => number @@ -365,17 +353,17 @@ async function waitForPiReady( ) } -async function sendMessage( +/** Unattributed peer send (bare CLI / no session client). Web JWT + peer header. */ +async function sendUnattributedPeerMessage( apiUrl: string, jwt: string, - sessionId: string, + targetSessionId: string, message: string, - http: AxiosInstance, - peer: PeerDeliveryMeta = {} + http: AxiosInstance ): Promise { const response = await http.post( - `${apiUrl}/api/sessions/${encodeURIComponent(sessionId)}/messages`, - { text: message, peer }, + `${apiUrl}/api/sessions/${encodeURIComponent(targetSessionId)}/messages`, + { text: message }, { headers: { ...authHeaders(jwt), @@ -396,6 +384,41 @@ async function sendMessage( throw new PingPeerError('send_failed', `send failed: ${detail}`) } +/** + * Attributed peer send: CLI token + path source id. Hub ignores any body + * sourceSessionId and fills sourceName from the store. + */ +async function sendAttributedPeerMessage( + apiUrl: string, + cliToken: string, + sourceSessionId: string, + targetSessionId: string, + message: string, + http: AxiosInstance +): Promise { + const response = await http.post( + `${apiUrl}/cli/sessions/${encodeURIComponent(sourceSessionId)}/peer-messages`, + { targetSessionId, text: message }, + { + headers: buildHubRequestHeaders({ + Authorization: `Bearer ${cliToken}`, + 'Content-Type': 'application/json' + }), + timeout: 30_000, + validateStatus: () => true + } + ) + if (response.status >= 200 && response.status < 300 && response.data?.ok === true) { + return + } + const detail = typeof response.data?.error === 'string' + ? response.data.error + : typeof response.data?.code === 'string' + ? response.data.code + : `HTTP ${response.status}` + throw new PingPeerError('send_failed', `send failed: ${detail}`) +} + export async function listPeerSessions( options: ListPeerSessionsOptions = {} ): Promise { @@ -544,9 +567,22 @@ export async function pingPeer(options: PingPeerOptions): Promise ({ })); vi.mock('@/utils/attachmentFormatter', () => ({ - formatMessageWithAttachments: vi.fn((text: string) => text) + formatMessageWithAttachments: vi.fn((text: string) => text), + formatUserMessageForAgent: vi.fn((text: string) => text) })); vi.mock('@/modules/common/slashCommands', () => ({ diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index e0a838f3c7..ed8bbe9f61 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -12,7 +12,7 @@ import { registerLocalHandoffHandler } from '@/agent/localHandoff'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc'; import { startOpencodeHookServer } from './utils/startOpencodeHookServer'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { formatUserMessageForAgent } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import { listSlashCommands } from '@/modules/common/slashCommands'; import { resolveOpencodeSlashCommand } from './utils/slashCommands'; @@ -241,7 +241,11 @@ export async function runOpencode(opts: { modelReasoningEffort: sessionModelReasoningEffort }); const pushPlain = () => { - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + const formattedText = formatUserMessageForAgent( + message.content.text, + message.content.attachments, + message.meta + ); messageQueue.push(formattedText, buildMode(), localId); }; try { @@ -417,7 +421,11 @@ export async function runOpencode(opts: { text = slash.text; } - const formattedText = formatMessageWithAttachments(text, message.content.attachments); + const formattedText = formatUserMessageForAgent( + text, + message.content.attachments, + message.meta + ); messageQueue.push(formattedText, buildMode(), localId); } catch (error) { logger.debug('[opencode] Failed to handle user message', error); @@ -442,7 +450,11 @@ export async function runOpencode(opts: { queuedClearLocalId = null; clearTransitionLatched = false; for (const held of heldDuringClear) { - const formattedText = formatMessageWithAttachments(held.message.content.text, held.message.content.attachments); + const formattedText = formatUserMessageForAgent( + held.message.content.text, + held.message.content.attachments, + held.message.meta + ); messageQueue.push(formattedText, { permissionMode: currentPermissionMode, model: sessionModel, diff --git a/cli/src/utils/attachmentFormatter.test.ts b/cli/src/utils/attachmentFormatter.test.ts new file mode 100644 index 0000000000..fbe605741f --- /dev/null +++ b/cli/src/utils/attachmentFormatter.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + annotatePeerDeliveryForAgent, + formatMessageWithAttachments, + formatUserMessageForAgent +} from './attachmentFormatter' + +describe('formatMessageWithAttachments', () => { + it('keeps the @path prefix shape agySessionScanner matches', () => { + expect(formatMessageWithAttachments('hello', [ + { id: '1', path: '/tmp/a.txt', filename: 'a.txt', mimeType: 'text/plain', size: 1 } + ])).toBe('@/tmp/a.txt\n\nhello') + }) +}) + +describe('annotatePeerDeliveryForAgent', () => { + it('prepends From: /sessions/ for attributed peer rows', () => { + expect(annotatePeerDeliveryForAgent('handoff body', { + sentFrom: 'peer', + peer: { + sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4', + sourceName: 'Orchestrator' + } + })).toBe( + 'From: /sessions/6212dae5-8a60-4284-b7a5-c09aa3571ce4 (Orchestrator)\n\nhandoff body' + ) + }) + + it('marks unattributed peer delivery without inventing a source id', () => { + expect(annotatePeerDeliveryForAgent('cli ping', { sentFrom: 'peer' })) + .toBe('From: peer (unattributed)\n\ncli ping') + }) + + it('leaves non-peer messages unchanged', () => { + expect(annotatePeerDeliveryForAgent('typed', { sentFrom: 'webapp' })).toBe('typed') + expect(annotatePeerDeliveryForAgent('typed', undefined)).toBe('typed') + }) +}) + +describe('formatUserMessageForAgent', () => { + it('preserves attachment prefix under the peer From header', () => { + expect(formatUserMessageForAgent( + 'body', + [{ id: '1', path: '/tmp/a.txt', filename: 'a.txt', mimeType: 'text/plain', size: 1 }], + { + sentFrom: 'peer', + peer: { sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4' } + } + )).toBe( + 'From: /sessions/6212dae5-8a60-4284-b7a5-c09aa3571ce4\n\n@/tmp/a.txt\n\nbody' + ) + }) +}) diff --git a/cli/src/utils/attachmentFormatter.ts b/cli/src/utils/attachmentFormatter.ts index 27af9f0afd..243a2c2e15 100644 --- a/cli/src/utils/attachmentFormatter.ts +++ b/cli/src/utils/attachmentFormatter.ts @@ -1,4 +1,4 @@ -import type { AttachmentMetadata } from '@/api/types' +import type { AttachmentMetadata, MessageMeta } from '@/api/types' /** * Formats attachments for Claude by converting them to @path references. @@ -14,6 +14,9 @@ export function formatAttachmentsForClaude(attachments: AttachmentMetadata[] | u /** * Combines text and formatted attachments into a single prompt string. * Attachments are formatted as @path references and prepended to the text. + * + * Shape is part of the contract for `agySessionScanner.extractBodyText` — + * do not change the `@path…\n\nbody` prefix without updating that matcher. */ export function formatMessageWithAttachments( text: string, @@ -28,3 +31,38 @@ export function formatMessageWithAttachments( } return `${attachmentText}\n\n${text}` } + +/** + * Prepend a machine-parseable peer provenance line for the receiving agent + * (#1203 / contract item 5). Kept separate from {@link formatMessageWithAttachments} + * so agy's attachment-prefix matcher stays exact. + */ +export function annotatePeerDeliveryForAgent( + text: string, + meta: MessageMeta | undefined | null +): string { + if (meta?.sentFrom !== 'peer') { + return text + } + const id = meta.peer?.sourceSessionId?.trim() ?? '' + if (!id) { + return `From: peer (unattributed)\n\n${text}` + } + const name = meta.peer?.sourceName?.trim() ?? '' + const header = name + ? `From: /sessions/${id} (${name})` + : `From: /sessions/${id}` + return `${header}\n\n${text}` +} + +/** Attachment formatting + peer provenance for agent-facing user prompts. */ +export function formatUserMessageForAgent( + text: string, + attachments: AttachmentMetadata[] | undefined, + meta?: MessageMeta | null +): string { + return annotatePeerDeliveryForAgent( + formatMessageWithAttachments(text, attachments), + meta + ) +} diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index 029b50699d..7d0a8a7c29 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -1146,6 +1146,35 @@ describe('MessageService.sendMessage peer provenance', () => { }) }) + it('omits empty peer objects from stored meta', async () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'peer-empty-meta', + { path: '/tmp/peer-empty-meta', host: 'localhost', flavor: 'cursor' }, + null, + 'default' + ) + const service = new MessageService(store, { + of: () => ({ + to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }), + adapter: { rooms: { get: () => undefined } } + }) + } as unknown as Server, makePublisher() as any) + + await service.sendMessage(session.id, { + text: 'unattributed peer', + localId: 'peer-empty', + sentFrom: 'peer', + peer: {} + }) + + const stored = store.messages.getUninvokedLocalMessages(session.id) + expect(stored[0]?.content).toMatchObject({ + meta: { sentFrom: 'peer' } + }) + expect((stored[0]?.content as { meta?: { peer?: unknown } }).meta?.peer).toBeUndefined() + }) + it('never stores peer meta when sentFrom is webapp', async () => { const store = makeStore() const session = store.sessions.getOrCreateSession( diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index 1b5672282c..32d861757e 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -632,11 +632,11 @@ export class MessageService { payload.deliveryMode, payload.scheduledAt ) - const peer = sentFrom === 'peer' && payload.peer + // Omit empty peer:{} — only persist when a sourceSessionId was resolved. + const peer = sentFrom === 'peer' + && payload.peer?.sourceSessionId ? { - ...(payload.peer.sourceSessionId - ? { sourceSessionId: payload.peer.sourceSessionId } - : {}), + sourceSessionId: payload.peer.sourceSessionId, ...(payload.peer.sourceName ? { sourceName: payload.peer.sourceName } : {}) diff --git a/hub/src/web/routes/cli.test.ts b/hub/src/web/routes/cli.test.ts index f903364320..a96cfdfb40 100644 --- a/hub/src/web/routes/cli.test.ts +++ b/hub/src/web/routes/cli.test.ts @@ -289,3 +289,96 @@ describe('cli lazy session creation', () => { expect(response.status).toBe(409) }) }) + +describe('POST /cli/sessions/:id/peer-messages', () => { + const sourceId = '6212dae5-8a60-4284-b7a5-c09aa3571ce4' + const targetId = '05d9f0f2-9273-4137-933c-07459a1146a2' + + it('attributes peer delivery from the path source session id', async () => { + const sentMessages: Array<{ sessionId: string; payload: unknown }> = [] + const app = createApp({ + resolveSessionAccess: (id: string, _namespace: string) => { + if (id === sourceId) { + return { + ok: true as const, + sessionId: sourceId, + session: { id: sourceId, active: true, metadata: { name: 'Orchestrator' } } + } + } + if (id === targetId) { + return { + ok: true as const, + sessionId: targetId, + session: { id: targetId, active: true, metadata: { name: 'Target' } } + } + } + return { ok: false as const, reason: 'not-found' as const } + }, + sendMessage: async (sessionId: string, payload: unknown) => { + sentMessages.push({ sessionId, payload }) + } + } as never) + + const response = await app.request(`/cli/sessions/${sourceId}/peer-messages`, { + method: 'POST', + headers: { + ...authHeaders(), + 'content-type': 'application/json' + }, + body: JSON.stringify({ + targetSessionId: targetId, + text: 'handoff', + // Body source claims must not override the path id. + peer: { sourceSessionId: targetId, sourceName: 'forged' } + }) + }) + + expect(response.status).toBe(200) + expect(sentMessages).toEqual([{ + sessionId: targetId, + payload: { + text: 'handoff', + localId: undefined, + sentFrom: 'peer', + peer: { sourceSessionId: sourceId, sourceName: 'Orchestrator' }, + deliveryMode: undefined + } + }]) + }) + + it('rejects delivery when the target is inactive', async () => { + const app = createApp({ + resolveSessionAccess: (id: string) => { + if (id === sourceId) { + return { + ok: true as const, + sessionId: sourceId, + session: { id: sourceId, active: true, metadata: { name: 'Source' } } + } + } + if (id === targetId) { + return { + ok: true as const, + sessionId: targetId, + session: { id: targetId, active: false, metadata: { name: 'Target' } } + } + } + return { ok: false as const, reason: 'not-found' as const } + }, + sendMessage: async () => { + throw new Error('should not send') + } + } as never) + + const response = await app.request(`/cli/sessions/${sourceId}/peer-messages`, { + method: 'POST', + headers: { + ...authHeaders(), + 'content-type': 'application/json' + }, + body: JSON.stringify({ targetSessionId: targetId, text: 'handoff' }) + }) + + expect(response.status).toBe(409) + }) +}) diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index 5e2345860c..ec4fb75611 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -1,12 +1,14 @@ import { Hono } from 'hono' import { z } from 'zod' import { + CliPeerDeliverRequestSchema, CreateOrLoadMachineRequestSchema, CreateOrLoadSessionRequestSchema, ClearOpencodeSessionCallbackRequestSchema, CursorMigrateToAcpRequestSchema, PROTOCOL_VERSION } from '@hapi/protocol' +import { resolvePeerMetaFromSourceSession } from './messages' import { getConfiguration } from '../../configuration' import { readSessionSummaryContractEnabled } from '../../config/sessionSummaryContract' import { constantTimeEquals } from '../../utils/crypto' @@ -285,6 +287,47 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const engine = getSyncEngine() + if (!engine) { + return c.json({ error: 'Not ready' }, 503) + } + const sourceSessionId = c.req.param('id') + const namespace = c.get('namespace') + const source = resolveSessionForNamespace(engine, sourceSessionId, namespace) + if (!source.ok) { + return c.json({ error: source.error }, source.status) + } + + const body = await c.req.json().catch(() => null) + const parsed = CliPeerDeliverRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + + const target = resolveSessionForNamespace(engine, parsed.data.targetSessionId, namespace) + if (!target.ok) { + return c.json({ error: target.error }, target.status) + } + if (!target.session.active) { + return c.json({ error: 'Session is not active' }, 409) + } + + const peer = resolvePeerMetaFromSourceSession(engine, namespace, source.sessionId) + await engine.sendMessage(target.sessionId, { + text: parsed.data.text, + localId: parsed.data.localId, + sentFrom: 'peer', + peer, + deliveryMode: parsed.data.deliveryMode + }) + return c.json({ ok: true }) + }) + app.post('/sessions/:id/migrate-to-acp', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/hub/src/web/routes/messages.test.ts b/hub/src/web/routes/messages.test.ts index 4a6389bc0c..84e21c50bb 100644 --- a/hub/src/web/routes/messages.test.ts +++ b/hub/src/web/routes/messages.test.ts @@ -28,7 +28,7 @@ function createApp(opts: { queuedLocalIds: string[] invokedLocalMessages: Array<{ localId: string; invokedAt: number }> } - /** Optional peer source sessions visible to resolveTrustedPeerMeta. */ + /** Optional peer source sessions (CLI peer-messages path tests). */ peerSessions?: Record }) { const sentMessages: Array<{ sessionId: string; payload: unknown }> = [] @@ -276,7 +276,7 @@ describe('POST /api/sessions/:id/messages — #2 scheduledAt upper bound', () => }) describe('POST /api/sessions/:id/messages — peer provenance (#1203)', () => { - it('marks delivery as peer when X-Hapi-Peer-Delivery is set', async () => { + it('marks header peer delivery as unattributed (body sourceSessionId ignored)', async () => { const sourceId = '6212dae5-8a60-4284-b7a5-c09aa3571ce4' const { app, sentMessages } = createApp({ peerSessions: { [sourceId]: { name: 'Orchestrator' } } @@ -302,8 +302,8 @@ describe('POST /api/sessions/:id/messages — peer provenance (#1203)', () => { localId: undefined, attachments: undefined, sentFrom: 'peer', - // Hub fills name from store; client sourceName is ignored. - peer: { sourceSessionId: sourceId, sourceName: 'Orchestrator' }, + // JWT path never stores a body-claimed source id. + peer: undefined, scheduledAt: undefined, deliveryMode: undefined } @@ -354,7 +354,7 @@ describe('POST /api/sessions/:id/messages — peer provenance (#1203)', () => { 'content-type': 'application/json', 'x-hapi-peer-delivery': '1' }, - body: JSON.stringify({ text: 'cli ping', peer: {} }) + body: JSON.stringify({ text: 'cli ping' }) }) expect(response.status).toBe(200) @@ -365,37 +365,7 @@ describe('POST /api/sessions/:id/messages — peer provenance (#1203)', () => { localId: undefined, attachments: undefined, sentFrom: 'peer', - peer: {}, - scheduledAt: undefined, - deliveryMode: undefined - } - }]) - }) - - it('drops sourceSessionId that is not in the caller namespace', async () => { - const { app, sentMessages } = createApp({}) - - const response = await app.request('/api/sessions/session-1/messages', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-hapi-peer-delivery': '1' - }, - body: JSON.stringify({ - text: 'forged source', - peer: { sourceSessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4' } - }) - }) - - expect(response.status).toBe(200) - expect(sentMessages).toEqual([{ - sessionId: 'session-1', - payload: { - text: 'forged source', - localId: undefined, - attachments: undefined, - sentFrom: 'peer', - peer: {}, + peer: undefined, scheduledAt: undefined, deliveryMode: undefined } diff --git a/hub/src/web/routes/messages.ts b/hub/src/web/routes/messages.ts index 39e3d549b7..a3760c4a7c 100644 --- a/hub/src/web/routes/messages.ts +++ b/hub/src/web/routes/messages.ts @@ -17,24 +17,24 @@ function isPeerDeliveryRequest(c: { req: { header: (name: string) => string | un } /** - * Keep sentFrom=peer, but only persist a sourceSessionId that exists in this - * namespace. Fill sourceName from hub metadata (ignore client-supplied name). + * Build stored peer meta from a hub-known source session id (CLI path param). + * Never use a web JWT request-body claim here (#1203 kill criterion). + * `sourceName` is a delivery-time snapshot from session metadata. */ -export function resolveTrustedPeerMeta( +export function resolvePeerMetaFromSourceSession( engine: SyncEngine, namespace: string, - claimed: PeerDeliveryMeta | undefined -): PeerDeliveryMeta { - const claimedId = claimed?.sourceSessionId?.trim() + sourceSessionId: string +): PeerDeliveryMeta | undefined { + const claimedId = sourceSessionId.trim() if (!claimedId) { - return {} + return undefined } const access = engine.resolveSessionAccess(claimedId, namespace) if (!access.ok) { - return {} + return undefined } - const meta = access.session.metadata as { name?: unknown } | null | undefined - const sourceName = typeof meta?.name === 'string' ? meta.name.trim() : '' + const sourceName = access.session.metadata?.name?.trim() ?? '' return { sourceSessionId: access.sessionId, ...(sourceName ? { sourceName: sourceName.slice(0, 255) } : {}) @@ -145,19 +145,16 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Message requires text or attachments' }, 400) } - // Peer provenance is header-gated (#1203). Body `peer` without the - // delivery header is ignored so the normal web send path cannot label - // operator keystrokes as peer. + // Peer header marks outside-session / unattributed peer delivery. + // Body `peer` / sourceSessionId is never authoritative on this JWT path + // (#1203 kill criterion) — attributed sends use /cli/.../peer-messages. const peerDelivery = isPeerDeliveryRequest(c) - const peer = peerDelivery - ? resolveTrustedPeerMeta(engine, c.get('namespace'), parsed.data.peer) - : undefined await engine.sendMessage(sessionId, { text: parsed.data.text, localId: parsed.data.localId, attachments: parsed.data.attachments, sentFrom: peerDelivery ? 'peer' : 'webapp', - peer, + peer: undefined, scheduledAt: parsed.data.scheduledAt, deliveryMode: parsed.data.deliveryMode }) diff --git a/shared/src/apiTypes.test.ts b/shared/src/apiTypes.test.ts index 5eb4e8366b..373ba9e8fe 100644 --- a/shared/src/apiTypes.test.ts +++ b/shared/src/apiTypes.test.ts @@ -5,7 +5,8 @@ import { ListCodexSessionsRpcResponseSchema, ListPiSessionsRpcResponseSchema, MessagesQuerySchema, - SendMessageRequestSchema + SendMessageRequestSchema, + isSessionId } from './apiTypes' describe('ListCodexSessionsRpcResponseSchema', () => { @@ -146,6 +147,14 @@ describe('SendMessageRequestSchema deliveryMode', () => { }) }) +describe('isSessionId', () => { + it('accepts UUIDs and rejects free-form strings', () => { + expect(isSessionId('6212dae5-8a60-4284-b7a5-c09aa3571ce4')).toBe(true) + expect(isSessionId('not-a-uuid')).toBe(false) + expect(isSessionId('')).toBe(false) + }) +}) + describe('SendMessageRequestSchema peer provenance', () => { it('accepts empty peer object and optional source fields', () => { expect(SendMessageRequestSchema.parse({ text: 'nudge', peer: {} }).peer).toEqual({}) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index f624ca962f..5cb210b60c 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -497,21 +497,19 @@ export const MessageDeliveryModeSchema = z.enum(['queue', 'steer']) export type MessageDeliveryMode = z.infer /** - * Peer-delivery provenance for `ping_peer` / `hapi ping-peer` (A2A Layer 0.1 / #1203). + * Peer-delivery provenance stored on user-message meta (#1203 / A2A Layer 0.1). * - * Wire shape (request): optional `sourceSessionId` hint from the CLI env. - * Hub only honors this when {@link HAPI_PEER_DELIVERY_HEADER} is present; without - * the header, `peer` is ignored and the row stays `sentFrom: webapp` (stops the - * normal web composer path from accidentally labeling operator keystrokes as peer). + * Authoritative `sourceSessionId` is never taken from the web JWT send body. + * Attributed delivery uses {@link CliPeerDeliverRequestSchema} on + * `POST /cli/sessions/:sourceSessionId/peer-messages` (CLI token + path id). + * The web path may still set `sentFrom: peer` via {@link HAPI_PEER_DELIVERY_HEADER} + * for unattributed outside-session CLI sends; any body `peer` field is ignored. * - * Trust note: the header is not a cryptographic authenticity bound - any holder of - * the namespace JWT can set it. Authoritative source id is still never an MCP/tool - * argument; the hub additionally drops ids that are not in the caller's namespace - * and fills `sourceName` from its own session store (client-supplied names ignored). + * `sourceName` is a delivery-time snapshot from the hub session store (titles + * can change later; the link still resolves to the live session). */ export const PeerDeliveryMetaSchema = z.object({ sourceSessionId: z.string().trim().min(1).max(128).optional(), - // Accepted for forward-compat but ignored by the hub (name is store-derived). sourceName: z.string().trim().min(1).max(255).optional() }) export type PeerDeliveryMeta = z.infer @@ -520,12 +518,30 @@ export type PeerDeliveryMeta = z.infer export const HAPI_PEER_DELIVERY_HEADER = 'x-hapi-peer-delivery' export const HAPI_PEER_DELIVERY_HEADER_VALUE = '1' +/** + * Attributed peer deliver: source id is the CLI route path param (the calling + * session's ApiSessionClient identity), never a tool argument or web body field. + */ +export const CliPeerDeliverRequestSchema = z.object({ + targetSessionId: z.string().trim().min(1).max(128), + text: z.string().min(1), + localId: z.string().min(1).optional(), + deliveryMode: MessageDeliveryModeSchema.optional() +}) +export type CliPeerDeliverRequest = z.infer + +/** Hub session ids are UUIDs today; single validator for CLI provenance gates. */ +export function isSessionId(value: string): boolean { + return z.string().uuid().safeParse(value).success +} + export const SendMessageRequestSchema = z.object({ text: z.string(), localId: z.string().min(1).optional(), attachments: z.array(AttachmentMetadataSchema).optional(), scheduledAt: z.number().int().positive().nullable().optional(), deliveryMode: MessageDeliveryModeSchema.optional(), + // Ignored by hub on the web JWT path (kill criterion: not authoritative). peer: PeerDeliveryMetaSchema.optional() }).refine( (data) => data.scheduledAt == null || typeof data.localId === 'string', diff --git a/web/src/components/AssistantChat/messages/PeerSenderChip.test.tsx b/web/src/components/AssistantChat/messages/PeerSenderChip.test.tsx index e09b79fab2..6bc0d4866b 100644 --- a/web/src/components/AssistantChat/messages/PeerSenderChip.test.tsx +++ b/web/src/components/AssistantChat/messages/PeerSenderChip.test.tsx @@ -12,6 +12,14 @@ vi.mock('@/lib/use-translation', () => ({ }), })) +vi.mock('@/components/AssistantChat/context', () => ({ + useOptionalHappyChatContext: () => null, +})) + +vi.mock('@/hooks/queries/useSessions', () => ({ + useSessions: () => ({ sessions: [], isLoading: false, error: null, refetch: vi.fn() }), +})) + describe('PeerSenderChip', () => { it('renders the same @title chip label as rich-composer mentions', () => { render( diff --git a/web/src/components/AssistantChat/messages/PeerSenderChip.tsx b/web/src/components/AssistantChat/messages/PeerSenderChip.tsx index 53ce2c0c04..937000ff47 100644 --- a/web/src/components/AssistantChat/messages/PeerSenderChip.tsx +++ b/web/src/components/AssistantChat/messages/PeerSenderChip.tsx @@ -1,4 +1,6 @@ import { useNavigate } from '@tanstack/react-router' +import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' +import { useSessions } from '@/hooks/queries/useSessions' import { SESSION_MENTION_CHIP_CLASSNAME, formatSessionMentionChipLabel, @@ -15,10 +17,16 @@ export type PeerSenderChipProps = { /** * Peer-delivery sender identity — same `@title` chip chrome as rich-composer * session mentions so "who sent this" matches @ referencing (#1203). + * + * `sourceName` is the delivery-time snapshot from hub meta; when the source + * session is still in the list we prefer navigating, otherwise render a + * non-link chip (deleted / inaccessible source). */ export function PeerSenderChip({ sourceSessionId, sourceName }: PeerSenderChipProps) { const navigate = useNavigate() const { t } = useTranslation() + const chatCtx = useOptionalHappyChatContext() + const { sessions } = useSessions(chatCtx?.api ?? null) const id = sourceSessionId?.trim() || '' const title = sourceName?.trim() || '' @@ -37,6 +45,24 @@ export function PeerSenderChip({ sourceSessionId, sourceName }: PeerSenderChipPr const label = formatSessionMentionChipLabel(title, id) const tip = formatSessionMentionTooltip(null, title, id) + // When the sessions query has loaded rows and this id is missing, do not + // offer a dead navigation. Empty/loading cache keeps the link (optimistic). + const sourceStillListed = sessions.length === 0 || sessions.some((session) => session.id === id) + + if (!sourceStillListed) { + return ( + + {label} + + ) + } return ( ) } diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index f09167fdbd..3a67e51718 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -8,6 +8,10 @@ import { PeerSenderChip } from '@/components/AssistantChat/messages/PeerSenderCh import { CliOutputBlock } from '@/components/CliOutputBlock' import { getConversationMessageAnchorId } from '@/chat/outline' import { MessageActions } from '@/components/AssistantChat/messages/MessageActions' +import { + parseClaimedPeerFromText, + stripClaimedPeerHeaderForDisplay, +} from '@/chat/peerDelivery' type AuiMessageSnapshot = { message: { @@ -122,6 +126,13 @@ export function HappyUserMessage() { const hasText = text.length > 0 const hasAttachments = attachments && attachments.length > 0 + const claimedPeer = isPeerDelivery && !peerSourceId + ? parseClaimedPeerFromText(text) + : null + const displayText = isPeerDelivery + ? stripClaimedPeerHeaderForDisplay(text) + : text + const displayHasText = displayText.length > 0 return (
) : null} - {hasText ? : null} + {displayHasText ? : null} {hasAttachments ? : null}
{showStatus && ( diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index c9b5b0a5a7..1632f6b962 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -13,6 +13,7 @@ export default { 'message.info': 'Message details', 'message.peerFromUnknown': 'From peer (unknown session)', 'message.peerUnknownChip': '@peer', + 'message.peerUnverifiedTooltip': 'Unverified sender — hub did not mint a session capability for this delivery. Resume or re-spawn the source session under the runner, then ping again.', 'message.fork': 'Fork', 'message.rewind': 'Rewind', 'message.fork.confirmTitle': 'Fork conversation', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index a7c218081e..36ce626617 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -13,6 +13,7 @@ export default { 'message.info': '消息详情', 'message.peerFromUnknown': '来自对等会话(未知会话)', 'message.peerUnknownChip': '@peer', + 'message.peerUnverifiedTooltip': '未验证的发送方 — 枢纽未为本次投递签发会话能力。请在 runner 下恢复或重新拉起源会话后再 ping。', 'message.fork': 'Fork', 'message.rewind': 'Rewind', 'message.fork.confirmTitle': '分叉对话', From 905c7e5f8642a520f530524f3a347c92992588f4 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:15:54 +0000 Subject: [PATCH 052/142] fix(web): only strip From: stamps on unverified peer rows Trusted hub-attributed deliveries keep agent provenance lines in the bubble; client stamp lift+strip stays for unattributed chips only. Co-authored-by: Cursor --- web/src/components/AssistantChat/messages/UserMessage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index 3a67e51718..460f9c2c3f 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -129,7 +129,9 @@ export function HappyUserMessage() { const claimedPeer = isPeerDelivery && !peerSourceId ? parseClaimedPeerFromText(text) : null - const displayText = isPeerDelivery + // Only strip client From:/Name: stamps on unverified rows. Trusted + // deliveries keep hub-stamped agent provenance lines in the bubble. + const displayText = isPeerDelivery && !peerSourceId ? stripClaimedPeerHeaderForDisplay(text) : text const displayHasText = displayText.length > 0 From db46b4e08f7ce8907ea34436385dc42d93302bc3 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Tue, 11 Aug 2026 10:03:09 +0800 Subject: [PATCH 053/142] fix(web): hide unavailable history actions and reorder message actions (#1494) * fix(web): reorder and hide history actions * test(web): cover locked history actions --- .../messages/MessageActions.test.tsx | 86 ++++++++++++++++++- .../AssistantChat/messages/MessageActions.tsx | 58 +++++++------ 2 files changed, 115 insertions(+), 29 deletions(-) diff --git a/web/src/components/AssistantChat/messages/MessageActions.test.tsx b/web/src/components/AssistantChat/messages/MessageActions.test.tsx index 940ae6fc03..17416c00bd 100644 --- a/web/src/components/AssistantChat/messages/MessageActions.test.tsx +++ b/web/src/components/AssistantChat/messages/MessageActions.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ComponentProps, PropsWithChildren } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' @@ -13,6 +13,7 @@ const auiState = { message: { id: 'msg-1', createdAt: new Date(2026, 6, 12, 10, 30) }, thread: { isRunning: false, + messages: [], extras: { shareHiddenByMessageId: new Set(), }, @@ -50,6 +51,10 @@ vi.mock('@/hooks/useCopyToClipboard', () => ({ useCopyToClipboard: () => ({ copied: false, copy }) })) +vi.mock('@/components/AssistantChat/context', () => ({ + useOptionalHappyChatContext: () => ({ onShareTurn: vi.fn() }) +})) + function renderActions(props: ComponentProps) { return render( @@ -211,6 +216,85 @@ describe('MessageActions', () => { } }) + it('hides Fork and Rewind while the thread is running', () => { + auiState.thread.isRunning = true + + renderActions({ + align: 'end', + copyText: 'body', + showFork: true, + showRewind: true, + onFork: async () => {}, + onRewind: async () => {} + }) + + expect(screen.queryByRole('button', { name: 'Fork' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Rewind' })).toBeNull() + }) + + it('hides Fork and Rewind while a history action is pending', () => { + renderActions({ + align: 'end', + copyText: 'body', + showFork: true, + showRewind: true, + historyActionPending: true, + onFork: async () => {}, + onRewind: async () => {} + }) + + expect(screen.queryByRole('button', { name: 'Fork' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Rewind' })).toBeNull() + }) + + it('hides all history actions while a confirmation is pending', async () => { + let resolveFork: (() => void) | undefined + const onFork = vi.fn(() => new Promise((resolve) => { + resolveFork = resolve + })) + + renderActions({ + align: 'end', + copyText: 'body', + showFork: true, + showRewind: true, + onFork, + onRewind: async () => {} + }) + + fireEvent.click(screen.getByRole('button', { name: 'Fork' })) + fireEvent.click(screen.getAllByRole('button', { name: 'Fork' }).at(-1)!) + + await waitFor(() => { + expect(document.querySelector('.happy-message-actions')?.querySelectorAll('button')).toHaveLength(1) + }) + expect(screen.queryByRole('button', { name: 'Rewind' })).toBeNull() + + resolveFork?.() + await waitFor(() => expect(onFork).toHaveBeenCalledTimes(1)) + }) + + it('orders user actions as Share, Rewind, Fork, Copy', () => { + renderActions({ + align: 'end', + copyText: 'body', + messageElementId: 'message-1', + showFork: true, + showRewind: true, + onFork: async () => {}, + onRewind: async () => {} + }) + + const row = document.querySelector('.happy-message-actions') + expect(row).not.toBeNull() + expect(Array.from(row!.querySelectorAll('button')).map((button) => button.getAttribute('aria-label'))).toEqual([ + 'Share turn as image', + 'Rewind', + 'Fork', + 'Copy' + ]) + }) + it('shows Fork confirm dialog and calls onFork only after confirm', async () => { const onFork = vi.fn(async () => {}) renderActions({ align: 'start', copyText: 'body', showFork: true, onFork }) diff --git a/web/src/components/AssistantChat/messages/MessageActions.tsx b/web/src/components/AssistantChat/messages/MessageActions.tsx index eb6c74923c..3645db57ad 100644 --- a/web/src/components/AssistantChat/messages/MessageActions.tsx +++ b/web/src/components/AssistantChat/messages/MessageActions.tsx @@ -88,34 +88,44 @@ export function MessageActions({ /> ) : null - const historyButtons = ( + const historyButtons = !actionsLocked ? ( <> - {showFork && onFork ? ( - - ) : null} {showRewind && onRewind ? ( ) : null} + {showFork && onFork ? ( + + ) : null} - ) + ) : null + + const copyButton = canCopy ? ( + + ) : null return ( <> @@ -128,18 +138,10 @@ export function MessageActions({ {align === 'end' ? : null} {align === 'end' && hasMetadata && metadata ? : null} {align === 'end' ? shareButton : null} - {canCopy ? ( - - ) : null} - {historyButtons} + {align === 'end' ? historyButtons : null} + {align === 'end' ? copyButton : null} + {align === 'start' ? copyButton : null} + {align === 'start' ? historyButtons : null} {align === 'start' ? shareButton : null} {align === 'start' && hasMetadata && metadata ? : null} {align === 'start' ? : null} From 2548eaf3ed67d51da6e0c37f4cf3dac1030fd221 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Tue, 11 Aug 2026 10:03:27 +0800 Subject: [PATCH 054/142] fix(cli): raise flaky claudeRemote first-test timeout to 15s under CI load (#1493) The first test in claudeRemote.test.ts imports the full remote-module graph and intermittently exceeds vitest's default 5s timeout on loaded CI runners, failing PRs that touch no cli/ files. Give that one test a 15s per-test timeout; verified: typecheck exit 0, claudeRemote suite 6/6 pass (~2s cold), full suite 223/224 files pass (runner.integration fails identically on pristine base in this sandbox). Fixes #1491 --- cli/src/claude/claudeRemote.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/claude/claudeRemote.test.ts b/cli/src/claude/claudeRemote.test.ts index 5aebb77a6e..60fe963238 100644 --- a/cli/src/claude/claudeRemote.test.ts +++ b/cli/src/claude/claudeRemote.test.ts @@ -69,7 +69,8 @@ async function waitFor(condition: () => boolean, timeoutMs = 300, intervalMs = 1 } describe('claudeRemote async message handling', () => { - it('reports the initial normal message once after the first result', async () => { + // CI occasionally exceeds the default 5s under load (unrelated to job work). + it('reports the initial normal message once after the first result', { timeout: 15_000 }, async () => { const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); const { claudeRemote } = await import('./claudeRemote'); const onFirstResult = vi.fn(); From 24e0c7671713ab3845278401e03a2a9b03818144 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:03:42 +0100 Subject: [PATCH 055/142] feat(cursor): bump hub thinking on ACP harness wake (#1487) * feat(cursor): bump hub thinking on ACP harness wake When Cursor resumes after idle (notify_on_output / mid-idle ACP activity or a permission request), flip thinking via the existing session-alive keepalive so the hub list matches reality. Fixes #1470. Co-authored-by: Cursor * fix(cursor): emit thinking true/false edges for ACP harness wake Address Codex Major on #1487: activity listener now reports idle as false, and the launcher only keepalives on actual thinking transitions so streamed chunks do not spam session-alive. Co-authored-by: Cursor * fix(cursor): reattach activity thinking listener after session/new remap Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../agent/backends/acp/AcpSdkBackend.test.ts | 79 +++++++++++++++++++ cli/src/agent/backends/acp/AcpSdkBackend.ts | 30 +++++++ ...houldBumpThinkingFromSessionUpdate.test.ts | 64 +++++++++++++++ .../shouldBumpThinkingFromSessionUpdate.ts | 48 +++++++++++ .../cursor/cursorAcpRemoteLauncher.test.ts | 46 ++++++++++- cli/src/cursor/cursorAcpRemoteLauncher.ts | 15 ++++ docs/guide/agents.md | 2 + docs/guide/faq.md | 4 + 8 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.test.ts create mode 100644 cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.ts diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index ceabcc16d8..051e26ec26 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -1412,4 +1412,83 @@ describe('AcpSdkBackend', () => { { type: 'turn_complete', stopReason: 'end_turn' } ]); }); + + it('notifies agent-activity listener for harness-wake activity, not usage noise (#1470)', () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const activity: boolean[] = []; + backend.setAgentActivityListener((thinking) => { + activity.push(thinking); + }); + + const backendInternal = backend as unknown as { + handleSessionUpdate: (params: unknown) => void; + }; + + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { type: 'text', text: 'resumed' } + } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { sessionUpdate: 'usage_update', used: 1_000, size: 200_000 } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + title: 'noise' + } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { sessionUpdate: 'state_update', state: 'running' } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { sessionUpdate: 'state_update', state: 'idle' } + }); + + expect(activity).toEqual([true, true, false]); + }); + + it('notifies agent-activity listener when a permission request arrives (#1470)', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const activity: boolean[] = []; + backend.setAgentActivityListener((thinking) => { + activity.push(thinking); + }); + backend.onPermissionRequest(() => { + // leave pending; we only care that activity fired first + }); + + const backendInternal = backend as unknown as { + handlePermissionRequest: (params: unknown, requestId: string) => Promise; + }; + + const pending = backendInternal.handlePermissionRequest({ + sessionId: 'session-1', + toolCall: { + toolCallId: 'tc-1', + title: 'Shell', + kind: 'execute', + status: 'pending' + }, + options: [{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }] + }, 'req-1'); + + expect(activity).toEqual([true]); + // Cancel so the promise does not hang the suite. + await backend.respondToPermission('session-1', { + id: 'tc-1', + sessionId: 'session-1', + toolCallId: 'tc-1', + title: 'Shell', + kind: 'execute', + options: [] + }, { outcome: 'cancelled' }); + await pending; + }); }); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index a204463f01..d583460b52 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -4,6 +4,7 @@ import { asString, isObject } from '@hapi/protocol'; import { AcpStdioTransport, type AcpStderrError } from './AcpStdioTransport'; import { AcpMessageHandler, type AcpTextChunkMode } from './AcpMessageHandler'; import { ACP_SESSION_UPDATE_TYPES } from './constants'; +import { thinkingHintFromSessionUpdate } from './shouldBumpThinkingFromSessionUpdate'; import { logger } from '@/ui/logger'; import { withRetry } from '@/utils/time'; import packageJson from '../../../../package.json'; @@ -82,6 +83,8 @@ export class AcpSdkBackend implements AgentBackend { private promptUsageCallback: ((msg: AgentMessage) => void) | null = null; private usageUpdateListener: ((msg: AgentMessage) => void) | null = null; private sessionInfoUpdateListener: ((update: AcpSessionInfoUpdate) => void) | null = null; + /** Fired on real agent activity so launchers can bump hub thinking (#1470). */ + private agentActivityListener: ((thinking: boolean) => void) | null = null; private lastForwardedUsageUpdate: AcpUsageUpdate | null = null; private sessionUpdateQueue: Promise = Promise.resolve(); @@ -440,6 +443,16 @@ export class AcpSdkBackend implements AgentBackend { this.sessionInfoUpdateListener = listener; } + /** + * Called when ACP reports thinking transitions for harness wake (#1470). + * `true` = activity / running / permission; `false` = state_update idle. + * Usage/title noise does not fire. Launchers should ignore no-ops when + * session.thinking already matches. + */ + setAgentActivityListener(listener: ((thinking: boolean) => void) | null): void { + this.agentActivityListener = listener; + } + /** Reads the agent's persisted native title through stable ACP session/list. */ async refreshSessionInfo(sessionId: string, cwd: string): Promise { const existingTimer = this.sessionInfoRefreshTimers.get(sessionId); @@ -771,6 +784,7 @@ export class AcpSdkBackend implements AgentBackend { } this.forwardSessionInfoUpdate(sessionId, update); this.captureUsageUpdate(update); + this.notifyAgentActivity(update); // Capture the handler at enqueue time. Looking up `this.messageHandler` // when the queued microtask runs can leak a suppressUpdatesDuring // update into the restored handler if earlier async image work kept @@ -788,6 +802,20 @@ export class AcpSdkBackend implements AgentBackend { }); } + private notifyAgentActivity(update: unknown): void { + if (!this.agentActivityListener) { + return; + } + if (!isObject(update)) { + return; + } + const hint = thinkingHintFromSessionUpdate(update); + if (hint === null) { + return; + } + this.agentActivityListener(hint); + } + private forwardSessionInfoUpdate(sessionId: string | null, update: unknown): void { if (!isObject(update) || update.sessionUpdate !== ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate) { return; @@ -963,6 +991,8 @@ export class AcpSdkBackend implements AgentBackend { if (this.permissionHandler) { try { + // Permission prompts imply the agent is awake (#1470). + this.agentActivityListener?.(true); this.permissionHandler(request); } catch (error) { this.pendingPermissions.delete(toolCallId); diff --git a/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.test.ts b/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.test.ts new file mode 100644 index 0000000000..8744641f27 --- /dev/null +++ b/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + shouldBumpThinkingFromSessionUpdate, + thinkingHintFromSessionUpdate, +} from './shouldBumpThinkingFromSessionUpdate' +import { ACP_SESSION_UPDATE_TYPES } from './constants' + +describe('thinkingHintFromSessionUpdate', () => { + it.each([ + ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, + ACP_SESSION_UPDATE_TYPES.toolCall, + ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + ACP_SESSION_UPDATE_TYPES.plan, + 'agent_message', + 'agent_thought', + 'user_message', + 'user_message_chunk', + 'tool_call_content_chunk', + ] as const)('returns true for activity type %s', (sessionUpdate) => { + expect(thinkingHintFromSessionUpdate({ sessionUpdate })).toBe(true) + expect(shouldBumpThinkingFromSessionUpdate({ sessionUpdate })).toBe(true) + }) + + it('returns true for ACP v2 state_update running / requires_action', () => { + expect(thinkingHintFromSessionUpdate({ + sessionUpdate: 'state_update', + state: 'running', + })).toBe(true) + expect(thinkingHintFromSessionUpdate({ + sessionUpdate: 'state_update', + state: 'requires_action', + })).toBe(true) + }) + + it('returns false for ACP v2 state_update idle', () => { + expect(thinkingHintFromSessionUpdate({ + sessionUpdate: 'state_update', + state: 'idle', + })).toBe(false) + expect(shouldBumpThinkingFromSessionUpdate({ + sessionUpdate: 'state_update', + state: 'idle', + })).toBe(false) + }) + + it.each([ + ACP_SESSION_UPDATE_TYPES.usageUpdate, + ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + 'available_commands_update', + 'current_mode_update', + 'config_option_update', + ] as const)('returns null for noise type %s', (sessionUpdate) => { + expect(thinkingHintFromSessionUpdate({ sessionUpdate })).toBeNull() + expect(shouldBumpThinkingFromSessionUpdate({ sessionUpdate })).toBe(false) + }) + + it('returns null for missing or non-string sessionUpdate', () => { + expect(thinkingHintFromSessionUpdate(null)).toBeNull() + expect(thinkingHintFromSessionUpdate(undefined)).toBeNull() + expect(thinkingHintFromSessionUpdate({})).toBeNull() + expect(thinkingHintFromSessionUpdate({ sessionUpdate: 12 })).toBeNull() + }) +}) diff --git a/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.ts b/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.ts new file mode 100644 index 0000000000..0313c8f690 --- /dev/null +++ b/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.ts @@ -0,0 +1,48 @@ +/** + * Gate for harness/ACP resume → hub thinking (#1470). + * + * Returns: + * - `true` — real agent activity / foreground running (bump thinking) + * - `false` — ACP v2 `state_update: idle` (clear thinking) + * - `null` — noise / unknown (do not touch thinking) + */ +export type SessionUpdateThinkingHint = boolean | null + +export function thinkingHintFromSessionUpdate( + update: { sessionUpdate?: unknown; state?: unknown } | null | undefined +): SessionUpdateThinkingHint { + if (!update || typeof update.sessionUpdate !== 'string') { + return null + } + + switch (update.sessionUpdate) { + case 'agent_message_chunk': + case 'agent_message': + case 'agent_thought_chunk': + case 'agent_thought': + case 'tool_call': + case 'tool_call_update': + case 'tool_call_content_chunk': + case 'plan': + case 'user_message': + case 'user_message_chunk': + return true + case 'state_update': + if (update.state === 'running' || update.state === 'requires_action') { + return true + } + if (update.state === 'idle') { + return false + } + return null + default: + return null + } +} + +/** @deprecated Prefer thinkingHintFromSessionUpdate; kept for call-site clarity in tests. */ +export function shouldBumpThinkingFromSessionUpdate( + update: { sessionUpdate?: unknown; state?: unknown } | null | undefined +): boolean { + return thinkingHintFromSessionUpdate(update) === true +} diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index 0e646982fa..e69dbe584a 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -22,7 +22,8 @@ const harness = vi.hoisted(() => ({ releaseLoadSession: null as (() => void) | null, stderrErrorHandler: null as ((error: { type: string; message: string; raw?: string }) => void) | null, disconnectError: null as Error | null, - overlayCleanup: null as ReturnType | null + overlayCleanup: null as ReturnType | null, + agentActivityListener: null as ((thinking: boolean) => void) | null })); const legacyLauncher = vi.hoisted(() => vi.fn()); @@ -135,6 +136,9 @@ vi.mock('./utils/cursorAcpBackend', () => ({ harness.stderrErrorHandler = handler ?? null; }), setUsageUpdateListener: vi.fn(), + setAgentActivityListener: vi.fn((listener: ((thinking: boolean) => void) | null) => { + harness.agentActivityListener = listener; + }), setSessionInfoUpdateListener: vi.fn(), refreshSessionInfo: vi.fn(async () => {}), onPermissionRequest: vi.fn(), @@ -256,6 +260,7 @@ describe('cursorAcpRemoteLauncher', () => { harness.stderrErrorHandler = null; harness.disconnectError = null; harness.overlayCleanup = null; + harness.agentActivityListener = null; legacyLauncher.mockClear(); process.stdin.isTTY = false; process.stdout.isTTY = false; @@ -275,6 +280,45 @@ describe('cursorAcpRemoteLauncher', () => { expect(legacyLauncher).not.toHaveBeenCalled(); }); + it('applies harness thinking transitions once per edge (#1470)', async () => { + const keepAlive = vi.fn(); + const queue = new MessageQueue2(() => 'mode'); + const client = { + ...makeClient(), + keepAlive + } as unknown as ApiSessionClient; + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + // Keep the launcher in the main loop long enough to wire the listener. + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.agentActivityListener).not.toBeNull()); + + expect(session.thinking).toBe(false); + keepAlive.mockClear(); + + harness.agentActivityListener!(true); + harness.agentActivityListener!(true); + harness.agentActivityListener!(false); + + expect(session.thinking).toBe(false); + expect(keepAlive.mock.calls.map((call) => call[0])).toEqual([true, false]); + + queue.close(); + await runPromise; + }); + it('removes the Cursor MCP overlay even when backend.disconnect rejects', async () => { harness.disconnectError = new Error('disconnect failed'); const session = makeSession(null); diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 55aa27868f..f4e38159be 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -138,6 +138,10 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { this.recordCursorNativeWorktreeMetadata(); backend.setUsageUpdateListener((message) => this.handleAgentMessage(message)); + // Harness resume (notify_on_output / mid-idle ACP activity) may not + // go through HAPI's prompt() window — bump thinking so the hub list + // matches reality (#1470). + this.wireAgentActivityThinking(backend, session); recentStderrHint = null; this.wireStderrErrorListener(backend, (hint) => { @@ -248,6 +252,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { this.backend = backend; registerAcpSessionTitleSync(backend, session.client); backend.setUsageUpdateListener((message) => this.handleAgentMessage(message)); + this.wireAgentActivityThinking(backend, session); recentStderrHint = null; this.wireStderrErrorListener(backend, (hint) => { recentStderrHint = hint; @@ -308,6 +313,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { this.backend = backend; registerAcpSessionTitleSync(backend, session.client); backend.setUsageUpdateListener((message) => this.handleAgentMessage(message)); + this.wireAgentActivityThinking(backend, session); recentStderrHint = null; this.wireStderrErrorListener(backend, (hint) => { recentStderrHint = hint; @@ -557,6 +563,15 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { }); } + /** #1470: ACP activity after idle → hub thinking via existing keepalive. */ + private wireAgentActivityThinking(backend: AcpSdkBackend, session: CursorSession): void { + backend.setAgentActivityListener((thinking) => { + if (session.thinking !== thinking) { + session.onThinkingChange(thinking); + } + }); + } + private handleAgentMessage(message: AgentMessage): void { const converted = convertAgentMessage(message, this.currentBackendModel); if (converted) { diff --git a/docs/guide/agents.md b/docs/guide/agents.md index cd92b8afff..061a9c1a0a 100644 --- a/docs/guide/agents.md +++ b/docs/guide/agents.md @@ -51,6 +51,8 @@ hapi resume # Resume a specific HAPI session HAPI supports [Cursor Agent CLI](https://cursor.com/docs/cli/using) for running Cursor's AI coding agent with remote control via web and phone. +When Cursor resumes mid-idle (for example after a Shell `notify_on_output` wake) and emits ACP activity, HAPI bumps session thinking over the normal `session-alive` keepalive so the list does not stay stuck idle. See [FAQ](./faq.md#why-did-my-session-look-idle-when-the-agent-woke-itself). + ### Prerequisites Install Cursor Agent CLI: diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 464ace9404..48356f37e3 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -93,6 +93,10 @@ In the session view, tap the "Files" tab to: Yes. Open any session and use the chat interface to send messages directly to the AI agent. +### Why did my session look idle when the agent woke itself? + +Some agents (especially Cursor) can resume after idle from harness signals such as background Shell `notify_on_output` or `/loop`, without you sending a new HAPI message. HAPI treats real ACP agent activity (and permission requests) as thinking again so the session list matches the agent - same keepalive path as a normal turn. This is different from session-attached jobs (`hapi job`), which show progress while the agent stays idle on purpose. + ### Can I access a terminal remotely? Yes. Open a session in the web app and tap the Terminal tab for a remote shell. From d147ae00aadf2678f02c4c4c6429dcbaf8fda01e Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Tue, 11 Aug 2026 10:04:18 +0800 Subject: [PATCH 056/142] fix(web): rename storageUsagePie helper to storageUsageSlices to avoid macOS case-collision with StorageUsagePie.tsx (#1483) Since #1383 renamed the helper to storageUsagePie.ts, the same directory contained both StorageUsagePie.tsx and storageUsagePie.ts. On case-insensitive filesystems (macOS), extensionless imports of the component resolve to the helper because '.ts' is tried before '.tsx' and 'StorageUsagePie.ts' matches 'storageUsagePie.ts' case-insensitively, breaking typecheck and component tests on macOS. Linux CI is unaffected. Renames the helper (and its test) to storageUsageSlices.ts, which cannot collide, and updates the two importers. Fixes #1482. --- web/src/components/settings/StorageUsagePie.tsx | 2 +- .../{storageUsagePie.test.ts => storageUsageSlices.test.ts} | 2 +- .../settings/{storageUsagePie.ts => storageUsageSlices.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename web/src/components/settings/{storageUsagePie.test.ts => storageUsageSlices.test.ts} (98%) rename web/src/components/settings/{storageUsagePie.ts => storageUsageSlices.ts} (100%) diff --git a/web/src/components/settings/StorageUsagePie.tsx b/web/src/components/settings/StorageUsagePie.tsx index ae1d03216e..3de375134e 100644 --- a/web/src/components/settings/StorageUsagePie.tsx +++ b/web/src/components/settings/StorageUsagePie.tsx @@ -7,7 +7,7 @@ import { type StorageUsageBytes, type StorageUsageSlice, type StorageUsageSliceKey, -} from '@/components/settings/storageUsagePie' +} from '@/components/settings/storageUsageSlices' const SLICE_FILL: Record = { database: 'var(--app-link)', diff --git a/web/src/components/settings/storageUsagePie.test.ts b/web/src/components/settings/storageUsageSlices.test.ts similarity index 98% rename from web/src/components/settings/storageUsagePie.test.ts rename to web/src/components/settings/storageUsageSlices.test.ts index 4171a5648b..a1b3568294 100644 --- a/web/src/components/settings/storageUsagePie.test.ts +++ b/web/src/components/settings/storageUsageSlices.test.ts @@ -4,7 +4,7 @@ import { describeDonutArc, formatStoragePercent, polarToCartesian, -} from './storageUsagePie' +} from './storageUsageSlices' describe('buildStorageUsageSlices', () => { it('drops zero-byte sidecars and returns empty when nothing has size', () => { diff --git a/web/src/components/settings/storageUsagePie.ts b/web/src/components/settings/storageUsageSlices.ts similarity index 100% rename from web/src/components/settings/storageUsagePie.ts rename to web/src/components/settings/storageUsageSlices.ts From a0621194bb1725628a0f1632f47c1038378fd447 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:36:58 +0100 Subject: [PATCH 057/142] feat(web): native/deep-link ingest for /share (GET url, text, title) (#1413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): ingest GET /share?url=&text=&title= deep links Native companions that cannot POST via Web Share Target can open the same session picker by synthesizing the IndexedDB transfer client-side. When id is present, the existing SW path still wins. Co-authored-by: Cursor * fix(web): preserve share deep-link whitespace; scrub content beside id Keep GET content strings verbatim when non-empty (match POST form-data). When id is present with leftover url/text/title, replace to ?id= only so payload does not linger in the address bar. Query-param contract stays — fragments would break shipped native companions. Co-authored-by: Cursor * fix(web): ingest /share deep links via URL fragment, not query Shared url/text/title must not appear on the HTTP request line — hub Hono logger (and any access log) records path+query. Native companions open /share#url=&text=&title=; the client reads the fragment, scrubs it, and continues with the existing ?id= picker path. Query validateSearch keeps only id/error (Web Share Target redirect). Co-authored-by: Cursor * fix(web): keep /share hash ingest across StrictMode remount Capture the fragment in useState and reuse a single putShareTransfer promise so the first effect's scrub + cleanup cancel does not lose the deep-link under React.StrictMode. Co-authored-by: Cursor * feat(web): fetch companion fileUrl into /share transfer files Native shares cannot put binaries in the hash fragment. Companions host a one-shot CORS URL and pass fileUrl/fileName/fileType; the share page fetches bytes into the same IndexedDB files[] as Web Share Target POST. * fix(web): cap share fileUrl fetch at the composer upload ceiling Stream fileUrl downloads with Content-Length and body size checks matching MAX_UPLOAD_BYTES so a crafted deep link cannot buffer unbounded bytes into IndexedDB. Align native deep-link docs on the fileUrl hand-off vs POST. Co-authored-by: Cursor * fix(web): cast streamed fileUrl chunks to BlobPart for tsc Co-authored-by: Cursor --------- Co-authored-by: Cursor --- docs/guide/pwa.md | 15 +++ web/README.md | 1 + web/src/lib/attachmentAdapter.ts | 3 +- web/src/lib/shareTransfer.test.ts | 200 ++++++++++++++++++++++++++++ web/src/lib/shareTransfer.ts | 200 ++++++++++++++++++++++++++++ web/src/router.tsx | 15 +-- web/src/routes/share/index.test.tsx | 158 +++++++++++++++++++++- web/src/routes/share/index.tsx | 71 +++++++--- 8 files changed, 629 insertions(+), 34 deletions(-) diff --git a/docs/guide/pwa.md b/docs/guide/pwa.md index 3eeb659efa..e40beea708 100644 --- a/docs/guide/pwa.md +++ b/docs/guide/pwa.md @@ -74,6 +74,21 @@ On Android, HAPI appears in the system share sheet. When you share content to HA This lets you share images, PDFs, text, and other files directly into a session from any app. +### Native / deep-link ingest + +Companions that cannot use Web Share Target (for example a native app on a headset share sheet) can open the same picker with a fragment deep link: + +``` +{hapiOrigin}/share#url=…&text=…&title=… +``` + +- Fragment params: `url`, `text`, `title` (all optional; omit empty). Optional companion file hand-off: `fileUrl`, `fileName`, `fileType` — the page fetches `fileUrl` (CORS) into the same IndexedDB `files[]` as Web Share Target (capped at the same 50 MiB upload limit). The fragment is **not** sent on the HTTP request, so shared content does not appear in hub access logs. +- When any are present and query `id` is absent, the web app synthesizes the same IndexedDB transfer used by the POST path, scrubs the fragment, then continues with the session picker / create-new flow (`?id=`). +- When query `id` is present (Web Share Target redirect), that path wins; fragment content is ignored for ingest. +- Deep links cannot embed binaries in the fragment; a companion may hand off one file with `fileUrl`. Use Web Share Target POST for direct or multi-file payloads. + +See [Web Share Target](https://developer.chrome.com/docs/capabilities/web-apis/web-share-target) for the POST vs GET distinction. + ## Caching Strategy HAPI uses intelligent caching: diff --git a/web/README.md b/web/README.md index 0f706c52f5..421424beb3 100644 --- a/web/README.md +++ b/web/README.md @@ -30,6 +30,7 @@ See `src/router.tsx` for route definitions. - `/sessions/$sessionId/files` - File browser with git status. - `/sessions/$sessionId/file` - File viewer with diff support. - `/sessions/$sessionId/terminal` - Terminal interface. +- `/share` - Share-target landing (Web Share Target POST → `?id=`, or native `/share#url=&text=&title=`). - `/settings` - Settings category hub (mobile) and responsive master-detail shell. - `/settings/general` - Language preferences. - `/settings/display` - Appearance, typography, colors, and session list preferences. diff --git a/web/src/lib/attachmentAdapter.ts b/web/src/lib/attachmentAdapter.ts index 89e071269c..01db33eb04 100644 --- a/web/src/lib/attachmentAdapter.ts +++ b/web/src/lib/attachmentAdapter.ts @@ -6,7 +6,8 @@ import { randomId } from '@/lib/randomId' import { getRestoredUploadMetadata } from '@/lib/composer-attachment-drafts' import type { AttachmentDraftHandoff } from '@/lib/composer-draft-transfer' -const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 +/** Composer / share upload ceiling — keep deep-link fetch in sync. */ +export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 const MAX_PREVIEW_BYTES = 5 * 1024 * 1024 type PendingUploadAttachment = PendingAttachment & { diff --git a/web/src/lib/shareTransfer.test.ts b/web/src/lib/shareTransfer.test.ts index f9244a349f..37c20339b7 100644 --- a/web/src/lib/shareTransfer.test.ts +++ b/web/src/lib/shareTransfer.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { + buildSharePayloadFromDeepLink, buildSharePayloadFromFormData, + buildSharePayloadFromSearchFields, + hasShareDeepLinkContent, ingestShareRequest, + parseShareDeepLinkFields, + parseShareHash, + parseShareSearch, type ShareTransferPayload, } from './shareTransfer' @@ -79,6 +85,200 @@ describe('buildSharePayloadFromFormData', () => { }) }) +describe('parseShareSearch', () => { + it('keeps id and error as today', () => { + expect(parseShareSearch({ id: 'xfer-1', error: 'ingest' })).toEqual({ + id: 'xfer-1', + error: 'ingest', + }) + }) + + it('ignores url/text/title in the query (content belongs in the fragment)', () => { + expect(parseShareSearch({ + id: 'xfer-1', + url: 'https://example.com', + text: 'hello', + title: 'Title', + })).toEqual({ id: 'xfer-1' }) + }) +}) + +describe('parseShareHash / parseShareDeepLinkFields', () => { + it('parses url, text, and title from a fragment', () => { + expect(parseShareHash('#url=https%3A%2F%2Fexample.com&text=hello&title=Title')).toEqual({ + url: 'https://example.com', + text: 'hello', + title: 'Title', + }) + }) + + it('accepts a hash without a leading #', () => { + expect(parseShareHash('text=note')).toEqual({ text: 'note' }) + }) + + it('omits empty content fields', () => { + expect(parseShareDeepLinkFields({ url: '', text: ' ', title: '' })).toEqual({}) + }) + + it('preserves surrounding whitespace on non-empty content fields', () => { + expect(parseShareDeepLinkFields({ + title: ' Title ', + text: ' indented\n', + url: ' https://example.com/path ', + })).toEqual({ + title: ' Title ', + text: ' indented\n', + url: ' https://example.com/path ', + }) + }) +}) + +describe('hasShareDeepLinkContent', () => { + it('is true for url-only', () => { + expect(hasShareDeepLinkContent({ url: 'https://a.example' })).toBe(true) + }) + + it('is true for text-only', () => { + expect(hasShareDeepLinkContent({ text: 'note' })).toBe(true) + }) + + it('is true when both url and text are set', () => { + expect(hasShareDeepLinkContent({ + url: 'https://a.example', + text: 'note', + })).toBe(true) + }) + + it('is false when empty', () => { + expect(hasShareDeepLinkContent({})).toBe(false) + }) + + it('is true when fileUrl is present', () => { + expect(hasShareDeepLinkContent({ + fileUrl: 'http://127.0.0.1:9/s', + })).toBe(true) + }) +}) + +describe('buildSharePayloadFromSearchFields', () => { + it('builds the same text payload shape as form-data (url-only)', () => { + expect(buildSharePayloadFromSearchFields( + { url: 'https://example.com/page' }, + 1700000000000, + )).toEqual({ + title: '', + text: '', + url: 'https://example.com/page', + files: [], + createdAt: 1700000000000, + }) + }) + + it('builds text-only payload', () => { + expect(buildSharePayloadFromSearchFields( + { text: 'Hello world' }, + 42, + )).toEqual({ + title: '', + text: 'Hello world', + url: '', + files: [], + createdAt: 42, + }) + }) + + it('builds combined title+text+url payload', () => { + expect(buildSharePayloadFromSearchFields({ + title: 'My note', + text: 'Hello world', + url: 'https://example.com/page', + }, 99)).toEqual({ + title: 'My note', + text: 'Hello world', + url: 'https://example.com/page', + files: [], + createdAt: 99, + }) + }) +}) + +describe('buildSharePayloadFromDeepLink', () => { + it('fetches fileUrl into files[]', async () => { + const bytes = new Uint8Array([1, 2, 3, 4]) + const fetchMock = vi.fn(async () => new Response(bytes, { + status: 200, + headers: { 'content-type': 'image/jpeg' }, + })) + const payload = await buildSharePayloadFromDeepLink( + { + title: 'Shot', + fileUrl: 'http://127.0.0.1:9/once', + fileName: 'shot.jpg', + fileType: 'image/jpeg', + }, + 55, + { fetch: fetchMock as unknown as typeof fetch }, + ) + expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:9/once') + expect(payload.title).toBe('Shot') + expect(payload.files).toHaveLength(1) + expect(payload.files[0]).toMatchObject({ + name: 'shot.jpg', + type: 'image/jpeg', + }) + expect(payload.files[0].blob.size).toBe(4) + expect(payload.createdAt).toBe(55) + }) + + it('throws when fileUrl fetch fails', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 404 })) + await expect(buildSharePayloadFromDeepLink( + { fileUrl: 'http://127.0.0.1:9/missing' }, + 1, + { fetch: fetchMock as unknown as typeof fetch }, + )).rejects.toThrow(/fileUrl fetch failed/) + }) + + it('rejects when Content-Length exceeds the upload ceiling', async () => { + const fetchMock = vi.fn(async () => new Response(new Uint8Array([1]), { + status: 200, + headers: { + 'content-type': 'application/octet-stream', + 'content-length': String(51 * 1024 * 1024), + }, + })) + await expect(buildSharePayloadFromDeepLink( + { fileUrl: 'http://127.0.0.1:9/huge' }, + 1, + { fetch: fetchMock as unknown as typeof fetch }, + )).rejects.toThrow(/too large/) + }) + + it('rejects when streamed body exceeds the upload ceiling', async () => { + const chunk = new Uint8Array(1024 * 1024) + let reads = 0 + const stream = new ReadableStream({ + pull(controller) { + reads += 1 + if (reads <= 51) { + controller.enqueue(chunk) + return + } + controller.close() + }, + }) + const fetchMock = vi.fn(async () => new Response(stream, { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + })) + await expect(buildSharePayloadFromDeepLink( + { fileUrl: 'http://127.0.0.1:9/stream' }, + 1, + { fetch: fetchMock as unknown as typeof fetch }, + )).rejects.toThrow(/too large/) + }) +}) + describe('ingestShareRequest', () => { // jsdom/undici loses File objects when serializing FormData through // `new Request({ body })` and re-parsing via `request.formData()`. The diff --git a/web/src/lib/shareTransfer.ts b/web/src/lib/shareTransfer.ts index 427b95aef6..3e7df4fa3a 100644 --- a/web/src/lib/shareTransfer.ts +++ b/web/src/lib/shareTransfer.ts @@ -1,4 +1,5 @@ import { shareTargetPathname } from './sharePath' +import { MAX_UPLOAD_BYTES } from './attachmentAdapter' /** * Share-target transfer storage. @@ -42,6 +43,205 @@ export type ShareTransferPayload = { createdAt: number } +/** + * Router search for `/share`: only SW redirect fields. Native deep-link + * content must NOT live in the query string — it is logged by hub request + * middleware (Hono `logger()`) and any upstream access log. Companions open + * `/share#url=&text=&title=` instead; see `parseShareHash`. + */ +export type ShareSearch = { + id?: string + error?: string +} + +/** Deep-link content fields (hash fragment only). */ +export type ShareDeepLinkFields = { + url?: string + text?: string + title?: string + /** + * Companion-hosted one-shot HTTP(S) URL for a shared file (image/video). + * Fragment stays text-sized; the share page fetches bytes into IDB. + * Not logged on the hub request line (fragment-only). + */ + fileUrl?: string + fileName?: string + fileType?: string +} + +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + // Emptiness uses trim, but return the original string so GET ingest + // matches POST form-data (which preserves surrounding whitespace). + return value.trim().length > 0 ? value : undefined +} + +/** Router `validateSearch` for `/share` — id/error only. */ +export function parseShareSearch(search: Record): ShareSearch { + const result: ShareSearch = {} + if (typeof search.id === 'string' && search.id) { + result.id = search.id + } + if (typeof search.error === 'string' && search.error) { + result.error = search.error + } + return result +} + +/** Parse url/text/title(/file*) from a flat record (hash params or tests). */ +export function parseShareDeepLinkFields( + fields: Record +): ShareDeepLinkFields { + const result: ShareDeepLinkFields = {} + const url = nonEmptyString(fields.url) + if (url) result.url = url + const text = nonEmptyString(fields.text) + if (text) result.text = text + const title = nonEmptyString(fields.title) + if (title) result.title = title + const fileUrl = nonEmptyString(fields.fileUrl) + if (fileUrl) result.fileUrl = fileUrl + const fileName = nonEmptyString(fields.fileName) + if (fileName) result.fileName = fileName + const fileType = nonEmptyString(fields.fileType) + if (fileType) result.fileType = fileType + return result +} + +/** + * Read native deep-link content from the URL fragment. + * Fragments are not sent on the HTTP request, so shared text never reaches + * hub access logs. `hash` may include a leading `#`. + */ +export function parseShareHash(hash: string): ShareDeepLinkFields { + const raw = hash.startsWith('#') ? hash.slice(1) : hash + if (!raw) return {} + return parseShareDeepLinkFields( + Object.fromEntries(new URLSearchParams(raw).entries()) + ) +} + +/** Drop the fragment from the address bar after reading (no navigation). */ +export function scrubShareHashFromLocation(): void { + if (typeof window === 'undefined') return + if (!window.location.hash) return + window.history.replaceState( + null, + '', + `${window.location.pathname}${window.location.search}` + ) +} + +/** True when deep-link content is present (id path is decided separately). */ +export function hasShareDeepLinkContent(fields: ShareDeepLinkFields): boolean { + return Boolean(fields.url || fields.text || fields.title || fields.fileUrl) +} + +/** + * Text/url/title payload (no fetch). Prefer {@link buildSharePayloadFromDeepLink} + * when `fileUrl` may be present. + */ +export function buildSharePayloadFromSearchFields( + fields: ShareDeepLinkFields, + now: number = Date.now() +): ShareTransferPayload { + return { + title: fields.title ?? '', + text: fields.text ?? '', + url: fields.url ?? '', + files: [], + createdAt: now, + } +} + +/** + * Native companion ingest: text fields plus optional `fileUrl` fetch into + * `files[]` (same shape as Web Share Target POST). `fileUrl` must be + * CORS-readable from the HAPI origin (companions send ACAO *). + * Enforces {@link MAX_UPLOAD_BYTES} while streaming so a crafted link cannot + * buffer an unbounded response into IndexedDB before the composer rejects it. + */ +export async function buildSharePayloadFromDeepLink( + fields: ShareDeepLinkFields, + now: number = Date.now(), + deps: { fetch?: typeof fetch } = {} +): Promise { + const base = buildSharePayloadFromSearchFields(fields, now) + const fileUrl = fields.fileUrl?.trim() + if (!fileUrl) return base + + const doFetch = deps.fetch ?? fetch + const response = await doFetch(fileUrl) + if (!response.ok) { + throw new Error(`share fileUrl fetch failed: ${response.status}`) + } + const contentLength = response.headers.get('content-length') + if (contentLength) { + const declared = Number(contentLength) + if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) { + throw new Error( + `share fileUrl too large: ${declared} bytes (max ${MAX_UPLOAD_BYTES})` + ) + } + } + const blob = await readResponseBlobLimited(response, MAX_UPLOAD_BYTES) + const headerType = response.headers.get('content-type')?.split(';')[0]?.trim() + const type = fields.fileType?.trim() + || headerType + || blob.type + || 'application/octet-stream' + const name = fields.fileName?.trim() || guessFileName(fileUrl, type) + return { + ...base, + files: [{ name, type, blob }], + } +} + +async function readResponseBlobLimited( + response: Response, + maxBytes: number +): Promise { + if (!response.body) { + const blob = await response.blob() + if (blob.size > maxBytes) { + throw new Error( + `share fileUrl too large: ${blob.size} bytes (max ${maxBytes})` + ) + } + return blob + } + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (!value) continue + total += value.byteLength + if (total > maxBytes) { + await reader.cancel() + throw new Error( + `share fileUrl too large: exceeds ${maxBytes} bytes` + ) + } + chunks.push(value) + } + const contentType = response.headers.get('content-type') ?? undefined + return new Blob(chunks as BlobPart[], { type: contentType }) +} + +function guessFileName(fileUrl: string, type: string): string { + try { + const path = new URL(fileUrl).pathname + const leaf = path.split('/').filter(Boolean).pop() + if (leaf && leaf.includes('.')) return leaf + } catch { + // ignore invalid URL + } + const subtype = type.split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'bin' + return `shared.${subtype}` +} + type StoredRecord = ShareTransferPayload & { id: string } function openDb(): Promise { diff --git a/web/src/router.tsx b/web/src/router.tsx index 086492af0b..55d21a8916 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -69,7 +69,7 @@ import SettingsStoragePage from '@/routes/settings/storage' import SettingsUsagePage from '@/routes/settings/usage' import SharePage from '@/routes/share' import { setSharePendingTransfer } from '@/lib/sharePendingState' -import { deleteShareTransfer } from '@/lib/shareTransfer' +import { deleteShareTransfer, parseShareSearch } from '@/lib/shareTransfer' function BackIcon(props: { className?: string }) { @@ -1218,19 +1218,12 @@ const settingsUsageRoute = createRoute({ // Web Share Target landing route. Service worker (`web/src/sw.ts`) // intercepts the manifest's `POST /share` and 303-redirects here with an // IDB transfer id. `error=ingest` is set when the SW failed to write IDB. +// Native / deep-link clients open `/share#url=&text=&title=` (fragment, not +// query) so shared content is never part of the HTTP request line. const shareRoute = createRoute({ getParentRoute: () => rootRoute, path: '/share', - validateSearch: (search: Record): { id?: string; error?: string } => { - const result: { id?: string; error?: string } = {} - if (typeof search.id === 'string' && search.id) { - result.id = search.id - } - if (typeof search.error === 'string' && search.error) { - result.error = search.error - } - return result - }, + validateSearch: (search: Record) => parseShareSearch(search), component: SharePage, }) diff --git a/web/src/routes/share/index.test.tsx b/web/src/routes/share/index.test.tsx index d2692195e8..36e11a7501 100644 --- a/web/src/routes/share/index.test.tsx +++ b/web/src/routes/share/index.test.tsx @@ -1,10 +1,16 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { StrictMode } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' import SharePage from './index' +const navigateMock = vi.fn() +const searchMock = vi.fn<() => Record>(() => ({})) +const putShareTransferMock = vi.fn() +const getShareTransferMock = vi.fn() + vi.mock('@tanstack/react-router', () => ({ - useNavigate: () => vi.fn(), - useSearch: () => ({}), + useNavigate: () => navigateMock, + useSearch: () => searchMock(), })) vi.mock('@/lib/app-context', () => ({ @@ -19,7 +25,31 @@ vi.mock('@/lib/use-translation', () => ({ useTranslation: () => ({ t: (key: string) => key }), })) +vi.mock('@/lib/shareTransfer', async () => { + const actual = await vi.importActual('@/lib/shareTransfer') + return { + ...actual, + putShareTransfer: (...args: unknown[]) => putShareTransferMock(...args), + getShareTransfer: (...args: unknown[]) => getShareTransferMock(...args), + deleteShareTransfer: vi.fn(), + } +}) + +function setShareHash(hash: string): void { + const path = `/share${hash ? (hash.startsWith('#') ? hash : `#${hash}`) : ''}` + window.history.replaceState(null, '', path) +} + describe('SharePage', () => { + beforeEach(() => { + navigateMock.mockReset() + searchMock.mockReset() + searchMock.mockReturnValue({}) + putShareTransferMock.mockReset() + getShareTransferMock.mockReset() + setShareHash('') + }) + it('uses paired button theme colors for the missing-share action', async () => { render() @@ -28,4 +58,124 @@ describe('SharePage', () => { expect(backButton).toHaveClass('text-[var(--app-button-text)]') expect(backButton).not.toHaveClass('text-white') }) + + it('empty hash → no-id UX and does not put a transfer', async () => { + searchMock.mockReturnValue({}) + setShareHash('') + render() + + expect(await screen.findByText('share.error.noId')).toBeInTheDocument() + expect(putShareTransferMock).not.toHaveBeenCalled() + }) + + it('url-only hash deep-link synthesizes a transfer then replaces to ?id=', async () => { + searchMock.mockReturnValue({}) + setShareHash('#url=https%3A%2F%2Fexample.com%2Fclip') + putShareTransferMock.mockResolvedValue('xfer-url') + + render() + + await waitFor(() => { + expect(putShareTransferMock).toHaveBeenCalledTimes(1) + }) + expect(putShareTransferMock.mock.calls[0][0]).toMatchObject({ + url: 'https://example.com/clip', + text: '', + title: '', + files: [], + }) + expect(navigateMock).toHaveBeenCalledWith({ + to: '/share', + search: { id: 'xfer-url' }, + replace: true, + }) + expect(window.location.hash).toBe('') + }) + + it('text-only hash deep-link synthesizes a transfer', async () => { + searchMock.mockReturnValue({}) + setShareHash('#text=shared%20note') + putShareTransferMock.mockResolvedValue('xfer-text') + + render() + + await waitFor(() => { + expect(putShareTransferMock).toHaveBeenCalledWith( + expect.objectContaining({ text: 'shared note', url: '', title: '' }), + ) + }) + expect(navigateMock).toHaveBeenCalledWith({ + to: '/share', + search: { id: 'xfer-text' }, + replace: true, + }) + }) + + it('url+text hash deep-link synthesizes both fields', async () => { + searchMock.mockReturnValue({}) + const hash = new URLSearchParams({ + url: 'https://example.com', + text: 'caption', + title: 'Title', + }).toString() + setShareHash(`#${hash}`) + putShareTransferMock.mockResolvedValue('xfer-both') + + render() + + await waitFor(() => { + expect(putShareTransferMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://example.com', + text: 'caption', + title: 'Title', + }), + ) + }) + }) + + it('StrictMode remount still ingests once and navigates to ?id=', async () => { + searchMock.mockReturnValue({}) + setShareHash('#text=strict-mode-proof') + putShareTransferMock.mockResolvedValue('xfer-strict') + + render( + + + , + ) + + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith({ + to: '/share', + search: { id: 'xfer-strict' }, + replace: true, + }) + }) + expect(putShareTransferMock).toHaveBeenCalledTimes(1) + expect(putShareTransferMock).toHaveBeenCalledWith( + expect.objectContaining({ text: 'strict-mode-proof' }), + ) + expect(window.location.hash).toBe('') + }) + + it('id present wins: loads IndexedDB and ignores hash content', async () => { + searchMock.mockReturnValue({ id: 'xfer-existing' }) + setShareHash('#url=https%3A%2F%2Fshould-not-ingest.example&text=ignored') + getShareTransferMock.mockResolvedValue({ + title: 'from-idb', + text: 'payload', + url: 'https://idb.example', + files: [], + createdAt: 1, + }) + + render() + + expect(await screen.findByText('share.title')).toBeInTheDocument() + expect(putShareTransferMock).not.toHaveBeenCalled() + expect(getShareTransferMock).toHaveBeenCalledWith('xfer-existing') + expect(navigateMock).not.toHaveBeenCalled() + expect(window.location.hash).toBe('') + }) }) diff --git a/web/src/routes/share/index.tsx b/web/src/routes/share/index.tsx index 637c7a9058..bd6c78c241 100644 --- a/web/src/routes/share/index.tsx +++ b/web/src/routes/share/index.tsx @@ -1,12 +1,18 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useAppContext } from '@/lib/app-context' import { useSessions } from '@/hooks/queries/useSessions' import { useTranslation } from '@/lib/use-translation' import { LoadingState } from '@/components/LoadingState' import { + buildSharePayloadFromDeepLink, deleteShareTransfer, getShareTransfer, + hasShareDeepLinkContent, + parseShareHash, + putShareTransfer, + scrubShareHashFromLocation, + type ShareSearch, type ShareTransferPayload, } from '@/lib/shareTransfer' import { setSharePendingTransfer } from '@/lib/sharePendingState' @@ -106,10 +112,17 @@ export default function SharePage() { // Pulled via the typed validateSearch in router.tsx; reading // `window.location.search` directly would diverge from the rest of the - // codebase and miss future schema tightening. - const search = useSearch({ from: '/share' }) as { id?: string; error?: string } + // codebase and miss future schema tightening. Deep-link *content* is + // intentionally read from the hash (not query) so it never hits hub logs. + // Capture the fragment once in state so StrictMode's effect remount does + // not lose it after the first pass scrubs the address bar. + const search = useSearch({ from: '/share' }) as ShareSearch const transferId = search.id ?? null const ingestError = search.error === 'ingest' + const [deepLink] = useState(() => + parseShareHash(typeof window === 'undefined' ? '' : window.location.hash) + ) + const ingestPromiseRef = useRef | null>(null) useEffect(() => { let cancelled = false @@ -117,23 +130,45 @@ export default function SharePage() { setLoad({ state: 'missing', reason: 'ingest-error' }) return } - if (!transferId) { - setLoad({ state: 'missing', reason: 'no-id' }) - return - } - getShareTransfer(transferId).then((payload) => { - if (cancelled) return - if (!payload) { + if (transferId) { + // id path wins; drop any leftover fragment so content is not left + // beside the transfer id in the address bar. + scrubShareHashFromLocation() + getShareTransfer(transferId).then((payload) => { + if (cancelled) return + if (!payload) { + setLoad({ state: 'missing', reason: 'not-found' }) + return + } + setLoad({ state: 'ready', payload }) + }).catch(() => { + if (cancelled) return setLoad({ state: 'missing', reason: 'not-found' }) - return - } - setLoad({ state: 'ready', payload }) - }).catch(() => { - if (cancelled) return - setLoad({ state: 'missing', reason: 'not-found' }) - }) + }) + return () => { cancelled = true } + } + // Native / deep-link ingest via URL fragment (not query): synthesize + // the same IDB transfer the SW would create from POST, scrub the + // fragment, then replace to ?id= for picker / create-new. + scrubShareHashFromLocation() + if (hasShareDeepLinkContent(deepLink)) { + ingestPromiseRef.current ??= buildSharePayloadFromDeepLink(deepLink) + .then((payload) => putShareTransfer(payload)) + void ingestPromiseRef.current.then( + (id) => { + if (cancelled) return + navigate({ to: '/share', search: { id }, replace: true }) + }, + () => { + if (cancelled) return + setLoad({ state: 'missing', reason: 'ingest-error' }) + }, + ) + return () => { cancelled = true } + } + setLoad({ state: 'missing', reason: 'no-id' }) return () => { cancelled = true } - }, [transferId, ingestError]) + }, [transferId, ingestError, navigate, deepLink]) // Snapshot the active session list once when sessions finish loading so // the picker doesn't re-shuffle under the operator's finger as SSE From 5d8cd9b8fa0d8d8f3f70f67bb640a33f17fa6135 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:38:46 +0100 Subject: [PATCH 058/142] fix(web): exclude path-only husks from @ session mentions (#1507) * fix(web): exclude path-only husks from @ session mentions Mention autocomplete required a real title signal (metadata.name or summary text) so sidebar-hidden stubs and path-basename husks cannot win @ queries over live named sessions (#1506). Co-authored-by: Cursor * fix(web): mention @ pool uses sidebar dedup before title filter Titled stale duplicates hidden by prepareSidebarSessions were still @-able and could outrank the live row. Align mention candidates with the visible list, then keep the #1506 title-signal exclusion. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- web/src/components/SessionList.tsx | 12 +-- web/src/lib/sessionReference.test.ts | 126 +++++++++++++++++++++++++++ web/src/lib/sessionReference.ts | 25 ++++-- web/src/lib/sessionTitle.test.ts | 20 ++++- web/src/lib/sessionTitle.ts | 12 +++ web/src/router.tsx | 2 +- 6 files changed, 179 insertions(+), 18 deletions(-) diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index dd159d93ee..96fa6802ae 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -30,7 +30,7 @@ import { getSessionLastSeenAt, getSessionLastSeenSnapshot } from '@/lib/sessionL import { useSessionRowTooltipIds } from '@/components/HoverTooltip' import { subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' -import { getSessionTitle } from '@/lib/sessionTitle' +import { getSessionTitle, hasSessionTitleSignal } from '@/lib/sessionTitle' import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel' import type { Machine } from '@/types/api' import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth' @@ -218,20 +218,12 @@ export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selecte return result } -function hasSidebarTitleSignal(session: SessionSummary): boolean { - const meta = session.metadata - if (!meta) return false - if (meta.name?.trim()) return true - if (meta.summary?.text?.trim()) return true - return false -} - export function isSidebarEmptySessionStub(session: SessionSummary): boolean { if (session.active) return false const meta = session.metadata if (!meta) return true if (meta.agentSessionId?.trim()) return false - if (hasSidebarTitleSignal(session)) return false + if (hasSessionTitleSignal(session)) return false return true } diff --git a/web/src/lib/sessionReference.test.ts b/web/src/lib/sessionReference.test.ts index 7f135af194..c8660d4c34 100644 --- a/web/src/lib/sessionReference.test.ts +++ b/web/src/lib/sessionReference.test.ts @@ -197,6 +197,132 @@ describe('matchSessionsForMention', () => { ]) expect(hits.map((s) => s.id)).not.toContain('ccc-old') }) + + // #1506 — mention pool is stricter than sidebar visibility. + it('excludes sidebar-hidden empty stubs from typed queries', () => { + const stub = makeSession({ + id: 'stub-hidden', + updatedAt: 999, + metadata: { + path: '/home/me/coding/hapi/worktrees/session-attached-jobs', + lifecycleState: 'archived', + }, + }) + const live = makeSession({ + id: 'live-named', + active: true, + updatedAt: 100, + metadata: { + path: '/home/me/coding/hapi/worktrees/session-attached-jobs', + name: 'Peer: session-attached jobs', + lifecycleState: 'running', + }, + }) + const hits = matchSessionsForMention([stub, live], 'session-attached') + expect(hits.map((s) => s.id)).toEqual(['live-named']) + expect(getSessionTitle(stub)).toBe('session-attached-jobs') + }) + + it('excludes path-only title husks even when sidebar would show them', () => { + // agentSessionId keeps the row in the sidebar (#836), but path fallback is not a title. + const husk = makeSession({ + id: 'husk-with-agent', + updatedAt: 999, + metadata: { + path: '/home/me/coding/hapi/worktrees/session-attached-jobs', + agentSessionId: 'agent-thread-1', + lifecycleState: 'archived', + }, + }) + const live = makeSession({ + id: 'live-named', + active: true, + updatedAt: 100, + metadata: { + path: '/home/me/coding/hapi/worktrees/session-attached-jobs', + name: 'Peer: session-attached jobs', + lifecycleState: 'running', + }, + }) + const hits = matchSessionsForMention([husk, live], 'session-attached') + expect(hits.map((s) => s.id)).toEqual(['live-named']) + expect(hits.map((s) => s.id)).not.toContain('husk-with-agent') + }) + + it('keeps summary-only titled sessions and still matches their id prefix', () => { + const summaryOnly = makeSession({ + id: 'summary-only-uuid', + updatedAt: 80, + metadata: { + path: '/work/summary-only', + summary: { text: 'Fix mention husks' }, + lifecycleState: 'archived', + }, + }) + const husk = makeSession({ + id: 'husk-path-only', + updatedAt: 90, + metadata: { + path: '/work/mention-husks', + agentSessionId: 'agent-2', + }, + }) + expect(matchSessionsForMention([summaryOnly, husk], 'mention husks').map((s) => s.id)).toEqual([ + 'summary-only-uuid', + ]) + expect(matchSessionsForMention([summaryOnly, husk], 'summary-o').map((s) => s.id)).toEqual([ + 'summary-only-uuid', + ]) + expect(matchSessionsForMention([husk], 'husk-pat').map((s) => s.id)).toEqual([]) + }) + + it('empty query also omits path-only husks from the shortlist', () => { + const husk = makeSession({ + id: 'active-path-husk', + active: true, + updatedAt: 500, + metadata: { + path: '/work/session-attached-jobs', + agentSessionId: 'agent-3', + }, + }) + const named = makeSession({ + id: 'named-active', + active: true, + updatedAt: 100, + metadata: { path: '/work/a', name: 'Real peer' }, + }) + expect(matchSessionsForMention([husk, named], '').map((s) => s.id)).toEqual(['named-active']) + }) + + it('excludes titled duplicates that sidebar dedup hides, even when their title matches better', () => { + const live = makeSession({ + id: 'live-visible', + active: true, + updatedAt: 100, + metadata: { + path: '/work/session-attached-jobs', + name: 'Peer: session-attached jobs', + flavor: 'cursor', + agentSessionId: 'shared-acp-thread', + lifecycleState: 'running', + }, + }) + const hidden = makeSession({ + id: 'stale-hidden', + updatedAt: 999, + metadata: { + path: '/work/session-attached-jobs', + name: 'session-attached-jobs', + flavor: 'cursor', + agentSessionId: 'shared-acp-thread', + lifecycleState: 'archived', + }, + }) + const hits = matchSessionsForMention([hidden, live], 'session-attached-jobs') + expect(hits.map((s) => s.id)).toEqual(['live-visible']) + expect(hits.map((s) => s.id)).not.toContain('stale-hidden') + }) }) describe('parseSessionPathHref', () => { diff --git a/web/src/lib/sessionReference.ts b/web/src/lib/sessionReference.ts index 2cb6155650..be54fac4c4 100644 --- a/web/src/lib/sessionReference.ts +++ b/web/src/lib/sessionReference.ts @@ -1,7 +1,7 @@ import type { SessionSummary } from '@/types/api' -import { normalizeSearch, sessionMatchesQuery } from '@/components/SessionList' +import { normalizeSearch, prepareSidebarSessions, sessionMatchesQuery } from '@/components/SessionList' import { truncateGraphemes } from '@/lib/graphemes' -import { getSessionTitle } from '@/lib/sessionTitle' +import { getSessionTitle, hasSessionTitleSignal } from '@/lib/sessionTitle' import { SESSION_REFERENCE_STEER_SUFFIX } from '@hapi/protocol/sessionCitation' export function buildSessionReferencePath(sessionId: string): string { @@ -53,11 +53,22 @@ function scoreMatchedSession(session: SessionSummary, query: string): number { return score * 1e13 + session.updatedAt } +/** + * Mention pool is stricter than sidebar visibility (#1506): require a real + * title signal (`metadata.name` or summary text). Path last-segment fallback + * and id-only labels are not @-targets — including husks sidebar still shows + * via flattened `agentSessionId` / `claudeSessionId`. + */ +export function isMentionableSession(session: SessionSummary): boolean { + return hasSessionTitleSignal(session) +} + /** * Rank sessions for composer `@` autocomplete. - * Match filter is the same code path as share/sidebar search (`sessionMatchesQuery`). - * Display/insert still use `getSessionTitle` (name before summary). - * Empty query → active/recent shortlist (excludes archived). + * Pool is sidebar-visible rows (`prepareSidebarSessions`) that also have a + * real title signal (#1506). Path husks stay out even if sidebar shows them. + * Match filter then reuses share/sidebar `sessionMatchesQuery`. + * Empty query → active/recent shortlist (excludes archived + untitled husks). */ export function matchSessionsForMention( sessions: readonly SessionSummary[], @@ -68,10 +79,12 @@ export function matchSessionsForMention( const excludeId = options.excludeId const resolveMachineLabel = options.resolveMachineLabel ?? (() => '') const normalized = normalizeSearch(query) + const candidates = prepareSidebarSessions([...sessions], excludeId) const scored: { session: SessionSummary; score: number }[] = [] - for (const session of sessions) { + for (const session of candidates) { if (excludeId && session.id === excludeId) continue + if (!isMentionableSession(session)) continue if (!normalized) { // Empty / whitespace query: shortlist only — active first, then recent. diff --git a/web/src/lib/sessionTitle.test.ts b/web/src/lib/sessionTitle.test.ts index 6316369db4..8acc2b6acf 100644 --- a/web/src/lib/sessionTitle.test.ts +++ b/web/src/lib/sessionTitle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getSessionTitle } from './sessionTitle' +import { getSessionTitle, hasSessionTitleSignal } from './sessionTitle' describe('getSessionTitle', () => { it('prefers metadata.name over summary.text (sidebar / share picker parity)', () => { @@ -36,3 +36,21 @@ describe('getSessionTitle', () => { expect(getSessionTitle({ id: 'abcdef0123456789' })).toBe('abcdef01') }) }) + +describe('hasSessionTitleSignal', () => { + it('is true for name or summary text and false for path-only', () => { + expect(hasSessionTitleSignal({ + id: 'x', + metadata: { name: 'Named', path: '/tmp/foo' }, + })).toBe(true) + expect(hasSessionTitleSignal({ + id: 'x', + metadata: { summary: { text: 'Summary only' }, path: '/tmp/foo' }, + })).toBe(true) + expect(hasSessionTitleSignal({ + id: 'x', + metadata: { path: '/tmp/foo' }, + })).toBe(false) + expect(hasSessionTitleSignal({ id: 'x' })).toBe(false) + }) +}) diff --git a/web/src/lib/sessionTitle.ts b/web/src/lib/sessionTitle.ts index 0a145b852c..24ffc0d919 100644 --- a/web/src/lib/sessionTitle.ts +++ b/web/src/lib/sessionTitle.ts @@ -7,6 +7,18 @@ type SessionTitleSource = { } | null } +/** + * Real title for sidebar / @ mention — not path last-segment or id fallback. + * Path-only husks are display labels, not reference targets (tiann/hapi#1506). + */ +export function hasSessionTitleSignal(session: SessionTitleSource): boolean { + const meta = session.metadata + if (!meta) return false + if (meta.name?.trim()) return true + if (meta.summary?.text?.trim()) return true + return false +} + export function getSessionTitle(session: SessionTitleSource): string { if (session.metadata?.name) { return session.metadata.name diff --git a/web/src/router.tsx b/web/src/router.tsx index 55d21a8916..6b796b64e0 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -641,7 +641,7 @@ function SessionPage() { const { getSuggestions: getSkillSuggestions, } = useSkills(api, sessionId) - // Same list + search matcher as sidebar / share picker (tiann/hapi#1213). + // Mention pool is stricter than sidebar (#1506): titled sessions only; match via sessionMatchesQuery. const { sessions: allSessions } = useSessions(api) const { machines: mentionMachines } = useMachines(api, true) const mentionMachineLabelsById = useMachineLabels(mentionMachines) From 450deae1889db87267d8dea2b24642efae394ed6 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:57:10 +0000 Subject: [PATCH 059/142] fix(cli): win32 peer-cap inject without named-pipe client PID Bun exposes `_handle.fd === -1` on Windows named pipes, so GetNamedPipeClientProcessId never runs and Teemo resume cannot obtain sessionCapability (RPC never registers). Accept armed deliverTo on win32 when pipe client PID is unavailable; keep Linux peercred checks. Also folds early-connect hold-until-armed race fixes needed for the same inject path. Co-authored-by: Cursor --- cli/src/api/peerCapabilityInject.test.ts | 86 +++++++++++++++++++ cli/src/api/peerCapabilityInject.ts | 103 +++++++++++++++++------ 2 files changed, 163 insertions(+), 26 deletions(-) diff --git a/cli/src/api/peerCapabilityInject.test.ts b/cli/src/api/peerCapabilityInject.test.ts index 40c8c9037e..f8e3cfce6a 100644 --- a/cli/src/api/peerCapabilityInject.test.ts +++ b/cli/src/api/peerCapabilityInject.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { + authorizePeerCapInjectClient, receivePeerCapabilityFromRunner, receiveRunnerProofFromHandoff, startPeerCapabilityInjectServer, @@ -76,6 +77,91 @@ describe('peerCapabilityInject (#1203 pass 2h)', () => { } }) + it('delivers capability when child connects before deliverTo arms payload', async () => { + const socketPath = tempSock() + const server = await startPeerCapabilityInjectServer({ + socketPath, + readPeerCred: () => ({ pid: process.pid, uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0 }), + }) + expect(server).not.toBeNull() + try { + // Child connects first (resume race). + const capabilityPromise = receivePeerCapabilityFromRunner({ + socketPath, + ownerPid: process.pid, + attempts: 100, + readPeerCred: () => ({ pid: process.pid, uid: 0, gid: 0 }), + }) + await new Promise((r) => setTimeout(r, 50)) + const deliver = server!.deliverTo(process.pid, { sessionCapability: 'cap-after-connect' }) + const capability = await capabilityPromise + await deliver + expect(capability).toBe('cap-after-connect') + } finally { + server!.close() + } + }) + + it('still receives when client peercred is null on connect (Bun race)', async () => { + const socketPath = tempSock() + const server = await startPeerCapabilityInjectServer({ + socketPath, + readPeerCred: () => ({ pid: process.pid, uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0 }), + }) + expect(server).not.toBeNull() + try { + const deliver = server!.deliverTo(process.pid, { sessionCapability: 'cap-null-cred' }) + const capability = await receivePeerCapabilityFromRunner({ + socketPath, + ownerPid: process.pid, + attempts: 20, + // Simulate SO_PEERCRED unavailable on the client connect path. + readPeerCred: () => null, + }) + await deliver + expect(capability).toBe('cap-null-cred') + } finally { + server!.close() + } + }) + + it('authorizePeerCapInjectClient: win32 allows null cred (Bun fd=-1)', () => { + expect(authorizePeerCapInjectClient(null, process.pid, 'win32')).toBe(true) + expect(authorizePeerCapInjectClient(null, process.pid, 'linux')).toBe(false) + expect(authorizePeerCapInjectClient( + { pid: process.pid, uid: 0, gid: 0 }, + process.pid, + 'win32', + )).toBe(true) + }) + + it('delivers on win32 when server cannot read named-pipe client pid', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }) + const socketPath = tempSock() + const server = await startPeerCapabilityInjectServer({ + socketPath, + // Bun Windows: GetNamedPipeClientProcessId never works (fd=-1). + readPeerCred: () => null, + }) + expect(server).not.toBeNull() + try { + const deliver = server!.deliverTo(process.pid, { sessionCapability: 'cap-win32-null-cred' }) + const capability = await receivePeerCapabilityFromRunner({ + socketPath, + ownerPid: process.pid, + attempts: 20, + readPeerCred: () => null, + }) + await deliver + expect(capability).toBe('cap-win32-null-cred') + } finally { + server!.close() + } + }) + it('rejects a sibling pid that is not the expected child', async () => { const socketPath = tempSock() const siblingPid = process.pid + 10_000_000 diff --git a/cli/src/api/peerCapabilityInject.ts b/cli/src/api/peerCapabilityInject.ts index 4f980b3ca9..476eaae689 100644 --- a/cli/src/api/peerCapabilityInject.ts +++ b/cli/src/api/peerCapabilityInject.ts @@ -83,29 +83,50 @@ export async function startPeerCapabilityInjectServer(options?: { } server = createServer((socket) => { - const cred = readPeerCred(socket) - const childPid = expectedChildPid - const payload = pendingPayload - if ( - !cred - || childPid === null - || !payload - || !isProcessDescendant(cred.pid, childPid) - ) { - socket.end(`${JSON.stringify({ ok: false, code: 'auth_failed' })}\n`) - return - } - socket.end(`${JSON.stringify({ ok: true, ...payload })}\n`) - if (deliverResolve) { - if (deliverTimer) { - clearTimeout(deliverTimer) - deliverTimer = null + // Child often connects before redeem+deliverTo arms payload + // (#1473 estate: early auth_failed exhausts retries → inject failed + // even when redeem HTTP 200). Hold the socket until armed or timeout. + const startedAt = Date.now() + const maxWaitMs = 16_000 + const tryDeliver = () => { + if (socket.destroyed) { + return + } + const childPid = expectedChildPid + const payload = pendingPayload + if (childPid === null || !payload) { + if (Date.now() - startedAt >= maxWaitMs) { + socket.end(`${JSON.stringify({ ok: false, code: 'not_armed' })}\n`) + return + } + setTimeout(tryDeliver, 20) + return + } + const cred = readPeerCred(socket) + if (!authorizePeerCapInjectClient(cred, childPid)) { + socket.end(`${JSON.stringify({ ok: false, code: 'auth_failed' })}\n`) + return + } + // Do not resolve deliverTo if the client already abandoned this + // socket (null peercred race → client finish(undefined) while we + // still held). Resolving here unlinks the sock and the real + // retry hits ENOENT (#1473 estate). + if (socket.destroyed) { + return + } + socket.end(`${JSON.stringify({ ok: true, ...payload })}\n`) + if (deliverResolve) { + if (deliverTimer) { + clearTimeout(deliverTimer) + deliverTimer = null + } + const resolve = deliverResolve + deliverResolve = null + deliverReject = null + resolve() } - const resolve = deliverResolve - deliverResolve = null - deliverReject = null - resolve() } + tryDeliver() }) await new Promise((resolve, reject) => { @@ -140,6 +161,8 @@ export async function startPeerCapabilityInjectServer(options?: { pendingPayload = payload deliverResolve = resolve deliverReject = reject + // Keep above child receivePeerCapabilityFromRunner attempts (~16s) + // and aligned with runner webhook default (25s). deliverTimer = setTimeout(() => { if (deliverReject) { const rej = deliverReject @@ -147,7 +170,7 @@ export async function startPeerCapabilityInjectServer(options?: { deliverReject = null rej(new Error('peer capability inject timed out waiting for session CLI')) } - }, 15_000) + }, 20_000) }), close: () => { if (deliverTimer) { @@ -303,10 +326,17 @@ function tryReceiveOnce( }) socket.on('connect', () => { const cred = readPeerCred(socket) - const authorized = expectedServerPid !== undefined - ? cred?.pid === expectedServerPid - : Boolean(cred && isProcessDescendant(ownerPid, cred.pid)) - if (!authorized) { + // Bun/Linux: SO_PEERCRED can be briefly unavailable on connect. + // Treat missing cred as "wait for server push", not hard fail — + // aborting here lets the server still mark deliverTo complete and + // unlink the socket while we retry into ENOENT (#1473). + if (expectedServerPid !== undefined) { + if (cred && cred.pid !== expectedServerPid) { + finish(undefined) + } + return + } + if (cred && !isProcessDescendant(ownerPid, cred.pid)) { finish(undefined) } // Server pushes secret on accept when armed. @@ -314,6 +344,27 @@ function tryReceiveOnce( }) } +/** + * Authorize a peer-cap inject client for an armed `deliverTo(childPid, …)`. + * + * Linux/macOS require SO_PEERCRED and a descendant of `expectedChildPid`. + * Bun on Windows exposes named-pipe `_handle.fd === -1`, so + * `GetNamedPipeClientProcessId` cannot run (Teemo 2026-08-11: every resume + * hit `auth_failed` → `peer capability inject timed out`). When credentials + * are unavailable on win32, possession of the ephemeral pipe path (set only + * in the child env) is the auth — still requires an armed deliverTo. + */ +export function authorizePeerCapInjectClient( + cred: PeerCredentials | null, + expectedChildPid: number, + platform: NodeJS.Platform = process.platform, +): boolean { + if (cred) { + return isProcessDescendant(cred.pid, expectedChildPid) + } + return platform === 'win32' +} + /** Windows client → verify named-pipe server PID (#1473 Major). */ export const readWindowsNamedPipeServerCredentials: PeerCredReader = (socket) => { try { From 134e741ddf4ccb3df16a51e0f5001f9dab8fd00a Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:04:30 +0000 Subject: [PATCH 060/142] fix(cli): drop duplicate claudeRemote it() timeout after main merge Merge with #1493 left it(name, { timeout }, fn, 15000); Vitest 4 only allows 1-3 args and CI typecheck failed on the PR merge ref. Co-authored-by: Cursor --- cli/src/claude/claudeRemote.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/claude/claudeRemote.test.ts b/cli/src/claude/claudeRemote.test.ts index 6f119801bf..60fe963238 100644 --- a/cli/src/claude/claudeRemote.test.ts +++ b/cli/src/claude/claudeRemote.test.ts @@ -107,7 +107,7 @@ describe('claudeRemote async message handling', () => { queryMock.mockReset(); querySpy.mockRestore(); } - }, 15_000); + }); it('continues consuming assistant messages even when next user message is pending', async () => { const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); From 6a9bbafd201db4922f95247118c7d209fccbe3ce Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:04:13 +0000 Subject: [PATCH 061/142] fix(hub): gate machine-alive and state updates on runner proof bind Namespace CLI token was enough to heartbeat or rewrite machine metadata/state. Require the same socket bind already used for machine RPC so siblings cannot keep a foreign machine alive. Co-authored-by: Cursor --- .../socket/handlers/cli/machineHandlers.ts | 17 +++++ .../handlers/cli/machineRpcAuth.test.ts | 73 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/hub/src/socket/handlers/cli/machineHandlers.ts b/hub/src/socket/handlers/cli/machineHandlers.ts index 4c98d40eb1..dee15af7dc 100644 --- a/hub/src/socket/handlers/cli/machineHandlers.ts +++ b/hub/src/socket/handlers/cli/machineHandlers.ts @@ -39,6 +39,10 @@ export type MachineHandlersDeps = { onWebappEvent?: (event: SyncEvent) => void } +function isSocketBoundToMachine(socket: CliSocketWithData, machineId: string): boolean { + return socket.data.machineRpcAuthorizedId === machineId +} + export function registerMachineHandlers(socket: CliSocketWithData, deps: MachineHandlersDeps): void { const { store, resolveMachineAccess, emitAccessError, onMachineAlive, onWebappEvent } = deps @@ -51,6 +55,11 @@ export function registerMachineHandlers(socket: CliSocketWithData, deps: Machine emitAccessError('machine', data.machineId, machineAccess.reason) return } + // Namespace token is not possession of this runner generation (#1473). + if (!isSocketBoundToMachine(socket, data.machineId)) { + emitAccessError('machine', data.machineId, 'access-denied') + return + } onMachineAlive?.(data) }) @@ -67,6 +76,10 @@ export function registerMachineHandlers(socket: CliSocketWithData, deps: Machine cb({ result: 'error', reason: machineAccess.reason }) return } + if (!isSocketBoundToMachine(socket, id)) { + cb({ result: 'error', reason: 'access-denied' }) + return + } const result = store.machines.updateMachineMetadata(id, metadata, expectedVersion, machineAccess.value.namespace) if (result.result === 'success') { @@ -107,6 +120,10 @@ export function registerMachineHandlers(socket: CliSocketWithData, deps: Machine cb({ result: 'error', reason: machineAccess.reason }) return } + if (!isSocketBoundToMachine(socket, id)) { + cb({ result: 'error', reason: 'access-denied' }) + return + } const result = store.machines.updateMachineRunnerState( id, diff --git a/hub/src/socket/handlers/cli/machineRpcAuth.test.ts b/hub/src/socket/handlers/cli/machineRpcAuth.test.ts index 63cae245b8..3f2dbada55 100644 --- a/hub/src/socket/handlers/cli/machineRpcAuth.test.ts +++ b/hub/src/socket/handlers/cli/machineRpcAuth.test.ts @@ -231,4 +231,77 @@ describe('machine RPC auth (#1473 B1)', () => { expect(ackResult).toEqual({ registered: false }) expect(register).not.toHaveBeenCalled() }) + + it('rejects machine-alive and state mutations without runner-proof bind', () => { + const onMachineAlive = mock(() => {}) + const updateMetadata = mock(() => ({ result: 'success', version: 2, value: {} })) + const { socket, handlers } = createSocketHarness({ + machineId: 'machine-1', + machineTag: 'secret-tag', + runnerProof: 'proof-sibling', + clientType: 'machine-scoped', + }) + registerCliHandlers(socket as never, { + io: { of: () => ({}) }, + store: { + sessions: { getSessionByNamespace: () => null, getSession: () => null }, + machines: { + ...machineStore(), + updateMachineMetadata: updateMetadata, + updateMachineRunnerState: mock(() => ({ result: 'success', version: 2, value: {} })), + }, + }, + rpcRegistry: { + register: mock(() => true), + unregister: mock(() => {}), + unregisterAll: mock(() => {}), + }, + terminalRegistry: {}, + jwtSecret: JWT_SECRET, + onMachineAlive, + } as never) + + expect(socket.data.machineRpcAuthorizedId).toBeUndefined() + handlers.get('machine-alive')?.({ machineId: 'machine-1', time: Date.now() }) + expect(onMachineAlive).not.toHaveBeenCalled() + + let metaAck: { result?: string; reason?: string } | undefined + handlers.get('machine-update-metadata')?.( + { machineId: 'machine-1', expectedVersion: 1, metadata: { host: 'hijack' } }, + (response: { result: string; reason?: string }) => { + metaAck = response + } + ) + expect(metaAck).toEqual({ result: 'error', reason: 'access-denied' }) + expect(updateMetadata).not.toHaveBeenCalled() + }) + + it('accepts machine-alive when the socket is runner-proof bound', () => { + const onMachineAlive = mock(() => {}) + const { socket, handlers } = createSocketHarness({ + machineId: 'machine-1', + machineTag: 'secret-tag', + runnerProof: PROOF, + clientType: 'machine-scoped', + }) + registerCliHandlers(socket as never, { + io: { of: () => ({}) }, + store: { + sessions: { getSessionByNamespace: () => null, getSession: () => null }, + machines: machineStore(), + }, + rpcRegistry: { + register: mock(() => true), + unregister: mock(() => {}), + unregisterAll: mock(() => {}), + }, + terminalRegistry: {}, + jwtSecret: JWT_SECRET, + onMachineAlive, + } as never) + + expect(socket.data.machineRpcAuthorizedId).toBe('machine-1') + handlers.get('machine-alive')?.({ machineId: 'machine-1', time: 42 }) + expect(onMachineAlive).toHaveBeenCalled() + }) }) From 0d120356749aa73861f70a70fcbbcbb52d4caa2d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:25:00 +0100 Subject: [PATCH 062/142] feat(web): searchable session picker on Android share target (#986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): searchable session picker on share target (#980) Add search to the Android share-target picker so operators can attach shared content to inactive or older sessions, not just recent actives. Reuses sidebar search helpers; caps default active list with a search hint. Co-authored-by: Cursor * fix(web): defer share pending consume until session is active ShareSeedConsumer was consuming the sessionStorage transfer id on first mount even when the target session was inactive. Reopening into a new session id remounted the chat with the key already gone, dropping the shared payload. Consume and seed only after sessionActive is true. Co-authored-by: Cursor * fix(web): bind share pending transfer to target session id Deferring consume until active left a global pending slot that the next unrelated active SessionChat could claim. Bind the pending transfer to the picked session id, retarget on reopen/spawn id-swap, and only consume when the mounting session matches. Co-authored-by: Cursor * fix(web): wire SessionListSearch date-range props on share picker Upstream added required customStart/customEnd/onDateRangeChange to SessionListSearch; share /share must pass them and honor the range when filtering sessions after rebase onto v0.23.1. Co-authored-by: Cursor * fix(web): pass sessionActivityDates to share SessionListSearch Upstream SessionListSearch now requires activity dates for the date picker; without them CI typecheck fails and the PR chip stays needs_work. Co-authored-by: Cursor * fix(web): use cached machine labels in share picker search Share picker now uses useMachineLabels like SessionList so stale machine ids still match search by display name. Co-authored-by: Cursor * fix(web): align share picker with sidebar session prep and preview limit Snapshot via prepareSidebarSessions and pass useSessionPreviewLimit so search targets and fold cap match the main session list. Co-authored-by: Cursor * fix(web): wire SessionListSearch expanded props on share picker Upstream search now requires expanded/onExpandedChange; default open on the share picker so the field is available immediately. Co-authored-by: Cursor * fix(web): unblock typecheck after SessionSummary and RawSendError fields Fill new required SessionSummary watermarks in the share picker test helper, and set deliveryMode on abort-restore send errors. Co-authored-by: Cursor * fix(web): mock machines hooks in share page missing-state test SharePage now calls useMachines for picker search labels; the missing- share unit test needs those hooks stubbed without a QueryClient. Co-authored-by: Cursor * fix(web): retarget pending share on automatic session supersession When SessionDetailRoute follows supersededBySessionId A→B, rewrite the share pending target so ShareSeedConsumer on B can still claim it. Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: Debian --- web/src/components/SessionChat.tsx | 95 +----------- web/src/components/SessionHeader.tsx | 2 + web/src/components/SessionList.tsx | 4 +- web/src/components/ShareSeedConsumer.test.tsx | 122 +++++++++++++++ web/src/components/ShareSeedConsumer.tsx | 85 ++++++++++ web/src/lib/locales/en.ts | 3 + web/src/lib/locales/zh-CN.ts | 3 + web/src/lib/sharePendingState.test.ts | 43 ++++-- web/src/lib/sharePendingState.ts | 84 ++++++++-- web/src/lib/sharePickerSessions.test.ts | 108 +++++++++++++ web/src/lib/sharePickerSessions.ts | 54 +++++++ web/src/router.tsx | 9 +- .../sessions/followSupersedingSession.test.ts | 34 +++- .../sessions/followSupersedingSession.ts | 14 ++ web/src/routes/share/index.test.tsx | 8 + web/src/routes/share/index.tsx | 146 +++++++++++++----- 16 files changed, 654 insertions(+), 160 deletions(-) create mode 100644 web/src/components/ShareSeedConsumer.test.tsx create mode 100644 web/src/components/ShareSeedConsumer.tsx create mode 100644 web/src/lib/sharePickerSessions.test.ts create mode 100644 web/src/lib/sharePickerSessions.ts diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 52bcbdd679..9c1bb77473 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -54,6 +54,7 @@ import { import type { MessageDeliveryMode } from '@hapi/protocol' import type { OlderLoadOutcome } from '@/lib/message-window-store' import { createAttachmentAdapter } from '@/lib/attachmentAdapter' +import { ShareSeedConsumer } from '@/components/ShareSeedConsumer' import { createScratchlistAttachmentAdapter, type ScratchlistAttachmentAdapter, @@ -69,9 +70,6 @@ import { } from '@/lib/scratchlistAttachmentFlow' import type { ScratchlistEntry } from '@/lib/scratchlist' import { isHubScratchlistAttachmentPath } from '@hapi/protocol' -import { consumeSharePendingTransfer } from '@/lib/sharePendingState' -import { deleteShareTransfer, getShareTransfer } from '@/lib/shareTransfer' -import { getDraft } from '@/lib/composer-drafts' import { type AttachmentDraftInput, } from '@/lib/composer-attachment-drafts' @@ -251,97 +249,6 @@ function isUninvokedScheduledMessage(message: DecryptedMessage): boolean { return message.invokedAt == null && message.scheduledAt != null } -/** - * Consumes a pending Web Share Target transfer once the assistant runtime - * is mounted and the session is active enough to accept attachments. - * - * Lifecycle: - * - A mount effect reads the transfer id out of sessionStorage *once* - * via consumeSharePendingTransfer() (not during render — StrictMode - * would consume on the discarded pass). The id is stashed in a ref. - * - The actual seed (composer.setText + composer.addAttachment per file) - * runs once `props.sessionActive` is true. Inactive sessions disable - * the attachmentAdapter, so writing attachments while inactive would - * no-op and leak Blobs in IDB. The seed waits in a re-renderable - * effect for the active flip. - * - `consumedRef` gates the effect to a single seed per component - * instance — refs survive a StrictMode mount/cleanup/remount pair, so - * the second invoke early-returns and the first invoke's async chain - * completes naturally (we deliberately don't cancel on cleanup; the - * upload is idempotent and the only side effects on the composer are - * no-ops once the runtime is unmounted). - * - The IDB row is deleted after the seed completes so a back-button - * refresh of /sessions/:id doesn't re-attach the same payload. - */ -function ShareSeedConsumer(props: { sessionId: string; sessionActive: boolean }) { - const assistantApi = useAui() - const composerText = useAuiState((s) => s.composer.text) - const composerTextRef = useRef(composerText) - const initRef = useRef(false) - const transferIdRef = useRef(null) - const consumedRef = useRef(false) - const [transferReady, setTransferReady] = useState(false) - - useEffect(() => { - composerTextRef.current = composerText - }, [composerText]) - - // Consume in an effect, not during render — React.StrictMode double- - // invokes render functions in dev; a render-time consume deletes the - // sessionStorage key on the discarded pass and the committed render - // then sees no transfer. - useEffect(() => { - if (initRef.current) return - initRef.current = true - transferIdRef.current = consumeSharePendingTransfer() - setTransferReady(true) - }, []) - - useEffect(() => { - if (!transferReady) return - if (consumedRef.current) return - const transferId = transferIdRef.current - if (!transferId) return - if (!props.sessionActive) return - consumedRef.current = true - - void (async () => { - try { - const payload = await getShareTransfer(transferId) - if (!payload) return - const seedText = [payload.title, payload.text, payload.url] - .filter((part) => typeof part === 'string' && part.length > 0) - .join('\n') - .trim() - if (seedText.length > 0) { - const existingText = composerTextRef.current.trim().length > 0 - ? composerTextRef.current - : getDraft(props.sessionId) - const nextText = [existingText.trim(), seedText] - .filter((part) => part.length > 0) - .join('\n\n') - if (nextText.length > 0) { - assistantApi.composer().setText(nextText) - } - } - for (const file of payload.files) { - const reconstructed = new File([file.blob], file.name, { type: file.type }) - try { - await assistantApi.composer().addAttachment(reconstructed) - } catch (err) { - console.error('share-seed addAttachment failed', err) - } - } - await deleteShareTransfer(transferId).catch(() => {}) - } catch (err) { - console.error('share-seed pull failed', err) - } - })() - }, [transferReady, props.sessionActive, props.sessionId, assistantApi]) - - return null -} - /** * Watches for incoming `abort-restore` events (emitted by the PTY launcher * when the user aborts a running turn) and surfaces the aborted prompt text — diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 51cb37ca5a..e9a68404ca 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { useScratchlistCount } from '@/lib/use-scratchlist-count' import { formatReopenError } from '@/lib/reopenError' import { formatReasoningLabel, getReasoningEffortForFlavor } from '@/lib/codexStatusLabels' +import { retargetSharePendingTransfer } from '@/lib/sharePendingState' import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' @@ -255,6 +256,7 @@ export function SessionHeader(props: { try { const result = await reopenSession() if (result.sessionId && result.sessionId !== session.id) { + retargetSharePendingTransfer(session.id, result.sessionId) await onSessionReopened?.(result.sessionId) } } catch (error) { diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 96fa6802ae..8181125b02 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -32,6 +32,7 @@ import { subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' import { getSessionTitle, hasSessionTitleSignal } from '@/lib/sessionTitle' import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel' +import { retargetSharePendingTransfer } from '@/lib/sharePendingState' import type { Machine } from '@/types/api' import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth' import { MachineFilterBar, MachineFilterMenu } from '@/components/MachineFilterBar' @@ -695,7 +696,7 @@ function SessionDateRangePicker(props: { ) } -function SessionListSearch(props: { +export function SessionListSearch(props: { value: string onChange: (value: string) => void customStart: string @@ -922,6 +923,7 @@ function SessionItem(props: { // resumeSession may merge the row into a freshly-spawned sessionId. // Follow it so the operator lands on the live session. if (result.sessionId && result.sessionId !== s.id) { + retargetSharePendingTransfer(s.id, result.sessionId) await transferComposerDraftThenNavigate( s.id, result.sessionId, diff --git a/web/src/components/ShareSeedConsumer.test.tsx b/web/src/components/ShareSeedConsumer.test.tsx new file mode 100644 index 0000000000..82b45aaa62 --- /dev/null +++ b/web/src/components/ShareSeedConsumer.test.tsx @@ -0,0 +1,122 @@ +import { StrictMode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, waitFor } from '@testing-library/react' +import { + SHARE_PENDING_TRANSFER_KEY, + peekSharePendingTransfer, + retargetSharePendingTransfer, + setSharePendingTransfer, +} from '@/lib/sharePendingState' + +const { setText, addAttachment, getShareTransfer, deleteShareTransfer } = vi.hoisted(() => ({ + setText: vi.fn(), + addAttachment: vi.fn(async () => undefined), + getShareTransfer: vi.fn(), + deleteShareTransfer: vi.fn(async () => undefined), +})) + +vi.mock('@assistant-ui/react', () => ({ + useAui: () => ({ + composer: () => ({ setText, addAttachment }), + }), + useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => + selector({ composer: { text: '' } }), +})) + +vi.mock('@/lib/shareTransfer', () => ({ + getShareTransfer, + deleteShareTransfer, +})) + +vi.mock('@/lib/composer-drafts', () => ({ + getDraft: () => '', +})) + +import { ShareSeedConsumer } from './ShareSeedConsumer' + +afterEach(() => { + cleanup() + setText.mockReset() + addAttachment.mockReset() + getShareTransfer.mockReset() + deleteShareTransfer.mockReset() + try { window.sessionStorage.clear() } catch { /* noop */ } +}) + +beforeEach(() => { + getShareTransfer.mockResolvedValue({ + title: '', + text: 'shared payload', + url: '', + files: [], + createdAt: Date.now(), + }) +}) + +describe('ShareSeedConsumer', () => { + it('leaves the pending key untouched while inactive, then seeds after retarget to a new active id', async () => { + setSharePendingTransfer('xfer-handoff', 'session-a') + + const inactive = render( + , + ) + expect(peekSharePendingTransfer()).toEqual({ transferId: 'xfer-handoff', sessionId: 'session-a' }) + expect(getShareTransfer).not.toHaveBeenCalled() + + inactive.unmount() + expect(peekSharePendingTransfer()).toEqual({ transferId: 'xfer-handoff', sessionId: 'session-a' }) + + // Unrelated active chat must not steal the pending share. + const other = render( + , + ) + await Promise.resolve() + expect(getShareTransfer).not.toHaveBeenCalled() + expect(peekSharePendingTransfer()).toEqual({ transferId: 'xfer-handoff', sessionId: 'session-a' }) + other.unmount() + + retargetSharePendingTransfer('session-a', 'session-b') + render() + + await waitFor(() => { + expect(getShareTransfer).toHaveBeenCalledWith('xfer-handoff') + expect(setText).toHaveBeenCalledWith('shared payload') + }) + expect(window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY)).toBeNull() + }) + + it('consumes and seeds when the same session flips from inactive to active', async () => { + setSharePendingTransfer('xfer-same-id', 'session-a') + + const { rerender } = render( + , + ) + expect(peekSharePendingTransfer()?.transferId).toBe('xfer-same-id') + expect(getShareTransfer).not.toHaveBeenCalled() + + rerender() + + await waitFor(() => { + expect(getShareTransfer).toHaveBeenCalledWith('xfer-same-id') + expect(setText).toHaveBeenCalledWith('shared payload') + }) + expect(window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY)).toBeNull() + }) + + it('seeds only once under StrictMode double-invoke', async () => { + setSharePendingTransfer('xfer-strict', 'session-a') + + render( + + + , + ) + + await waitFor(() => { + expect(setText).toHaveBeenCalledWith('shared payload') + }) + expect(getShareTransfer).toHaveBeenCalledTimes(1) + expect(setText).toHaveBeenCalledTimes(1) + expect(window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY)).toBeNull() + }) +}) diff --git a/web/src/components/ShareSeedConsumer.tsx b/web/src/components/ShareSeedConsumer.tsx new file mode 100644 index 0000000000..a2f824bb09 --- /dev/null +++ b/web/src/components/ShareSeedConsumer.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef } from 'react' +import { useAui, useAuiState } from '@assistant-ui/react' +import { consumeSharePendingTransfer } from '@/lib/sharePendingState' +import { deleteShareTransfer, getShareTransfer } from '@/lib/shareTransfer' +import { getDraft } from '@/lib/composer-drafts' + +/** + * Consumes a pending Web Share Target transfer once the assistant runtime + * is mounted and the session is active enough to accept attachments. + * + * Lifecycle: + * - The sessionStorage pending key is left untouched while + * `sessionActive` is false. Inactive mounts (e.g. sharing into a + * stopped session that then reopens under a new HAPI session id) must + * not steal the hand-off — otherwise the remounted chat never sees + * the transfer. Consume + seed happen in the same effect once active. + * - The pending slot is bound to a target session id, so a different + * active chat cannot claim a share armed for an inactive pick. + * Reopen paths that swap A → B must call + * `retargetSharePendingTransfer(A, B)` before navigating. + * - Consume runs in an effect, not during render — React.StrictMode + * double-invokes render functions in dev; a render-time consume + * would delete the key on the discarded pass. + * - `consumedRef` gates a single seed per component instance — refs + * survive a StrictMode mount/cleanup/remount pair, so the second + * effect invoke early-returns and the first invoke's async chain + * completes naturally (we deliberately don't cancel on cleanup; the + * upload is idempotent and composer side effects no-op once the + * runtime is unmounted). + * - The IDB row is deleted after the seed completes so a back-button + * refresh of /sessions/:id doesn't re-attach the same payload. + */ +export function ShareSeedConsumer(props: { sessionId: string; sessionActive: boolean }) { + const assistantApi = useAui() + const composerText = useAuiState((s) => s.composer.text) + const composerTextRef = useRef(composerText) + const consumedRef = useRef(false) + + useEffect(() => { + composerTextRef.current = composerText + }, [composerText]) + + useEffect(() => { + if (!props.sessionActive) return + if (consumedRef.current) return + const transferId = consumeSharePendingTransfer(props.sessionId) + if (!transferId) return + consumedRef.current = true + + void (async () => { + try { + const payload = await getShareTransfer(transferId) + if (!payload) return + const seedText = [payload.title, payload.text, payload.url] + .filter((part) => typeof part === 'string' && part.length > 0) + .join('\n') + .trim() + if (seedText.length > 0) { + const existingText = composerTextRef.current.trim().length > 0 + ? composerTextRef.current + : getDraft(props.sessionId) + const nextText = [existingText.trim(), seedText] + .filter((part) => part.length > 0) + .join('\n\n') + if (nextText.length > 0) { + assistantApi.composer().setText(nextText) + } + } + for (const file of payload.files) { + const reconstructed = new File([file.blob], file.name, { type: file.type }) + try { + await assistantApi.composer().addAttachment(reconstructed) + } catch (err) { + console.error('share-seed addAttachment failed', err) + } + } + await deleteShareTransfer(transferId).catch(() => {}) + } catch (err) { + console.error('share-seed pull failed', err) + } + })() + }, [props.sessionActive, props.sessionId, assistantApi]) + + return null +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7f7117191c..b2ca315325 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -1105,4 +1105,7 @@ export default { 'session.status.subagent.running': 'Running', 'session.status.subagent.waiting': 'Waiting', 'session.status.subagent.error': 'Error', + 'share.searchResults': 'Matching sessions', + 'share.noSearchResults': 'No sessions match your search.', + 'share.searchForMore': '{n} more active sessions — search to find them.', } as const diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 0b95581b11..9e9a401450 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -1104,4 +1104,7 @@ export default { 'session.status.subagent.running': '运行中', 'session.status.subagent.waiting': '等待中', 'session.status.subagent.error': '错误', + 'share.searchResults': '匹配的会话', + 'share.noSearchResults': '没有匹配的会话。', + 'share.searchForMore': '还有 {n} 个活跃会话 — 搜索以查找。', } as const diff --git a/web/src/lib/sharePendingState.test.ts b/web/src/lib/sharePendingState.test.ts index 0dd4420d9d..001718abd6 100644 --- a/web/src/lib/sharePendingState.test.ts +++ b/web/src/lib/sharePendingState.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { SHARE_PENDING_TRANSFER_KEY, consumeSharePendingTransfer, + peekSharePendingTransfer, + retargetSharePendingTransfer, setSharePendingTransfer, } from './sharePendingState' @@ -10,25 +12,46 @@ afterEach(() => { }) describe('sharePendingState', () => { - it('round-trips a transfer id and clears the slot on consume', () => { - setSharePendingTransfer('xfer-1') - expect(window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY)).toBe('xfer-1') + it('round-trips a transfer id bound to a session and clears on consume', () => { + setSharePendingTransfer('xfer-1', 'session-a') + expect(peekSharePendingTransfer()).toEqual({ transferId: 'xfer-1', sessionId: 'session-a' }) - const first = consumeSharePendingTransfer() + const first = consumeSharePendingTransfer('session-a') expect(first).toBe('xfer-1') - const second = consumeSharePendingTransfer() + const second = consumeSharePendingTransfer('session-a') expect(second).toBeNull() }) + it('does not let a different session steal a bound pending transfer', () => { + setSharePendingTransfer('xfer-bound', 'session-a') + expect(consumeSharePendingTransfer('session-b')).toBeNull() + expect(window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY)).not.toBeNull() + expect(consumeSharePendingTransfer('session-a')).toBe('xfer-bound') + }) + + it('retargets the pending session id across reopen id-swap', () => { + setSharePendingTransfer('xfer-1', 'session-a') + retargetSharePendingTransfer('session-a', 'session-b') + expect(peekSharePendingTransfer()).toEqual({ transferId: 'xfer-1', sessionId: 'session-b' }) + expect(consumeSharePendingTransfer('session-a')).toBeNull() + expect(consumeSharePendingTransfer('session-b')).toBe('xfer-1') + }) + it('returns null when no transfer is pending', () => { - expect(consumeSharePendingTransfer()).toBeNull() + expect(consumeSharePendingTransfer('any')).toBeNull() }) it('overwrites a stale id rather than appending', () => { - setSharePendingTransfer('a') - setSharePendingTransfer('b') - expect(consumeSharePendingTransfer()).toBe('b') - expect(consumeSharePendingTransfer()).toBeNull() + setSharePendingTransfer('a', 'session-a') + setSharePendingTransfer('b', 'session-a') + expect(consumeSharePendingTransfer('session-a')).toBe('b') + expect(consumeSharePendingTransfer('session-a')).toBeNull() + }) + + it('claims a legacy unbound bare transfer id string', () => { + window.sessionStorage.setItem(SHARE_PENDING_TRANSFER_KEY, 'legacy-xfer') + expect(consumeSharePendingTransfer('session-any')).toBe('legacy-xfer') + expect(consumeSharePendingTransfer('session-any')).toBeNull() }) }) diff --git a/web/src/lib/sharePendingState.ts b/web/src/lib/sharePendingState.ts index 3673b54d6c..9000053f63 100644 --- a/web/src/lib/sharePendingState.ts +++ b/web/src/lib/sharePendingState.ts @@ -4,7 +4,8 @@ * * The picker stores the IDB transfer id under this key, navigates to * `/sessions/:id` (or `/sessions/new`), and the session mounter reads + clears - * the key on first render. sessionStorage rather than router state because: + * the key once that session is active. sessionStorage rather than router + * state because: * * - it survives the `/sessions/new` -> `/sessions/:id` navigation that * `NewSessionPage` performs internally with `replace: true`, which @@ -13,29 +14,90 @@ * target in the installed PWA's own window, so collisions with other * tabs are not a concern. * - * The key is read **once** per mount; consume() returns the id and clears - * the slot atomically so a refresh of /sessions/:id doesn't replay the - * upload. + * The payload is bound to a **target session id**: + * - Consume only succeeds when the mounting SessionChat's id matches. + * - That prevents an unrelated active chat from stealing a pending + * share that was armed for an inactive target. + * - When reopen/spawn swaps A → B, call `retargetSharePendingTransfer(A, B)` + * before navigating so B can still seed. */ export const SHARE_PENDING_TRANSFER_KEY = 'hapi.share.pendingTransferId' -export function setSharePendingTransfer(transferId: string): void { +type SharePendingRecord = { + transferId: string + sessionId: string +} + +function readRecord(): SharePendingRecord | null { + try { + const raw = window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY) + if (!raw) return null + // Legacy: bare transfer id string (pre-session-binding). Treat as + // unbound — any active consumer may claim it (restore old behavior + // for in-flight shares during deploy). Prefer writing the object form. + if (raw[0] !== '{') { + return { transferId: raw, sessionId: '' } + } + const parsed = JSON.parse(raw) as Partial + if (typeof parsed.transferId !== 'string' || typeof parsed.sessionId !== 'string') { + return null + } + return { transferId: parsed.transferId, sessionId: parsed.sessionId } + } catch { + return null + } +} + +function writeRecord(record: SharePendingRecord): void { + window.sessionStorage.setItem(SHARE_PENDING_TRANSFER_KEY, JSON.stringify(record)) +} + +export function setSharePendingTransfer(transferId: string, sessionId: string): void { try { - window.sessionStorage.setItem(SHARE_PENDING_TRANSFER_KEY, transferId) + writeRecord({ transferId, sessionId }) } catch { // Quota errors / disabled storage — caller proceeds without seed. } } -export function consumeSharePendingTransfer(): string | null { +/** + * Claim the pending transfer for `sessionId`. Returns null (and leaves the + * slot alone) when the pending target is a different session — so another + * active chat cannot steal a share armed for an inactive pick. + * + * Legacy unbound records (`sessionId === ''`) are claimed by the first caller. + */ +export function consumeSharePendingTransfer(sessionId: string): string | null { try { - const value = window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY) - if (value) { - window.sessionStorage.removeItem(SHARE_PENDING_TRANSFER_KEY) + const record = readRecord() + if (!record) return null + if (record.sessionId !== '' && record.sessionId !== sessionId) { + return null } - return value + window.sessionStorage.removeItem(SHARE_PENDING_TRANSFER_KEY) + return record.transferId } catch { return null } } + +/** + * When resume/reopen merges session `fromSessionId` into a new live id + * `toSessionId`, rewrite the pending target so the remounted chat can seed. + */ +export function retargetSharePendingTransfer(fromSessionId: string, toSessionId: string): void { + if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) return + try { + const record = readRecord() + if (!record) return + if (record.sessionId !== fromSessionId) return + writeRecord({ transferId: record.transferId, sessionId: toSessionId }) + } catch { + // Ignore storage failures — seed is best-effort. + } +} + +export function peekSharePendingTransfer(): SharePendingRecord | null { + return readRecord() +} diff --git a/web/src/lib/sharePickerSessions.test.ts b/web/src/lib/sharePickerSessions.test.ts new file mode 100644 index 0000000000..77aa0812b3 --- /dev/null +++ b/web/src/lib/sharePickerSessions.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import type { SessionSummary } from '@/types/api' +import { DEFAULT_SESSION_PREVIEW_LIMIT } from '@/hooks/useSessionPreviewLimit' +import { + countHiddenActiveSharePickerSessions, + filterSharePickerSessions, +} from './sharePickerSessions' + +function makeSession(overrides: Partial & { id: string }): SessionSummary { + return { + active: false, + thinking: false, + activeAt: 0, + updatedAt: 0, + metadata: null, + metadataVersion: 0, + agentStateVersion: 0, + todosUpdatedAt: 0, + todoProgress: null, + pendingRequestsCount: 0, + pendingRequestKinds: [], + pendingRequests: [], + backgroundTaskCount: 0, + futureScheduledMessageCount: 0, + nextScheduledAt: null, + model: null, + effort: null, + ...overrides, + } +} + +const machineLabel = () => 'desktop' + +describe('filterSharePickerSessions', () => { + it('returns active sessions sorted by updatedAt when query is empty', () => { + const sessions = [ + makeSession({ id: 'old-active', active: true, updatedAt: 100 }), + makeSession({ id: 'inactive-recent', active: false, updatedAt: 300 }), + makeSession({ id: 'new-active', active: true, updatedAt: 200 }), + ] + const result = filterSharePickerSessions(sessions, '', machineLabel) + expect(result.map((s) => s.id)).toEqual(['new-active', 'old-active']) + }) + + it('caps active sessions when query is empty', () => { + const previewLimit = DEFAULT_SESSION_PREVIEW_LIMIT + const sessions = Array.from({ length: previewLimit + 3 }, (_, index) => + makeSession({ id: `s-${index}`, active: true, updatedAt: index })) + const result = filterSharePickerSessions(sessions, '', machineLabel, null, previewLimit) + expect(result).toHaveLength(previewLimit) + expect(result[0].id).toBe(`s-${previewLimit + 2}`) + }) + + it('searches all sessions including inactive when query is non-empty', () => { + const sessions = [ + makeSession({ + id: 'inactive-match', + active: false, + updatedAt: 50, + metadata: { path: '/proj/archive', summary: { text: 'Old bugfix' } }, + }), + makeSession({ id: 'active-no-match', active: true, updatedAt: 200, metadata: { path: '/other' } }), + ] + const result = filterSharePickerSessions(sessions, 'archive', machineLabel) + expect(result.map((s) => s.id)).toEqual(['inactive-match']) + }) + + it('matches machine label in search mode', () => { + const sessions = [ + makeSession({ + id: 'remote', + active: false, + updatedAt: 100, + metadata: { path: '/proj', machineId: 'machine-abc' }, + }), + ] + const result = filterSharePickerSessions( + sessions, + 'laptop', + (machineId) => (machineId === 'machine-abc' ? 'dev-laptop' : 'unknown'), + ) + expect(result.map((s) => s.id)).toEqual(['remote']) + }) +}) + +describe('countHiddenActiveSharePickerSessions', () => { + it('returns zero when active count is within cap', () => { + const sessions = [ + makeSession({ id: 'a', active: true }), + makeSession({ id: 'b', active: false }), + ] + expect(countHiddenActiveSharePickerSessions(sessions)).toBe(0) + }) + + it('counts active sessions beyond the cap', () => { + const previewLimit = DEFAULT_SESSION_PREVIEW_LIMIT + const sessions = Array.from({ length: previewLimit + 2 }, (_, index) => + makeSession({ id: `s-${index}`, active: true })) + expect(countHiddenActiveSharePickerSessions(sessions, previewLimit)).toBe(2) + }) + + it('honors a custom preview limit', () => { + const sessions = Array.from({ length: 5 }, (_, index) => + makeSession({ id: `s-${index}`, active: true, updatedAt: index })) + expect(filterSharePickerSessions(sessions, '', machineLabel, null, 3)).toHaveLength(3) + expect(countHiddenActiveSharePickerSessions(sessions, 3)).toBe(2) + }) +}) diff --git a/web/src/lib/sharePickerSessions.ts b/web/src/lib/sharePickerSessions.ts new file mode 100644 index 0000000000..75400dda3d --- /dev/null +++ b/web/src/lib/sharePickerSessions.ts @@ -0,0 +1,54 @@ +import type { SessionSummary } from '@/types/api' +import { DEFAULT_SESSION_PREVIEW_LIMIT } from '@/hooks/useSessionPreviewLimit' +import { + normalizeSearch, + sessionMatchesQuery, + sessionMatchesTimeRange, + type SessionTimeRange, +} from '@/components/SessionList' + +export type SharePickerMachineLabelResolver = (machineId: string | null) => string + +function sortByUpdatedAtDesc(sessions: SessionSummary[]): SessionSummary[] { + return [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) +} + +/** + * Share-target session picker filter. + * Empty query (and no date range): recent active sessions (capped). + * Non-empty query and/or date range: all sessions matching those filters. + */ +export function filterSharePickerSessions( + sessions: SessionSummary[], + query: string, + resolveMachineLabel: SharePickerMachineLabelResolver, + timeRange: SessionTimeRange | null = null, + previewLimit: number = DEFAULT_SESSION_PREVIEW_LIMIT, +): SessionSummary[] { + const normalizedQuery = normalizeSearch(query) + const sorted = sortByUpdatedAtDesc(sessions) + const inRange = (session: SessionSummary) => sessionMatchesTimeRange(session, timeRange) + + if (normalizedQuery || timeRange) { + return sorted.filter((session) => { + if (!inRange(session)) return false + if (!normalizedQuery) return true + return sessionMatchesQuery( + session, + normalizedQuery, + resolveMachineLabel(session.metadata?.machineId ?? null), + ) + }) + } + + return sorted.filter((session) => session.active).slice(0, previewLimit) +} + +/** Active sessions hidden by the empty-query cap (for "search for more" hint). */ +export function countHiddenActiveSharePickerSessions( + sessions: SessionSummary[], + previewLimit: number = DEFAULT_SESSION_PREVIEW_LIMIT, +): number { + const activeCount = sessions.filter((session) => session.active).length + return Math.max(0, activeCount - previewLimit) +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 6b796b64e0..6e519a22c9 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -50,7 +50,7 @@ import { inactiveSessionCanResume } from '@/lib/sessionResume' import { initializeSessionLastSeen, markSessionSeen } from '@/lib/sessionLastSeen' import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle' import { clearCodexImportedSession } from '@/lib/codexImportedSessions' -import { getSupersedingSessionId, shouldFollowSupersedingSession } from '@/routes/sessions/followSupersedingSession' +import { getSupersedingSessionId, prepareFollowSupersedingSession, shouldFollowSupersedingSession } from '@/routes/sessions/followSupersedingSession' import { migrateSuppressedSendError } from '@/lib/suppressed-send-error' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' @@ -68,7 +68,7 @@ import SettingsAboutPage from '@/routes/settings/about' import SettingsStoragePage from '@/routes/settings/storage' import SettingsUsagePage from '@/routes/settings/usage' import SharePage from '@/routes/share' -import { setSharePendingTransfer } from '@/lib/sharePendingState' +import { retargetSharePendingTransfer, setSharePendingTransfer } from '@/lib/sharePendingState' import { deleteShareTransfer, parseShareSearch } from '@/lib/shareTransfer' @@ -436,6 +436,7 @@ function SessionPage() { await queryClient.invalidateQueries({ queryKey: queryKeys.session(result.sessionId) }) await queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) if (result.sessionId && result.sessionId !== errorSessionId) { + retargetSharePendingTransfer(errorSessionId, result.sessionId) await transferComposerDraftThenNavigate( errorSessionId, result.sessionId, @@ -537,6 +538,7 @@ function SessionPage() { const handleSessionResolved = useCallback((resolvedSessionId: string) => { if (session) { if (resolvedSessionId !== session.id) { + retargetSharePendingTransfer(session.id, resolvedSessionId) seedMessageWindowFromSession(session.id, resolvedSessionId) } queryClient.setQueryData(queryKeys.session(resolvedSessionId), (previous: { session?: typeof session } | undefined) => ({ @@ -840,6 +842,7 @@ function SessionDetailRoute() { ) observedSessionRef.current = { sessionId, supersedingSessionId } if (!shouldFollow || !supersedingSessionId) return + prepareFollowSupersedingSession(sessionId, supersedingSessionId) navigate({ to: '/sessions/$sessionId', params: { sessionId: supersedingSessionId }, @@ -883,7 +886,7 @@ function NewSessionPage() { const handleSuccess = useCallback((sessionId: string) => { if (shareTransferId) { - setSharePendingTransfer(shareTransferId) + setSharePendingTransfer(shareTransferId, sessionId) } void queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) // Replace current page with /sessions to clear spawn flow from history diff --git a/web/src/routes/sessions/followSupersedingSession.test.ts b/web/src/routes/sessions/followSupersedingSession.test.ts index 3d4845acad..03fb783072 100644 --- a/web/src/routes/sessions/followSupersedingSession.test.ts +++ b/web/src/routes/sessions/followSupersedingSession.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, it } from 'vitest' -import { getSupersedingSessionId, shouldFollowSupersedingSession } from './followSupersedingSession' +import { describe, expect, it, afterEach } from 'vitest' +import { + getSupersedingSessionId, + prepareFollowSupersedingSession, + shouldFollowSupersedingSession, +} from './followSupersedingSession' +import { + consumeSharePendingTransfer, + setSharePendingTransfer, +} from '@/lib/sharePendingState' + +afterEach(() => { + try { window.sessionStorage.clear() } catch { /* noop */ } +}) describe('getSupersedingSessionId', () => { it('follows a different persisted replacement identity', () => { @@ -41,3 +53,21 @@ describe('shouldFollowSupersedingSession', () => { })).toBe(false) }) }) + +describe('prepareFollowSupersedingSession', () => { + it('retargets a pending share transfer before the automatic A→B navigation', () => { + setSharePendingTransfer('xfer-share', 'source') + const shouldFollow = shouldFollowSupersedingSession({ + sessionId: 'source', + supersedingSessionId: null, + }, 'source', { + supersededBySessionId: 'fresh', + }) + expect(shouldFollow).toBe(true) + + prepareFollowSupersedingSession('source', 'fresh') + + expect(consumeSharePendingTransfer('source')).toBeNull() + expect(consumeSharePendingTransfer('fresh')).toBe('xfer-share') + }) +}) diff --git a/web/src/routes/sessions/followSupersedingSession.ts b/web/src/routes/sessions/followSupersedingSession.ts index ee0365d55b..b0c96c5d2e 100644 --- a/web/src/routes/sessions/followSupersedingSession.ts +++ b/web/src/routes/sessions/followSupersedingSession.ts @@ -1,3 +1,5 @@ +import { retargetSharePendingTransfer } from '@/lib/sharePendingState' + export function getSupersedingSessionId( currentSessionId: string, metadata: { supersededBySessionId?: string } | null | undefined @@ -18,3 +20,15 @@ export function shouldFollowSupersedingSession( && previous.supersedingSessionId === null && getSupersedingSessionId(currentSessionId, metadata) !== null } + +/** + * Side effects that must run before navigating A → B on automatic + * supersession. Keeps a share-target pending transfer bound to the live + * session id so ShareSeedConsumer on B can still claim it. + */ +export function prepareFollowSupersedingSession( + fromSessionId: string, + toSessionId: string, +): void { + retargetSharePendingTransfer(fromSessionId, toSessionId) +} diff --git a/web/src/routes/share/index.test.tsx b/web/src/routes/share/index.test.tsx index 36e11a7501..601b0235d4 100644 --- a/web/src/routes/share/index.test.tsx +++ b/web/src/routes/share/index.test.tsx @@ -21,6 +21,14 @@ vi.mock('@/hooks/queries/useSessions', () => ({ useSessions: () => ({ sessions: [], isLoading: false }), })) +vi.mock('@/hooks/queries/useMachines', () => ({ + useMachines: () => ({ machines: [] }), +})) + +vi.mock('@/hooks/useMachineLabels', () => ({ + useMachineLabels: () => ({}), +})) + vi.mock('@/lib/use-translation', () => ({ useTranslation: () => ({ t: (key: string) => key }), })) diff --git a/web/src/routes/share/index.tsx b/web/src/routes/share/index.tsx index bd6c78c241..1b862ede8c 100644 --- a/web/src/routes/share/index.tsx +++ b/web/src/routes/share/index.tsx @@ -2,8 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useAppContext } from '@/lib/app-context' import { useSessions } from '@/hooks/queries/useSessions' +import { useMachines } from '@/hooks/queries/useMachines' +import { useMachineLabels } from '@/hooks/useMachineLabels' import { useTranslation } from '@/lib/use-translation' import { LoadingState } from '@/components/LoadingState' +import { SessionListSearch, getSessionTimeRange, prepareSidebarSessions } from '@/components/SessionList' +import { + countHiddenActiveSharePickerSessions, + filterSharePickerSessions, +} from '@/lib/sharePickerSessions' +import { useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit' import { buildSharePayloadFromDeepLink, deleteShareTransfer, @@ -109,6 +117,27 @@ export default function SharePage() { const navigate = useNavigate() const [load, setLoad] = useState({ state: 'loading' }) const { sessions, isLoading: sessionsLoading } = useSessions(api) + const { machines } = useMachines(api, true) + const machineLabelsById = useMachineLabels(machines) + const { sessionPreviewLimit } = useSessionPreviewLimit() + const [searchQuery, setSearchQuery] = useState('') + const [searchExpanded, setSearchExpanded] = useState(true) + const [customStart, setCustomStart] = useState('') + const [customEnd, setCustomEnd] = useState('') + const timeRange = useMemo( + () => getSessionTimeRange(customStart, customEnd), + [customStart, customEnd], + ) + + const resolveMachineLabel = useCallback((machineId: string | null): string => { + if (machineId && machineLabelsById[machineId]) { + return machineLabelsById[machineId] + } + if (machineId) { + return machineId.slice(0, 8) + } + return t('machine.unknown') + }, [machineLabelsById, t]) // Pulled via the typed validateSearch in router.tsx; reading // `window.location.search` directly would diverge from the rest of the @@ -170,31 +199,50 @@ export default function SharePage() { return () => { cancelled = true } }, [transferId, ingestError, navigate, deepLink]) - // Snapshot the active session list once when sessions finish loading so - // the picker doesn't re-shuffle under the operator's finger as SSE - // updates roll in (activeAt heartbeats nudge the order every few - // seconds; even updatedAt-keyed sorts visually flicker on every - // metadata patch). The picker is a one-shot interaction — closing the - // share sheet and re-sharing produces a fresh snapshot. Sorted by - // updatedAt desc to match SessionList's canonical "most recent - // interaction first" order. - const [pickerSessions, setPickerSessions] = useState(null) + // Snapshot the session list once when sessions finish loading so the + // picker doesn't re-shuffle under the operator's finger as SSE updates + // roll in. The picker is a one-shot interaction — closing the share + // sheet and re-sharing produces a fresh snapshot. + const [sessionsSnapshot, setSessionsSnapshot] = useState(null) useEffect(() => { - if (pickerSessions !== null) return + if (sessionsSnapshot !== null) return if (sessionsLoading) return - setPickerSessions( - [...sessions] - .filter((s) => s.active) - .sort((a, b) => b.updatedAt - a.updatedAt) + setSessionsSnapshot(prepareSidebarSessions(sessions)) + }, [sessionsSnapshot, sessions, sessionsLoading]) + + const isSearching = searchQuery.trim().length > 0 || timeRange !== null + const sessionActivityDates = useMemo(() => { + if (!sessionsSnapshot) return new Set() + return new Set(sessionsSnapshot.map((session) => { + const date = new Date(session.updatedAt) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + })) + }, [sessionsSnapshot]) + const pickerSessions = useMemo(() => { + if (!sessionsSnapshot) return null + return filterSharePickerSessions( + sessionsSnapshot, + searchQuery, + resolveMachineLabel, + timeRange, + sessionPreviewLimit, ) - }, [pickerSessions, sessions, sessionsLoading]) + }, [sessionsSnapshot, searchQuery, resolveMachineLabel, timeRange, sessionPreviewLimit]) + + const hiddenActiveCount = useMemo(() => { + if (!sessionsSnapshot || isSearching) return 0 + return countHiddenActiveSharePickerSessions(sessionsSnapshot, sessionPreviewLimit) + }, [sessionsSnapshot, isSearching, sessionPreviewLimit]) const handlePickSession = useCallback((sessionId: string) => { if (!transferId) return // Don't await deleteShareTransfer here — SessionChat consumes the // payload then deletes the IDB row (it owns the lifecycle once we // hand off). If we delete here, SessionChat won't find it. - setSharePendingTransfer(transferId) + setSharePendingTransfer(transferId, sessionId) navigate({ to: '/sessions/$sessionId', params: { sessionId } }) }, [navigate, transferId]) @@ -274,37 +322,57 @@ export default function SharePage() {
- {t('share.recentSessions')} + {isSearching ? t('share.searchResults') : t('share.recentSessions')}
+ { + setCustomStart(start) + setCustomEnd(end) + }} + expanded={searchExpanded} + onExpandedChange={setSearchExpanded} + /> {pickerSessions === null ? ( ) : pickerSessions.length === 0 ? (
- {t('share.noActiveSessions')} + {isSearching ? t('share.noSearchResults') : t('share.noActiveSessions')}
) : ( -
    - {pickerSessions.map((session) => ( -
  • - -
  • - ))} -
+ {session.metadata?.path ? ( +
+ {session.metadata.path} +
+ ) : null} +
+ + + ))} + + {hiddenActiveCount > 0 ? ( +
+ {t('share.searchForMore', { n: hiddenActiveCount })} +
+ ) : null} + )}
From ee52ede803ef9c9db9648b4bd1fa693c9c8864bf Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:05:45 +0000 Subject: [PATCH 063/142] fix(cli): win32 peer-broker auth when named-pipe PID is unavailable Bun exposes _handle.fd=-1 on Windows pipes, so treating null peercred as auth_failed stranded in-session ping-peer. Match inject: pipe-path possession is the Windows auth, keep attributed delivery instead of dropping to unattributed. Co-authored-by: Cursor --- cli/src/api/peerDeliverBroker.test.ts | 60 +++++++++++++++++++++++++++ cli/src/api/peerDeliverBroker.ts | 34 ++++++++++++--- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/cli/src/api/peerDeliverBroker.test.ts b/cli/src/api/peerDeliverBroker.test.ts index 101c2289bb..6dab9c0efa 100644 --- a/cli/src/api/peerDeliverBroker.test.ts +++ b/cli/src/api/peerDeliverBroker.test.ts @@ -6,6 +6,7 @@ import { createConnection } from 'node:net' import { MAX_UNIX_SOCKET_PATH_BYTES, PeerDeliverBroker, + authorizeBrokerListener, defaultBrokerSocketPath, requestParentPeerDeliver, } from './peerDeliverBroker' @@ -36,6 +37,7 @@ vi.mock('@/ui/logger', () => ({ describe('PeerDeliverBroker', () => { const dirs: string[] = [] const previousXdg = process.env.XDG_RUNTIME_DIR + const originalPlatform = process.platform afterEach(() => { for (const dir of dirs.splice(0)) { @@ -47,6 +49,11 @@ describe('PeerDeliverBroker', () => { } else { process.env.XDG_RUNTIME_DIR = previousXdg } + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }) + delete process.env[HAPI_SESSION_ID_ENV] }) it('keeps the default socket path within the portable unix pathname budget', () => { @@ -194,6 +201,59 @@ describe('PeerDeliverBroker', () => { } }) + it('authorizeBrokerListener: win32 allows null cred (Bun fd=-1)', () => { + expect(authorizeBrokerListener(null, process.pid, process.pid, 'win32')).toBe(true) + expect(authorizeBrokerListener(null, undefined, process.pid, 'linux')).toBe(false) + expect(authorizeBrokerListener( + { pid: process.pid, uid: 0, gid: 0 }, + process.pid, + process.pid, + 'win32', + )).toBe(true) + expect(authorizeBrokerListener( + { pid: process.pid + 99_999, uid: 0, gid: 0 }, + process.pid, + process.pid, + 'win32', + )).toBe(false) + }) + + it('delivers on win32 when named-pipe peer pid is unavailable', async () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + process.env[HAPI_SESSION_ID_ENV] = '6212dae5-8a60-4284-b7a5-c09aa3571ce4' + const dir = mkdtempSync(join(tmpdir(), 'hapi-peer-broker-')) + dirs.push(dir) + const socketPath = join(dir, 'win32.sock') + pingPeerMock.mockResolvedValue({ + sessionId: '05d9f0f2-9273-4137-933c-07459a1146a2', + name: 'Target', + resumed: false, + }) + const broker = new PeerDeliverBroker({ + sessionId: '6212dae5-8a60-4284-b7a5-c09aa3571ce4', + sessionCapability: 'cap-win32', + ownerPid: process.pid, + socketPath, + readPeerCred: () => null, + }) + await broker.start() + try { + const result = await requestParentPeerDeliver({ + sessionIdPrefix: '05d9f0f2', + message: 'teemo ping', + socketPath, + readPeerCred: () => null, + }) + expect(result.sessionId).toBe('05d9f0f2-9273-4137-933c-07459a1146a2') + expect(pingPeerMock).toHaveBeenCalledWith(expect.objectContaining({ + sessionCapability: 'cap-win32', + message: 'teemo ping', + })) + } finally { + broker.stop() + } + }) + it('client rejects a listener that is not an ancestor (M3)', async () => { process.env[HAPI_SESSION_ID_ENV] = '6212dae5-8a60-4284-b7a5-c09aa3571ce4' const dir = mkdtempSync(join(tmpdir(), 'hapi-peer-broker-')) diff --git a/cli/src/api/peerDeliverBroker.ts b/cli/src/api/peerDeliverBroker.ts index bc8939dd38..7be8769c07 100644 --- a/cli/src/api/peerDeliverBroker.ts +++ b/cli/src/api/peerDeliverBroker.ts @@ -5,8 +5,9 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { logger } from '@/ui/logger' import { isProcessDescendant } from './processDescendant' -import { readUnixPeerCredentials, type PeerCredReader } from './peercred' +import { readUnixPeerCredentials, type PeerCredentials, type PeerCredReader } from './peercred' import { + authorizePeerCapInjectClient, readWindowsNamedPipeClientCredentials, readWindowsNamedPipeServerCredentials, } from './peerCapabilityInject' @@ -144,7 +145,7 @@ export class PeerDeliverBroker { private async handleConnection(socket: Socket): Promise { const cred = this.readPeerCred(socket) - if (!cred || !isProcessDescendant(cred.pid, this.ownerPid)) { + if (!authorizePeerCapInjectClient(cred, this.ownerPid)) { socket.end(`${JSON.stringify({ ok: false, code: 'auth_failed', @@ -250,6 +251,31 @@ function readSocketLine(socket: Socket): Promise { }) } +/** + * Child verifies the broker listener. + * + * Linux/macOS: SO_PEERCRED and ancestor (or exported server PID). + * Win32: Bun named-pipe `_handle.fd === -1`, so GetNamedPipeServerProcessId + * never runs. Null cred + possession of the ephemeral pipe path (env) is the + * auth — same as inject. Mapping that to `auth_failed` strands `hapi ping-peer` + * on Teemo; `broker_unavailable` would drop every live Windows broker to + * unattributed. + */ +export function authorizeBrokerListener( + cred: PeerCredentials | null, + expectedServerPid: number | undefined, + childPid: number = process.pid, + platform: NodeJS.Platform = process.platform, +): boolean { + if (!cred) { + return platform === 'win32' + } + if (expectedServerPid !== undefined) { + return cred.pid === expectedServerPid + } + return isProcessDescendant(childPid, cred.pid) +} + /** Child-side: ask the session parent to deliver an attributed ping. */ export async function requestParentPeerDeliver(options: { sessionIdPrefix: string @@ -305,9 +331,7 @@ export async function requestParentPeerDeliver(options: { // M3: verify the listener is an ancestor (Unix) or the exported // server PID (Windows — ancestry walk is unavailable). const cred = readPeerCred(socket) - const authorized = expectedServerPid !== undefined - ? cred?.pid === expectedServerPid - : Boolean(cred && isProcessDescendant(process.pid, cred.pid)) + const authorized = authorizeBrokerListener(cred, expectedServerPid) if (!authorized) { const err = new PingPeerError( 'auth_failed', From 1cd4d1137a13c2501f5f1a251d8ab877a11f4874 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:24:39 +0100 Subject: [PATCH 064/142] feat(hub,cli,web): fleet runner version governance (skew, self-upgrade, soft-fail reopen) (#1108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew Hub↔runner protocol drift was reported as missing Cursor chat data when cursor-chat-store-status was unregistered. Soft-fail reopen on probe errors, advertise required machine capabilities, surface an unmissable upgrade banner, and stop-runner when a newer CLI binary is already on disk. Fixes #1084 Co-authored-by: Cursor * fix(web,hub): make runner skew banner dismissible; gate auto-upgrade Compact the out-of-date banner (minimize + 1h snooze + per-host Restart) so it no longer blocks the session list. Auto stop-runner on skew stays opt-in via HAPI_AUTO_UPGRADE_RUNNERS / autoUpgradeRunners (default off). Co-authored-by: Cursor * fix(web): tolerate full sessionStorage on skew banner minimize QuotaExceededError from setItem aborted minimize before React state updated, leaving the banner stuck over the session list. Persist to memory when storage fails; only enable Restart when a newer CLI is already on disk; clarify opt-in is stop-runner only, not package push. Co-authored-by: Cursor * fix(hub): drop redundant autoUpgradeRunners; runners already self-restart CLI version handoff already reloads the runner when the on-disk binary mtime changes. Hub-driven stop-runner on skew duplicated that. Keep the skew banner and manual Restart only as a stuck/disabled-handoff escape. Co-authored-by: Cursor * fix(cli,hub,web): runner-only caps ads; gate Restart on supervisor Address #1108 bot Majors on the thin tip: terminal/lazy bootstraps no longer merge CURRENT_MACHINE_CAPABILITIES into the machine row (only asRunner registration does). Banner Restart refuses unsupervised hosts so stop-runner cannot leave a detached laptop offline; supervised runners advertise supervisedRestart via HAPI_RUNNER_SUPERVISED=1. Co-authored-by: Cursor * fix(hub,cli,web): clear sticky runner ads; docs SUPERVISED; i18n skew label Omit-means-clear on runner registration so rollback cannot leave supervisedRestart/capabilities sticky; always advertise boolean supervisedRestart from asRunner. Document HAPI_RUNNER_SUPERVISED=1 and localize MachineSelector UPDATE REQUIRED. Co-authored-by: Cursor --------- Co-authored-by: Debian Co-authored-by: Cursor --- cli/src/agent/sessionFactory.test.ts | 43 ++- cli/src/agent/sessionFactory.ts | 32 +- cli/src/api/apiMachine.ts | 14 + cli/src/runner/run.ts | 6 +- docs/guide/deployment.md | 12 +- docs/guide/installation.md | 6 +- hub/src/store/machines.test.ts | 79 +++++ hub/src/store/machines.ts | 33 +- hub/src/sync/rpcGateway.ts | 4 + hub/src/sync/runnerEnsure.test.ts | 75 +++++ hub/src/sync/sessionModel.test.ts | 53 +++ hub/src/sync/syncEngine.ts | 58 +++- hub/src/web/routes/machines.ts | 22 ++ shared/package.json | 1 + shared/src/index.ts | 1 + shared/src/runnerCapabilities.test.ts | 48 +++ shared/src/runnerCapabilities.ts | 58 ++++ shared/src/schemas.ts | 13 +- web/src/App.tsx | 2 + web/src/api/client.ts | 7 + .../components/NewSession/MachineSelector.tsx | 16 +- .../RunnerVersionSkewBanner.test.tsx | 303 ++++++++++++++++++ .../components/RunnerVersionSkewBanner.tsx | 227 +++++++++++++ web/src/components/SessionActionMenu.test.tsx | 16 + web/src/components/SessionActionMenu.tsx | 13 +- web/src/components/SessionChat.tsx | 2 + web/src/components/SessionHeader.tsx | 2 + web/src/components/SessionList.tsx | 22 +- web/src/lib/locales/en.ts | 19 ++ web/src/lib/locales/zh-CN.ts | 19 ++ web/src/lib/runnerSkewBannerState.test.ts | 51 +++ web/src/lib/runnerSkewBannerState.ts | 93 ++++++ web/src/lib/sessionResume.test.ts | 37 ++- web/src/lib/sessionResume.ts | 37 ++- web/src/router.tsx | 23 +- 35 files changed, 1405 insertions(+), 42 deletions(-) create mode 100644 hub/src/sync/runnerEnsure.test.ts create mode 100644 shared/src/runnerCapabilities.test.ts create mode 100644 web/src/components/RunnerVersionSkewBanner.test.tsx create mode 100644 web/src/components/RunnerVersionSkewBanner.tsx create mode 100644 web/src/lib/runnerSkewBannerState.test.ts create mode 100644 web/src/lib/runnerSkewBannerState.ts diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index 01789e1a67..fb50342e75 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Session } from '@/api/types' const { @@ -29,7 +29,8 @@ vi.mock('@/api/api', () => ({ })) vi.mock('@/runner/controlClient', () => ({ - notifyRunnerSessionStarted: notifyRunnerSessionStartedMock + notifyRunnerSessionStarted: notifyRunnerSessionStartedMock, + getInstalledCliMtimeMs: () => 1_700_000_000_000, })) vi.mock('@/persistence', () => ({ @@ -55,6 +56,7 @@ import { bootstrapExistingSession, bootstrapLazySession, bootstrapSession, + buildMachineMetadata, buildSessionMetadata } from './sessionFactory' @@ -363,3 +365,40 @@ describe('bootstrapSession HAPI_SESSION_ID export', () => { expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hub-session-42') }) }) + +describe('buildMachineMetadata runner-only capabilities', () => { + const originalSupervised = process.env.HAPI_RUNNER_SUPERVISED + + afterEach(() => { + if (originalSupervised === undefined) { + delete process.env.HAPI_RUNNER_SUPERVISED + } else { + process.env.HAPI_RUNNER_SUPERVISED = originalSupervised + } + }) + + it('omits machine RPC capabilities for terminal bootstrap metadata', () => { + delete process.env.HAPI_RUNNER_SUPERVISED + const metadata = buildMachineMetadata() + expect(metadata.capabilities).toBeUndefined() + expect(metadata.startedCliMtimeMs).toBeUndefined() + expect(metadata.installedCliMtimeMs).toBeUndefined() + expect(metadata.supervisedRestart).toBeUndefined() + }) + + it('advertises capabilities and supervisedRestart only for asRunner', () => { + process.env.HAPI_RUNNER_SUPERVISED = '1' + const metadata = buildMachineMetadata({ asRunner: true, startedCliMtimeMs: 42 }) + expect(metadata.capabilities).toEqual(expect.arrayContaining(['cursor-chat-store-status', 'stop-runner'])) + expect(metadata.startedCliMtimeMs).toBe(42) + expect(metadata.installedCliMtimeMs).toBe(1_700_000_000_000) + expect(metadata.supervisedRestart).toBe(true) + }) + + it('always sends supervisedRestart boolean for asRunner so sticky true can clear', () => { + delete process.env.HAPI_RUNNER_SUPERVISED + const metadata = buildMachineMetadata({ asRunner: true }) + expect(metadata.capabilities).toEqual(expect.arrayContaining(['stop-runner'])) + expect(metadata.supervisedRestart).toBe(false) + }) +}) diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index c7fbbd1b7a..e42e767abc 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -5,13 +5,14 @@ import { resolve } from 'node:path' import { ApiClient } from '@/api/api' import type { ApiSessionClient } from '@/api/apiSession' import type { AgentState, MachineMetadata, Metadata, Session } from '@/api/types' -import { notifyRunnerSessionStarted } from '@/runner/controlClient' +import { getInstalledCliMtimeMs, notifyRunnerSessionStarted } from '@/runner/controlClient' import { readSettings } from '@/persistence' import { configuration } from '@/configuration' import { logger } from '@/ui/logger' import { runtimePath } from '@/projectPath' import { getInvokedCwd } from '@/utils/invokedCwd' import { readWorktreeEnv } from '@/utils/worktreeEnv' +import { CURRENT_MACHINE_CAPABILITIES } from '@hapi/protocol/runnerCapabilities' import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv' import packageJson from '../../package.json' @@ -41,15 +42,38 @@ export type SessionBootstrapResult = { workingDirectory: string } -export function buildMachineMetadata(options?: { workspaceRoots?: string[] }): MachineMetadata { - return { +export function buildMachineMetadata(options?: { + workspaceRoots?: string[] + startedCliMtimeMs?: number + /** + * Only the long-lived runner daemon may advertise machine RPC capabilities + * and CLI mtimes. Terminal/lazy/existing session bootstraps must omit this + * so a newer CLI session cannot paint an old connected runner as current + * (#1108 bot Major). + */ + asRunner?: boolean +}): MachineMetadata { + const installedCliMtimeMs = getInstalledCliMtimeMs() + const startedCliMtimeMs = options?.startedCliMtimeMs ?? installedCliMtimeMs + const base: MachineMetadata = { host: process.env.HAPI_HOSTNAME || os.hostname(), platform: os.platform(), happyCliVersion: packageJson.version, homeDir: os.homedir(), happyHomeDir: configuration.happyHomeDir, happyLibDir: runtimePath(), - workspaceRoots: options?.workspaceRoots + workspaceRoots: options?.workspaceRoots, + } + if (!options?.asRunner) { + return base + } + return { + ...base, + capabilities: [...CURRENT_MACHINE_CAPABILITIES], + ...(typeof startedCliMtimeMs === 'number' ? { startedCliMtimeMs } : {}), + ...(typeof installedCliMtimeMs === 'number' ? { installedCliMtimeMs } : {}), + // Always boolean so hub merge can clear a prior true on unsupervised restart. + supervisedRestart: process.env.HAPI_RUNNER_SUPERVISED === '1', } } diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index dc5bdf8329..2d7a6632a9 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -24,6 +24,7 @@ import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import { RUNNER_CAPABILITIES } from '@hapi/protocol' import type { RunnerState, Machine, MachineMetadata } from './types' import { RunnerStateSchema, MachineMetadataSchema } from './types' +import { getInstalledCliMtimeMs } from '@/runner/controlClient' import { backoff } from '@/utils/time' import { getInvokedCwd } from '@/utils/invokedCwd' import { RpcHandlerManager } from './rpc/RpcHandlerManager' @@ -656,6 +657,19 @@ export class ApiMachineClient { time: Date.now(), health: collectMachineHealth() }) + const installedCliMtimeMs = getInstalledCliMtimeMs() + if ( + typeof installedCliMtimeMs === 'number' + && this.machine.metadata + && this.machine.metadata.installedCliMtimeMs !== installedCliMtimeMs + ) { + void this.updateMachineMetadata((current) => ({ + ...(current ?? this.machine.metadata!), + installedCliMtimeMs, + })).catch((error) => { + logger.debug('[API MACHINE] Failed to refresh installedCliMtimeMs', error) + }) + } } // Prime CPU sampling so the first heartbeat already includes CPU %. collectMachineHealth() diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 8de1db3f30..60b76d450a 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1121,7 +1121,11 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const machine = await withRetry( () => api.getOrCreateMachine({ machineId, - metadata: buildMachineMetadata({ workspaceRoots }), + metadata: buildMachineMetadata({ + workspaceRoots, + startedCliMtimeMs: startedWithCliMtimeMs, + asRunner: true, + }), runnerState: initialRunnerState }), { diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 91a17073fd..3eb470976c 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -136,7 +136,9 @@ npm install -g pm2 # Start hub and runner pm2 start "hapi hub --relay" --name hapi-hub -pm2 start "hapi runner start-sync" --name hapi-runner +# HAPI_RUNNER_SUPERVISED=1 lets the web Restart button stop the runner knowing +# pm2 will cold-start it again (unsupervised stop would leave the host offline). +HAPI_RUNNER_SUPERVISED=1 pm2 start "hapi runner start-sync" --name hapi-runner # View status and logs pm2 status @@ -196,6 +198,11 @@ Create plist files for automatic startup on macOS. runner start-sync + EnvironmentVariables + + HAPI_RUNNER_SUPERVISED + 1 + RunAtLoad KeepAlive @@ -259,6 +266,9 @@ After=network.target hapi-hub.service [Service] Type=simple KillMode=process +# Advertise supervisedRestart so the web UI Restart button may stop-runner +# knowing systemd will cold-start the unit again. +Environment=HAPI_RUNNER_SUPERVISED=1 ExecStart=/usr/local/bin/hapi runner start-sync Restart=always RestartSec=5 diff --git a/docs/guide/installation.md b/docs/guide/installation.md index e208dc31bd..62c48ac8f5 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -324,7 +324,11 @@ Use `--workspace-root ` to restrict which directories the runner can brows hapi runner start --workspace-root ~/projects --workspace-root ~/work ``` -For running the hub and runner as persistent background services (pm2, launchd, systemd), see [Deployment](./deployment.md). +For running the hub and runner as persistent background services (pm2, launchd, systemd), see [Deployment](./deployment.md). Supervised installs should set `HAPI_RUNNER_SUPERVISED=1` on the runner process (systemd `Environment=` / pm2 `--env`) so the web **Restart** control can safely stop-runner knowing the supervisor will cold-start it. + +### Multi-machine hubs + +You can run **one hub** and **runners on many machines** (each machine installs its own CLI). When you upgrade the hub, upgrade the HAPI CLI on every machine that parents sessions. After the CLI binary on disk changes, that machine’s runner normally **self-restarts** via version handoff (unless `HAPI_DISABLE_VERSION_HANDOFF=1`). Until a runner reports the capabilities the hub requires, the web UI shows a **Runner out of date** banner (minimizable / snoozeable) with the host name and upgrade steps. The banner’s per-host **Restart** is only an escape hatch when handoff is stuck or disabled — the hub never downloads or installs packages on remotes. ## Security notes diff --git a/hub/src/store/machines.test.ts b/hub/src/store/machines.test.ts index 0398278071..fdba2f3675 100644 --- a/hub/src/store/machines.test.ts +++ b/hub/src/store/machines.test.ts @@ -49,6 +49,85 @@ describe('mergeMachineMetadata', () => { it('returns undefined when the merge is a no-op', () => { expect(mergeMachineMetadata({ host: 'a' }, { host: 'a' })).toBeUndefined() }) + + it('clears omitted runner ads when clearOmittedRunnerAds is set', () => { + const merged = mergeMachineMetadata( + { + host: 'box', + capabilities: ['stop-runner'], + supervisedRestart: true, + startedCliMtimeMs: 1, + installedCliMtimeMs: 2, + displayName: 'keep-me', + }, + { host: 'box', supervisedRestart: false }, + { clearOmittedRunnerAds: true }, + ) + expect(merged).toEqual({ + host: 'box', + supervisedRestart: false, + displayName: 'keep-me', + }) + }) + + it('keeps sticky runner ads without clearOmittedRunnerAds (terminal bootstrap)', () => { + const merged = mergeMachineMetadata( + { host: 'box', capabilities: ['stop-runner'], supervisedRestart: true }, + { host: 'box' }, + ) + expect(merged).toBeUndefined() + }) +}) + +describe('runner metadata ad clear on re-registration', () => { + it('drops sticky supervisedRestart and capabilities when runner re-registers without them', () => { + const store = new Store(':memory:') + store.machines.getOrCreateMachine( + 'machine-1', + { + host: 'box', + capabilities: ['stop-runner'], + supervisedRestart: true, + startedCliMtimeMs: 10, + }, + { status: 'running', pid: 1 }, + 'ns', + ) + + const refreshed = store.machines.getOrCreateMachine( + 'machine-1', + { host: 'box', supervisedRestart: false }, + { status: 'running', pid: 2 }, + 'ns', + ) + + expect(refreshed.metadata).toEqual({ host: 'box', supervisedRestart: false }) + expect(refreshed.metadata).not.toHaveProperty('capabilities') + expect(refreshed.metadata).not.toHaveProperty('startedCliMtimeMs') + }) + + it('does not clear runner ads on terminal-only metadata refresh (no runnerState)', () => { + const store = new Store(':memory:') + store.machines.getOrCreateMachine( + 'machine-1', + { host: 'box', capabilities: ['stop-runner'], supervisedRestart: true }, + { status: 'running', pid: 1 }, + 'ns', + ) + + const refreshed = store.machines.getOrCreateMachine( + 'machine-1', + { host: 'box' }, + null, + 'ns', + ) + + expect(refreshed.metadata).toEqual({ + host: 'box', + capabilities: ['stop-runner'], + supervisedRestart: true, + }) + }) }) describe('runner capabilities backfill', () => { diff --git a/hub/src/store/machines.ts b/hub/src/store/machines.ts index 892f5a39fb..6e8f4c50d4 100644 --- a/hub/src/store/machines.ts +++ b/hub/src/store/machines.ts @@ -44,10 +44,33 @@ function isPlainObject(value: unknown): value is Record { // machine-owned fields over the stored ones so registration doubles as a // refresh; hub-side fields the CLI never sends (e.g. displayName) survive. // Returns undefined when the merge would not change anything. -export function mergeMachineMetadata(stored: unknown, incoming: unknown): Record | undefined { +// +// When `clearOmittedRunnerAds` is set (full runner daemon registration with +// runnerState), runner-advertised keys omitted from incoming are deleted so +// rollback / unsupervised restart cannot leave sticky capabilities or +// supervisedRestart:true (#1108 bot Major). +export const RUNNER_ADVERTISED_METADATA_KEYS = [ + 'capabilities', + 'supervisedRestart', + 'startedCliMtimeMs', + 'installedCliMtimeMs', +] as const + +export function mergeMachineMetadata( + stored: unknown, + incoming: unknown, + options?: { clearOmittedRunnerAds?: boolean }, +): Record | undefined { if (!isPlainObject(incoming)) return undefined const base = isPlainObject(stored) ? stored : {} - const merged = { ...base, ...incoming } + const merged: Record = { ...base, ...incoming } + if (options?.clearOmittedRunnerAds) { + for (const key of RUNNER_ADVERTISED_METADATA_KEYS) { + if (!(key in incoming)) { + delete merged[key] + } + } + } return JSON.stringify(merged) === JSON.stringify(base) ? undefined : merged } @@ -82,7 +105,11 @@ export function getOrCreateMachine( if (stored.namespace !== namespace) { throw new Error('Machine namespace mismatch') } - const merged = mergeMachineMetadata(stored.metadata, metadata) + const merged = mergeMachineMetadata(stored.metadata, metadata, { + // Full runner registration (with runnerState) owns the skew ads — + // omit means clear, so rollback cannot leave sticky supervisedRestart. + clearOmittedRunnerAds: runnerState !== null && runnerState !== undefined, + }) let current = stored if (merged !== undefined) { db.prepare(` diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 259155a8c4..405dadafc7 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -279,6 +279,10 @@ export class RpcGateway { return CursorChatStoreStatusSchema.parse(result) } + async stopRunner(machineId: string): Promise { + await this.machineRpc(machineId, RPC_METHODS.StopRunner, {}) + } + async getGitStatus(sessionId: string, cwd?: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.GitStatus, { cwd }) as RpcCommandResponse } diff --git a/hub/src/sync/runnerEnsure.test.ts b/hub/src/sync/runnerEnsure.test.ts new file mode 100644 index 0000000000..02742ee3d2 --- /dev/null +++ b/hub/src/sync/runnerEnsure.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, mock } from 'bun:test' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' + +describe('SyncEngine restartMachineRunner', () => { + it('refuses Restart on unsupervised hosts (stop would leave runner offline)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const stopRunner = mock(async () => undefined) + ;(engine as any).rpcGateway.stopRunner = stopRunner + + engine.getOrCreateMachine( + 'manual-runner', + { host: 'laptop', platform: 'linux', happyCliVersion: '0.20.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'manual-runner', time: Date.now() }) + + const result = await engine.restartMachineRunner('manual-runner', 'default') + expect(result.type).toBe('error') + if (result.type === 'error') { + expect(result.code).toBe('restart_unsupported') + } + expect(stopRunner).not.toHaveBeenCalled() + } finally { + engine.stop() + } + }) + + it('stop-runners for a supervised online machine (banner escape hatch)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const stopRunner = mock(async () => undefined) + ;(engine as any).rpcGateway.stopRunner = stopRunner + + engine.getOrCreateMachine( + 'supervised-runner', + { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + supervisedRestart: true, + }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'supervised-runner', time: Date.now() }) + + const result = await engine.restartMachineRunner('supervised-runner', 'default') + expect(result).toEqual({ + type: 'success', + message: 'Runner stop requested; supervisor will relaunch', + }) + expect(stopRunner).toHaveBeenCalledWith('supervised-runner') + } finally { + engine.stop() + } + }) +}) diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 7a3fea136a..9886076d05 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -3226,6 +3226,59 @@ describe('session model', () => { } }) + it('soft-fails Cursor reopen when chat-store probe throws (missing handler / skew)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'cursor-probe-skew-reopen', + { + path: '/tmp/project', + host: 'cursor-host', + machineId: 'cursor-machine', + homeDir: '/home/cursor-owner', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-skew', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'cursor-machine', + { host: 'cursor-host', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'cursor-machine', time: Date.now() }) + + let spawnCalled = false + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => { + throw new Error('RPC handler not registered: cursor-machine:cursor-chat-store-status') + } + ;(engine as any).rpcGateway.spawnSession = async () => { + spawnCalled = true + engine.handleSessionAlive({ sid: session.id, time: Date.now() }) + return { type: 'success', sessionId: session.id } + } + ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionReady = async () => 'ready' + + const result = await engine.resumeSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(spawnCalled).toBe(true) + } finally { + engine.stop() + } + }) + it('probes Cursor chat data on the session recorded machine', async () => { const store = new Store(':memory:') const engine = new SyncEngine( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 92f38fb91d..d74ab078e4 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -8,6 +8,10 @@ */ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession, type SessionEndReason } from '@hapi/protocol' +import { + cliBinaryUpdatedOnDisk, + isMachineCapabilitySkewed, +} from '@hapi/protocol/runnerCapabilities' import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' @@ -196,7 +200,7 @@ export class SyncEngine { private readonly store: Store, private readonly io: Server, rpcRegistry: RpcRegistry, - sseManager: SSEManager + sseManager: SSEManager, ) { this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event)) this.sessionCache = new SessionCache(store, this.eventPublisher) @@ -773,7 +777,7 @@ export class SyncEngine { } } -async uploadScratchlistAttachment( + async uploadScratchlistAttachment( sessionId: string, namespace: string, filename: string, @@ -874,6 +878,42 @@ async uploadScratchlistAttachment( this.machineCache.handleMachineAlive(payload) } + /** + * Manual stop-runner for supervised hosts only (banner Restart). + * Detached `hapi runner start` has no supervisor — stop would leave the + * host offline. Require `metadata.supervisedRestart` (HAPI_RUNNER_SUPERVISED=1). + */ + async restartMachineRunner(machineId: string, namespace: string): Promise< + | { type: 'success'; message: string } + | { type: 'error'; message: string; code: 'machine_not_found' | 'machine_offline' | 'restart_unsupported' | 'restart_failed' } + > { + const machine = this.machineCache.getMachineByNamespace(machineId, namespace) + ?? this.machineCache.refreshMachine(machineId) + if (!machine || machine.namespace !== namespace) { + return { type: 'error', message: 'Machine not found', code: 'machine_not_found' } + } + if (!machine.active) { + return { type: 'error', message: 'Machine is offline', code: 'machine_offline' } + } + if (machine.metadata?.supervisedRestart !== true) { + return { + type: 'error', + message: 'Restart requires a supervised runner (HAPI_RUNNER_SUPERVISED=1); unsupervised stop would leave the host offline', + code: 'restart_unsupported', + } + } + try { + await this.rpcGateway.stopRunner(machineId) + return { type: 'success', message: 'Runner stop requested; supervisor will relaunch' } + } catch (error) { + return { + type: 'error', + message: error instanceof Error ? error.message : 'Failed to restart runner', + code: 'restart_failed', + } + } + } + private expireInactive(): void { const expired = this.sessionCache.expireInactive() // Sort by most recent first so dedup keeps the newest session when multiple @@ -2785,11 +2825,15 @@ async uploadScratchlistAttachment( } } } catch (error) { - return { - type: 'error', - message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store', - code: 'resume_failed' - } + // Soft-fail on probe skew / missing handler (#1084): definitive + // onDisk:false still blocks above; probe errors must not be + // reported as missing chat data. + const message = error instanceof Error ? error.message : 'Failed to inspect Cursor chat store' + console.warn('[resume] Cursor chat-store probe failed; proceeding with reopen attempt', { + sessionId: access.sessionId, + machineId: targetMachine.id, + message + }) } } diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 10abbeaac2..e367b3230d 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -320,5 +320,27 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.post('/machines/:id/restart-runner', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + const result = await engine.restartMachineRunner(machineId, c.get('namespace')) + if (result.type === 'error') { + const status = result.code === 'machine_not_found' ? 404 + : result.code === 'machine_offline' ? 503 + : 502 + return c.json({ error: result.message, code: result.code }, status) + } + return c.json({ message: result.message }) + }) + return app } diff --git a/shared/package.json b/shared/package.json index b1e8edfe28..760d72d22f 100644 --- a/shared/package.json +++ b/shared/package.json @@ -14,6 +14,7 @@ "./conversationHistory": "./src/conversationHistory.ts", "./modes": "./src/modes.ts", "./rpcMethods": "./src/rpcMethods.ts", + "./runnerCapabilities": "./src/runnerCapabilities.ts", "./schemas": "./src/schemas.ts", "./sessionCitation": "./src/sessionCitation.ts", "./sessionExport": "./src/sessionExport.ts", diff --git a/shared/src/index.ts b/shared/src/index.ts index 714001a097..824e0d55c6 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -11,6 +11,7 @@ export * from './models' export * from './modes' export * from './resume' export * from './rpcMethods' +export * from './runnerCapabilities' export * from './socket' export * from './sessionSummary' export * from './sessionCitation' diff --git a/shared/src/runnerCapabilities.test.ts b/shared/src/runnerCapabilities.test.ts new file mode 100644 index 0000000000..d9404b0f4c --- /dev/null +++ b/shared/src/runnerCapabilities.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + CURRENT_MACHINE_CAPABILITIES, + MACHINE_CAPABILITIES, + REQUIRED_MACHINE_CAPABILITIES, + cliBinaryUpdatedOnDisk, + isMachineCapabilitySkewed, + missingRequiredCapabilities, +} from './runnerCapabilities' + +describe('runnerCapabilities', () => { + it('requires cursor-chat-store-status so hub features cannot fail-closed without a registry entry', () => { + expect(REQUIRED_MACHINE_CAPABILITIES).toContain(MACHINE_CAPABILITIES.CursorChatStoreStatus) + expect(CURRENT_MACHINE_CAPABILITIES).toEqual(expect.arrayContaining([ + ...REQUIRED_MACHINE_CAPABILITIES, + ])) + }) + + it('treats missing/empty advertised capabilities as skewed', () => { + expect(isMachineCapabilitySkewed(undefined)).toBe(true) + expect(isMachineCapabilitySkewed(null)).toBe(true) + expect(isMachineCapabilitySkewed([])).toBe(true) + expect(missingRequiredCapabilities([])).toEqual([ + MACHINE_CAPABILITIES.CursorChatStoreStatus, + ]) + }) + + it('is not skewed when required capabilities are advertised', () => { + expect(isMachineCapabilitySkewed([...CURRENT_MACHINE_CAPABILITIES])).toBe(false) + expect(missingRequiredCapabilities([ + MACHINE_CAPABILITIES.CursorChatStoreStatus, + 'other-cap', + ])).toEqual([]) + }) + + it('detects on-disk CLI binary updates via mtime drift', () => { + expect(cliBinaryUpdatedOnDisk({ + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + })).toBe(true) + expect(cliBinaryUpdatedOnDisk({ + startedCliMtimeMs: 100, + installedCliMtimeMs: 100, + })).toBe(false) + expect(cliBinaryUpdatedOnDisk({})).toBe(false) + expect(cliBinaryUpdatedOnDisk(null)).toBe(false) + }) +}) diff --git a/shared/src/runnerCapabilities.ts b/shared/src/runnerCapabilities.ts index 4b2c59b8b5..f51c253eda 100644 --- a/shared/src/runnerCapabilities.ts +++ b/shared/src/runnerCapabilities.ts @@ -1,3 +1,5 @@ +import { RPC_METHODS } from './rpcMethods' + /** * Capabilities the current runner generation advertises to the hub. * @@ -15,3 +17,59 @@ export const RUNNER_CAPABILITIES = { } as const export type RunnerCapabilities = typeof RUNNER_CAPABILITIES + +/** + * Machine-scoped capabilities runners advertise on connect. + * Hub features that hard-depend on a machine RPC must list that capability + * in {@link REQUIRED_MACHINE_CAPABILITIES} so skew surfaces as a banner + * instead of a silent fail-closed product bug. + */ +export const MACHINE_CAPABILITIES = { + CursorChatStoreStatus: RPC_METHODS.CursorChatStoreStatus, + StopRunner: RPC_METHODS.StopRunner, +} as const + +export type MachineCapability = + (typeof MACHINE_CAPABILITIES)[keyof typeof MACHINE_CAPABILITIES] + +/** Capabilities this CLI generation registers on the machine socket. */ +export const CURRENT_MACHINE_CAPABILITIES: readonly MachineCapability[] = [ + MACHINE_CAPABILITIES.CursorChatStoreStatus, + MACHINE_CAPABILITIES.StopRunner, +] + +/** + * Capabilities the hub requires on every connected runner for features it + * hard-depends on. Missing entries → operator-visible skew banner (+ optional + * stop-runner ensure when a newer binary is already on disk). + */ +export const REQUIRED_MACHINE_CAPABILITIES: readonly MachineCapability[] = [ + MACHINE_CAPABILITIES.CursorChatStoreStatus, +] + +export function missingRequiredCapabilities( + advertised: readonly string[] | null | undefined, +): MachineCapability[] { + const set = new Set(advertised ?? []) + return REQUIRED_MACHINE_CAPABILITIES.filter((cap) => !set.has(cap)) +} + +export function isMachineCapabilitySkewed( + advertised: readonly string[] | null | undefined, +): boolean { + return missingRequiredCapabilities(advertised).length > 0 +} + +/** True when the running process started from a different CLI binary/mtime than what's installed now. */ +export function cliBinaryUpdatedOnDisk(metadata: { + startedCliMtimeMs?: number | null + installedCliMtimeMs?: number | null +} | null | undefined): boolean { + const started = metadata?.startedCliMtimeMs + const installed = metadata?.installedCliMtimeMs + return typeof started === 'number' + && typeof installed === 'number' + && Number.isFinite(started) + && Number.isFinite(installed) + && started !== installed +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index dc9288ae31..7c7d3215ea 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -432,7 +432,18 @@ export const MachineMetadataSchema = z.object({ homeDir: z.string().optional(), happyHomeDir: z.string().optional(), happyLibDir: z.string().optional(), - workspaceRoots: z.array(z.string()).optional() + workspaceRoots: z.array(z.string()).optional(), + /** Machine-scoped RPC capability ids this runner registers (see runnerCapabilities). */ + capabilities: z.array(z.string()).optional(), + /** CLI binary/package mtime when this runner process started. */ + startedCliMtimeMs: z.number().optional(), + /** Current on-disk CLI binary/package mtime (may differ after upgrade). */ + installedCliMtimeMs: z.number().optional(), + /** + * Runner is under systemd/pm2 (HAPI_RUNNER_SUPERVISED=1). Banner Restart + * may stop-runner; unsupervised detached runners must not use that path. + */ + supervisedRestart: z.boolean().optional(), }) export type MachineMetadata = z.infer diff --git a/web/src/App.tsx b/web/src/App.tsx index 3f9ea2c394..c71f5cb453 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -30,6 +30,7 @@ import { PwaUpdateBanner, PwaUpdateBannerWithStatusOffset } from '@/components/P import { SyncingBanner } from '@/components/SyncingBanner' import { ReconnectingBanner } from '@/components/ReconnectingBanner' import { VoiceErrorBanner } from '@/components/VoiceErrorBanner' +import { RunnerVersionSkewBanner } from '@/components/RunnerVersionSkewBanner' import { LoadingState } from '@/components/LoadingState' import { ToastContainer } from '@/components/ToastContainer' import { PwaUpdateProvider } from '@/lib/pwa-update-context' @@ -478,6 +479,7 @@ function AppInner() { isHubConnected={globalSubscriptionId !== null} isReconnecting={showReconnectingBanner} /> +
diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 08650fdd62..3e5dac619b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -747,6 +747,13 @@ export class ApiClient { return await this.request(`/api/usage/summary?${params.toString()}`) } + async restartMachineRunner(machineId: string): Promise<{ message: string }> { + return await this.request<{ message: string }>( + `/api/machines/${encodeURIComponent(machineId)}/restart-runner`, + { method: 'POST', body: '{}' } + ) + } + async listMachineDirectory( machineId: string, path: string, diff --git a/web/src/components/NewSession/MachineSelector.tsx b/web/src/components/NewSession/MachineSelector.tsx index 1ee076e476..553ddeadb8 100644 --- a/web/src/components/NewSession/MachineSelector.tsx +++ b/web/src/components/NewSession/MachineSelector.tsx @@ -1,4 +1,5 @@ import type { Machine } from '@/types/api' +import { isMachineCapabilitySkewed } from '@hapi/protocol/runnerCapabilities' import { useTranslation } from '@/lib/use-translation' import { SelectControl } from '@/components/ui/select-control' @@ -8,6 +9,18 @@ function getMachineTitle(machine: Machine): string { return machine.id.slice(0, 8) } +function getMachineOptionLabel(machine: Machine, updateRequiredLabel: string): string { + const title = getMachineTitle(machine) + const platform = machine.metadata?.platform ? ` (${machine.metadata.platform})` : '' + const version = machine.metadata?.happyCliVersion + ? ` · CLI ${machine.metadata.happyCliVersion}` + : '' + const skew = machine.active && isMachineCapabilitySkewed(machine.metadata?.capabilities) + ? ` · ${updateRequiredLabel}` + : '' + return `${title}${platform}${version}${skew}` +} + export function MachineSelector(props: { machines: Machine[] machineId: string | null @@ -36,8 +49,7 @@ export function MachineSelector(props: { )} {props.machines.map((m) => ( ))} diff --git a/web/src/components/RunnerVersionSkewBanner.test.tsx b/web/src/components/RunnerVersionSkewBanner.test.tsx new file mode 100644 index 0000000000..33e0041a01 --- /dev/null +++ b/web/src/components/RunnerVersionSkewBanner.test.tsx @@ -0,0 +1,303 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CURRENT_MACHINE_CAPABILITIES } from '@hapi/protocol/runnerCapabilities' +import type { Machine } from '@/types/api' +import { + RunnerVersionSkewBanner, + listSkewedMachines, + machineDisplayHost, +} from './RunnerVersionSkewBanner' +import { I18nProvider } from '@/lib/i18n-context' +import { + clearRunnerSkewTempDismiss, + resetRunnerSkewBannerMemoryForTests, + setRunnerSkewMinimized, +} from '@/lib/runnerSkewBannerState' + +const useMachinesMock = vi.fn() +const restartMachineRunnerMock = vi.fn(async () => ({ message: 'ok' })) +const useAppContextMock = vi.fn(() => ({ + api: { restartMachineRunner: restartMachineRunnerMock } as never, + token: 't', + baseUrl: 'http://localhost', +})) + +vi.mock('@/hooks/queries/useMachines', () => ({ + useMachines: (...args: unknown[]) => useMachinesMock(...args), +})) + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => useAppContextMock(), +})) + +vi.mock('@/hooks/useOnlineStatus', () => ({ + useOnlineStatus: () => true, +})) + +vi.mock('@/hooks/usePlatform', () => ({ + usePlatform: () => ({ + haptic: { impact: vi.fn(), notification: vi.fn() }, + }), +})) + +function makeMachine(overrides: Partial & { id: string }): Machine { + const { id, ...rest } = overrides + return { + id, + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: rest.active ?? true, + activeAt: Date.now(), + metadata: rest.metadata ?? { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 0, + ...rest, + } as Machine +} + +describe('listSkewedMachines', () => { + it('flags online machines without required capabilities', () => { + const skewed = listSkewedMachines([ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + makeMachine({ + id: 'new', + metadata: { + host: 'oos', + platform: 'linux', + happyCliVersion: '0.23.0', + capabilities: [...CURRENT_MACHINE_CAPABILITIES], + }, + }), + makeMachine({ + id: 'offline-old', + active: false, + metadata: { host: 'ha', platform: 'linux', happyCliVersion: '0.19.0' }, + }), + ]) + expect(skewed.map((m) => m.id)).toEqual(['old']) + }) + + it('uses displayName when present', () => { + expect(machineDisplayHost(makeMachine({ + id: 'm1', + metadata: { + host: 'proxmox.local', + platform: 'linux', + happyCliVersion: '0.20.0', + displayName: 'Proxmox box', + }, + }))).toBe('Proxmox box') + }) +}) + +describe('RunnerVersionSkewBanner', () => { + beforeEach(() => { + window.sessionStorage.clear() + resetRunnerSkewBannerMemoryForTests() + setRunnerSkewMinimized(false) + clearRunnerSkewTempDismiss() + restartMachineRunnerMock.mockClear() + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders a compact banner with minimize and snooze actions', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + expect(screen.getByTestId('runner-version-skew-banner')).toHaveAttribute('data-state', 'expanded') + expect(screen.getByText(/1 runner\(s\) out of date/)).toBeInTheDocument() + expect(screen.getByTestId('runner-version-skew-minimize')).toBeInTheDocument() + expect(screen.getByTestId('runner-version-skew-dismiss')).toBeInTheDocument() + expect(screen.getByTestId('runner-version-skew-restart-old')).toBeInTheDocument() + }) + + it('minimizes so the strip stays small', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId('runner-version-skew-minimize')) + expect(screen.getByTestId('runner-version-skew-banner')).toHaveAttribute('data-state', 'minimized') + expect(screen.getByTestId('runner-version-skew-expand')).toBeInTheDocument() + }) + + it('temp-dismisses so sessions are reachable', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId('runner-version-skew-dismiss')) + expect(screen.queryByTestId('runner-version-skew-banner')).not.toBeInTheDocument() + }) + + it('disables Restart when no newer CLI is on disk', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + const restart = screen.getByTestId('runner-version-skew-restart-old') + expect(restart).toBeDisabled() + expect(restart).toHaveTextContent(/Upgrade CLI first/) + }) + + it('disables Restart when newer CLI is on disk but runner is unsupervised', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ + id: 'old', + metadata: { + host: 'laptop', + platform: 'linux', + happyCliVersion: '0.20.0', + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + }, + }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + expect(screen.getByTestId('runner-version-skew-restart-old')).toBeDisabled() + }) + + it('calls restartMachineRunner when Restart is clicked on a supervised host with newer CLI', async () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ + id: 'old', + metadata: { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + supervisedRestart: true, + }, + }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId('runner-version-skew-restart-old')) + await waitFor(() => { + expect(restartMachineRunnerMock).toHaveBeenCalledWith('old') + }) + }) + it('minimizes even when sessionStorage setItem throws QuotaExceededError', () => { + const proto = Object.getPrototypeOf(window.sessionStorage) as Storage + vi.spyOn(proto, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError') + }) + + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + expect(() => fireEvent.click(screen.getByTestId('runner-version-skew-minimize'))).not.toThrow() + expect(screen.getByTestId('runner-version-skew-banner')).toHaveAttribute('data-state', 'minimized') + }) + + it('hides when all online machines advertise required capabilities', async () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ + id: 'new', + metadata: { + host: 'oos', + platform: 'linux', + happyCliVersion: '0.23.0', + capabilities: [...CURRENT_MACHINE_CAPABILITIES], + }, + }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.queryByTestId('runner-version-skew-banner')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/src/components/RunnerVersionSkewBanner.tsx b/web/src/components/RunnerVersionSkewBanner.tsx new file mode 100644 index 0000000000..7edd76be1d --- /dev/null +++ b/web/src/components/RunnerVersionSkewBanner.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useState } from 'react' +import { isMachineCapabilitySkewed, cliBinaryUpdatedOnDisk } from '@hapi/protocol/runnerCapabilities' +import type { Machine } from '@/types/api' +import { useMachines } from '@/hooks/queries/useMachines' +import { useTranslation } from '@/lib/use-translation' +import { useAppContext } from '@/lib/app-context' +import { useOnlineStatus } from '@/hooks/useOnlineStatus' +import { usePlatform } from '@/hooks/usePlatform' +import { + clearRunnerSkewTempDismiss, + getRunnerSkewDismissUntil, + isRunnerSkewMinimized, + isRunnerSkewTempDismissed, + setRunnerSkewMinimized, + tempDismissRunnerSkew, +} from '@/lib/runnerSkewBannerState' + +export function machineDisplayHost(machine: Machine): string { + return machine.metadata?.displayName + ?? machine.metadata?.host + ?? machine.id +} + +export function listSkewedMachines(machines: Machine[]): Machine[] { + return machines.filter((machine) => ( + machine.active + && isMachineCapabilitySkewed(machine.metadata?.capabilities) + )) +} + +/** + * Compact, minimizable skew banner (#1084 dogfood). + * Temp-dismiss (1h, sessionStorage) or minimize so sessions stay clickable. + * Manual Restart asks hub to stop-runner (escape hatch when version handoff + * is stuck or HAPI_DISABLE_VERSION_HANDOFF=1). Normal upgrades self-restart. + */ +export function RunnerVersionSkewBanner({ topClassName }: { topClassName?: string } = {}) { + const { api } = useAppContext() + const { machines } = useMachines(api, true) + const { t } = useTranslation() + const isOnline = useOnlineStatus() + const { haptic } = usePlatform() + const skewed = listSkewedMachines(machines) + const [minimized, setMinimized] = useState(() => isRunnerSkewMinimized()) + const [dismissed, setDismissed] = useState(() => isRunnerSkewTempDismissed()) + const [restartingId, setRestartingId] = useState(null) + const [restartError, setRestartError] = useState(null) + + useEffect(() => { + if (!dismissed) { + return + } + const remaining = Math.max(0, getRunnerSkewDismissUntil() - Date.now()) + if (remaining === 0) { + clearRunnerSkewTempDismiss() + setDismissed(false) + return + } + const timer = window.setTimeout(() => { + clearRunnerSkewTempDismiss() + setDismissed(false) + }, remaining) + return () => window.clearTimeout(timer) + }, [dismissed]) + + const onMinimize = useCallback(() => { + haptic.impact('light') + // UI first — storage may throw QuotaExceededError on full sessionStorage. + setMinimized(true) + setRunnerSkewMinimized(true) + }, [haptic]) + + const onExpand = useCallback(() => { + haptic.impact('light') + setMinimized(false) + setRunnerSkewMinimized(false) + }, [haptic]) + + const onTempDismiss = useCallback(() => { + haptic.impact('light') + setDismissed(true) + tempDismissRunnerSkew() + }, [haptic]) + + const onRestart = useCallback(async (machine: Machine) => { + if (!api) { + return + } + haptic.impact('medium') + setRestartError(null) + setRestartingId(machine.id) + try { + await api.restartMachineRunner(machine.id) + } catch (error) { + setRestartError(error instanceof Error ? error.message : t('runner.skew.restartFailed')) + } finally { + setRestartingId(null) + } + }, [api, haptic, t]) + + if (skewed.length === 0 || dismissed) { + return null + } + + const topClass = topClassName ?? (isOnline ? 'top-2' : 'top-10') + const hosts = skewed.map(machineDisplayHost).join(', ') + + if (minimized) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+

+ {t('runner.skew.banner.summaryTitle', { count: skewed.length })} +

+

+ {t('runner.skew.banner.summaryBody')} +

+
+
+ + +
+
+ +
    + {skewed.map((machine) => { + const host = machineDisplayHost(machine) + const version = machine.metadata?.happyCliVersion + const newerOnDisk = cliBinaryUpdatedOnDisk(machine.metadata) + const supervised = machine.metadata?.supervisedRestart === true + const canRestart = newerOnDisk && supervised + const restartBusy = restartingId === machine.id + const restartTitle = !newerOnDisk + ? t('runner.skew.banner.restartNeedsNewerBinary') + : !supervised + ? t('runner.skew.banner.restartNeedsSupervisor') + : undefined + return ( +
  • +
    +
    + {host} + {version ? ` · CLI ${version}` : null} + {newerOnDisk ? ( + + {t('runner.skew.banner.binaryUpdatedHint')} + + ) : ( + + {t('runner.skew.banner.upgradeCliFirst')} + + )} +
    + +
    +
  • + ) + })} +
+ + {restartError ? ( +

+ {restartError} +

+ ) : null} + +

+ {t('runner.skew.banner.handoffHint')} +

+
+ ) +} diff --git a/web/src/components/SessionActionMenu.test.tsx b/web/src/components/SessionActionMenu.test.tsx index 5ac0f5f6a6..f89a4c5960 100644 --- a/web/src/components/SessionActionMenu.test.tsx +++ b/web/src/components/SessionActionMenu.test.tsx @@ -112,6 +112,22 @@ describe('SessionActionMenu - Reopen action', () => { expect(onClose).not.toHaveBeenCalled() }) + it('keeps Reopen enabled with a soft-fail hint when probe is unverified', () => { + const onReopen = vi.fn() + renderMenu({ + sessionActive: false, + onReopen, + reopenHint: 'Could not verify Cursor chat data (runner may be outdated).', + }) + + const reopen = screen.getByRole('menuitem', { name: /Reopen/ }) + expect(reopen).not.toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('tooltip')).toHaveTextContent('Could not verify Cursor chat data') + + fireEvent.click(reopen) + expect(onReopen).toHaveBeenCalledTimes(1) + }) + it('fires onReopen and closes the menu when the Reopen item is clicked', () => { const onReopen = vi.fn() const onClose = vi.fn() diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index f7e66aa906..49ae993486 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -30,6 +30,8 @@ type SessionActionMenuProps = { onArchive: () => void onReopen?: () => void reopenDisabledReason?: string + /** Soft-fail tip when reopen is allowed but chat-store probe could not verify. */ + reopenHint?: string onDelete: () => void anchorPoint: { x: number; y: number } menuId?: string @@ -199,6 +201,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { onArchive, onReopen, reopenDisabledReason, + reopenHint, onDelete, anchorPoint, menuId @@ -456,7 +459,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { ) : ( <> - {onReopen || reopenDisabledReason ? ( + {onReopen || reopenDisabledReason || reopenHint ? ( )} > - {reopenDisabledReason ?? t('session.action.reopen')} + {reopenDisabledReason ?? reopenHint ?? t('session.action.reopen')} ) : null}
-
+
{t('shareTurn.generated')}
From 173da49c849a68bb67e06f46b15a7041305db7ea Mon Sep 17 00:00:00 2001 From: Ananovo Date: Tue, 11 Aug 2026 22:25:25 +0800 Subject: [PATCH 066/142] fix(web): remove duplicate queued composer gap (#1505) --- .../AssistantChat/QueuedMessagesBar.test.tsx | 13 +++++++++++++ .../components/AssistantChat/QueuedMessagesBar.tsx | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx index a84da2e188..1450934c56 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx @@ -171,6 +171,19 @@ afterEach(() => { vi.unstubAllGlobals() }) +describe('QueuedMessagesBar layout', () => { + it('keeps the queue footer flush with the composer area', () => { + renderQueuedMessage() + + const bar = screen.getByRole('status') + const content = bar.firstElementChild + + expect(bar).not.toHaveClass('mb-1') + expect(content).toHaveClass('pt-2', 'pb-0') + expect(content).not.toHaveClass('py-2') + }) +}) + describe('QueuedMessagesBar edit restore', () => { it('keeps a newly typed draft and its schedule when the deferred cancel succeeds', async () => { const scheduledAt = Date.now() + 60_000 diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.tsx index 384b231498..7a34b25050 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.tsx @@ -303,9 +303,9 @@ export function QueuedMessagesBar({
-
+
Queued From eb7a762984316ee3666b758c2962de00afc5fa65 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Tue, 11 Aug 2026 22:26:07 +0800 Subject: [PATCH 067/142] fix(web): clarify session list status hints (#1504) --- web/src/components/SessionRowSummary.test.tsx | 64 +++++++++++++++++++ web/src/components/SessionRowSummary.tsx | 13 ++++ web/src/lib/locales/en.ts | 8 +-- web/src/lib/locales/zh-CN.ts | 8 +-- web/src/routes/settings/index.test.tsx | 6 +- 5 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 web/src/components/SessionRowSummary.test.tsx diff --git a/web/src/components/SessionRowSummary.test.tsx b/web/src/components/SessionRowSummary.test.tsx new file mode 100644 index 0000000000..31dc989d9f --- /dev/null +++ b/web/src/components/SessionRowSummary.test.tsx @@ -0,0 +1,64 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { SessionSummary } from '@/types/api' +import { I18nProvider } from '@/lib/i18n-context' +import { SessionRowSummary } from './SessionRowSummary' + +afterEach(() => cleanup()) + +function makeSummary(overrides: Partial = {}): SessionSummary { + return { + id: 'background-demo', + active: true, + thinking: false, + activeAt: 0, + updatedAt: 0, + metadata: { path: '/demo/status', name: 'Background demo', flavor: 'claude' }, + metadataVersion: 0, + agentStateVersion: 0, + todosUpdatedAt: 0, + todoProgress: null, + pendingRequestsCount: 0, + pendingRequestKinds: [], + pendingRequests: [], + backgroundTaskCount: 2, + futureScheduledMessageCount: 0, + nextScheduledAt: null, + model: null, + effort: null, + ...overrides + } +} + +function renderSummary(showDetailedStatus: boolean) { + return render( + + + + ) +} + +describe('SessionRowSummary background status', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('shows the basic running label in Basic mode', () => { + renderSummary(false) + + expect(screen.getByText('Running', { exact: true })).toBeInTheDocument() + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument() + }) + + it('shows a detailed background dot with the task-count tooltip in Extended mode', () => { + renderSummary(true) + + expect(screen.queryByText('Running', { exact: true })).not.toBeInTheDocument() + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip).toHaveTextContent('Background tasks running') + expect(tooltip).toHaveTextContent('2 tasks running') + }) +}) diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index c31d944095..8f6ad7cafd 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -185,6 +185,19 @@ export function SessionRowSummary(props: { title={attentionLabel ?? undefined} aria-label={attentionLabel ?? undefined} /> + ) : showDetailedStatus && attention?.kind === 'background' && nestedTooltips && attentionId ? ( + + ) : showDetailedStatus && attention?.kind === 'background' ? ( + ) : s.active && (s.backgroundTaskCount ?? 0) > 0 ? ( { it('keeps the session status description visible with its choice group', () => { renderPage() - const description = screen.getByText('Shows why a session stopped: permission, input, background work, new activity, or a scheduled message (clock icon).') - const choices = screen.getByRole('radiogroup', { name: 'Session list status' }) + const description = screen.getByText('Choose which status hints appear in the session list. Basic shows runtime state; Extended also shows permission, input, background-task, new-activity, and scheduled-message hints (clock icon).') + const choices = screen.getByRole('radiogroup', { name: 'Session list status hints' }) + expect(screen.getByRole('radio', { name: 'Basic' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Extended' })).toBeInTheDocument() expect(description.parentElement?.parentElement).toBe(choices.parentElement) expect(description.compareDocumentPosition(choices) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() }) From e6b9fd68e6e7c7b793e80e9721273ad705118f52 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Tue, 11 Aug 2026 22:27:14 +0800 Subject: [PATCH 068/142] feat(pi): queue mid-turn messages by default; steer only via explicit per-message Steer button (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pi): queue mid-turn messages by default; steer only via explicit per-message Steer button Pi (PyAgent) was the only flavor whose ordinary composer submission while streaming bypassed the queue: the web resolved it to deliveryMode 'steer' and the CLI dispatched a native steer into the running turn immediately, with no waiting state. This makes Pi match Codex/Claude behavior (issue #1466): mid-turn messages wait in the queue by default, and the operator delivers one into the running turn with the new per-queued-message Steer button. - web: resolveMessageDeliveryMode now queues for every flavor; QueuedMessagesBar gains a Steer button (pi + thinking + remote-controlled + immediate rows) backed by a new useSteerQueuedMessage hook + api.steerMessage. - hub: POST /sessions/:id/messages/:messageId/steer -> syncEngine.steerQueuedMessage (pi-only gate, remote-only, scheduled/absent/invoked rejection) -> RPC. - cli: pi runner registers 'steer-queued-message'; a queued message is promoted into the active turn via the existing PiSteerDispatcher (target generation captured at promote time; turn-ended steers fall back to the prompt FIFO). Steers requested while the message is still preparing are deferred and promoted right after preparation completes. - Removed the now-dead Alt+Enter / touch-hold queue gesture (its only purpose was opting out of the removed automatic steer). Verified: bun typecheck; cli/hub/web/shared suites (env-dependent runner integration + kimi wire-locator flakes reproduce on pristine upstream and are unrelated to this diff). * fix(pi): preserve queued messages on rejected steers and pin the steering generation Addresses both Major findings from the HAPI Bot review of PR #1480. - steerDispatcher: a deterministic native rejection (Pi responded error) now degrades the message to the ordinary prompt FIFO instead of emitting messages-consumed. A promoted queued message must not be lost just because the steer was rejected; the hub row stays queued until the FIFO delivers it. The indeterminate-timeout path keeps its fail-closed consume + escalate behavior (a duplicate delivery would be worse). - runPi: the deferred-steer path now captures the streaming generation at RPC request time (Map) instead of reading it after preparation completes, so a steer requested against turn G1 can never be injected into a turn G2 that started while the message was preparing — the dispatcher's generation-mismatch check degrades it to the FIFO. Regression coverage: negative steer response preserves the entry via the FIFO (no consume); generation rollover while preparing delivers as a normal prompt at the next settle (no steer into the new turn). Verified: bun typecheck; cli pi suites (48 tests), hub 1041, web 2301, shared 240 — all green; only the pre-existing environment-dependent runner integration test fails locally (reproduces on pristine upstream). * fix(pi): reject all scheduled steers and always clear deferred-steer bookkeeping Addresses the two Minor findings from the HAPI Bot follow-up review. - hub: steerQueuedMessage rejects every scheduled row — mature ones included — aligning the endpoint with the web UI (Steer is never offered on scheduled rows) and preserving scheduled-FIFO delivery semantics. - cli: the deferred-steer bookkeeping map is now cleared in a finally on the preparation chain, covering the early exits (cancellation before/after attachment I/O, empty prepared message, preparation failure) that previously could leave a stale generation entry behind for the session lifetime. Regression coverage: hub steer gate tests (mature scheduled row stays queued, non-pi flavor rejected) and a runPi test proving cancellation wins over a deferred steer (no steer/prompt/consume after preparation completes). Verified: bun typecheck; hub 1043 pass, cli 2421 pass (only the pre-existing environment-dependent runner integration suite fails locally), web 2301 and shared 240 unchanged since their green runs. * fix(web): reconcile stale queued rows when a steer returns invoked Addresses the remaining Minor finding from the HAPI Bot follow-up review: when the steer endpoint reports the message was already invoked and the messages-consumed SSE was missed while the row was still queued, the hook now marks the row consumed locally (mirroring useCancelQueuedMessage) so the queued bar cannot keep a stale actionable row until the next sync. Regression coverage: steer returning status 'invoked' reconciles the row via markMessagesConsumed and shows no toast. Verified: bun typecheck; web 2302 pass (hub/cli/shared unchanged since their green runs). --- cli/src/pi/promptQueue.test.ts | 25 +++ cli/src/pi/promptQueue.ts | 13 ++ cli/src/pi/runPi.test.ts | 212 ++++++++++++++++++ cli/src/pi/runPi.ts | 65 ++++++ cli/src/pi/steerDispatcher.test.ts | 12 +- cli/src/pi/steerDispatcher.ts | 22 +- hub/src/sync/rpcGateway.ts | 14 ++ hub/src/sync/steerQueuedMessage.test.ts | 73 ++++++ hub/src/sync/syncEngine.ts | 67 ++++++ hub/src/web/routes/messages.test.ts | 31 +++ hub/src/web/routes/messages.ts | 17 ++ shared/src/rpcMethods.ts | 2 + shared/src/schemas.ts | 8 + web/src/api/client.test.ts | 16 ++ web/src/api/client.ts | 10 +- .../AssistantChat/ComposerButtons.test.tsx | 58 +---- .../AssistantChat/ComposerButtons.tsx | 27 +-- .../HappyComposer.sendError.test.tsx | 8 +- .../AssistantChat/HappyComposer.tsx | 24 -- .../AssistantChat/QueuedMessagesBar.test.tsx | 163 ++++++++++++-- .../AssistantChat/QueuedMessagesBar.tsx | 65 ++++++ web/src/components/SessionChat.tsx | 1 + .../hooks/mutations/useSteerQueuedMessage.ts | 63 ++++++ web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/lib/messageDelivery.test.ts | 31 ++- web/src/lib/messageDelivery.ts | 18 +- 27 files changed, 885 insertions(+), 164 deletions(-) create mode 100644 hub/src/sync/steerQueuedMessage.test.ts create mode 100644 web/src/hooks/mutations/useSteerQueuedMessage.ts diff --git a/cli/src/pi/promptQueue.test.ts b/cli/src/pi/promptQueue.test.ts index adb50a6a69..34a1c184ab 100644 --- a/cli/src/pi/promptQueue.test.ts +++ b/cli/src/pi/promptQueue.test.ts @@ -21,4 +21,29 @@ describe('PiPromptQueue', () => { expect(queue.dequeue()?.message).toBe('earlier steer fallback'); expect(queue.dequeue()?.message).toBe('later ordinary'); }); + + it('removes a queued entry by localId for explicit steer promotion', () => { + const queue = new PiPromptQueue(); + queue.enqueue({ message: 'first', images: [], outboundSequence: 1, localId: 'one' }); + queue.enqueue({ message: 'steer me', images: [], outboundSequence: 2, localId: 'two' }); + queue.enqueue({ message: 'third', images: [], outboundSequence: 3, localId: 'three' }); + + const removed = queue.removeByLocalId('two'); + expect(removed?.message).toBe('steer me'); + expect(removed?.localId).toBe('two'); + // Remaining order preserved. + expect(queue.dequeue()?.message).toBe('first'); + expect(queue.dequeue()?.message).toBe('third'); + expect(queue.dequeue()).toBeUndefined(); + }); + + it('returns undefined when removing an absent or already-dispatched localId', () => { + const queue = new PiPromptQueue(); + queue.enqueue({ message: 'only', images: [], outboundSequence: 1, localId: 'one' }); + + expect(queue.removeByLocalId('missing')).toBeUndefined(); + expect(queue.removeByLocalId('')).toBeUndefined(); + expect(queue.removeByLocalId('one')?.message).toBe('only'); + expect(queue.removeByLocalId('one')).toBeUndefined(); + }); }); diff --git a/cli/src/pi/promptQueue.ts b/cli/src/pi/promptQueue.ts index 0addeefe13..f8e861a598 100644 --- a/cli/src/pi/promptQueue.ts +++ b/cli/src/pi/promptQueue.ts @@ -36,6 +36,19 @@ export class PiPromptQueue { return true; } + /** + * Remove and return a queued entry by localId — used to promote a message + * into the active turn (explicit steer). Returns undefined when the entry + * is absent (already dispatched, cancelled, or still preparing). + */ + removeByLocalId(localId: string): PiPreparedPrompt | undefined { + if (!localId) return undefined; + const index = this.entries.findIndex((entry) => entry.localId === localId); + if (index === -1) return undefined; + const [entry] = this.entries.splice(index, 1); + return entry; + } + get size(): number { return this.entries.length; } diff --git a/cli/src/pi/runPi.test.ts b/cli/src/pi/runPi.test.ts index bc01d6dc09..c2d4ed8db3 100644 --- a/cli/src/pi/runPi.test.ts +++ b/cli/src/pi/runPi.test.ts @@ -1038,3 +1038,215 @@ describe('Pi prompt preparation', () => { } }); }); + +describe('Pi steer-queued-message RPC', () => { + beforeEach(() => { + harness.sent.length = 0; + harness.throwOnGetCommands = false; + harness.onError = null; + harness.onEvent = null; + harness.rpcHandlers.clear(); + harness.session.rpcHandlerManager.registerHandler.mockReset(); + harness.session.rpcHandlerManager.registerHandler.mockImplementation( + (method: string, handler: (payload: unknown) => Promise) => { + harness.rpcHandlers.set(method, handler); + } + ); + harness.session.onUserMessage.mockReset(); + harness.session.emitMessagesConsumed.mockReset(); + harness.session.sendSessionEvent.mockReset(); + harness.session.updateMetadata.mockReset(); + harness.killCount = 0; + harness.cleanupCount = 0; + vi.useFakeTimers(); + }); + + // Startup helper used by the flow tests. Mirrors the existing "establishes + // the history baseline" test: the 30s ready fallback establishes the + // baseline first, then get_state reports the streaming state and the native + // preparation probe completes. Advancing timers explicitly (instead of + // letting vi.waitFor auto-advance) keeps the fallback from re-firing mid-test. + async function startReadySession(streaming: boolean): Promise<{ running: Promise }> { + const running = runPi({ workingDirectory: '/work' }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(31_000); + await completeHistoryBaseline(); + harness.onEvent!({ + type: 'response', command: 'get_state', success: true, + data: { sessionId: 'pi-session', sessionFile: '/tmp/pi-session.jsonl', ...(streaming ? { isStreaming: true } : {}) }, + }); + await completeHistoryProbe(); + await vi.advanceTimersByTimeAsync(0); + return { running }; + } + + it('registers the steer-queued-message RPC handler', async () => { + const { running } = await startReadySession(false); + + expect(harness.rpcHandlers.has(RPC_METHODS.SteerQueuedMessage)).toBe(true); + + harness.onError?.(new Error('stop test transport')); + await running; + }); + + it('requires a localId', async () => { + const { running } = await startReadySession(false); + + const handler = harness.rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!; + const result = await handler({}); + + expect(result).toEqual({ steered: false, error: 'localId is required' }); + + harness.onError?.(new Error('stop test transport')); + await running; + }); + + it('promotes a queued message into the active turn while Pi is streaming', async () => { + const { running } = await startReadySession(true); + + // Pi reports a streaming turn: the prompt pump stays blocked, so the + // message waits in the queue instead of being sent as a prompt. + const onUserMessage = harness.session.onUserMessage.mock.calls.at(-1)![0] as ( + message: { role: 'user'; content: { type: 'text'; text: string } }, + localId: string + ) => void; + onUserMessage({ role: 'user', content: { type: 'text', text: 'steer me' } }, 'steer-local'); + await vi.advanceTimersByTimeAsync(0); + + expect(harness.sent).not.toContainEqual(expect.objectContaining({ type: 'prompt', message: 'steer me' })); + + const handler = harness.rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!; + const result = await handler({ localId: 'steer-local' }); + + expect(result).toEqual({ steered: true }); + + // The native steer reaches Pi stdin and is acked once Pi confirms it. + await vi.advanceTimersByTimeAsync(0); + const steer = harness.sent.find((item) => (item as { type?: string }).type === 'steer') as + { id: string; message: string } | undefined; + expect(steer?.message).toBe('steer me'); + harness.onEvent!({ type: 'response', id: steer!.id, command: 'steer', success: true }); + await vi.advanceTimersByTimeAsync(0); + expect(harness.session.emitMessagesConsumed).toHaveBeenCalledWith(['steer-local'], undefined); + + harness.onError?.(new Error('stop test transport')); + await running; + }); + + it('rejects a steer when the message is not queued (already dispatched)', async () => { + const { running } = await startReadySession(false); + + // Idle Pi: the pump dispatches the message as a normal prompt right away. + const onUserMessage = harness.session.onUserMessage.mock.calls.at(-1)![0] as ( + message: { role: 'user'; content: { type: 'text'; text: string } }, + localId: string + ) => void; + onUserMessage({ role: 'user', content: { type: 'text', text: 'prompt me' } }, 'prompt-local'); + await vi.advanceTimersByTimeAsync(0); + + expect(harness.sent).toContainEqual(expect.objectContaining({ type: 'prompt', message: 'prompt me' })); + + const handler = harness.rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!; + const result = await handler({ localId: 'prompt-local' }); + + expect(result).toEqual({ steered: false, error: 'Message not found or already dispatched' }); + expect(harness.sent.filter((item) => (item as { type?: string }).type === 'steer')).toHaveLength(0); + + harness.onError?.(new Error('stop test transport')); + await running; + }); + + it('defers a steer requested while the message is still preparing, then steers after preparation', async () => { + const { running } = await startReadySession(true); + + const onUserMessage = harness.session.onUserMessage.mock.calls.at(-1)![0] as ( + message: { role: 'user'; content: { type: 'text'; text: string } }, + localId: string + ) => void; + const handler = harness.rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!; + + // The handler registers the localId in preparingLocalIds synchronously; + // call the steer RPC before the preparation microtask completes so the + // message is still "preparing". + onUserMessage({ role: 'user', content: { type: 'text', text: 'attach me' } }, 'attach-local'); + const result = await handler({ localId: 'attach-local' }); + + expect(result).toEqual({ steered: true }); + expect(harness.sent.filter((item) => (item as { type?: string }).type === 'steer')).toHaveLength(0); + + // Preparation completes and the pending steer is promoted into the turn. + await vi.advanceTimersByTimeAsync(0); + const steer = harness.sent.find((item) => (item as { type?: string }).type === 'steer') as + { id: string; message: string } | undefined; + expect(steer?.message).toBe('attach me'); + harness.onEvent!({ type: 'response', id: steer!.id, command: 'steer', success: true }); + await vi.advanceTimersByTimeAsync(0); + expect(harness.session.emitMessagesConsumed).toHaveBeenCalledWith(['attach-local'], undefined); + + harness.onError?.(new Error('stop test transport')); + await running; + }); + + it('steers into the generation captured at request time, not one that started mid-preparation', async () => { + const { running } = await startReadySession(true); + + const onUserMessage = harness.session.onUserMessage.mock.calls.at(-1)![0] as ( + message: { role: 'user'; content: { type: 'text'; text: string } }, + localId: string + ) => void; + const handler = harness.rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!; + + // Request the steer while the message is still preparing (generation G1). + onUserMessage({ role: 'user', content: { type: 'text', text: 'rollover me' } }, 'rollover-local'); + const result = await handler({ localId: 'rollover-local' }); + expect(result).toEqual({ steered: true }); + expect(harness.sent.filter((item) => (item as { type?: string }).type === 'steer')).toHaveLength(0); + + // G1 ends and G2 starts while the message is still preparing. + const state = { sessionId: 'pi-session', sessionFile: '/tmp/pi-session.jsonl' }; + harness.onEvent!({ type: 'response', command: 'get_state', success: true, data: { ...state, isStreaming: false } }); + harness.onEvent!({ type: 'response', command: 'get_state', success: true, data: { ...state, isStreaming: true } }); + + // Preparation completes; the dispatcher sees generation G2 != captured + // G1 and degrades the message to the prompt FIFO instead of steering it. + await vi.advanceTimersByTimeAsync(0); + expect(harness.sent.filter((item) => (item as { type?: string }).type === 'steer')).toHaveLength(0); + expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalledWith(['rollover-local'], undefined); + + // Once G2 settles, the FIFO delivers the message as a normal prompt. + harness.onEvent!({ type: 'response', command: 'get_state', success: true, data: { ...state, isStreaming: false } }); + await vi.advanceTimersByTimeAsync(0); + expect(harness.sent).toContainEqual(expect.objectContaining({ type: 'prompt', message: 'rollover me' })); + + harness.onError?.(new Error('stop test transport')); + await running; + }); + + it('drops a deferred steer when the message is cancelled while preparing', async () => { + const { running } = await startReadySession(true); + + const onUserMessage = harness.session.onUserMessage.mock.calls.at(-1)![0] as ( + message: { role: 'user'; content: { type: 'text'; text: string } }, + localId: string + ) => void; + const handler = harness.rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!; + + onUserMessage({ role: 'user', content: { type: 'text', text: 'cancel me' } }, 'cancel-local'); + const result = await handler({ localId: 'cancel-local' }); + expect(result).toEqual({ steered: true }); + + // Cancellation wins over the deferred steer (checked first in the chain). + const onCancelQueuedMessage = harness.session.onCancelQueuedMessage.mock.calls.at(-1)![0] as (localId: string) => boolean; + expect(onCancelQueuedMessage('cancel-local')).toBe(true); + + // Preparation completes: the message is dropped — never steered, never + // sent as a prompt, never consumed (the hub deletes the row instead). + await vi.advanceTimersByTimeAsync(0); + expect(harness.sent.filter((item) => (item as { type?: string }).type === 'steer')).toHaveLength(0); + expect(harness.sent.filter((item) => (item as { type?: string }).type === 'prompt')).toHaveLength(0); + expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalled(); + + harness.onError?.(new Error('stop test transport')); + await running; + }); +}); diff --git a/cli/src/pi/runPi.ts b/cli/src/pi/runPi.ts index f2a3fb3f4d..d1fdd926e5 100644 --- a/cli/src/pi/runPi.ts +++ b/cli/src/pi/runPi.ts @@ -355,6 +355,16 @@ export async function runPi(opts: { const promptQueue = new PiPromptQueue(); const preparingLocalIds = new Set(); const cancelledWhilePreparing = new Set(); + // LocalIds whose owner pressed Steer while image preparation was still in + // flight, mapped to the streaming generation observed at request time. + // Checked after preparation completes, before normal FIFO routing, so an + // explicit steer request is never lost to the queue (and cancel still wins + // over it because cancellation is checked earlier in the chain). The + // captured generation matters: if the original turn ends and a new one + // starts while the message is preparing, the dispatcher's mismatch check + // degrades the message to the prompt FIFO instead of steering into a turn + // the operator never targeted. + const steerPendingWhilePreparing = new Map(); let preparationChain = Promise.resolve(); let promptCommandInFlight = false; let abortInFlight = false; @@ -553,6 +563,47 @@ export async function runPi(opts: { throw error; } }); + // --- Steer-queued-message RPC --- + // Delivers one queued message into the active Pi turn (native steer). The + // web shows a per-message Steer button only while Pi is thinking; the hub + // gates flavor/remote/scheduled before reaching this handler. Messages + // still being prepared are marked for steering and promoted right after + // preparation completes, so the request is never lost to the FIFO. + apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.SteerQueuedMessage, async (payload: unknown) => { + const localId = payload && typeof payload === 'object' + && typeof (payload as { localId?: unknown }).localId === 'string' + ? (payload as { localId: string }).localId + : undefined; + if (!localId) { + return { steered: false, error: 'localId is required' }; + } + if (preparingLocalIds.has(localId)) { + const generation = piSession.currentStreamingGeneration; + if (!piSession.isReady || generation === null) { + return { steered: false, error: 'Session is not streaming' }; + } + steerPendingWhilePreparing.set(localId, generation); + return { steered: true }; + } + if (!steerDispatcher) { + return { steered: false, error: 'Steering is not ready' }; + } + const entry = promptQueue.removeByLocalId(localId); + if (!entry) { + return { steered: false, error: 'Message not found or already dispatched' }; + } + // Only steer into a live Pi generation. Otherwise restore the entry to + // its FIFO position (enqueue re-orders by outboundSequence) and let the + // normal pump deliver it when the agent settles. + const currentGeneration = piSession.currentStreamingGeneration; + if (!piSession.isReady || currentGeneration === null) { + promptQueue.enqueue(entry); + return { steered: false, error: 'Session is not streaming' }; + } + steerDispatcher.enqueue({ ...entry, targetStreamingGeneration: currentGeneration }); + return { steered: true }; + }); + apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.RewindConversation, async (payload: unknown) => { if (!payload || typeof payload !== 'object' || typeof (payload as { messageLocalId?: unknown }).messageLocalId !== 'string') { throw new Error('messageLocalId is required'); @@ -788,6 +839,13 @@ export async function runPi(opts: { }; if (deliveryMode === 'steer') { steerDispatcher?.enqueue({ ...entry, targetStreamingGeneration }); + } else if (localId && steerPendingWhilePreparing.has(localId)) { + // The user pressed Steer while this message was still preparing. + // Promote it into the turn observed at request time; if that + // turn already ended, the dispatcher degrades it to the FIFO. + const targetGeneration = steerPendingWhilePreparing.get(localId)!; + steerPendingWhilePreparing.delete(localId); + steerDispatcher?.enqueue({ ...entry, targetStreamingGeneration: targetGeneration }); } else { promptQueue.enqueue(entry); pumpPromptQueue(); @@ -800,6 +858,13 @@ export async function runPi(opts: { if (localId && !wasCancelled) { piSession.emitMessagesConsumed([localId], { clearQueuedThinkingGrace: true }); } + }).finally(() => { + // Deferred-steer bookkeeping must not outlive the message it refers + // to: the promotion path already deletes it, and every early exit + // (cancellation before/after preparation, empty prepared message, + // preparation failure) lands here. A stale entry could misroute a + // later reuse of the same localId. + if (localId) steerPendingWhilePreparing.delete(localId); }); }); diff --git a/cli/src/pi/steerDispatcher.test.ts b/cli/src/pi/steerDispatcher.test.ts index d273e0d057..b76e198af2 100644 --- a/cli/src/pi/steerDispatcher.test.ts +++ b/cli/src/pi/steerDispatcher.test.ts @@ -178,17 +178,19 @@ describe('PiSteerDispatcher', () => { expect(h.history.registerUserEntry).not.toHaveBeenCalled(); }); - it('removes failed native steers from history and clears only their queued thinking grace', async () => { + it('preserves a deterministically rejected steer by degrading it to the prompt FIFO', async () => { const h = createHarness(); h.dispatcher.enqueue({ localId: 'failed-steer', message: 'will fail', images: [], outboundSequence: 1, targetStreamingGeneration: h.session.currentStreamingGeneration }); await vi.waitFor(() => expect(steerCommands(h.transport)).toHaveLength(1)); resolveSteer(h.session, steerCommands(h.transport)[0]!, false, 'steer rejected'); await vi.waitFor(() => expect(h.history.rejectPendingEntry).toHaveBeenCalledWith('failed-steer')); - expect(h.client.emitMessagesConsumed).toHaveBeenCalledWith( - ['failed-steer'], - { clearQueuedThinkingGrace: true }, - ); + // Pi never accepted the steer: the message must survive by falling back + // to the ordinary prompt FIFO instead of being consumed (issue #1466). + expect(h.enqueuePrompt).toHaveBeenCalledWith({ + localId: 'failed-steer', message: 'will fail', images: [], outboundSequence: 1, + }); + expect(h.client.emitMessagesConsumed).not.toHaveBeenCalled(); expect(h.client.sendSessionEvent).toHaveBeenCalledWith({ type: 'message', message: 'Pi steer failed: steer rejected', }); diff --git a/cli/src/pi/steerDispatcher.ts b/cli/src/pi/steerDispatcher.ts index 50ea0df09f..6d1aa71820 100644 --- a/cli/src/pi/steerDispatcher.ts +++ b/cli/src/pi/steerDispatcher.ts @@ -135,6 +135,26 @@ export class PiSteerDispatcher { const detail = error instanceof Error ? error.message : String(error); this.options.conversationHistory.rejectPendingEntry(active.entry.localId); + + // A deterministic native rejection means Pi never accepted the + // steer. Preserve the message by degrading it to the ordinary + // prompt FIFO (it is delivered when the agent settles) instead of + // consuming the hub row — a promoted queued message must not be + // lost just because the steer was rejected. + if (!(error instanceof PiRpcTimeoutError)) { + this.options.enqueuePrompt({ + message: active.entry.message, + images: active.entry.images, + outboundSequence: active.entry.outboundSequence, + ...(active.entry.localId ? { localId: active.entry.localId } : {}), + }); + this.options.session.sendSessionEvent({ type: 'message', message: `Pi steer failed: ${detail}` }); + return; + } + + // Indeterminate timeout: Pi may or may not have accepted the + // steer. Keep the fail-closed handling (consume + escalate) rather + // than risking a duplicate delivery via the prompt FIFO. if (active.entry.localId) { this.options.session.emitMessagesConsumed( [active.entry.localId], @@ -142,7 +162,7 @@ export class PiSteerDispatcher { ); } this.options.session.sendSessionEvent({ type: 'message', message: `Pi steer failed: ${detail}` }); - if (error instanceof PiRpcTimeoutError) this.options.onIndeterminateTimeout(error); + this.options.onIndeterminateTimeout(error); } } } diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 405dadafc7..454126567e 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -419,6 +419,20 @@ export class RpcGateway { return await this.sessionRpc(sessionId, method, params ?? {}, timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS) as T } + /** + * Ask the CLI to deliver one queued message into the active Pi turn + * (Pi native steer). Only the pi flavor registers this handler. + */ + async steerQueuedMessage( + sessionId: string, + localId: string + ): Promise<{ steered: boolean; error?: string }> { + return await this.sessionRpc(sessionId, RPC_METHODS.SteerQueuedMessage, { localId }) as { + steered: boolean + error?: string + } + } + async forkConversation( sessionId: string, params: { messageLocalId?: string } diff --git a/hub/src/sync/steerQueuedMessage.test.ts b/hub/src/sync/steerQueuedMessage.test.ts new file mode 100644 index 0000000000..78b51e877e --- /dev/null +++ b/hub/src/sync/steerQueuedMessage.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' + +function createEngine() { + const store = new Store(':memory:') + const io = { + of: () => ({ + to: () => ({ emit: () => {} }) + }) + } + const engine = new SyncEngine(store, io as never, new RpcRegistry(), { broadcast() {} } as never) + return { store, engine } +} + +describe('SyncEngine.steerQueuedMessage', () => { + it('rejects every scheduled row, mature ones included, without invoking the CLI', async () => { + const { store, engine } = createEngine() + try { + const session = engine.getOrCreateSession( + 'steer-scheduled', + { path: '/tmp/project', host: 'localhost', flavor: 'pi' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + // A mature scheduled row: the fire time already passed, but the row + // is still uninvoked and waiting for the scheduled-FIFO release. + const message = store.messages.addMessage( + session.id, + { text: 'mature scheduled' }, + 'mature-local', + Date.now() - 1_000 + ) + + const result = await engine.steerQueuedMessage(session.id, message.id) + + expect(result).toEqual({ + status: 'failed', + error: 'Scheduled messages cannot be steered', + localId: 'mature-local' + }) + // The row must stay queued — untouched by the rejected steer. + const lookup = store.messages.lookupQueuedMessage(session.id, message.id) + expect(lookup.status).toBe('queued') + } finally { + engine.stop() + } + }) + + it('rejects non-pi sessions without invoking the CLI', async () => { + const { store, engine } = createEngine() + try { + const session = engine.getOrCreateSession( + 'steer-codex', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + const message = store.messages.addMessage(session.id, { text: 'hi' }, 'local-id') + + const result = await engine.steerQueuedMessage(session.id, message.id) + + expect(result).toEqual({ + status: 'failed', + error: 'Steering is only supported for Pi sessions', + localId: null + }) + } finally { + engine.stop() + } + }) +}) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d74ab078e4..fc1dc82b60 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -13,6 +13,7 @@ import { isMachineCapabilitySkewed, } from '@hapi/protocol/runnerCapabilities' import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes' +import type { SteerQueuedMessageResponse } from '@hapi/protocol/schemas' import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' @@ -1020,6 +1021,72 @@ export class SyncEngine { return this.messageService.cancelQueuedMessage(sessionId, messageId) } + /** + * Ask the CLI to deliver one waiting-queue message into the active Pi turn + * (Pi native steer). Only pi sessions support this today; the CLI's + * `steer-queued-message` handler is registered by the pi runner alone. + */ + async steerQueuedMessage( + sessionId: string, + messageId: string + ): Promise { + const session = this.getSession(sessionId) + if (!session) { + return { status: 'failed', error: 'Session not found', localId: null } + } + if (session.metadata?.flavor !== 'pi') { + return { status: 'failed', error: 'Steering is only supported for Pi sessions', localId: null } + } + if (session.agentState?.controlledByUser === true) { + return { status: 'failed', error: 'Steering is only available for remote sessions', localId: null } + } + + const lookup = this.store.messages.lookupQueuedMessage(sessionId, messageId) + if (lookup.status === 'absent') { + return { status: 'failed', error: 'Message not found', localId: null } + } + if (lookup.status === 'invoked') { + const message = lookup.message + return { + status: 'invoked', + message: { + id: message.id, + seq: message.seq, + localId: message.localId, + content: message.content, + createdAt: message.createdAt, + invokedAt: message.invokedAt, + scheduledAt: message.scheduledAt + } + } + } + const { localId, scheduledAt } = lookup + if (!localId) { + return { status: 'failed', error: 'Message has no localId', localId: null } + } + // Reject every scheduled row — mature ones included. A matured row is + // released by the scheduled-FIFO path moments later anyway, and the web + // never offers Steer on scheduled rows. + if (scheduledAt != null) { + return { status: 'failed', error: 'Scheduled messages cannot be steered', localId } + } + + try { + const result = await this.rpcGateway.steerQueuedMessage(sessionId, localId) + if (result.steered) { + return { status: 'steered', localId } + } + return { + status: 'failed', + error: result.error ?? 'Steer failed', + localId + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Steer failed' + return { status: 'failed', error: message, localId } + } + } + sweepImmediateQueuedOnSessionEnd(sessionId: string, invokedAt: number): void { this.messageService.sweepImmediateQueuedOnSessionEnd(sessionId, invokedAt) } diff --git a/hub/src/web/routes/messages.test.ts b/hub/src/web/routes/messages.test.ts index 3876d3f855..55b96cdd01 100644 --- a/hub/src/web/routes/messages.test.ts +++ b/hub/src/web/routes/messages.test.ts @@ -28,6 +28,7 @@ function createApp(opts: { queuedLocalIds: string[] invokedLocalMessages: Array<{ localId: string; invokedAt: number }> } + steerQueuedMessage?: (sessionId: string, messageId: string) => Promise }) { const sentMessages: Array<{ sessionId: string; payload: unknown }> = [] const queuedStateCalls: Array<{ sessionId: string; localIds: string[] }> = [] @@ -69,6 +70,7 @@ function createApp(opts: { sendMessage, getQueuedState, cancelQueuedMessage: async () => ({ status: 'cancelled' }), + steerQueuedMessage: opts.steerQueuedMessage ?? (async () => ({ status: 'failed', error: 'Steer failed', localId: null })), getMessagesPage, } as unknown as SyncEngine @@ -485,3 +487,32 @@ describe('POST /api/sessions/:id/messages/queued-state', () => { expect(queuedStateCalls).toHaveLength(0) }) }) + +describe('POST /api/sessions/:id/messages/:messageId/steer', () => { + it('forwards the steer request to the engine and returns its result', async () => { + const calls: Array<{ sessionId: string; messageId: string }> = [] + const { app } = createApp({ + steerQueuedMessage: async (sessionId: string, messageId: string) => { + calls.push({ sessionId, messageId }) + return { status: 'steered', localId: 'local-1' } + } + }) + + const response = await app.request('/api/sessions/session-1/messages/msg-1/steer', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(calls).toEqual([{ sessionId: 'session-1', messageId: 'msg-1' }]) + expect(await response.json()).toEqual({ status: 'steered', localId: 'local-1' }) + }) + + it('rejects inactive sessions', async () => { + const { app } = createApp({ active: false }) + + const response = await app.request('/api/sessions/session-1/messages/msg-1/steer', { method: 'POST' }) + + expect(response.status).toBe(409) + const body = await response.json() as { error: string; code: string } + expect(body.error).toBe('Session is inactive') + expect(body.code).toBe('session_inactive') + }) +}) diff --git a/hub/src/web/routes/messages.ts b/hub/src/web/routes/messages.ts index b4eb79b378..dcd33d7cad 100644 --- a/hub/src/web/routes/messages.ts +++ b/hub/src/web/routes/messages.ts @@ -60,6 +60,23 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json(result) }) + app.post('/sessions/:id/messages/:messageId/steer', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + const sessionId = sessionResult.sessionId + const messageId = c.req.param('messageId') + + const result = await engine.steerQueuedMessage(sessionId, messageId) + return c.json(result) + }) + app.post('/sessions/:id/messages/queued-state', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index ef57eaa7f6..af06f53e94 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -42,6 +42,8 @@ export const RPC_METHODS = { ListCopilotModels: 'listCopilotModels', ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions', ListAgyModels: 'listAgyModels', + /** Deliver one queued message into the active Pi turn (native steer). */ + SteerQueuedMessage: 'steer-queued-message', ForkConversation: 'fork-conversation', RewindConversation: 'rewind-conversation', } as const diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 7c7d3215ea..1a3d38d2e3 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -601,3 +601,11 @@ export const CancelMessageResponseSchema = z.discriminatedUnion('status', [ ]) export type CancelMessageResponse = z.infer + +export const SteerQueuedMessageResponseSchema = z.discriminatedUnion('status', [ + z.object({ status: z.literal('steered'), localId: z.string() }), + z.object({ status: z.literal('invoked'), message: DecryptedMessageSchema }), + z.object({ status: z.literal('failed'), error: z.string(), localId: z.string().nullable() }), +]) + +export type SteerQueuedMessageResponse = z.infer diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 5d3a1e0e94..df920910d6 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -151,6 +151,22 @@ describe('ApiClient error mapping', () => { }) }) + it('posts a steer for a queued message', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ status: 'steered', localId: 'local-1' }), { status: 200 }) + ) + + const api = new ApiClient('test-token') + await expect(api.steerMessage('session /?#', 'msg-1')).resolves.toEqual({ + status: 'steered', + localId: 'local-1', + }) + + const [url, init] = fetchMock.mock.calls[0] ?? [] + expect(url).toBe('/api/sessions/session%20%2F%3F%23/messages/msg-1/steer') + expect(init).toMatchObject({ method: 'POST' }) + }) + it('requests usage buckets in the viewer IANA time zone', async () => { fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 })) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3e5dac619b..471fd635fd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -53,7 +53,7 @@ import type { UploadFileResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor, MessageDeliveryMode } from '@hapi/protocol' -import type { CancelMessageResponse } from '@hapi/protocol/schemas' +import type { CancelMessageResponse, SteerQueuedMessageResponse } from '@hapi/protocol/schemas' import type { TranscriptionMode, TranscriptionProvider, TranscriptionProviderInfo } from '@hapi/protocol/voice' export type ProviderCredentialSource = 'env' | 'settings' | 'none' @@ -539,6 +539,14 @@ export class ApiClient { return response as CancelMessageResponse } + async steerMessage(sessionId: string, messageId: string): Promise { + const response = await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/steer`, + { method: 'POST' } + ) + return response as SteerQueuedMessageResponse + } + async abortSession(sessionId: string): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/abort`, { method: 'POST', diff --git a/web/src/components/AssistantChat/ComposerButtons.test.tsx b/web/src/components/AssistantChat/ComposerButtons.test.tsx index 8f3f123e0e..d6ac051be3 100644 --- a/web/src/components/AssistantChat/ComposerButtons.test.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.test.tsx @@ -107,12 +107,7 @@ describe('UnifiedButton — routesToScratchlist visual state', () => { }) }) -describe('UnifiedButton — touch queue gesture', () => { - afterEach(() => { - cleanup() - vi.useRealTimers() - }) - +describe('UnifiedButton — default send intent', () => { function renderSendButton(overrides: Partial> = {}) { const onSend = vi.fn() renderInProviders( @@ -123,7 +118,6 @@ describe('UnifiedButton — touch queue gesture', () => { controlsDisabled={false} onSend={onSend} onVoiceToggle={() => {}} - allowQueueGesture {...overrides} />, ) @@ -146,40 +140,6 @@ describe('UnifiedButton — touch queue gesture', () => { expect(onSend).toHaveBeenCalledWith('default') }) - it('uses queue only for a mobile touch long-press and suppresses its native click', () => { - vi.useFakeTimers() - const { onSend, button } = renderSendButton() - - fireEvent.touchStart(button, { touches: [{ clientX: 10, clientY: 10 }] }) - act(() => vi.advanceTimersByTime(500)) - fireEvent.touchEnd(button, { changedTouches: [{ clientX: 10, clientY: 10 }] }) - fireEvent.click(button, { detail: 1 }) - - expect(onSend).toHaveBeenCalledOnce() - expect(onSend).toHaveBeenCalledWith('queue') - - fireEvent.click(button, { detail: 1 }) - expect(onSend).toHaveBeenCalledTimes(2) - expect(onSend).toHaveBeenLastCalledWith('default') - }) - - it('keeps keyboard and assistive send activation after a long touch has no compatibility click', () => { - vi.useFakeTimers() - const { onSend, button } = renderSendButton() - - fireEvent.touchStart(button, { touches: [{ clientX: 10, clientY: 10 }] }) - act(() => vi.advanceTimersByTime(500)) - fireEvent.touchEnd(button, { changedTouches: [{ clientX: 10, clientY: 10 }] }) - - // The browser does not emit its touch compatibility click. A detail-0 - // click is the native keyboard/assistive activation path. - fireEvent.click(button, { detail: 0 }) - - expect(onSend).toHaveBeenCalledTimes(2) - expect(onSend).toHaveBeenNthCalledWith(1, 'queue') - expect(onSend).toHaveBeenNthCalledWith(2, 'default') - }) - it('keeps touch tap, desktop mouse hold, and desktop right-click on normal behavior', () => { vi.useFakeTimers() const { onSend, button } = renderSendButton() @@ -199,22 +159,6 @@ describe('UnifiedButton — touch queue gesture', () => { expect(onSend).toHaveBeenNthCalledWith(2, 'default') expect(contextMenuWasNotPrevented).toBe(true) }) - - it.each([ - ['voice is active', { voiceStatus: 'connected' as const }], - ['scratchlist route is active', { routesToScratchlist: true }], - ['queue gesture is disabled', { allowQueueGesture: false }], - ])('does not queue on long-press when %s', (_name, overrides) => { - vi.useFakeTimers() - const { onSend, button } = renderSendButton(overrides) - - fireEvent.touchStart(button, { touches: [{ clientX: 10, clientY: 10 }] }) - act(() => vi.advanceTimersByTime(500)) - fireEvent.touchEnd(button, { changedTouches: [{ clientX: 10, clientY: 10 }] }) - fireEvent.click(button) - - expect(onSend).not.toHaveBeenCalledWith('queue') - }) }) describe('DictationButton', () => { diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 8973a807ab..e90c1da809 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -8,7 +8,6 @@ import { useFue } from '@/lib/use-fue' import { FueCallout, FueDot } from '@/components/Fue' import { Children, isValidElement, useRef, useState, type ReactElement, type ReactNode, type Ref } from 'react' import { useComposerToolbarLayout, type ComposerToolbarItemId, type ComposerToolbarLayout } from '@/hooks/useComposerToolbarLayout' -import { useLongPress } from '@/hooks/useLongPress' import type { ComposerSendIntent } from '@/lib/messageDelivery' function ToolbarItemSlot(props: { item: ComposerToolbarItemId; children: ReactNode }) { @@ -490,8 +489,6 @@ export function UnifiedButton(props: { * would fall back to chat, the button must look like a normal chat send. */ routesToScratchlist?: boolean - /** Pi-only explicit follow-up gesture; never changes the normal click. */ - allowQueueGesture?: boolean }) { const { t } = useTranslation() @@ -511,25 +508,6 @@ export function UnifiedButton(props: { } } - // This is intentionally narrower than the button's general enabled state: - // a touch hold changes only an active Pi-main-thread chat submission. Voice - // controls, scratchlist routing, scheduled sends, and desktop input retain - // their existing native behavior. - const canQueueGesture = Boolean( - props.allowQueueGesture - && hasText - && !isVoiceActive - && !routesToScratchlist - && !props.controlsDisabled, - ) - const sendButtonHandlers = useLongPress({ - interaction: 'touch-only-native-click', - onClick: handleClick, - onLongPress: () => props.onSend('queue'), - longPressEnabled: canQueueGesture, - disabled: props.controlsDisabled, - }) - let icon: React.ReactNode let className: string let ariaLabel: string @@ -577,7 +555,7 @@ export function UnifiedButton(props: { return (
) diff --git a/web/src/components/AssistantChat/HappyComposer.sendError.test.tsx b/web/src/components/AssistantChat/HappyComposer.sendError.test.tsx index a8d60826ab..1490118ada 100644 --- a/web/src/components/AssistantChat/HappyComposer.sendError.test.tsx +++ b/web/src/components/AssistantChat/HappyComposer.sendError.test.tsx @@ -565,12 +565,14 @@ describe('HappyComposer send intent gestures', () => { runtime.sentIntents = [] }) - it('uses queue for Alt/Option+Enter only while the Pi main thread is running', () => { + it('ignores Alt/Option+Enter (the old explicit-queue gesture) entirely', () => { renderComposer('follow-up', null, true) fireEvent.keyDown(input(), { key: 'Enter', altKey: true }) - expect(runtime.sentIntents).toEqual(['queue']) + // Every send now queues by default (issue #1466); the Alt+Enter + // gesture was removed with the Pi automatic steer. + expect(runtime.sentIntents).toEqual([]) expect(runtime.pendingSendIntentRef?.current).toBe('default') }) @@ -593,7 +595,7 @@ describe('HappyComposer send intent gestures', () => { expect(runtime.pendingSendIntentRef?.current).toBe('default') }) - it('does not turn Alt/Option+Enter into queue when Pi is idle or a schedule is active', () => { + it('keeps Alt/Option+Enter inert when Pi is idle or a schedule is active', () => { const idle = renderComposer('idle', null, false) fireEvent.keyDown(input(), { key: 'Enter', altKey: true }) expect(runtime.sentIntents).toEqual([]) diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index f2515a3751..f037d0c579 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1219,12 +1219,6 @@ export function HappyComposer(props: { void handleSend(intent) }, [handleSend]) - const canQueueSend = agentFlavor === 'pi' - && thinking - && threadIsRunning - && pendingSchedule == null - && !props.scratchlistMode - const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { const key = e.key @@ -1246,22 +1240,6 @@ export function HappyComposer(props: { return } - // Alt/Option+Enter is an explicit Pi follow-up request. It is - // orthogonal to the normal Enter preference but never overrides IME, - // Shift+Enter, autocomplete, scheduling, or scratchlist routing. - if ( - key === 'Enter' - && e.altKey - && !e.ctrlKey - && !e.metaKey - && canQueueSend - ) { - e.preventDefault() - flushAndSend('queue') - setShowContinueHint(false) - return - } - // Only plain Enter (no modifiers) sends; other modifier combos are ignored if (key === 'Enter') { if (composerEnterBehavior === 'newline') { @@ -1350,7 +1328,6 @@ export function HappyComposer(props: { richComposerFueStatus, dismissRichComposerFue, flushAndSend, - canQueueSend, isExpanded, handleExpandedToggle, ]) @@ -2315,7 +2292,6 @@ export function HappyComposer(props: { onVoiceToggle={effectiveVoiceToggle ?? (() => {})} onVoiceMicToggle={dictationActive ? undefined : onVoiceMicToggle} onSend={handleSend} - allowQueueGesture={canQueueSend} pendingSchedule={pendingSchedule} onSchedule={handleUserSchedule} onClearSchedule={onUserClearSchedule} diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx index 1450934c56..11424c2ce6 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx @@ -1,5 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ApiClient } from '@/api/client' import type { DecryptedMessage } from '@/types/api' import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' import { @@ -22,6 +24,9 @@ const mocks = vi.hoisted(() => ({ mutateAsync: vi.fn(), resolveCancel: null as ((result: DeferredCancelResult) => void) | null, rejectCancel: null as ((reason?: unknown) => void) | null, + steerMessage: vi.fn(), + resolveSteer: null as ((result: unknown) => void) | null, + markMessagesConsumed: vi.fn(), saveDraft: vi.fn(), messageWindowState: { messages: [] as unknown[] }, })) @@ -41,6 +46,7 @@ vi.mock('@assistant-ui/react', () => ({ vi.mock('@/lib/message-window-store', () => ({ getMessageWindowState: () => mocks.messageWindowState, subscribeMessageWindow: () => () => {}, + markMessagesConsumed: mocks.markMessagesConsumed, })) vi.mock('@/hooks/mutations/useCancelQueuedMessage', () => ({ @@ -83,18 +89,28 @@ function renderQueuedMessage( scheduledAt: number | null = null, pendingSchedule: PendingSchedule | null = null, pendingScheduleRevision = 0, + canSteer = false, + api: ApiClient | null = null, ) { const onEdit = vi.fn() let currentPendingScheduleRevision = pendingScheduleRevision mocks.messageWindowState = { messages: [makeQueuedMessage(scheduledAt)] } + // The real useSteerQueuedMessage hook runs inside the bar, so every render + // needs a QueryClient (its mutations use the tanstack defaults). + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) const view = render( - + + + ) return { onEdit, @@ -102,13 +118,16 @@ function renderQueuedMessage( rerender: (nextPendingSchedule: PendingSchedule | null, nextPendingScheduleRevision = currentPendingScheduleRevision) => { currentPendingScheduleRevision = nextPendingScheduleRevision view.rerender( - + + + ) }, } @@ -121,6 +140,9 @@ beforeEach(() => { mocks.mutateAsync.mockReset() mocks.resolveCancel = null mocks.rejectCancel = null + mocks.steerMessage.mockReset() + mocks.resolveSteer = null + mocks.markMessagesConsumed.mockReset() mocks.saveDraft.mockReset() mocks.messageWindowState = { messages: [] } clearQueuedEditRecovery('session-1') @@ -280,14 +302,19 @@ describe('QueuedMessagesBar edit restore', () => { const second = makeQueuedMessage(scheduledAt, 'server-message-b') mocks.messageWindowState = { messages: [first, second] } const onEdit = vi.fn() + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) render( - + + + ) const editButtons = screen.getAllByRole('button', { name: 'Edit queued message' }) @@ -696,3 +723,97 @@ describe('formatScheduledTime', () => { expect(result).toContain(String(nextYear)) }) }) + +describe('QueuedMessagesBar steer action', () => { + // The real useSteerQueuedMessage hook runs here (only the cancel hook is + // module-mocked); pass a fake api whose steerMessage resolves on demand. + function renderSteerable(canSteer = true) { + mocks.steerMessage.mockImplementation(() => new Promise((resolve) => { + mocks.resolveSteer = resolve + })) + const api = { steerMessage: mocks.steerMessage } as unknown as ApiClient + const view = renderQueuedMessage(null, null, 0, canSteer, api) + return { unmount: view.unmount } + } + + it('shows the Steer button only when canSteer is set and the row is immediate', () => { + const immediate = renderSteerable(true) + expect(screen.getByRole('button', { name: 'Steer queued message' })).toBeTruthy() + immediate.unmount() + + renderSteerable(false) + expect(screen.queryByRole('button', { name: 'Steer queued message' })).toBeNull() + }) + + it('hides the Steer button on future-scheduled rows', () => { + const api = { steerMessage: mocks.steerMessage } as unknown as ApiClient + renderQueuedMessage(Date.now() + 60_000, null, 0, true, api) + expect(screen.queryByRole('button', { name: 'Steer queued message' })).toBeNull() + }) + + it('calls the steer api with the session and message id', async () => { + renderSteerable(true) + + fireEvent.click(screen.getByRole('button', { name: 'Steer queued message' })) + + await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalledWith('session-1', 'server-message-id')) + // Settle the pending mutation so the queued-operation token releases; + // otherwise the next test would see the session as busy. + await act(async () => { + mocks.resolveSteer?.({ status: 'steered', localId: 'local-server-message-id' }) + await Promise.resolve() + }) + }) + + it('toasts when the steer fails and leaves the row queued', async () => { + renderSteerable(true) + + fireEvent.click(screen.getByRole('button', { name: 'Steer queued message' })) + await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalled()) + await act(async () => { + mocks.resolveSteer?.({ status: 'failed', error: 'Session is not streaming', localId: 'local-server-message-id' }) + await Promise.resolve() + }) + + expect(mocks.addToast).toHaveBeenCalledWith({ + title: 'queuedMessages.steerFailed', + body: 'Session is not streaming', + sessionId: 'session-1', + url: window.location.href, + }) + }) + + it('does not toast on a successful steer (the consumed event clears the row)', async () => { + renderSteerable(true) + + fireEvent.click(screen.getByRole('button', { name: 'Steer queued message' })) + await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalled()) + await act(async () => { + mocks.resolveSteer?.({ status: 'steered', localId: 'local-server-message-id' }) + await Promise.resolve() + }) + + expect(mocks.addToast).not.toHaveBeenCalled() + }) + + it('reconciles a stale queued row when the steer returns invoked (missed consumption SSE)', async () => { + renderSteerable(true) + + fireEvent.click(screen.getByRole('button', { name: 'Steer queued message' })) + await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalled()) + await act(async () => { + mocks.resolveSteer?.({ + status: 'invoked', + message: { localId: 'local-server-message-id', invokedAt: 5_000 }, + }) + await Promise.resolve() + }) + + expect(mocks.markMessagesConsumed).toHaveBeenCalledWith( + 'session-1', + ['local-server-message-id'], + 5_000, + ) + expect(mocks.addToast).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.tsx index 7a34b25050..5bcf148f6b 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.tsx @@ -7,6 +7,7 @@ import { EMPTY_STATE } from '@/hooks/queries/useMessages' import { normalizeDecryptedMessage } from '@/chat/normalize' import type { DecryptedMessage } from '@/types/api' import { useCancelQueuedMessage } from '@/hooks/mutations/useCancelQueuedMessage' +import { useSteerQueuedMessage } from '@/hooks/mutations/useSteerQueuedMessage' import { useTranslation } from '@/lib/use-translation' import { useToast } from '@/lib/toast-context' import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' @@ -42,6 +43,24 @@ function ClockIcon() { ) } +function SteerIcon() { + return ( + + ) +} + /** * Orders queued messages so the floating bar reads top-down as a single timeline: * 1. Immediate-queued messages first, in the order they were submitted. @@ -176,6 +195,7 @@ export function QueuedMessagesBar({ pendingSchedule, pendingScheduleRevision, onEdit, + canSteer, }: { sessionId: string api: ApiClient | null @@ -189,11 +209,18 @@ export function QueuedMessagesBar({ * Edit is always cancel + prefill, regardless of whether the message is scheduled or immediate. */ onEdit?: (params: { text: string; pendingSchedule: PendingSchedule | null }) => void + /** + * When true, each queued row gets a Steer button that delivers that + * message into the active turn (Pi native steer). The parent computes it + * as: pi flavor && session thinking && remote-controlled. + */ + canSteer?: boolean }) { const queued = useQueuedMessages(sessionId) const assistantApi = useAui() const composerText = useAuiState((state) => state.composer.text) const cancelMutation = useCancelQueuedMessage(api) + const steerMutation = useSteerQueuedMessage(api) const { t } = useTranslation() const { addToast } = useToast() const pendingScheduleRef = useRef(pendingSchedule) @@ -339,6 +366,31 @@ export function QueuedMessagesBar({ }) } + // Steer delivers this message into the active Pi turn. Gated + // on the same server-echo + no-pending-op conditions as + // Edit/Cancel, and never offered for future-scheduled rows + // (the hub rejects those). + const canSteerRow = Boolean( + canSteer + && msg.scheduledAt == null + && canCancel + ) + const steerPending = steerMutation.isPending + && steerMutation.variables?.messageId === msg.id + const handleSteer = () => { + if (!canSteerRow) return + const token = beginQueuedOperation(sessionId) + if (!token) return + void steerMutation.mutateAsync({ + sessionId, + messageId: msg.id, + }).catch(() => { + // useSteerQueuedMessage already toasts the failure. + }).finally(() => { + endQueuedOperation(sessionId, token) + }) + } + const handleEdit = async () => { if (!canCancel) return // Edit = cancel + restore composer (text + schedule). @@ -452,6 +504,19 @@ export function QueuedMessagesBar({ )}
+ {canSteerRow ? ( + + ) : null}
diff --git a/web/src/hooks/mutations/useSteerQueuedMessage.ts b/web/src/hooks/mutations/useSteerQueuedMessage.ts new file mode 100644 index 0000000000..e96c951ea5 --- /dev/null +++ b/web/src/hooks/mutations/useSteerQueuedMessage.ts @@ -0,0 +1,63 @@ +import { useMutation } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import { markMessagesConsumed } from '@/lib/message-window-store' +import { useTranslation } from '@/lib/use-translation' +import { useToast } from '@/lib/toast-context' + +type SteerQueuedMessageInput = { + sessionId: string + messageId: string +} + +/** + * Mutation: deliver one queued message into the active Pi turn (native steer). + * + * Non-optimistic on purpose: the CLI acknowledges the steer via the existing + * `messages-consumed` event, which flips the row to invoked and removes it from + * the floating bar. An optimistic removal here would fight that event and + * would need a revert path for the failure case anyway. + * + * Failure surfaces as a toast; the row stays queued and can be retried. + */ +export function useSteerQueuedMessage(api: ApiClient | null) { + const { t } = useTranslation() + const { addToast } = useToast() + + const mutation = useMutation({ + mutationFn: async (input: SteerQueuedMessageInput) => { + if (!api) { + throw new Error('API unavailable') + } + return api.steerMessage(input.sessionId, input.messageId) + }, + onSuccess: (result, input) => { + if (result.status === 'failed') { + addToast({ + title: t('queuedMessages.steerFailed'), + body: result.error ?? '', + sessionId: input.sessionId, + url: window.location.href, + }) + return + } + if (result.status === 'invoked' && result.message.localId && typeof result.message.invokedAt === 'number') { + // The CLI consumed this message before the steer arrived. If the + // messages-consumed SSE was missed while the row was still + // queued, reconcile it now so the queued bar cannot keep a + // stale actionable row (mirrors useCancelQueuedMessage). + markMessagesConsumed(input.sessionId, [result.message.localId], result.message.invokedAt) + } + // status === 'steered': the messages-consumed SSE will remove the row. + }, + onError: (error, input) => { + addToast({ + title: t('queuedMessages.steerFailed'), + body: error instanceof Error ? error.message : '', + sessionId: input.sessionId, + url: window.location.href, + }) + }, + }) + + return mutation +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 21c0a33fa7..0d82e52f51 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -619,6 +619,8 @@ export default { 'queuedMessages.scheduledFor': 'Scheduled for {time}', 'queuedMessages.editAlreadyInvoked': "Message already sent — it can't be edited", 'queuedMessages.editCurrentDraftKept': 'Queued message cancelled — current draft and schedule were kept.', + 'queuedMessages.steer': 'Deliver into the running turn now', + 'queuedMessages.steerFailed': 'Steer failed — message stays queued', // Scratchlist (per-session workbench, issue #11) 'scratchlist.title': 'Scratchlist', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 079d3d07bb..9608204d7f 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -618,6 +618,8 @@ export default { 'queuedMessages.scheduledFor': '定时发送: {time}', 'queuedMessages.editAlreadyInvoked': '消息已发送,无法编辑', 'queuedMessages.editCurrentDraftKept': '队列消息已取消,已保留当前草稿和定时设置。', + 'queuedMessages.steer': '立即插入当前回合', + 'queuedMessages.steerFailed': '插入失败,消息仍在队列中', // Scratchlist (per-session workbench, issue #11) 'scratchlist.title': '草稿夹', diff --git a/web/src/lib/messageDelivery.test.ts b/web/src/lib/messageDelivery.test.ts index 646b498462..90856ed7e3 100644 --- a/web/src/lib/messageDelivery.test.ts +++ b/web/src/lib/messageDelivery.test.ts @@ -38,24 +38,12 @@ describe('resolveMessageDeliveryMode', () => { intent: 'default' as const, } - it('steers an immediate fresh Pi send while the main session is thinking', () => { - expect(resolveMessageDeliveryMode(base)).toBe('steer') - }) - - it('keeps an explicit queue gesture queued even while Pi is thinking', () => { - expect(resolveMessageDeliveryMode({ ...base, intent: 'queue' })).toBe('queue') - }) - - it('queues a failed steer retry even when a later Pi generation is active', () => { - const ref = { current: getRestoredComposerSendIntent('steer') } - const retryIntent = consumeComposerSendIntent(ref) - - expect(resolveMessageDeliveryMode({ ...base, intent: retryIntent })).toBe('queue') - expect(ref.current).toBe('default') - expect(resolveMessageDeliveryMode({ ...base, intent: consumeComposerSendIntent(ref) })).toBe('steer') - }) - + // Every composer submission queues — mid-turn delivery happens only via + // the explicit per-queued-message Steer action (issue #1466). The old Pi + // automatic steer while thinking was removed. it.each([ + { name: 'thinking Pi (previously auto-steered)', input: base }, + { name: 'thinking Pi with explicit queue intent', input: { ...base, intent: 'queue' as const } }, { name: 'idle Pi', input: { ...base, isSessionThinking: false } }, { name: 'non-Pi flavor', input: { ...base, agentFlavor: 'codex' } }, { name: 'scheduled message', input: { ...base, scheduledAt: Date.now() + 60_000 } }, @@ -63,4 +51,13 @@ describe('resolveMessageDeliveryMode', () => { ])('queues $name', ({ input }) => { expect(resolveMessageDeliveryMode(input)).toBe('queue') }) + + it('keeps the retry-restore contract: a failed steer retry restores as queue', () => { + const ref = { current: getRestoredComposerSendIntent('steer') } + const retryIntent = consumeComposerSendIntent(ref) + + expect(retryIntent).toBe('queue') + expect(ref.current).toBe('default') + expect(resolveMessageDeliveryMode({ ...base, intent: retryIntent })).toBe('queue') + }) }) diff --git a/web/src/lib/messageDelivery.ts b/web/src/lib/messageDelivery.ts index 482424115e..0e994280bf 100644 --- a/web/src/lib/messageDelivery.ts +++ b/web/src/lib/messageDelivery.ts @@ -43,10 +43,13 @@ export function getRestoredComposerSendIntent( /** * Resolve the web composer intent into the durable message delivery mode. * - * Fresh steering is deliberately narrow: it only applies to an immediate - * ordinary composer submission while the Pi *main session* reports that it - * is thinking. Scheduled messages, scratchlist additions, and retries never - * steer because none can prove the original turn identity. + * Every composer submission queues by default — for every flavor. The Pi + * automatic steer (deliveryMode 'steer' while the main session is thinking) + * was removed in favor of the explicit per-queued-message Steer action + * (issue #1466), matching Codex/Claude behavior: a mid-turn message waits, + * and the operator presses Steer to deliver it into the running turn. + * Scheduled messages, scratchlist additions, and retries always queued + * already. */ export function resolveMessageDeliveryMode(input: { agentFlavor: string | null | undefined @@ -55,9 +58,6 @@ export function resolveMessageDeliveryMode(input: { scheduledAt?: number | null routesToScratchlist?: boolean }): MessageDeliveryMode { - if (input.scheduledAt != null) return 'queue' - if (input.routesToScratchlist === true) return 'queue' - if (input.intent === 'queue') return 'queue' - if (input.agentFlavor !== 'pi') return 'queue' - return input.isSessionThinking ? 'steer' : 'queue' + void input + return 'queue' } From d396e9d6d42b808d780adbb5cdb9f7abc4696c93 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Tue, 11 Aug 2026 22:27:44 +0800 Subject: [PATCH 069/142] feat(voice): curate dictation credential presets to ElevenLabs, OpenAI, Groq (#1474) * feat(voice): curate dictation credential presets to ElevenLabs, OpenAI, Groq Groq transcription was already wired end-to-end (GROQ_API_KEY, whisper-large-v3, standard mode), but the credential onboarding panel listed five providers with no hint that Groq is supported, so mobile users could not discover it. - Curate Settings > Voice > Dictation credential presets to ElevenLabs, OpenAI, and Groq (Deepgram / OpenAI-compatible remain fully supported via env and stay listed when configured) - Name the three presets in the empty-state and manage hints (en + zh-CN) - Lock the curated list in with a web preset test, a hub route test for the Groq whisper-large-v3 proxy, and shared provider-listing coverage - Note the presets and no-restart save behavior in voice-assistant.md Verified: bun typecheck (cli+web+hub) and targeted suites pass; full test gate green except pre-existing load-sensitive runner stress tests. * fix(voice): keep legacy dictation providers manageable when configured HAPI Bot review finding (Major): curating the onboard panel to the three presets made settings-managed Deepgram / OpenAI-compatible credentials impossible to rotate or clear from the UI. - Re-add deepgram / openai-compatible to the onboard provider list conditionally when credentials exist, restoring update/clear controls - Fall back to the first preset if the selected provider leaves the list - Cover the conditional list in the preset test * fix(voice): surface partial OpenAI-compatible credentials in onboard panel HAPI Bot follow-up finding (Major): hub marks openaiCompatible.configured only when both base URL and model exist, so api-key-only or endpoint-only stored settings lost the UI path to rotate or clear them. - Gate the openai-compatible onboard entry on any stored field (base URL, model, or API key) via hasOpenAICompatibleCredentials() - Cover api-key-only / base-url-only / model-only cases in tests --- docs/guide/voice-assistant.md | 2 +- hub/src/web/routes/voice.test.ts | 37 +++++++++ shared/src/voice.backends.test.ts | 2 + .../settings/TranscriptionProviderOnboard.tsx | 35 ++++---- .../settings/transcriptionProviders.test.ts | 79 +++++++++++++++++++ .../settings/transcriptionProviders.ts | 44 +++++++++++ web/src/lib/locales/en.ts | 6 +- web/src/lib/locales/zh-CN.ts | 6 +- 8 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 web/src/components/settings/transcriptionProviders.test.ts create mode 100644 web/src/components/settings/transcriptionProviders.ts diff --git a/docs/guide/voice-assistant.md b/docs/guide/voice-assistant.md index 6690e67960..6df1585e1e 100644 --- a/docs/guide/voice-assistant.md +++ b/docs/guide/voice-assistant.md @@ -48,7 +48,7 @@ You need API credentials for at least one assistant backend: - **Gemini Live** - a Gemini API key from [Google AI Studio](https://aistudio.google.com/apikey) - **Qwen Realtime** - a DashScope API key from [Alibaba Cloud Model Studio](https://www.alibabacloud.com/help/en/model-studio/get-api-key) -Dictation needs at least one configured transcription provider from the list above, or an OpenAI-compatible local server. +Dictation needs at least one configured transcription provider from the list above, or an OpenAI-compatible local server. In **Settings → Voice → Dictation**, the credential presets are **ElevenLabs**, **OpenAI**, and **Groq**; already-configured Deepgram / OpenAI-compatible credentials stay manageable there too. Saving a key updates the provider list without restarting the hub. ## Setup diff --git a/hub/src/web/routes/voice.test.ts b/hub/src/web/routes/voice.test.ts index 36f7714c4c..729203133c 100644 --- a/hub/src/web/routes/voice.test.ts +++ b/hub/src/web/routes/voice.test.ts @@ -98,6 +98,7 @@ describe('voice transcription routes', () => { delete process.env.TRANSCRIPTION_BASE_URL delete process.env.TRANSCRIPTION_MODEL process.env.OPENAI_API_KEY = 'server-only-key' + process.env.GROQ_API_KEY = 'groq-server-key' process.env.TRANSCRIPTION_BASE_URL = 'http://localhost:8000/v1' process.env.TRANSCRIPTION_MODEL = 'local-whisper' @@ -105,6 +106,7 @@ describe('voice transcription routes', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual({ providers: [ { id: 'openai', label: 'OpenAI', modes: ['standard', 'realtime'] }, + { id: 'groq', label: 'Groq', modes: ['standard'] }, { id: 'openai-compatible', label: 'OpenAI-compatible / local', modes: ['standard'] } ] }) @@ -201,6 +203,41 @@ describe('voice transcription routes', () => { } }) + test('proxies a bounded recording to Groq with whisper-large-v3', async () => { + const app = createApp() + const headers = await authHeaders() + const previousKey = process.env.GROQ_API_KEY + process.env.GROQ_API_KEY = 'groq-server-key' + const originalFetch = global.fetch + let upstreamUrl = '' + let upstreamInit: RequestInit | undefined + // @ts-expect-error test override + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + upstreamUrl = String(input) + upstreamInit = init + return new Response(JSON.stringify({ text: 'groq transcription', language: 'zh' }), { status: 200 }) + }) as typeof fetch + + const form = new FormData() + form.set('provider', 'groq') + form.set('mode', 'standard') + form.set('language', 'zh-CN') + form.set('file', new File(['audio bytes'], 'speech.webm', { type: 'audio/webm' })) + const res = await app.request('/api/voice/transcription', { method: 'POST', headers, body: form }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ text: 'groq transcription', language: 'zh' }) + expect(upstreamUrl).toBe('https://api.groq.com/openai/v1/audio/transcriptions') + expect(new Headers(upstreamInit?.headers).get('authorization')).toBe('Bearer groq-server-key') + expect(upstreamInit?.body).toBeInstanceOf(FormData) + expect((upstreamInit?.body as FormData).get('model')).toBe('whisper-large-v3') + expect((upstreamInit?.body as FormData).get('language')).toBe('zh') + + global.fetch = originalFetch + if (previousKey === undefined) delete process.env.GROQ_API_KEY + else process.env.GROQ_API_KEY = previousKey + }) + test('rejects unsupported files before calling a provider', async () => { const app = createApp() const headers = await authHeaders() diff --git a/shared/src/voice.backends.test.ts b/shared/src/voice.backends.test.ts index b336206e87..8b38a47743 100644 --- a/shared/src/voice.backends.test.ts +++ b/shared/src/voice.backends.test.ts @@ -13,12 +13,14 @@ describe('listConfiguredTranscriptionProviders', () => { OPENAI_API_KEY: 'openai', ELEVENLABS_API_KEY: 'elevenlabs', DEEPGRAM_API_KEY: 'deepgram', + GROQ_API_KEY: 'groq', TRANSCRIPTION_BASE_URL: 'http://localhost:8000/v1', TRANSCRIPTION_MODEL: 'whisper-large-v3' })).toEqual([ { id: 'openai', label: 'OpenAI', modes: ['standard', 'realtime'] }, { id: 'elevenlabs', label: 'ElevenLabs', modes: ['standard', 'realtime'] }, { id: 'deepgram', label: 'Deepgram', modes: ['standard', 'realtime'] }, + { id: 'groq', label: 'Groq', modes: ['standard'] }, { id: 'openai-compatible', label: 'OpenAI-compatible / local', modes: ['standard'] } ]) }) diff --git a/web/src/components/settings/TranscriptionProviderOnboard.tsx b/web/src/components/settings/TranscriptionProviderOnboard.tsx index 53293556cb..d1ce8aeb56 100644 --- a/web/src/components/settings/TranscriptionProviderOnboard.tsx +++ b/web/src/components/settings/TranscriptionProviderOnboard.tsx @@ -3,19 +3,12 @@ import { ApiError, type ApiClient, type TranscriptionCredentialStatus, type Tran import { useTranslation } from '@/lib/use-translation' import { SelectControl } from '@/components/ui/select-control' import { Button } from '@/components/ui/button' +import { DICTATION_PROVIDER_PRESETS, dictationOnboardProviders, hasOpenAICompatibleCredentials, type DictationProviderPreset } from './transcriptionProviders' -type DictationProvider = 'openai' | 'elevenlabs' | 'deepgram' | 'groq' | 'openai-compatible' +type DictationProvider = DictationProviderPreset | 'deepgram' | 'openai-compatible' type AssistantProvider = 'elevenlabs' | 'gemini-live' | 'qwen-realtime' type CloudProvider = DictationProvider | AssistantProvider -const DICTATION_PROVIDERS: DictationProvider[] = [ - 'openai', - 'elevenlabs', - 'deepgram', - 'groq', - 'openai-compatible', -] - const ASSISTANT_PROVIDERS: AssistantProvider[] = [ 'elevenlabs', 'gemini-live', @@ -28,10 +21,10 @@ function providerLabel(provider: CloudProvider, t: (key: string) => string): str return 'OpenAI' case 'elevenlabs': return 'ElevenLabs' - case 'deepgram': - return 'Deepgram' case 'groq': return 'Groq' + case 'deepgram': + return 'Deepgram' case 'openai-compatible': return t('settings.voice.credentials.openaiCompatible') case 'gemini-live': @@ -81,9 +74,8 @@ export function TranscriptionProviderOnboard(props: { onConfigured: () => void }) { const { t } = useTranslation() - const providers: CloudProvider[] = props.mode === 'assistant' ? ASSISTANT_PROVIDERS : DICTATION_PROVIDERS const [status, setStatus] = useState(null) - const [provider, setProvider] = useState(providers[0]!) + const [provider, setProvider] = useState(props.mode === 'assistant' ? ASSISTANT_PROVIDERS[0]! : DICTATION_PROVIDER_PRESETS[0]!) const [apiKey, setApiKey] = useState('') const [baseUrl, setBaseUrl] = useState('') const [model, setModel] = useState('') @@ -91,13 +83,28 @@ export function TranscriptionProviderOnboard(props: { const [error, setError] = useState(null) const [message, setMessage] = useState(null) + const providers: CloudProvider[] = props.mode === 'assistant' + ? ASSISTANT_PROVIDERS + : dictationOnboardProviders( + status?.deepgram.configured ?? false, + hasOpenAICompatibleCredentials(status) + ) + useEffect(() => { - setProvider(providers[0]!) + setProvider(props.mode === 'assistant' ? ASSISTANT_PROVIDERS[0]! : DICTATION_PROVIDER_PRESETS[0]!) setApiKey('') setError(null) setMessage(null) }, [props.mode]) + // If the selected provider left the list (e.g. its credentials were cleared), + // fall back to the mode-appropriate first option. + useEffect(() => { + if (!providers.some((option) => option === provider)) { + setProvider(props.mode === 'assistant' ? ASSISTANT_PROVIDERS[0]! : DICTATION_PROVIDER_PRESETS[0]!) + } + }, [providers, provider, props.mode]) + const reload = useCallback(async () => { try { const next = await props.api.fetchTranscriptionCredentials() diff --git a/web/src/components/settings/transcriptionProviders.test.ts b/web/src/components/settings/transcriptionProviders.test.ts new file mode 100644 index 0000000000..cb90b39db4 --- /dev/null +++ b/web/src/components/settings/transcriptionProviders.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'vitest' +import { DICTATION_PROVIDER_PRESETS, dictationOnboardProviders, hasOpenAICompatibleCredentials } from './transcriptionProviders' +import type { TranscriptionCredentialStatus } from '@/api/client' + +describe('dictation provider presets', () => { + test('offers the supported hosted presets in the intended order', () => { + expect(DICTATION_PROVIDER_PRESETS).toEqual(['elevenlabs', 'openai', 'groq']) + }) +}) + +describe('dictationOnboardProviders', () => { + test('shows only the curated presets when no legacy credentials exist', () => { + expect(dictationOnboardProviders(false, false)).toEqual(['elevenlabs', 'openai', 'groq']) + }) + + test('keeps legacy providers manageable once their credentials exist', () => { + expect(dictationOnboardProviders(true, true)).toEqual([ + 'elevenlabs', + 'openai', + 'groq', + 'deepgram', + 'openai-compatible', + ]) + expect(dictationOnboardProviders(true, false)).toEqual([ + 'elevenlabs', + 'openai', + 'groq', + 'deepgram', + ]) + }) +}) + +describe('hasOpenAICompatibleCredentials', () => { + const base = { + openai: { configured: false, source: 'none' as const, hint: null, editable: true }, + elevenlabs: { configured: false, source: 'none' as const, hint: null, editable: true }, + deepgram: { configured: false, source: 'none' as const, hint: null, editable: true }, + groq: { configured: false, source: 'none' as const, hint: null, editable: true }, + openaiCompatible: { + configured: false, + source: 'none' as const, + baseUrl: null, + model: null, + baseUrlEditable: true, + modelEditable: true, + apiKey: { configured: false, source: 'none' as const, hint: null, editable: true }, + }, + voiceBackends: { + elevenlabs: { configured: false, source: 'none' as const, hint: null, editable: true }, + geminiLive: { configured: false, source: 'none' as const, hint: null, editable: true }, + qwenRealtime: { configured: false, source: 'none' as const, hint: null, editable: true }, + }, + } satisfies TranscriptionCredentialStatus + + test('is false when no OpenAI-compatible field is present', () => { + expect(hasOpenAICompatibleCredentials(base)).toBe(false) + expect(hasOpenAICompatibleCredentials(null)).toBe(false) + expect(hasOpenAICompatibleCredentials(undefined)).toBe(false) + }) + + test('is true for api-key-only, base-url-only, and model-only partial entries', () => { + expect(hasOpenAICompatibleCredentials({ + ...base, + openaiCompatible: { ...base.openaiCompatible, apiKey: { ...base.openaiCompatible.apiKey, configured: true } }, + })).toBe(true) + expect(hasOpenAICompatibleCredentials({ + ...base, + openaiCompatible: { ...base.openaiCompatible, baseUrl: 'http://127.0.0.1:8000/v1' }, + })).toBe(true) + expect(hasOpenAICompatibleCredentials({ + ...base, + openaiCompatible: { ...base.openaiCompatible, model: 'whisper-large-v3' }, + })).toBe(true) + expect(hasOpenAICompatibleCredentials({ + ...base, + openaiCompatible: { ...base.openaiCompatible, configured: true, baseUrl: 'http://127.0.0.1:8000/v1', model: 'whisper-large-v3' }, + })).toBe(true) + }) +}) diff --git a/web/src/components/settings/transcriptionProviders.ts b/web/src/components/settings/transcriptionProviders.ts new file mode 100644 index 0000000000..495fa0d132 --- /dev/null +++ b/web/src/components/settings/transcriptionProviders.ts @@ -0,0 +1,44 @@ +import type { TranscriptionProvider } from '@hapi/protocol/voice' +import type { TranscriptionCredentialStatus } from '@/api/client' + +/** Curated cloud presets shown when onboarding dictation credentials. */ +export const DICTATION_PROVIDER_PRESETS = [ + 'elevenlabs', + 'openai', + 'groq', +] as const satisfies readonly TranscriptionProvider[] + +export type DictationProviderPreset = typeof DICTATION_PROVIDER_PRESETS[number] + +/** + * True when any OpenAI-compatible credential field is stored/configured. + * Hub status marks `openaiCompatible.configured` only when both base URL and + * model exist, but the API stores base URL, model, and API key independently + * — partial entries must stay manageable too. + */ +export function hasOpenAICompatibleCredentials( + status: TranscriptionCredentialStatus | null | undefined +): boolean { + if (!status) return false + return Boolean( + status.openaiCompatible.baseUrl + || status.openaiCompatible.model + || status.openaiCompatible.apiKey.configured + ) +} + +/** + * Providers offered in the dictation credential onboard panel: the curated + * presets, plus legacy providers that already have hub credentials so + * existing keys stay rotatable/clearable from the UI. + */ +export function dictationOnboardProviders( + includeDeepgram: boolean, + includeOpenAICompatible: boolean +): Array { + return [ + ...DICTATION_PROVIDER_PRESETS, + ...(includeDeepgram ? ['deepgram' as const] : []), + ...(includeOpenAICompatible ? ['openai-compatible' as const] : []), + ] +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 0d82e52f51..2559844de7 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -895,9 +895,9 @@ export default { 'settings.voice.inputMode.dictation': 'Dictation', 'settings.voice.inputMode.dictation.hint': 'Speech-to-text input only', 'settings.voice.transcriptionProvider': 'Transcription provider', - 'settings.voice.noTranscriptionProvider': 'No hub transcription provider yet — add one below (or use Browser on-device if available).', + 'settings.voice.noTranscriptionProvider': 'No hub transcription provider yet — add OpenAI, ElevenLabs, or Groq below (or use Browser on-device if available).', 'settings.voice.noVoiceBackend': 'No voice assistant backend is configured on the hub — add one below.', - 'settings.voice.credentials.hint': 'Keys stay on the hub. The browser never keeps long-lived provider secrets.', + 'settings.voice.credentials.hint': 'Choose OpenAI, ElevenLabs, or Groq. Keys stay on the hub; the browser never keeps long-lived provider secrets.', 'settings.voice.credentials.assistantHint': 'Add an ElevenLabs, Gemini Live, or Qwen Realtime key. Keys stay on the hub.', 'settings.voice.credentials.provider': 'Provider', 'settings.voice.credentials.openaiCompatible': 'OpenAI-compatible / local', @@ -918,7 +918,7 @@ export default { 'settings.voice.credentials.source.settings': 'Settings', 'settings.voice.credentials.envLocked': 'This key comes from the hub environment and cannot be changed here.', 'settings.voice.credentials.manage': 'Add or manage providers', - 'settings.voice.credentials.manageHint': 'Paste an API key to enable another transcription provider on the hub.', + 'settings.voice.credentials.manageHint': 'Add OpenAI, ElevenLabs, or Groq to enable dictation on the hub.', 'settings.voice.credentials.manageAssistantHint': 'Paste an API key to enable ElevenLabs, Gemini Live, or Qwen Realtime on the hub.', 'settings.voice.transcriptionMode': 'Transcription mode', 'settings.voice.transcriptionMode.standard': 'Standard', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 9608204d7f..d1deb621fb 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -894,9 +894,9 @@ export default { 'settings.voice.inputMode.dictation': '语音输入', 'settings.voice.inputMode.dictation.hint': '只做语音转文字', 'settings.voice.transcriptionProvider': '转录提供商', - 'settings.voice.noTranscriptionProvider': 'Hub 尚未配置转录提供商 — 请在下方添加(或在可用时使用浏览器本地识别)。', + 'settings.voice.noTranscriptionProvider': 'Hub 尚未配置转录提供商 — 请在下方添加 OpenAI、ElevenLabs 或 Groq(或在可用时使用浏览器本地识别)。', 'settings.voice.noVoiceBackend': 'Hub 尚未配置语音助手后端 — 请在下方添加。', - 'settings.voice.credentials.hint': '密钥保存在 Hub 上。浏览器不会长期保存提供商密钥。', + 'settings.voice.credentials.hint': '可选择 OpenAI、ElevenLabs 或 Groq。密钥保存在 Hub 上,浏览器不会长期保存提供商密钥。', 'settings.voice.credentials.assistantHint': '添加 ElevenLabs、Gemini Live 或 Qwen Realtime 密钥。密钥保存在 Hub 上。', 'settings.voice.credentials.provider': '提供商', 'settings.voice.credentials.openaiCompatible': 'OpenAI 兼容 / 本地', @@ -917,7 +917,7 @@ export default { 'settings.voice.credentials.source.settings': '设置', 'settings.voice.credentials.envLocked': '该密钥来自 Hub 环境变量,无法在此修改。', 'settings.voice.credentials.manage': '添加或管理提供商', - 'settings.voice.credentials.manageHint': '粘贴 API 密钥以在 Hub 上启用其他转录提供商。', + 'settings.voice.credentials.manageHint': '添加 OpenAI、ElevenLabs 或 Groq,即可在 Hub 上启用语音转文字。', 'settings.voice.credentials.manageAssistantHint': '粘贴 API 密钥以在 Hub 上启用 ElevenLabs、Gemini Live 或 Qwen Realtime。', 'settings.voice.transcriptionMode': '转录模式', 'settings.voice.transcriptionMode.standard': '标准', From 11964b4b0d5388c7ab98b8645f43a078d1917b34 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:08:02 +0100 Subject: [PATCH 070/142] fix(a2a): stamp causing inbound on work_ad notify ingest (#1510) * fix(a2a): stamp causing inbound on work_ad notify ingest Resolve the turn cause from the full session messages table at insert so consumers do not guess from a truncated transcript. Stamp causeMessageId, causeText, causeKind, and related_event_id, and link follows to the previous work_ad. Co-authored-by: Cursor * fix(a2a): ignore scheduled and client-posted work_ads in cause chain Future-scheduled inbounds are not this turn's cause. Only AGENT_NOTIFY_SUMMARY rows chain related_event_id / sticky cause, so HTTP-posted work_ads cannot steer hub-derived attribution. Co-authored-by: Cursor * fix(a2a): skip transcript echoes and reserve notify provenance Claude jsonl echoes remote prompts as extra role=user CLI rows; mark them and ignore them when choosing a work_ad cause. HTTP event writes cannot claim AGENT_NOTIFY_SUMMARY provenance. Co-authored-by: Cursor * fix(a2a): stamp transcript echo only for hub-delivered prompts Local Claude TTY prompts share isExternalUserMessage; matching pending web/telegram text keeps those rows as work_ad causes. Co-authored-by: Cursor * fix(a2a): match Claude queue text and bound cause message scan Register pending transcript echoes at the formatted queue boundary (and drop them on cancel). Later work_ad notifies scan after causeSeq instead of decoding the full session transcript. Co-authored-by: Cursor * fix(a2a): note delivered Claude batch and skip uninvoked causes Register transcript-echo text at SDK delivery after batch join and skill expansion. Keep the marker when cancel misses the queue. Cause candidates require invokedAt so queued localId rows wait. Co-authored-by: Cursor * fix(a2a): note parked Claude batches and chain work_ads by insert order Stamp echo markers on the pending mode-switch delivery path. Advance causeSeq past every invoked inbound in the same batch. List session work_ads by rowid so backdated transcript timestamps cannot rewind the chain. Co-authored-by: Cursor * fix(a2a): pick latest invoked inbound after history hydration Fork/merge copies leave old user rows without prior work_ads; choose the newest invoked inbound before the assistant. Echo markers now keep batch localIds so cancel can drop a restored-then-cancelled prompt. Co-authored-by: Cursor * fix(a2a): drop echo markers when a Claude batch is abandoned The three-failure launcher cap discarded the in-flight prompt without clearing pending transcript-echo text, so a later identical local prompt could be misclassified. Co-authored-by: Cursor * fix(a2a): replace echo markers when a restored prompt is rebatched Recoverable launch failure keeps the original marker; a later same-mode prompt joins into new delivered text. Drop overlapping localId markers so a later identical local prompt is not stamped isTranscriptEcho. Co-authored-by: Cursor * fix(a2a): keep notify cause chain across session merge Re-key AGENT_NOTIFY_SUMMARY work_ads onto the surviving session and bound later scans by causeCursorMessageId so merge seq-shift cannot revive an already-consumed batch inbound as the sticky cause. Co-authored-by: Cursor * fix(a2a): keep notify rows on live source during history merge mergeSessionHistory leaves the source socket alive. Only re-key AGENT_NOTIFY_SUMMARY work_ads when mergeSessions deletes that id. Co-authored-by: Cursor * fix(a2a): drop id-less echo markers on rebatch and launch drop API callers may omit localId. Replace the nameless in-flight marker when a new id-less delivery is noted, and discard by delivered text when the three-failure path abandons the batch. Do not mint hub localIds (that would change invokedAt ack semantics). Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/api/apiSession.createdAt.test.ts | 240 ++++++- cli/src/api/apiSession.ts | 64 +- cli/src/api/types.ts | 3 + ...claudeRemoteLauncher.launchFailure.test.ts | 3 + .../claudeRemoteLauncher.modeGate.test.ts | 5 +- cli/src/claude/claudeRemoteLauncher.test.ts | 5 +- cli/src/claude/claudeRemoteLauncher.ts | 27 +- cli/src/claude/runClaude.ts | 4 + hub/src/store/messageStore.ts | 5 + hub/src/store/messages.ts | 12 + hub/src/store/workGraph.test.ts | 93 +++ hub/src/store/workGraph.ts | 92 +++ hub/src/store/workGraphStore.ts | 10 + hub/src/sync/sessionCache.ts | 5 + hub/src/sync/workGraphNotifyIngest.test.ts | 586 ++++++++++++++++++ hub/src/sync/workGraphNotifyIngest.ts | 276 ++++++++- hub/src/web/routes/workGraph.test.ts | 27 + hub/src/web/routes/workGraph.ts | 4 + 18 files changed, 1448 insertions(+), 13 deletions(-) diff --git a/cli/src/api/apiSession.createdAt.test.ts b/cli/src/api/apiSession.createdAt.test.ts index de11007dec..f3c393c69e 100644 --- a/cli/src/api/apiSession.createdAt.test.ts +++ b/cli/src/api/apiSession.createdAt.test.ts @@ -74,6 +74,33 @@ describe('sendClaudeSessionMessage createdAt propagation', () => { return { client, fakeSocket } } + function fireIncomingUserMessage( + fakeSocket: { on: ReturnType }, + message: { seq: number; text: string; sentFrom: 'webapp' | 'telegram-bot' } + ): void { + const handler = fakeSocket.on.mock.calls.find((call) => call[0] === 'update')?.[1] as + | ((data: unknown) => void) + | undefined + if (typeof handler !== 'function') { + throw new Error('ApiSessionClient did not register an update handler') + } + handler({ + body: { + t: 'new-message', + message: { + id: `hub-${message.seq}`, + seq: message.seq, + localId: null, + content: { + role: 'user', + content: { type: 'text', text: message.text }, + meta: { sentFrom: message.sentFrom } + } + } + } + }) + } + beforeEach(() => { configuration._setApiUrl('https://hapi.example.com') ioMock.mockReset() @@ -96,7 +123,7 @@ describe('sendClaudeSessionMessage createdAt propagation', () => { })) }) - it('external user message (echoed prompt): does not add createdAt — path is unchanged', () => { + it('local Claude prompt: does not add createdAt and does not stamp isTranscriptEcho', () => { const { client, fakeSocket } = makeClient() const body = { type: 'user', @@ -113,6 +140,217 @@ describe('sendClaudeSessionMessage createdAt propagation', () => { const [, payload] = fakeSocket.emit.mock.calls[0] as [string, Record] expect(payload.sid).toBe('session-1') expect(payload).not.toHaveProperty('createdAt') + expect(payload.message).toMatchObject({ + role: 'user', + meta: { sentFrom: 'cli' } + }) + expect((payload.message as { meta?: { isTranscriptEcho?: boolean } }).meta?.isTranscriptEcho) + .not.toBe(true) + }) + + it('remote hub prompt: matching Claude transcript row stamps isTranscriptEcho', () => { + const { client, fakeSocket } = makeClient() + client.notePendingHubPromptEcho('hello from web', 'local-1') + + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-echo-1', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:00.000Z', + message: { role: 'user', content: 'hello from web' } + } as unknown as RawJSONLines) + + const [, payload] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect(payload.message).toMatchObject({ + role: 'user', + meta: { sentFrom: 'cli', isTranscriptEcho: true } + }) + }) + + it('batched same-mode prompts match the joined Claude transcript row', () => { + const { client, fakeSocket } = makeClient() + client.notePendingHubPromptEcho('one\ntwo') + + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-batch-1', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:00.000Z', + message: { role: 'user', content: 'one\ntwo' } + } as unknown as RawJSONLines) + + const [, payload] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect(payload.message).toMatchObject({ + role: 'user', + meta: { sentFrom: 'cli', isTranscriptEcho: true } + }) + }) + + it('formatted Claude prompt (attachments/plan) matches the queue-boundary text, not raw hub text', () => { + const { client, fakeSocket } = makeClient() + fireIncomingUserMessage(fakeSocket, { seq: 1, text: 'hello from web', sentFrom: 'webapp' }) + client.notePendingHubPromptEcho('/path/to/file.ts\nhello from web', 'local-1') + + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-echo-fmt', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:00.000Z', + message: { role: 'user', content: '/path/to/file.ts\nhello from web' } + } as unknown as RawJSONLines) + + const [, payload] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect(payload.message).toMatchObject({ + role: 'user', + meta: { sentFrom: 'cli', isTranscriptEcho: true } + }) + }) + + it('raw hub delivery alone does not stamp isTranscriptEcho', () => { + const { client, fakeSocket } = makeClient() + fireIncomingUserMessage(fakeSocket, { seq: 1, text: 'hello from web', sentFrom: 'webapp' }) + + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-raw-1', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:00.000Z', + message: { role: 'user', content: 'hello from web' } + } as unknown as RawJSONLines) + + const [, payload] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect((payload.message as { meta?: { isTranscriptEcho?: boolean } }).meta?.isTranscriptEcho) + .not.toBe(true) + }) + + it('cancelled queued prompt does not misclassify a later matching local prompt', () => { + const { client, fakeSocket } = makeClient() + client.notePendingHubPromptEcho('hello from web', ['local-1', 'local-2']) + client.discardPendingHubPromptEcho('local-2') + + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-local-cancel', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:00.000Z', + message: { role: 'user', content: 'hello from web' } + } as unknown as RawJSONLines) + + const [, payload] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect((payload.message as { meta?: { isTranscriptEcho?: boolean } }).meta?.isTranscriptEcho) + .not.toBe(true) + }) + + it('rebatched restored prompt replaces the original echo marker', () => { + const { client, fakeSocket } = makeClient() + client.notePendingHubPromptEcho('hello from web', 'local-1') + client.notePendingHubPromptEcho('hello from web\nqueued while retrying', ['local-1', 'local-2']) + + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-rebatch-combined', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:00.000Z', + message: { role: 'user', content: 'hello from web\nqueued while retrying' } + } as unknown as RawJSONLines) + + const [, combined] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect(combined.message).toMatchObject({ + role: 'user', + meta: { sentFrom: 'cli', isTranscriptEcho: true } + }) + + fakeSocket.emit.mockClear() + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-local-after-rebatch', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:01.000Z', + message: { role: 'user', content: 'hello from web' } + } as unknown as RawJSONLines) + + const [, local] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect((local.message as { meta?: { isTranscriptEcho?: boolean } }).meta?.isTranscriptEcho) + .not.toBe(true) + }) + + it('id-less rebatch replaces the previous id-less echo marker', () => { + const { client, fakeSocket } = makeClient() + client.notePendingHubPromptEcho('hello from web') + client.notePendingHubPromptEcho('hello from web\nqueued while retrying') + + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-idless-combined', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:00.000Z', + message: { role: 'user', content: 'hello from web\nqueued while retrying' } + } as unknown as RawJSONLines) + + const [, combined] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect(combined.message).toMatchObject({ + role: 'user', + meta: { sentFrom: 'cli', isTranscriptEcho: true } + }) + + fakeSocket.emit.mockClear() + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-local-after-idless', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:01.000Z', + message: { role: 'user', content: 'hello from web' } + } as unknown as RawJSONLines) + + const [, local] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect((local.message as { meta?: { isTranscriptEcho?: boolean } }).meta?.isTranscriptEcho) + .not.toBe(true) + }) + + it('id-less dropped marker does not misclassify a later matching local prompt', () => { + const { client, fakeSocket } = makeClient() + client.notePendingHubPromptEcho('hello from web') + client.discardPendingHubPromptEchoText('hello from web') + + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-local-after-idless-drop', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:00.000Z', + message: { role: 'user', content: 'hello from web' } + } as unknown as RawJSONLines) + + const [, payload] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect((payload.message as { meta?: { isTranscriptEcho?: boolean } }).meta?.isTranscriptEcho) + .not.toBe(true) + }) + + it('unmatched local Claude prompt stays unmarked when a different hub prompt is pending', () => { + const { client, fakeSocket } = makeClient() + client.notePendingHubPromptEcho('hello from web', 'local-1') + + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'user-local-1', + userType: 'external', + isSidechain: false, + timestamp: '2024-03-10T00:00:00.000Z', + message: { role: 'user', content: 'typed in the TTY' } + } as unknown as RawJSONLines) + + const [, payload] = fakeSocket.emit.mock.calls[0] as [string, Record] + expect((payload.message as { meta?: { isTranscriptEcho?: boolean } }).meta?.isTranscriptEcho) + .not.toBe(true) }) it('agent message without a parseable timestamp: omits createdAt (hub falls back to Date.now())', () => { diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 553a29f78d..cc50e4eb32 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -239,6 +239,7 @@ export class ApiSessionClient extends EventEmitter { private agentStateVersion: number private readonly socket: Socket private pendingMessages: { message: UserMessage; localId?: string }[] = [] + private pendingHubPromptEchoes: { text: string; localIds: string[] }[] = [] private pendingMessageCallback: ((message: UserMessage, localId?: string) => void) | null = null private cancelQueuedMessageCallback: ((localId: string) => boolean) | null = null private readonly incomingFilter = new IncomingMessageFilter() @@ -676,6 +677,62 @@ export class ApiSessionClient extends EventEmitter { } } + /** + * Record the text Claude will actually see (after attachment/skill//plan + * formatting). Matching transcript rows then stamp isTranscriptEcho. + * Call this at the queue boundary, not on raw hub delivery. + */ + notePendingHubPromptEcho(text: string, localId?: string | readonly string[]): void { + const normalized = text.trim() + if (!normalized) return + const localIds = (Array.isArray(localId) ? localId : localId ? [localId] : []) + .filter((id) => id.length > 0) + // Recoverable launch failure restores the original items; a later + // same-mode prompt can rebatch them under new delivered text. Drop + // the stale marker that still names those localIds so a later + // identical local prompt is not stamped isTranscriptEcho. + if (localIds.length > 0) { + const replacementIds = new Set(localIds) + this.pendingHubPromptEchoes = this.pendingHubPromptEchoes.filter( + (entry) => !entry.localIds.some((id) => replacementIds.has(id)) + ) + } else { + // SendMessageRequest allows omitting localId. One id-less delivery + // is in flight at a time; replace the previous nameless marker. + this.pendingHubPromptEchoes = this.pendingHubPromptEchoes.filter( + (entry) => entry.localIds.length > 0 + ) + } + if (this.pendingHubPromptEchoes.some((entry) => entry.text === normalized)) return + this.pendingHubPromptEchoes.push({ text: normalized, localIds }) + if (this.pendingHubPromptEchoes.length > 32) { + this.pendingHubPromptEchoes.shift() + } + } + + discardPendingHubPromptEcho(localId: string): void { + const index = this.pendingHubPromptEchoes.findIndex((entry) => entry.localIds.includes(localId)) + if (index < 0) return + this.pendingHubPromptEchoes.splice(index, 1) + } + + discardPendingHubPromptEchoText(text: string): void { + const normalized = text.trim() + if (!normalized) return + const index = this.pendingHubPromptEchoes.findIndex((entry) => entry.text === normalized) + if (index < 0) return + this.pendingHubPromptEchoes.splice(index, 1) + } + + private consumePendingHubPromptEcho(text: string): boolean { + const normalized = text.trim() + if (!normalized) return false + const index = this.pendingHubPromptEchoes.findIndex((entry) => entry.text === normalized) + if (index < 0) return false + this.pendingHubPromptEchoes.splice(index, 1) + return true + } + private handleIncomingMessage(message: { id?: string; seq?: number; localId?: string | null; content: unknown }): void { if (!this.incomingFilter.accept({ id: message.id, seq: message.seq })) { return @@ -794,14 +851,17 @@ export class ApiSessionClient extends EventEmitter { let createdAt: number | undefined if (isExternalUserMessage(body)) { + const text = extractRawUserTextContent(body.message.content) ?? '' + const isTranscriptEcho = this.consumePendingHubPromptEcho(text) content = { role: 'user', content: { type: 'text', - text: extractRawUserTextContent(body.message.content) ?? '' + text }, meta: { - sentFrom: 'cli' + sentFrom: 'cli', + ...(isTranscriptEcho ? { isTranscriptEcho: true } : {}) } } } else { diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 4a5af91e97..ae06f8602d 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -71,6 +71,9 @@ export type { export const MessageMetaSchema = z.object({ sentFrom: z.string().optional(), + // Claude jsonl echoes the remote (web/telegram) prompt as a second user row. + // Hub notify ingest skips these so they do not consume a work_ad cause slot. + isTranscriptEcho: z.boolean().optional(), // Queue remains the default for existing clients. Pi-aware callers may // explicitly request native steering while a turn is streaming. deliveryMode: z.enum(['queue', 'steer']).optional(), diff --git a/cli/src/claude/claudeRemoteLauncher.launchFailure.test.ts b/cli/src/claude/claudeRemoteLauncher.launchFailure.test.ts index 2a9ad79f84..65bc222aa2 100644 --- a/cli/src/claude/claudeRemoteLauncher.launchFailure.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.launchFailure.test.ts @@ -41,6 +41,9 @@ function makeClient() { }), sendSessionEvent: vi.fn(), sendClaudeSessionMessage: vi.fn(), + notePendingHubPromptEcho: vi.fn(), + discardPendingHubPromptEcho: vi.fn(), + discardPendingHubPromptEchoText: vi.fn(), sendAgentMessage: vi.fn(), keepAlive: vi.fn(), emitMessagesConsumed: vi.fn() diff --git a/cli/src/claude/claudeRemoteLauncher.modeGate.test.ts b/cli/src/claude/claudeRemoteLauncher.modeGate.test.ts index e3a4d92e77..5b893e14cf 100644 --- a/cli/src/claude/claudeRemoteLauncher.modeGate.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.modeGate.test.ts @@ -96,7 +96,10 @@ function createClientStub() { updateMetadata: (mutator: (metadata: any) => any) => { mutator({}) }, emitMessagesConsumed: () => {}, sendClaudeSessionMessage: () => {}, - sendSessionEvent: () => {} + sendSessionEvent: () => {}, + notePendingHubPromptEcho: () => {}, + discardPendingHubPromptEcho: () => {}, + discardPendingHubPromptEchoText: () => {} } } diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts index 59589710cb..ec8f6417f4 100644 --- a/cli/src/claude/claudeRemoteLauncher.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -102,7 +102,10 @@ function createClientStub() { updateMetadata: (mutator: (metadata: any) => any) => { mutator({}) }, emitMessagesConsumed: () => {}, sendClaudeSessionMessage: () => {}, - sendSessionEvent: () => {} + sendSessionEvent: () => {}, + notePendingHubPromptEcho: () => {}, + discardPendingHubPromptEcho: () => {}, + discardPendingHubPromptEchoText: () => {} } } diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 2fa8e5416d..e4fda90fe9 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -368,6 +368,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { items: Array<{ message: string; localId?: string }>; mode: EnhancedMode; isolate: boolean; + deliveredText: string; }; // The `as InFlightMessage | null` (rather than plain `= null`) // is required, not decorative: the only assignments of a @@ -421,9 +422,14 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { // mid-session model switch), so a construction-time snapshot // would go stale. See SDKToLogConverter.updateSelectedModel. sdkToLogConverter.updateSelectedModel(p.mode.model ?? null); - inFlightMessage = { items: p.items, mode: p.mode, isolate: p.isolate }; deliveredMessageThisAttempt = true; - return { ...p, message: session.expandSkillReference(p.message) }; + const deliveredText = session.expandSkillReference(p.message) + inFlightMessage = { items: p.items, mode: p.mode, isolate: p.isolate, deliveredText }; + session.client.notePendingHubPromptEcho( + deliveredText, + p.items.flatMap((item) => item.localId ? [item.localId] : []) + ) + return { ...p, message: deliveredText }; } let msg = await session.queue.waitForMessagesAndGetAsString(controller.signal); @@ -451,10 +457,15 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { mode = msg.mode; permissionHandler.handleModeChange(mode.permissionMode); sdkToLogConverter.updateSelectedModel(mode.model ?? null); - inFlightMessage = { items: msg.items, mode: msg.mode, isolate: msg.isolate }; deliveredMessageThisAttempt = true; + const deliveredText = session.expandSkillReference(msg.message) + inFlightMessage = { items: msg.items, mode: msg.mode, isolate: msg.isolate, deliveredText }; + session.client.notePendingHubPromptEcho( + deliveredText, + msg.items.flatMap((item) => item.localId ? [item.localId] : []) + ) return { - message: session.expandSkillReference(msg.message), + message: deliveredText, mode: msg.mode }; } @@ -592,6 +603,14 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { // Reset the streak and keep the loop (and this OS // process) alive so an unrelated later message // gets its own fresh budget. + for (const item of inFlightMessage?.items ?? []) { + if (item.localId) { + session.client.discardPendingHubPromptEcho(item.localId) + } + } + if (inFlightMessage?.deliveredText) { + session.client.discardPendingHubPromptEchoText(inFlightMessage.deliveredText) + } inFlightMessage = null; session.client.sendSessionEvent({ type: 'message', diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 8d51680cde..b8bccece8b 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -489,9 +489,13 @@ export async function runClaude(options: StartOptions = {}): Promise { const deferredIndex = deferredMessages.findIndex(([, id]) => id === localId); if (deferredIndex >= 0) { deferredMessages.splice(deferredIndex, 1); + session.discardPendingHubPromptEcho(localId); return true; } const removed = messageQueue.cancelByLocalId(localId); + if (removed) { + session.discardPendingHubPromptEcho(localId); + } logger.debug(`[claude] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`); return removed; }); diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index 079e8d49c3..5652d30b9e 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -32,6 +32,7 @@ import { copyMessagesToSession as copyStoredMessagesToSession, getAllMessages, getMessagesAfterSeq, + getMessageSeqById, truncateMessagesFromLocalId, type CancelQueuedMessageResult, type LookupQueuedMessageResult, @@ -77,6 +78,10 @@ export class MessageStore { return getMessagesAfterSeq(this.db, sessionId, afterSeq) } + getSeqById(sessionId: string, messageId: string): number | null { + return getMessageSeqById(this.db, sessionId, messageId) + } + getMessages(sessionId: string, limit: number = 200): StoredMessage[] { return getMessages(this.db, sessionId, limit) } diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index dbf69dda71..5f10ad7e54 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -320,6 +320,18 @@ export function getMessagesAfterSeq( return rows.map(toStoredMessage) } +/** Current seq for a message that still lives on this session (merge-stable id). */ +export function getMessageSeqById( + db: Database, + sessionId: string, + messageId: string +): number | null { + const row = db.prepare( + 'SELECT seq FROM messages WHERE id = ? AND session_id = ?' + ).get(messageId, sessionId) as { seq: number } | undefined + return row ? row.seq : null +} + export function getFirstMessages( db: Database, sessionId: string, diff --git a/hub/src/store/workGraph.test.ts b/hub/src/store/workGraph.test.ts index 79557218aa..9634a6e784 100644 --- a/hub/src/store/workGraph.test.ts +++ b/hub/src/store/workGraph.test.ts @@ -155,6 +155,61 @@ describe('WorkGraphStore', () => { expect(store.workGraph.listLinksForEvent('default', handoff.event.id)).toHaveLength(1) }) + it('lists work_ads for a session in chronological order without the HTTP cap', () => { + const store = new Store(':memory:') + const first = store.workGraph.insertEvent('default', { + source_kind: 'session', + source_ref: 'sess-a', + event_type: 'work_ad', + related_session_id: 'sess-a', + summary: 'first', + principal: humanPrincipal + }, { ts: 1000 }) + store.workGraph.insertEvent('default', { + source_kind: 'session', + source_ref: 'sess-a', + event_type: 'handoff', + related_session_id: 'sess-a', + summary: 'not an ad', + principal: humanPrincipal + }, { ts: 1500 }) + const second = store.workGraph.insertEvent('default', { + source_kind: 'session', + source_ref: 'sess-a', + event_type: 'work_ad', + related_session_id: 'sess-a', + summary: 'second', + principal: humanPrincipal + }, { ts: 2000 }) + + const ads = store.workGraph.listWorkAdsByRelatedSession('default', 'sess-a') + expect(ads.map((event) => event.id)).toEqual([first.event.id, second.event.id]) + expect(store.workGraph.listWorkAdsByRelatedSession('beta', 'sess-a')).toEqual([]) + }) + + it('lists work_ads in insert order even when a later row has an older ts', () => { + const store = new Store(':memory:') + const first = store.workGraph.insertEvent('default', { + source_kind: 'session', + source_ref: 'sess-a', + event_type: 'work_ad', + related_session_id: 'sess-a', + summary: 'inserted first', + principal: humanPrincipal + }, { ts: 5000 }) + const second = store.workGraph.insertEvent('default', { + source_kind: 'session', + source_ref: 'sess-a', + event_type: 'work_ad', + related_session_id: 'sess-a', + summary: 'inserted second, older ts', + principal: humanPrincipal + }, { ts: 1000 }) + + const ads = store.workGraph.listWorkAdsByRelatedSession('default', 'sess-a') + expect(ads.map((event) => event.id)).toEqual([first.event.id, second.event.id]) + }) + it('accepts agent principal with on_behalf_of human owner', () => { const store = new Store(':memory:') const result = store.workGraph.insertEvent('default', { @@ -170,4 +225,42 @@ describe('WorkGraphStore', () => { on_behalf_of: '1' }) }) + + it('reassigns only AGENT_NOTIFY_SUMMARY rows onto the surviving session', () => { + const store = new Store(':memory:') + const notify = store.workGraph.insertEvent('default', { + source_kind: 'session', + source_ref: 'sess-old', + event_type: 'work_ad', + related_session_id: 'sess-old', + summary: 'notify', + provenance: 'AGENT_NOTIFY_SUMMARY', + idempotency_key: 'session:sess-old:message:msg-1:notify', + principal: { kind: 'agent', id: 'session:sess-old', on_behalf_of: '1' } + }) + const posted = store.workGraph.insertEvent('default', { + source_kind: 'session', + source_ref: 'sess-old', + event_type: 'work_ad', + related_session_id: 'sess-old', + summary: 'http posted', + principal: humanPrincipal + }) + + expect(store.workGraph.reassignNotifySession('default', 'sess-old', 'sess-new')).toBe(1) + + const moved = store.workGraph.getEvent(notify.event.id, 'default') + expect(moved?.relatedSessionId).toBe('sess-new') + expect(moved?.sourceRef).toBe('sess-new') + expect(moved?.idempotencyKey).toBe('session:sess-new:message:msg-1:notify') + expect(moved?.principal).toEqual({ + kind: 'agent', + id: 'session:sess-new', + on_behalf_of: '1' + }) + + const untouched = store.workGraph.getEvent(posted.event.id, 'default') + expect(untouched?.relatedSessionId).toBe('sess-old') + expect(untouched?.sourceRef).toBe('sess-old') + }) }) diff --git a/hub/src/store/workGraph.ts b/hub/src/store/workGraph.ts index 7089a0b89e..b7818e5c27 100644 --- a/hub/src/store/workGraph.ts +++ b/hub/src/store/workGraph.ts @@ -279,6 +279,20 @@ export function listWorkGraphEventsByRelatedSession( return rows.map(toEvent) } +/** Full-session work_ad history for notify-ingest cause resolution (no HTTP list cap). */ +export function listWorkGraphWorkAdsByRelatedSession( + db: Database, + namespace: string, + relatedSessionId: string +): WorkGraphEvent[] { + const rows = db.prepare(` + SELECT * FROM events + WHERE namespace = ? AND related_session_id = ? AND event_type = 'work_ad' + ORDER BY rowid ASC + `).all(namespace, relatedSessionId) as EventRow[] + return rows.map(toEvent) +} + export function insertWorkGraphEventLink( db: Database, namespace: string, @@ -323,6 +337,84 @@ export function insertWorkGraphEventLink( return toLink(row) } +/** + * Move hub-elevated notify work_ads onto the surviving session id. + * HTTP-posted rows keep their original session keys. + */ +export function reassignWorkGraphNotifySession( + db: Database, + namespace: string, + oldSessionId: string, + newSessionId: string +): number { + if (oldSessionId === newSessionId) return 0 + const rows = db.prepare(` + SELECT id, related_session_id, source_ref, idempotency_key, principal_json + FROM events + WHERE namespace = ? + AND provenance = 'AGENT_NOTIFY_SUMMARY' + AND (related_session_id = ? OR source_ref = ?) + `).all(namespace, oldSessionId, oldSessionId) as Array<{ + id: string + related_session_id: string | null + source_ref: string + idempotency_key: string | null + principal_json: string + }> + if (rows.length === 0) return 0 + + const update = db.prepare(` + UPDATE events + SET related_session_id = ?, + source_ref = ?, + idempotency_key = ?, + principal_json = ? + WHERE id = ? AND namespace = ? + `) + const oldPrefix = `session:${oldSessionId}:` + const newPrefix = `session:${newSessionId}:` + const oldPrincipal = `session:${oldSessionId}` + const newPrincipal = `session:${newSessionId}` + + return db.transaction(() => { + let changed = 0 + for (const row of rows) { + const relatedSessionId = row.related_session_id === oldSessionId + ? newSessionId + : row.related_session_id + const sourceRef = row.source_ref === oldSessionId ? newSessionId : row.source_ref + let idempotencyKey = row.idempotency_key + if (idempotencyKey?.startsWith(oldPrefix)) { + idempotencyKey = newPrefix + idempotencyKey.slice(oldPrefix.length) + } + const principalJson = row.principal_json.includes(oldPrincipal) + ? row.principal_json.split(oldPrincipal).join(newPrincipal) + : row.principal_json + try { + update.run( + relatedSessionId, + sourceRef, + idempotencyKey, + principalJson, + row.id, + namespace + ) + } catch { + update.run( + relatedSessionId, + sourceRef, + row.idempotency_key, + principalJson, + row.id, + namespace + ) + } + changed += 1 + } + return changed + })() +} + export function listWorkGraphEventLinksForEvent( db: Database, namespace: string, diff --git a/hub/src/store/workGraphStore.ts b/hub/src/store/workGraphStore.ts index 9ec5541a8b..74a6d9ce9f 100644 --- a/hub/src/store/workGraphStore.ts +++ b/hub/src/store/workGraphStore.ts @@ -11,6 +11,8 @@ import { insertWorkGraphEventLink, listWorkGraphEventLinksForEvent, listWorkGraphEventsByRelatedSession, + listWorkGraphWorkAdsByRelatedSession, + reassignWorkGraphNotifySession, type InsertWorkGraphEventResult } from './workGraph' @@ -41,6 +43,14 @@ export class WorkGraphStore { return listWorkGraphEventsByRelatedSession(this.db, namespace, relatedSessionId, options) } + listWorkAdsByRelatedSession(namespace: string, relatedSessionId: string): WorkGraphEvent[] { + return listWorkGraphWorkAdsByRelatedSession(this.db, namespace, relatedSessionId) + } + + reassignNotifySession(namespace: string, oldSessionId: string, newSessionId: string): number { + return reassignWorkGraphNotifySession(this.db, namespace, oldSessionId, newSessionId) + } + insertLink(namespace: string, input: WorkGraphEventLinkCreate): WorkGraphEventLink { return insertWorkGraphEventLink(this.db, namespace, input) } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index fdd3dd460b..e9b71e8938 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1076,6 +1076,11 @@ export class SessionCache { } const movedMessages = this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) + // mergeSessions deletes the source. mergeSessionHistory keeps it alive + // with the original socket, so its notify chain must stay on that id. + if (options.deleteOldSession) { + this.store.workGraph.reassignNotifySession(namespace, oldSessionId, newSessionId) + } if (movedMessages.moved > 0) { this.store.usage.transferSession(oldSessionId, newSessionId) if (!options.deleteOldSession) { diff --git a/hub/src/sync/workGraphNotifyIngest.test.ts b/hub/src/sync/workGraphNotifyIngest.test.ts index eec0eb1b26..ebf2559b5b 100644 --- a/hub/src/sync/workGraphNotifyIngest.test.ts +++ b/hub/src/sync/workGraphNotifyIngest.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'bun:test' import { WORK_GRAPH_MAX_STRING, WORK_GRAPH_MAX_SUMMARY } from '@hapi/protocol' +import type { SyncEvent } from '@hapi/protocol/types' import { Store } from '../store' +import type { EventPublisher } from './eventPublisher' +import { SessionCache } from './sessionCache' import { WORK_AD_DEFAULT_TTL_MS, buildWorkAdFromNotify, @@ -23,6 +26,51 @@ function assistantOutput(text: string) { } } +function userInbound(text: string, sentFrom: string = 'webapp', extraMeta: Record = {}) { + return { + role: 'user' as const, + content: { type: 'text' as const, text }, + meta: { sentFrom, ...extraMeta } + } +} + +function agentToolRow() { + return { + role: 'agent' as const, + content: { + type: 'output', + data: { type: 'tool_use', name: 'Read', id: 'tool-1' } + } + } +} + +function notifyFooter(summary: string): string { + return `Prose.\n\nAGENT_NOTIFY_SUMMARY ${JSON.stringify({ + version: 1, + status: 'done', + summary + })}` +} + +function ingestNotify( + store: Store, + sessionId: string, + namespace: string, + content: unknown, + messageId: string, + ts: number = Date.now() +) { + return ingestNotifySummaryFromMessage({ + store, + namespace, + sessionId, + messageId, + content, + ts, + ownerUserId: 1 + }) +} + describe('mapNotifyStatusToWorkAdStatus', () => { it('maps notify contract statuses onto RFC WorkAd vocabulary', () => { expect(mapNotifyStatusToWorkAdStatus('done')).toBe('done') @@ -372,3 +420,541 @@ describe('ingestNotifySummaryFromMessage', () => { expect(store.workGraph.listByRelatedSession('default', session.id)).toHaveLength(1) }) }) + +describe('ingestNotifySummaryFromMessage cause stamping', () => { + it('happy path: first unconsumed inbound is the cause; previous work_ad is related', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-happy', {}, null, 'default') + + const firstUser = store.messages.addMessage(session.id, userInbound('do the first thing')) + const firstAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Turn one'))) + const first = ingestNotify(store, session.id, 'default', firstAssistant.content, firstAssistant.id) + + expect(first?.inserted).toBe(true) + expect(first?.event.relatedEventId).toBeNull() + expect(first?.event.payloadJson).toMatchObject({ + messageId: firstAssistant.id, + causeMessageId: firstUser.id, + causeText: 'do the first thing', + causeKind: 'webapp', + causeSeq: firstUser.seq, + causeCursorMessageId: firstUser.id + }) + + const secondUser = store.messages.addMessage(session.id, userInbound('do the second thing', 'cli')) + const secondAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Turn two'))) + const second = ingestNotify(store, session.id, 'default', secondAssistant.content, secondAssistant.id) + + expect(second?.inserted).toBe(true) + expect(second?.event.relatedEventId).toBe(first!.event.id) + expect(second?.event.payloadJson).toMatchObject({ + messageId: secondAssistant.id, + causeMessageId: secondUser.id, + causeText: 'do the second thing', + causeKind: 'cli' + }) + + const links = store.workGraph.listLinksForEvent('default', second!.event.id) + expect(links).toEqual(expect.arrayContaining([ + expect.objectContaining({ + fromEventId: second!.event.id, + toEventId: first!.event.id, + relationType: 'follows' + }) + ])) + }) + + it('queued inbound: cause is the unconsumed inbound, not the nearest user before the assistant', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-queued', {}, null, 'default') + + const causing = store.messages.addMessage(session.id, userInbound('start the long turn')) + store.messages.addMessage(session.id, agentToolRow()) + const queued = store.messages.addMessage( + session.id, + userInbound('queued while in flight'), + 'queued-while-in-flight' + ) + const assistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Finished long turn'))) + + const first = ingestNotify(store, session.id, 'default', assistant.content, assistant.id) + expect(first?.event.payloadJson).toMatchObject({ + causeMessageId: causing.id, + causeText: 'start the long turn' + }) + expect((first?.event.payloadJson as { causeMessageId?: string })?.causeMessageId) + .not.toBe(queued.id) + + store.messages.markMessagesInvoked(session.id, ['queued-while-in-flight'], Date.now()) + const secondAssistant = store.messages.addMessage( + session.id, + assistantOutput(notifyFooter('Queued turn')) + ) + const second = ingestNotify(store, session.id, 'default', secondAssistant.content, secondAssistant.id) + expect(second?.event.payloadJson).toMatchObject({ + causeMessageId: queued.id, + causeText: 'queued while in flight' + }) + expect(second?.event.relatedEventId).toBe(first!.event.id) + }) + + it('sticky cause: two summaries with no new inbound reuse the previous cause', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-sticky', {}, null, 'default') + + const user = store.messages.addMessage(session.id, userInbound('keep going')) + const firstAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('First summary'))) + const first = ingestNotify(store, session.id, 'default', firstAssistant.content, firstAssistant.id) + + const secondAssistant = store.messages.addMessage( + session.id, + assistantOutput(notifyFooter('Second summary same turn')) + ) + const second = ingestNotify(store, session.id, 'default', secondAssistant.content, secondAssistant.id) + + expect(second?.event.payloadJson).toMatchObject({ + messageId: secondAssistant.id, + causeMessageId: user.id, + causeText: 'keep going', + causeKind: 'webapp' + }) + expect(second?.event.relatedEventId).toBe(first!.event.id) + expect(second?.event.summary).toBe('Second summary same turn') + expect(first?.event.summary).toBe('First summary') + }) + + it('peer inbound meta.sentFrom counts as cause', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-peer', {}, null, 'default') + + const peer = store.messages.addMessage( + session.id, + userInbound('please take this handoff', 'peer') + ) + const assistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Ack peer'))) + const result = ingestNotify(store, session.id, 'default', assistant.content, assistant.id) + + expect(result?.event.payloadJson).toMatchObject({ + messageId: assistant.id, + causeMessageId: peer.id, + causeText: 'please take this handoff', + causeKind: 'peer' + }) + }) + + it('skips agent-role tool/prose rows when choosing cause', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-skip-agent', {}, null, 'default') + + const user = store.messages.addMessage(session.id, userInbound('the real prompt')) + store.messages.addMessage(session.id, agentToolRow()) + store.messages.addMessage(session.id, assistantOutput('intermediate prose, no footer')) + const assistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Done'))) + + const result = ingestNotify(store, session.id, 'default', assistant.content, assistant.id) + expect(result?.event.payloadJson).toMatchObject({ + causeMessageId: user.id, + causeText: 'the real prompt' + }) + }) + + it('clamps oversized inbound causeText so elevation still inserts', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-bound', {}, null, 'default') + const fat = 'q'.repeat(WORK_GRAPH_MAX_SUMMARY + 400) + store.messages.addMessage(session.id, userInbound(fat)) + const assistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('ok'))) + const result = ingestNotify(store, session.id, 'default', assistant.content, assistant.id) + + expect(result?.inserted).toBe(true) + const causeText = (result?.event.payloadJson as { causeText?: string })?.causeText ?? '' + expect(causeText.length).toBeLessThanOrEqual(WORK_GRAPH_MAX_SUMMARY) + expect(causeText.startsWith('qq')).toBe(true) + }) + + it('1:1 consume: extra uninvoked queued inbounds wait for later work_ads', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-burst', {}, null, 'default') + const one = store.messages.addMessage(session.id, userInbound('one: read the file')) + const two = store.messages.addMessage(session.id, userInbound('two: also fix the typo'), 'burst-two') + const three = store.messages.addMessage(session.id, userInbound('three: and push'), 'burst-three') + const firstAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Drained queue'))) + const first = ingestNotify(store, session.id, 'default', firstAssistant.content, firstAssistant.id) + expect(first?.event.payloadJson).toMatchObject({ causeMessageId: one.id }) + + const secondAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Still first turn'))) + const second = ingestNotify(store, session.id, 'default', secondAssistant.content, secondAssistant.id) + expect(second?.event.payloadJson).toMatchObject({ causeMessageId: one.id }) + + store.messages.markMessagesInvoked(session.id, ['burst-two'], Date.now()) + const thirdAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Next leftover'))) + const third = ingestNotify(store, session.id, 'default', thirdAssistant.content, thirdAssistant.id) + expect(third?.event.payloadJson).toMatchObject({ causeMessageId: two.id }) + expect((third?.event.payloadJson as { causeMessageId?: string })?.causeMessageId) + .not.toBe(three.id) + }) + + it('advances causeSeq past every invoked inbound in the same Claude batch', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-batch', {}, null, 'default') + const one = store.messages.addMessage(session.id, userInbound('one'), 'batch-1') + const two = store.messages.addMessage(session.id, userInbound('two'), 'batch-2') + const three = store.messages.addMessage(session.id, userInbound('three'), 'batch-3') + store.messages.markMessagesInvoked(session.id, ['batch-1', 'batch-2', 'batch-3'], 1_700_000_111_000) + const assistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Batched'))) + const first = ingestNotify(store, session.id, 'default', assistant.content, assistant.id) + expect(first?.event.payloadJson).toMatchObject({ + causeMessageId: one.id, + causeSeq: three.seq, + causeCursorMessageId: three.id + }) + + const next = store.messages.addMessage(session.id, userInbound('next turn')) + const secondAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Next'))) + const second = ingestNotify(store, session.id, 'default', secondAssistant.content, secondAssistant.id) + expect(second?.event.payloadJson).toMatchObject({ + causeMessageId: next.id, + causeText: 'next turn' + }) + expect((second?.event.payloadJson as { causeMessageId?: string })?.causeMessageId) + .not.toBe(two.id) + }) + + it('does not treat an uninvoked queued inbound as a cause', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-uninvoked', {}, null, 'default') + const causing = store.messages.addMessage(session.id, userInbound('current turn')) + const queued = store.messages.addMessage( + session.id, + userInbound('queued not yet started'), + 'queued-local' + ) + expect(queued.invokedAt).toBeNull() + const firstAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('First'))) + const first = ingestNotify(store, session.id, 'default', firstAssistant.content, firstAssistant.id) + expect(first?.event.payloadJson).toMatchObject({ + causeMessageId: causing.id, + causeText: 'current turn' + }) + + const secondAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Still first turn'))) + const second = ingestNotify(store, session.id, 'default', secondAssistant.content, secondAssistant.id) + expect(second?.event.payloadJson).toMatchObject({ + causeMessageId: causing.id, + causeText: 'current turn' + }) + expect((second?.event.payloadJson as { causeMessageId?: string })?.causeMessageId) + .not.toBe(queued.id) + + store.messages.markMessagesInvoked(session.id, ['queued-local'], Date.now()) + const thirdAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Queued turn'))) + const third = ingestNotify(store, session.id, 'default', thirdAssistant.content, thirdAssistant.id) + expect(third?.event.payloadJson).toMatchObject({ + causeMessageId: queued.id, + causeText: 'queued not yet started' + }) + }) + + it('does not treat a future-scheduled inbound as a cause', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-sched', {}, null, 'default') + const user = store.messages.addMessage(session.id, userInbound('current turn')) + const firstAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('First'))) + const first = ingestNotify(store, session.id, 'default', firstAssistant.content, firstAssistant.id) + + store.messages.addMessage( + session.id, + userInbound('deploy to prod at 5pm'), + 'sched-later', + Date.now() + 60 * 60 * 1000 + ) + const secondAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Still first turn'))) + const second = ingestNotify(store, session.id, 'default', secondAssistant.content, secondAssistant.id) + expect(second?.event.payloadJson).toMatchObject({ + causeMessageId: user.id, + causeText: 'current turn' + }) + expect(second?.event.relatedEventId).toBe(first!.event.id) + }) + + it('ignores client-posted work_ads when chaining cause and related_event_id', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-forge', {}, null, 'default') + const user = store.messages.addMessage(session.id, userInbound('real prompt')) + const firstAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Real ad'))) + const first = ingestNotify(store, session.id, 'default', firstAssistant.content, firstAssistant.id) + + store.workGraph.insertEvent('default', { + source_kind: 'session', + source_ref: session.id, + event_type: 'work_ad', + related_session_id: session.id, + summary: 'forged', + payload_json: { + status: 'done', + causeMessageId: user.id, + causeText: 'FORGED CAUSE TEXT', + causeKind: 'webapp' + }, + principal: { kind: 'human', id: '1' } + }) + + const nextUser = store.messages.addMessage(session.id, userInbound('second prompt')) + const secondAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Second real'))) + const second = ingestNotify(store, session.id, 'default', secondAssistant.content, secondAssistant.id) + expect(second?.event.relatedEventId).toBe(first!.event.id) + expect(second?.event.payloadJson).toMatchObject({ + causeMessageId: nextUser.id, + causeText: 'second prompt' + }) + expect((second?.event.payloadJson as { causeText?: string })?.causeText) + .not.toBe('FORGED CAUSE TEXT') + }) + + it('first notify after copied history uses the latest invoked inbound, not the oldest copy', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-fork-hydrate', {}, null, 'default') + const copied = store.messages.addMessage( + session.id, + userInbound('copied prefix'), + undefined, + undefined, + 1_000 + ) + const forkPrompt = store.messages.addMessage( + session.id, + userInbound('fork prompt'), + undefined, + undefined, + 2_000 + ) + const assistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Forked'))) + const result = ingestNotify(store, session.id, 'default', assistant.content, assistant.id) + expect(result?.event.payloadJson).toMatchObject({ + causeMessageId: forkPrompt.id, + causeText: 'fork prompt' + }) + expect((result?.event.payloadJson as { causeMessageId?: string })?.causeMessageId) + .not.toBe(copied.id) + }) + + it('later notifies bound the scan after the previous causeSeq', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-after-seq', {}, null, 'default') + const firstUser = store.messages.addMessage(session.id, userInbound('first')) + const firstAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('First'))) + const first = ingestNotify(store, session.id, 'default', firstAssistant.content, firstAssistant.id) + expect(first?.event.payloadJson).toMatchObject({ causeSeq: firstUser.seq }) + + const nextUser = store.messages.addMessage(session.id, userInbound('second')) + const secondAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Second'))) + const second = ingestNotify(store, session.id, 'default', secondAssistant.content, secondAssistant.id) + expect(second?.event.payloadJson).toMatchObject({ + causeMessageId: nextUser.id, + causeText: 'second', + causeSeq: nextUser.seq + }) + }) + + it('legacy notify without causeSeq still consumes inbounds at or before that assistant', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-legacy-seq', {}, null, 'default') + const oldUser = store.messages.addMessage(session.id, userInbound('old prompt')) + const oldAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Legacy'))) + store.workGraph.insertEvent('default', { + source_kind: 'session', + source_ref: session.id, + event_type: 'work_ad', + related_session_id: session.id, + summary: 'legacy', + provenance: 'AGENT_NOTIFY_SUMMARY', + payload_json: { + status: 'done', + messageId: oldAssistant.id + }, + principal: { kind: 'agent', id: `session:${session.id}`, on_behalf_of: '1' } + }) + + const nextUser = store.messages.addMessage(session.id, userInbound('new prompt')) + const nextAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Next'))) + const result = ingestNotify(store, session.id, 'default', nextAssistant.content, nextAssistant.id) + expect(result?.event.payloadJson).toMatchObject({ + causeMessageId: nextUser.id, + causeText: 'new prompt' + }) + expect((result?.event.payloadJson as { causeMessageId?: string })?.causeMessageId) + .not.toBe(oldUser.id) + }) + + it('treats an unmarked local CLI prompt as a cause', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-local-cli', {}, null, 'default') + const local = store.messages.addMessage(session.id, userInbound('typed in the TTY', 'cli')) + const assistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Local turn'))) + const result = ingestNotify(store, session.id, 'default', assistant.content, assistant.id) + expect(result?.event.payloadJson).toMatchObject({ + causeMessageId: local.id, + causeText: 'typed in the TTY', + causeKind: 'cli' + }) + }) + + it('skips Claude transcript echoes so the next turn is not attributed to the previous prompt copy', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-echo', {}, null, 'default') + + const web1 = store.messages.addMessage(session.id, userInbound('turn one')) + store.messages.addMessage( + session.id, + userInbound('turn one', 'cli', { isTranscriptEcho: true }) + ) + const firstAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('One'))) + const first = ingestNotify(store, session.id, 'default', firstAssistant.content, firstAssistant.id) + expect(first?.event.payloadJson).toMatchObject({ causeMessageId: web1.id }) + + const web2 = store.messages.addMessage(session.id, userInbound('turn two')) + store.messages.addMessage( + session.id, + userInbound('turn two', 'cli', { isTranscriptEcho: true }) + ) + const secondAssistant = store.messages.addMessage(session.id, assistantOutput(notifyFooter('Two'))) + const second = ingestNotify(store, session.id, 'default', secondAssistant.content, secondAssistant.id) + expect(second?.event.payloadJson).toMatchObject({ + causeMessageId: web2.id, + causeText: 'turn two' + }) + }) + + it('still inserts when max-clamped footer fields share the payload with cause', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('sess-cause-budget', {}, null, 'default') + store.messages.addMessage(session.id, userInbound('prompt')) + const fat = 'a'.repeat(6_000) + const assistant = store.messages.addMessage(session.id, assistantOutput( + `AGENT_NOTIFY_SUMMARY ${JSON.stringify({ + status: 'done', + summary: 'ok', + action: fat, + project: fat, + agent: fat + })}` + )) + const result = ingestNotify(store, session.id, 'default', assistant.content, assistant.id) + expect(result?.inserted).toBe(true) + expect(result?.event.payloadJson).toMatchObject({ + causeText: 'prompt', + action: fat + }) + }) + + it('preserves notify history across mergeSessions into the surviving id', async () => { + const store = new Store(':memory:') + const cache = new SessionCache(store, { + emit: (_event: SyncEvent) => {} + } as EventPublisher) + const oldSession = cache.getOrCreateSession( + 'sess-cause-merge-old', + { path: '/tmp/project', host: 'localhost' }, + null, + 'default' + ) + const newSession = cache.getOrCreateSession( + 'sess-cause-merge-new', + { path: '/tmp/project', host: 'localhost' }, + null, + 'default' + ) + + store.messages.addMessage(oldSession.id, userInbound('from the old session')) + const firstAssistant = store.messages.addMessage(oldSession.id, assistantOutput(notifyFooter('Old turn'))) + const first = ingestNotify(store, oldSession.id, 'default', firstAssistant.content, firstAssistant.id) + expect(first?.inserted).toBe(true) + + await cache.mergeSessions(oldSession.id, newSession.id, 'default') + + const nextUser = store.messages.addMessage(newSession.id, userInbound('after merge')) + const nextAssistant = store.messages.addMessage(newSession.id, assistantOutput(notifyFooter('New turn'))) + const second = ingestNotify(store, newSession.id, 'default', nextAssistant.content, nextAssistant.id) + + expect(second?.event.relatedEventId).toBe(first!.event.id) + expect(second?.event.payloadJson).toMatchObject({ + causeMessageId: nextUser.id, + causeText: 'after merge' + }) + const onSurvivor = store.workGraph.listWorkAdsByRelatedSession('default', newSession.id) + .map((event) => event.id) + expect(onSurvivor).toContain(first!.event.id) + expect(onSurvivor).toContain(second!.event.id) + }) + + it('keeps notify history on the live source after mergeSessionHistory', async () => { + const store = new Store(':memory:') + const cache = new SessionCache(store, { + emit: (_event: SyncEvent) => {} + } as EventPublisher) + const source = cache.getOrCreateSession( + 'sess-cause-hist-src', + { path: '/tmp/project', host: 'localhost' }, + null, + 'default' + ) + const target = cache.getOrCreateSession( + 'sess-cause-hist-tgt', + { path: '/tmp/project', host: 'localhost' }, + null, + 'default' + ) + + const firstUser = store.messages.addMessage(source.id, userInbound('live source prompt')) + const firstAssistant = store.messages.addMessage(source.id, assistantOutput(notifyFooter('Before history merge'))) + const first = ingestNotify(store, source.id, 'default', firstAssistant.content, firstAssistant.id) + expect(first?.inserted).toBe(true) + + await cache.mergeSessionHistory(source.id, target.id, 'default', { mergeAgentState: false }) + + const nextUser = store.messages.addMessage(source.id, userInbound('still on the live source')) + const nextAssistant = store.messages.addMessage(source.id, assistantOutput(notifyFooter('After history merge'))) + const second = ingestNotify(store, source.id, 'default', nextAssistant.content, nextAssistant.id) + + expect(second?.event.relatedEventId).toBe(first!.event.id) + expect(second?.event.payloadJson).toMatchObject({ + causeMessageId: nextUser.id, + causeText: 'still on the live source' + }) + expect((second?.event.payloadJson as { causeMessageId?: string })?.causeMessageId) + .not.toBe(firstUser.id) + const onSource = store.workGraph.listWorkAdsByRelatedSession('default', source.id) + .map((event) => event.id) + expect(onSource).toContain(first!.event.id) + expect(onSource).toContain(second!.event.id) + }) + + it('does not re-attribute a prior batch after surviving-session seq-shift', () => { + const store = new Store(':memory:') + const surviving = store.sessions.getOrCreateSession('sess-cause-shift-live', {}, null, 'default') + const incoming = store.sessions.getOrCreateSession('sess-cause-shift-in', {}, null, 'default') + + const one = store.messages.addMessage(surviving.id, userInbound('one'), 'shift-1') + const two = store.messages.addMessage(surviving.id, userInbound('two'), 'shift-2') + store.messages.addMessage(surviving.id, userInbound('three'), 'shift-3') + store.messages.markMessagesInvoked(surviving.id, ['shift-1', 'shift-2', 'shift-3'], 1_700_000_222_000) + const assistant = store.messages.addMessage(surviving.id, assistantOutput(notifyFooter('Batched'))) + const first = ingestNotify(store, surviving.id, 'default', assistant.content, assistant.id) + expect(first?.event.payloadJson).toMatchObject({ causeMessageId: one.id }) + + store.messages.addMessage(incoming.id, userInbound('history from the other id')) + store.messages.mergeSessionMessages(incoming.id, surviving.id) + + const secondAssistant = store.messages.addMessage( + surviving.id, + assistantOutput(notifyFooter('Sticky after merge')) + ) + const second = ingestNotify(store, surviving.id, 'default', secondAssistant.content, secondAssistant.id) + expect(second?.event.payloadJson).toMatchObject({ + causeMessageId: one.id, + causeText: 'one' + }) + expect((second?.event.payloadJson as { causeMessageId?: string })?.causeMessageId) + .not.toBe(two.id) + }) +}) diff --git a/hub/src/sync/workGraphNotifyIngest.ts b/hub/src/sync/workGraphNotifyIngest.ts index 4f4959f74c..3c6266a014 100644 --- a/hub/src/sync/workGraphNotifyIngest.ts +++ b/hub/src/sync/workGraphNotifyIngest.ts @@ -6,9 +6,10 @@ import { extractNotifySummary, unwrapRoleWrappedRecordEnvelope, type NotifySummary, + type WorkGraphEvent, type WorkGraphEventCreate } from '@hapi/protocol' -import type { Store } from '../store' +import type { Store, StoredMessage } from '../store' import { WorkGraphValidationError } from '../store' import type { InsertWorkGraphEventResult } from '../store/workGraph' @@ -135,6 +136,225 @@ function isAgentMessageContent(content: unknown): boolean { return false } +export type WorkAdCause = { + causeMessageId: string + causeText: string | null + causeKind: string | null + causeSeq: number | null + causeCursorMessageId: string | null +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +function isInboundUserMessage(content: unknown): boolean { + return asRecord(content)?.role === 'user' +} + +function isCauseCandidate(message: StoredMessage, now: number = Date.now()): boolean { + if (!isInboundUserMessage(message.content)) return false + const meta = asRecord(asRecord(message.content)?.meta) + // Claude jsonl echoes the remote prompt as a second role=user row (sentFrom cli). + if (meta?.isTranscriptEcho === true) return false + // Queued (localId, not yet acked) and unmatured scheduled rows are not this turn. + if (message.invokedAt === null) return false + if (message.scheduledAt != null && message.scheduledAt > now) return false + return true +} + +function extractInboundSentFrom(content: unknown): string | null { + const meta = asRecord(asRecord(content)?.meta) + return typeof meta?.sentFrom === 'string' && meta.sentFrom.trim().length > 0 + ? meta.sentFrom.trim() + : null +} + +function extractInboundCauseText(content: unknown): string | null { + const record = asRecord(content) + if (!record) return null + const inner = record.content + if (typeof inner === 'string') { + const text = inner.trim() + return text.length > 0 ? text : null + } + if (Array.isArray(inner)) { + const parts = inner.flatMap((block) => { + const item = asRecord(block) + return item?.type === 'text' && typeof item.text === 'string' ? [item.text] : [] + }) + const text = parts.join(' ').trim() + return text.length > 0 ? text : null + } + const nested = asRecord(inner) + if (nested?.type === 'text' && typeof nested.text === 'string') { + const text = nested.text.trim() + return text.length > 0 ? text : null + } + return null +} + +function readCauseSeq(payload: unknown): number | null { + const record = asRecord(payload) + return typeof record?.causeSeq === 'number' && Number.isInteger(record.causeSeq) + ? record.causeSeq + : null +} + +function readCauseCursorMessageId(payload: unknown): string | null { + const record = asRecord(payload) + return typeof record?.causeCursorMessageId === 'string' && record.causeCursorMessageId.length > 0 + ? record.causeCursorMessageId + : null +} + +function readCauseFromPayload(payload: unknown): WorkAdCause | null { + const record = asRecord(payload) + if (typeof record?.causeMessageId !== 'string' || record.causeMessageId.length === 0) { + return null + } + return { + causeMessageId: record.causeMessageId, + causeText: typeof record.causeText === 'string' ? record.causeText : null, + causeKind: typeof record.causeKind === 'string' ? record.causeKind : null, + causeSeq: readCauseSeq(payload), + causeCursorMessageId: readCauseCursorMessageId(payload) + } +} + +function loadMessagesForCause( + store: Store, + sessionId: string, + previousWorkAds: WorkGraphEvent[] +): StoredMessage[] { + const previous = previousWorkAds.at(-1) ?? null + const cursorId = readCauseCursorMessageId(previous?.payloadJson) + if (cursorId) { + const cursorSeq = store.messages.getSeqById(sessionId, cursorId) + if (cursorSeq != null) { + return store.messages.getMessagesAfterSeq(sessionId, cursorSeq) + } + return store.messages.getAllMessages(sessionId) + } + const afterSeq = readCauseSeq(previous?.payloadJson) + // First event / legacy rows without causeSeq still need the full session. + // Later notifies only need rows after the previous cause (not every + // compressed agent/tool blob since session start). + if (afterSeq == null) { + return store.messages.getAllMessages(sessionId) + } + return store.messages.getMessagesAfterSeq(sessionId, afterSeq) +} + +function listPreviousWorkAds( + store: Store, + namespace: string, + sessionId: string +): WorkGraphEvent[] { + // Only hub notify elevation. Client POST /work-graph/events can mint + // work_ad rows; those must not steal related_event_id, follows, or sticky cause. + return store.workGraph + .listWorkAdsByRelatedSession(namespace, sessionId) + .filter((event) => ( + event.provenance === 'AGENT_NOTIFY_SUMMARY' + && event.sourceRef === sessionId + )) +} + +function consumedInboundIds( + messages: StoredMessage[], + previousWorkAds: WorkGraphEvent[] +): Set { + const consumed = new Set() + const byId = new Map(messages.map((message) => [message.id, message])) + for (const event of previousWorkAds) { + const stamped = readCauseFromPayload(event.payloadJson) + if (stamped) { + consumed.add(stamped.causeMessageId) + continue + } + // Legacy notify rows have no causeMessageId. Treat inbounds at/before + // that notify as consumed so the next turn does not re-attribute them. + const payload = asRecord(event.payloadJson) + const assistantId = typeof payload?.messageId === 'string' ? payload.messageId : null + const assistant = assistantId ? byId.get(assistantId) : undefined + if (!assistant) continue + for (const message of messages) { + if (message.seq <= assistant.seq && isCauseCandidate(message)) { + consumed.add(message.id) + } + } + } + return consumed +} + +/** + * Sequential rule: first unconsumed invoked inbound is the cause identity. + * causeSeq advances past other invoked inbounds before this assistant (one + * Claude batch can join several same-mode prompts). Uninvoked leftovers wait. + * No new invoked inbound → sticky copy of the previous event's cause. + */ +function batchCauseCursor( + messages: StoredMessage[], + inbound: StoredMessage, + assistantSeq: number | null +): { causeSeq: number; causeCursorMessageId: string } { + let maxSeq = inbound.seq + let cursorId = inbound.id + for (const message of messages) { + if (assistantSeq != null && message.seq >= assistantSeq) continue + if (!isCauseCandidate(message)) continue + if (message.seq > maxSeq) { + maxSeq = message.seq + cursorId = message.id + } + } + return { causeSeq: maxSeq, causeCursorMessageId: cursorId } +} + +export function resolveWorkAdCause(params: { + messages: StoredMessage[] + previousWorkAds: WorkGraphEvent[] + assistantSeq?: number | null +}): { cause: WorkAdCause | null; previousEventId: string | null } { + const previous = params.previousWorkAds.at(-1) ?? null + const consumed = consumedInboundIds(params.messages, params.previousWorkAds) + const inbound = params.messages + .filter((message) => ( + isCauseCandidate(message) + && !consumed.has(message.id) + && (params.assistantSeq == null || message.seq < params.assistantSeq) + )) + .sort((left, right) => { + const invokedDelta = (right.invokedAt ?? 0) - (left.invokedAt ?? 0) + if (invokedDelta !== 0) return invokedDelta + return left.seq - right.seq + })[0] + if (inbound) { + const text = extractInboundCauseText(inbound.content) + const cursor = batchCauseCursor(params.messages, inbound, params.assistantSeq ?? null) + return { + cause: { + causeMessageId: inbound.id, + causeText: text === null ? null : clampJsonUtf8(text, WORK_GRAPH_MAX_SUMMARY), + causeKind: extractInboundSentFrom(inbound.content), + causeSeq: cursor.causeSeq, + causeCursorMessageId: cursor.causeCursorMessageId + }, + previousEventId: previous?.id ?? null + } + } + if (previous) { + const sticky = readCauseFromPayload(previous.payloadJson) + if (sticky) { + return { cause: sticky, previousEventId: previous.id } + } + } + return { cause: null, previousEventId: previous?.id ?? null } +} + function buildTags(notify: NotifySummary, flavor: string | null | undefined): string[] { // Project stays in tags + payload for now. Indexed `project` column / // project-scoped list query is deferred to #1374 / P4 (cold review M4). @@ -159,6 +379,8 @@ export function buildWorkAdFromNotify(params: { ts: number flavor?: string | null expiresAt?: number + cause?: WorkAdCause | null + relatedEventId?: string | null }): WorkGraphEventCreate { const status = mapNotifyStatusToWorkAdStatus(params.notify.status) // Footer fields are untrusted. Clamp to ledger schema bounds so elevation @@ -167,6 +389,16 @@ export function buildWorkAdFromNotify(params: { const action = clampJsonUtf8Opt(params.notify.action, WORK_GRAPH_MAX_STRING) ?? null const project = clampJsonUtf8Opt(params.notify.project, WORK_GRAPH_MAX_STRING) ?? null const agent = clampJsonUtf8Opt(params.notify.agent, WORK_GRAPH_MAX_STRING) ?? null + const cause = params.cause + const causeMessageId = cause + ? clampJsonUtf8(cause.causeMessageId, 256) + : null + const causeText = cause?.causeText == null + ? null + : clampJsonUtf8(cause.causeText, WORK_GRAPH_MAX_SUMMARY) + const causeKind = cause?.causeKind == null + ? null + : clampJsonUtf8(cause.causeKind, WORK_GRAPH_MAX_TAG) // Audit principal is always session-bound. notify.agent is untrusted // self-label text and stays advisory in payload/tags only. // Do not nest a full notify_summary copy — duplicating clamped strings @@ -181,10 +413,22 @@ export function buildWorkAdFromNotify(params: { action, project, agent, - messageId: params.messageId + messageId: params.messageId, + ...(cause && causeMessageId + ? { + causeMessageId, + causeText, + causeKind, + causeSeq: cause.causeSeq, + ...(cause.causeCursorMessageId + ? { causeCursorMessageId: clampJsonUtf8(cause.causeCursorMessageId, 256) } + : {}) + } + : {}) }, tags: buildTags(params.notify, params.flavor), related_session_id: params.sessionId, + related_event_id: params.relatedEventId || undefined, provenance: 'AGENT_NOTIFY_SUMMARY', idempotency_key: `session:${params.sessionId}:message:${params.messageId}:notify`, expires_at: params.expiresAt ?? (params.ts + WORK_AD_DEFAULT_TTL_MS), @@ -220,17 +464,41 @@ export function ingestNotifySummaryFromMessage(input: NotifyIngestInput): Notify return null } + // Cause is hub-derived from session messages SQL (no REST 200 cap). + const previousWorkAds = listPreviousWorkAds(input.store, input.namespace, input.sessionId) + const messages = loadMessagesForCause(input.store, input.sessionId, previousWorkAds) + const assistantSeq = messages.find((message) => message.id === input.messageId)?.seq ?? null + const { cause, previousEventId } = resolveWorkAdCause({ + messages, + previousWorkAds, + assistantSeq + }) + const create = buildWorkAdFromNotify({ sessionId: input.sessionId, messageId: input.messageId, notify, ownerUserId: input.ownerUserId, flavor: input.flavor, - ts: input.ts + ts: input.ts, + cause, + relatedEventId: previousEventId }) try { - return input.store.workGraph.insertEvent(input.namespace, create, { ts: input.ts }) + const result = input.store.workGraph.insertEvent(input.namespace, create, { ts: input.ts }) + if (result.inserted && previousEventId) { + try { + input.store.workGraph.insertLink(input.namespace, { + from_event_id: result.event.id, + to_event_id: previousEventId, + relation_type: 'follows' + }) + } catch { + // Best-effort edge; related_event_id is already on the row. + } + } + return result } catch (error) { // Best-effort capture: never break message ingest on ledger bounds. if (error instanceof WorkGraphValidationError) { diff --git a/hub/src/web/routes/workGraph.test.ts b/hub/src/web/routes/workGraph.test.ts index bda5aaa68f..bd68f616fb 100644 --- a/hub/src/web/routes/workGraph.test.ts +++ b/hub/src/web/routes/workGraph.test.ts @@ -178,6 +178,33 @@ describe('work-graph routes', () => { expect(response.status).toBe(413) }) + it('rejects reserved AGENT_NOTIFY_SUMMARY provenance on HTTP writes', async () => { + const store = new Store(':memory:') + const app = createApp(store) + const headers = await authHeaders('default') + const response = await app.request('/api/work-graph/events', { + method: 'POST', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ + source_kind: 'session', + source_ref: 'sess-1', + event_type: 'work_ad', + related_session_id: 'sess-1', + provenance: 'AGENT_NOTIFY_SUMMARY', + payload_json: { + status: 'done', + causeMessageId: 'msg-forged', + causeText: 'FORGED CAUSE TEXT' + }, + principal: { kind: 'human', id: '1' } + }) + }) + expect(response.status).toBe(400) + const body = await response.json() as { error: string } + expect(body.error).toBe('Reserved provenance') + expect(store.workGraph.listByRelatedSession('default', 'sess-1')).toHaveLength(0) + }) + it('rejects fractional list limit with 400 (not SQLite 500)', async () => { const store = new Store(':memory:') const app = createApp(store) diff --git a/hub/src/web/routes/workGraph.ts b/hub/src/web/routes/workGraph.ts index d8101053b5..8d272b0c3c 100644 --- a/hub/src/web/routes/workGraph.ts +++ b/hub/src/web/routes/workGraph.ts @@ -41,6 +41,10 @@ export function createWorkGraphRoutes(store: Store): Hono { }, 403) } + if (parsed.data.provenance === 'AGENT_NOTIFY_SUMMARY') { + return c.json({ error: 'Reserved provenance' }, 400) + } + try { const result = store.workGraph.insertEvent(namespace, parsed.data) return c.json({ From c5d2a76f009c8f3db2508cb89c836acb8f45f5aa Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:22:34 +0000 Subject: [PATCH 071/142] fix(hub): do not fake-archive in-flight CLIs without KillSession After session RPC requires a possession proof, an upgraded hub can lose KillSession on still-connected pre-proof CLIs. #916 treated that as already gone and stamped archived while the agent kept heartbeating. Fall back to runner StopSession; if the process or socket is still live, return 409 instead of a lying 200. Co-authored-by: Cursor --- hub/src/sync/archiveSession.test.ts | 229 ++++++++++++++++++++++++++++ hub/src/sync/syncEngine.ts | 83 ++++++++-- hub/src/web/routes/sessions.test.ts | 19 ++- hub/src/web/routes/sessions.ts | 14 +- 4 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 hub/src/sync/archiveSession.test.ts diff --git a/hub/src/sync/archiveSession.test.ts b/hub/src/sync/archiveSession.test.ts new file mode 100644 index 0000000000..fa5807962b --- /dev/null +++ b/hub/src/sync/archiveSession.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { RpcTargetMissingError } from './rpcGateway' +import { SyncEngine } from './syncEngine' + +type StopStatus = 'stopped' | 'already_gone' | 'still_alive' + +type RpcGatewayStub = { + killSession: (sessionId: string) => Promise + stopRunnerSession: (machineId: string, sessionId: string) => Promise +} + +function gateway(engine: SyncEngine): RpcGatewayStub { + return (engine as unknown as { rpcGateway: RpcGatewayStub }).rpcGateway +} + +function createIo(rooms = new Map>()) { + return { + of() { + return { + adapter: { rooms }, + to() { + return { emit() {} } + } + } + } + } as never +} + +function createEngine(rooms = new Map>()) { + const store = new Store(':memory:') + const engine = new SyncEngine(store, createIo(rooms), new RpcRegistry(), { broadcast() {} } as never) + return { store, engine, rooms } +} + +function seedActiveSession( + engine: SyncEngine, + tag: string, + metadata: { machineId?: string } = {} +) { + const session = engine.getOrCreateSession( + tag, + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + ...metadata + }, + null, + 'default' + ) + engine.handleSessionAlive({ sid: session.id, time: Date.now() }) + return session +} + +function missingKill(sessionId: string) { + return new RpcTargetMissingError(`${sessionId}:killSession`, 'handler-not-registered') +} + +function missingStop(machineId: string) { + return new RpcTargetMissingError(`${machineId}:stopSession`, 'handler-not-registered') +} + +describe('archiveSession (#1203 in-flight CLI)', () => { + it('archives when KillSession succeeds and does not call StopSession', async () => { + const { engine } = createEngine() + try { + const session = seedActiveSession(engine, 'kill-ok', { machineId: 'machine-1' }) + const stops: string[] = [] + const rpc = gateway(engine) + rpc.killSession = async () => {} + rpc.stopRunnerSession = async (_machineId, sessionId) => { + stops.push(sessionId) + return 'stopped' + } + + await engine.archiveSession(session.id) + + expect(engine.getSessionByNamespace(session.id, 'default')?.active).toBe(false) + expect(stops).toEqual([]) + } finally { + engine.stop() + } + }) + + it('falls back to runner StopSession when KillSession is missing', async () => { + const { engine } = createEngine() + try { + const session = seedActiveSession(engine, 'stop-ok', { machineId: 'machine-1' }) + const stops: Array<[string, string]> = [] + const rpc = gateway(engine) + rpc.killSession = async (sessionId) => { + throw missingKill(sessionId) + } + rpc.stopRunnerSession = async (machineId, sessionId) => { + stops.push([machineId, sessionId]) + return 'stopped' + } + + await engine.archiveSession(session.id) + + expect(stops).toEqual([['machine-1', session.id]]) + const row = engine.getSessionByNamespace(session.id, 'default') + expect(row?.active).toBe(false) + expect(row?.metadata?.lifecycleState).toBe('archived') + } finally { + engine.stop() + } + }) + + it('refuses to archive when the runner says the process is still alive', async () => { + const { engine } = createEngine() + try { + const session = seedActiveSession(engine, 'still-alive', { machineId: 'machine-1' }) + const rpc = gateway(engine) + rpc.killSession = async (sessionId) => { + throw missingKill(sessionId) + } + rpc.stopRunnerSession = async () => 'still_alive' + + await expect(engine.archiveSession(session.id)).rejects.toThrow(/not controllable/) + + const row = engine.getSessionByNamespace(session.id, 'default') + expect(row?.active).toBe(true) + expect(row?.metadata?.lifecycleState).not.toBe('archived') + } finally { + engine.stop() + } + }) + + it('refuses to archive when StopSession is already_gone but a CLI socket is still in the room', async () => { + const { engine, rooms } = createEngine() + try { + const session = seedActiveSession(engine, 'zombie-socket', { machineId: 'machine-1' }) + rooms.set(`session:${session.id}`, new Set(['sock-1'])) + const rpc = gateway(engine) + rpc.killSession = async (sessionId) => { + throw missingKill(sessionId) + } + rpc.stopRunnerSession = async () => 'already_gone' + + await expect(engine.archiveSession(session.id)).rejects.toThrow(/not controllable/) + + const row = engine.getSessionByNamespace(session.id, 'default') + expect(row?.active).toBe(true) + expect(row?.metadata?.lifecycleState).not.toBe('archived') + } finally { + engine.stop() + } + }) + + it('archives the classic #916 case: no kill handler, runner already_gone, no live socket', async () => { + const { engine } = createEngine() + try { + const session = seedActiveSession(engine, 'truly-gone', { machineId: 'machine-1' }) + const rpc = gateway(engine) + rpc.killSession = async (sessionId) => { + throw missingKill(sessionId) + } + rpc.stopRunnerSession = async () => 'already_gone' + + await engine.archiveSession(session.id) + + const row = engine.getSessionByNamespace(session.id, 'default') + expect(row?.active).toBe(false) + expect(row?.metadata?.lifecycleState).toBe('archived') + expect(row?.metadata?.archiveReason).toBe('Archived from hub (CLI unreachable)') + } finally { + engine.stop() + } + }) + + it('refuses to archive a connected unproven CLI when there is no machineId', async () => { + const { engine, rooms } = createEngine() + try { + const session = seedActiveSession(engine, 'no-machine') + rooms.set(`session:${session.id}`, new Set(['sock-1'])) + gateway(engine).killSession = async (sessionId) => { + throw missingKill(sessionId) + } + + await expect(engine.archiveSession(session.id)).rejects.toThrow(/not controllable/) + + const row = engine.getSessionByNamespace(session.id, 'default') + expect(row?.active).toBe(true) + expect(row?.metadata?.lifecycleState).not.toBe('archived') + } finally { + engine.stop() + } + }) + + it('archives when KillSession and StopSession are both missing and no CLI socket remains', async () => { + const { engine } = createEngine() + try { + const session = seedActiveSession(engine, 'both-missing', { machineId: 'machine-1' }) + const rpc = gateway(engine) + rpc.killSession = async (sessionId) => { + throw missingKill(sessionId) + } + rpc.stopRunnerSession = async (machineId) => { + throw missingStop(machineId) + } + + await engine.archiveSession(session.id) + + const row = engine.getSessionByNamespace(session.id, 'default') + expect(row?.active).toBe(false) + expect(row?.metadata?.lifecycleState).toBe('archived') + } finally { + engine.stop() + } + }) + + it('propagates non-missing KillSession errors', async () => { + const { engine } = createEngine() + try { + const session = seedActiveSession(engine, 'timeout') + gateway(engine).killSession = async () => { + throw new Error('RPC timeout') + } + + await expect(engine.archiveSession(session.id)).rejects.toThrow(/RPC timeout/) + expect(engine.getSessionByNamespace(session.id, 'default')?.active).toBe(true) + } finally { + engine.stop() + } + }) +}) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 3edcb60a36..887e628433 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -106,6 +106,17 @@ export type LocalHandoffResult = | { type: 'success' } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' } +/** Archive refused: CLI is still connected (or runner says still alive) but session RPC is missing. */ +export class SessionArchiveUncontrollableError extends Error { + readonly sessionId: string + + constructor(sessionId: string) { + super('Session is connected but not controllable. Reopen or restart the CLI on that machine.') + this.name = 'SessionArchiveUncontrollableError' + this.sessionId = sessionId + } +} + export type ClearOpencodeSessionResult = | { type: 'success'; sessionId: string } | { @@ -1773,27 +1784,75 @@ export class SyncEngine { } async archiveSession(sessionId: string): Promise { - // tiann/hapi#916: when the CLI is already gone (e.g. after a - // hub-restart cascade SIGTERMed the runner but the in-memory - // `active` flag has not been reconciled yet) the kill-RPC throws - // and the route used to surface that as HTTP 500. Treat the - // missing target as a benign condition: still flip the session's - // lifecycleState to `archived` in the hub-side metadata so the - // UI does not see a half-cleaned zombie, and continue to mark - // it inactive in the cache. Real RPC errors (timeout, protocol - // failure) still propagate as 5xx. + // tiann/hapi#916: missing KillSession used to mean "CLI already gone". + // After #1203, an in-flight pre-proof CLI can stay connected without + // registering `${sessionId}:killSession`. Do not stamp archived while + // that process is still alive — try runner StopSession, then refuse. try { await this.rpcGateway.killSession(sessionId) + this.handleSessionEnd({ sid: sessionId, time: Date.now() }) + return } catch (error) { - if (error instanceof RpcTargetMissingError) { - this.sessionCache.markSessionArchivedFromHub(sessionId, 'Archived from hub (CLI unreachable)') - } else { + if (!(error instanceof RpcTargetMissingError)) { throw error } } + + const session = this.sessionCache.getSession(sessionId) + const machineId = typeof session?.metadata?.machineId === 'string' + ? session.metadata.machineId.trim() + : '' + + if (machineId) { + try { + const status = await this.rpcGateway.stopRunnerSession(machineId, sessionId) + if (status === 'still_alive') { + throw new SessionArchiveUncontrollableError(sessionId) + } + if (status === 'stopped') { + this.sessionCache.markSessionArchivedFromHub( + sessionId, + 'Archived from hub (CLI unreachable)' + ) + this.handleSessionEnd({ sid: sessionId, time: Date.now() }) + return + } + // already_gone: runner does not have the pid. A live unproven + // socket must not be stamped archived (#1203 / dual-CLI). + } catch (error) { + if (error instanceof SessionArchiveUncontrollableError) { + throw error + } + if (!(error instanceof RpcTargetMissingError)) { + throw error + } + } + } + + if (this.hasLiveCliSocket(sessionId)) { + throw new SessionArchiveUncontrollableError(sessionId) + } + + this.sessionCache.markSessionArchivedFromHub(sessionId, 'Archived from hub (CLI unreachable)') this.handleSessionEnd({ sid: sessionId, time: Date.now() }) } + private hasLiveCliSocket(sessionId: string): boolean { + const of = this.io?.of + if (typeof of !== 'function') { + return false + } + try { + const nsp = of.call(this.io, '/cli') as { + adapter?: { rooms?: Map> } + } | undefined + const room = nsp?.adapter?.rooms?.get(`session:${sessionId}`) + return Boolean(room && room.size > 0) + } catch { + return false + } + } + /** * Apply the post-migration metadata flip in hapi.db: * - metadata.cursorSessionProtocol = 'acp' diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 365b5260b5..06e13e4f90 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test' import { Hono } from 'hono' -import type { Session, SyncEngine } from '../../sync/syncEngine' +import { SessionArchiveUncontrollableError, type Session, type SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { createSessionsRoutes } from './sessions' @@ -1284,6 +1284,23 @@ describe('sessions routes', () => { expect(await response.json()).toEqual({ ok: true }) }) + it('returns 409 when archiveSession refuses a connected unproven CLI (#1203)', async () => { + const session = createSession({ active: true }) + const { app } = createApp(session, { + archiveSession: async () => { + throw new SessionArchiveUncontrollableError(session.id) + } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Session is connected but not controllable. Reopen or restart the CLI on that machine.', + code: 'session_uncontrollable' + }) + }) + it('still surfaces a 5xx for non-RPC errors (e.g. DB write failure)', async () => { const session = createSession({ active: true }) const { app } = createApp(session, { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index e935ac8f82..773cdc6c42 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -26,7 +26,7 @@ import { import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import type { SlashCommand } from '@hapi/protocol/apiTypes' import { Hono, type Context } from 'hono' -import type { SyncEngine, Session } from '../../sync/syncEngine' +import { SessionArchiveUncontrollableError, type SyncEngine, type Session } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { loadScratchlistAttachmentLimitsFromEnv } from '../../config/scratchlistAttachmentLimits' import { validateScratchlistAttachmentsForWrite, scratchlistSessionBytesBeforeForPut } from '../../scratchlistAttachments/validate' @@ -443,7 +443,17 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Session is inactive' }, 409) } - await engine.archiveSession(sessionResult.sessionId) + try { + await engine.archiveSession(sessionResult.sessionId) + } catch (error) { + if (error instanceof SessionArchiveUncontrollableError) { + return c.json({ + error: error.message, + code: 'session_uncontrollable' + }, 409) + } + throw error + } return c.json({ ok: true }) }) From 119e258fc8e98a92f123df36797459076c37ebaf Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:06:15 +0000 Subject: [PATCH 072/142] fix(hub): treat archive liveness as heartbeat, not room join Namespace token joins session:${id} before tag/capability, so raw room membership lets a sibling 409-DoS Archive. Authorized-socket-only would miss the unproven upgrade CLI. StopSession remains the process signal; a still-heartbeating row refuses archive until expireInactive. Co-authored-by: Cursor --- hub/src/sync/archiveSession.test.ts | 48 ++++++++++++++++++++--------- hub/src/sync/syncEngine.ts | 32 ++++++++----------- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/hub/src/sync/archiveSession.test.ts b/hub/src/sync/archiveSession.test.ts index fa5807962b..2455f1f026 100644 --- a/hub/src/sync/archiveSession.test.ts +++ b/hub/src/sync/archiveSession.test.ts @@ -15,11 +15,10 @@ function gateway(engine: SyncEngine): RpcGatewayStub { return (engine as unknown as { rpcGateway: RpcGatewayStub }).rpcGateway } -function createIo(rooms = new Map>()) { +function createIo() { return { of() { return { - adapter: { rooms }, to() { return { emit() {} } } @@ -28,10 +27,10 @@ function createIo(rooms = new Map>()) { } as never } -function createEngine(rooms = new Map>()) { +function createEngine() { const store = new Store(':memory:') - const engine = new SyncEngine(store, createIo(rooms), new RpcRegistry(), { broadcast() {} } as never) - return { store, engine, rooms } + const engine = new SyncEngine(store, createIo(), new RpcRegistry(), { broadcast() {} } as never) + return { store, engine } } function seedActiveSession( @@ -129,11 +128,10 @@ describe('archiveSession (#1203 in-flight CLI)', () => { } }) - it('refuses to archive when StopSession is already_gone but a CLI socket is still in the room', async () => { - const { engine, rooms } = createEngine() + it('refuses to archive when StopSession is already_gone but the session is still heartbeating', async () => { + const { engine } = createEngine() try { - const session = seedActiveSession(engine, 'zombie-socket', { machineId: 'machine-1' }) - rooms.set(`session:${session.id}`, new Set(['sock-1'])) + const session = seedActiveSession(engine, 'zombie-active', { machineId: 'machine-1' }) const rpc = gateway(engine) rpc.killSession = async (sessionId) => { throw missingKill(sessionId) @@ -150,10 +148,11 @@ describe('archiveSession (#1203 in-flight CLI)', () => { } }) - it('archives the classic #916 case: no kill handler, runner already_gone, no live socket', async () => { + it('archives the classic #916 case: no kill handler, runner already_gone, heartbeat already expired', async () => { const { engine } = createEngine() try { const session = seedActiveSession(engine, 'truly-gone', { machineId: 'machine-1' }) + engine.handleSessionEnd({ sid: session.id, time: Date.now() }) const rpc = gateway(engine) rpc.killSession = async (sessionId) => { throw missingKill(sessionId) @@ -171,11 +170,10 @@ describe('archiveSession (#1203 in-flight CLI)', () => { } }) - it('refuses to archive a connected unproven CLI when there is no machineId', async () => { - const { engine, rooms } = createEngine() + it('refuses to archive a heartbeating unproven CLI when there is no machineId', async () => { + const { engine } = createEngine() try { const session = seedActiveSession(engine, 'no-machine') - rooms.set(`session:${session.id}`, new Set(['sock-1'])) gateway(engine).killSession = async (sessionId) => { throw missingKill(sessionId) } @@ -190,10 +188,30 @@ describe('archiveSession (#1203 in-flight CLI)', () => { } }) - it('archives when KillSession and StopSession are both missing and no CLI socket remains', async () => { + it('refuses to archive when KillSession and StopSession are both missing but the session is still heartbeating', async () => { + const { engine } = createEngine() + try { + const session = seedActiveSession(engine, 'both-missing-live', { machineId: 'machine-1' }) + const rpc = gateway(engine) + rpc.killSession = async (sessionId) => { + throw missingKill(sessionId) + } + rpc.stopRunnerSession = async (machineId) => { + throw missingStop(machineId) + } + + await expect(engine.archiveSession(session.id)).rejects.toThrow(/not controllable/) + expect(engine.getSessionByNamespace(session.id, 'default')?.active).toBe(true) + } finally { + engine.stop() + } + }) + + it('archives when KillSession and StopSession are both missing and the heartbeat has expired', async () => { const { engine } = createEngine() try { - const session = seedActiveSession(engine, 'both-missing', { machineId: 'machine-1' }) + const session = seedActiveSession(engine, 'both-missing-dead', { machineId: 'machine-1' }) + engine.handleSessionEnd({ sid: session.id, time: Date.now() }) const rpc = gateway(engine) rpc.killSession = async (sessionId) => { throw missingKill(sessionId) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 887e628433..4a7a6fa07b 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -1787,7 +1787,8 @@ export class SyncEngine { // tiann/hapi#916: missing KillSession used to mean "CLI already gone". // After #1203, an in-flight pre-proof CLI can stay connected without // registering `${sessionId}:killSession`. Do not stamp archived while - // that process is still alive — try runner StopSession, then refuse. + // that process is still alive: try runner StopSession, then refuse if + // the session is still heartbeating. try { await this.rpcGateway.killSession(sessionId) this.handleSessionEnd({ sid: sessionId, time: Date.now() }) @@ -1817,8 +1818,9 @@ export class SyncEngine { this.handleSessionEnd({ sid: sessionId, time: Date.now() }) return } - // already_gone: runner does not have the pid. A live unproven - // socket must not be stamped archived (#1203 / dual-CLI). + // already_gone: runner does not have the pid. Fall through to + // the heartbeat check — do not trust `/cli` room membership + // (namespace token joins that room before tag/capability). } catch (error) { if (error instanceof SessionArchiveUncontrollableError) { throw error @@ -1829,7 +1831,13 @@ export class SyncEngine { } } - if (this.hasLiveCliSocket(sessionId)) { + // Unproven in-flight CLIs keep session-alive without KillSession. + // Counting raw room sockets is attacker-controlled (#1473 review). + // Counting only sessionRpcAuthorizedId sockets misses this CLI. + // Heartbeat is the hub-side liveness signal; expireInactive (~30s) + // clears it when the process is actually gone (#916). + const latest = this.sessionCache.getSession(sessionId) + if (latest?.active) { throw new SessionArchiveUncontrollableError(sessionId) } @@ -1837,22 +1845,6 @@ export class SyncEngine { this.handleSessionEnd({ sid: sessionId, time: Date.now() }) } - private hasLiveCliSocket(sessionId: string): boolean { - const of = this.io?.of - if (typeof of !== 'function') { - return false - } - try { - const nsp = of.call(this.io, '/cli') as { - adapter?: { rooms?: Map> } - } | undefined - const room = nsp?.adapter?.rooms?.get(`session:${sessionId}`) - return Boolean(room && room.size > 0) - } catch { - return false - } - } - /** * Apply the post-migration metadata flip in hapi.db: * - metadata.cursorSessionProtocol = 'acp' From 291e7bc40b34d477a3115e5b8d7ca3c21bdd5d35 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Wed, 12 Aug 2026 09:29:59 +0800 Subject: [PATCH 073/142] fix(test): stop runner integration suite from leaking detached process trees (#1515) (#1521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(test): stop runner integration suite from leaking detached process trees (#1515) The default CLI test run included runner.integration.test.ts, which spawns real detached runner/session process trees. A failing, timed-out, or interrupted test (or a plain runner stop) left those trees alive under PID 1 — on the Mac this accumulated ~600 Node/Bun/agent processes and several GiB of RSS over repeated runs. Test harness changes only; production runner session-preservation semantics are untouched: - Exclude runner.integration.test.ts from the default parallel unit-test suite; move it into a dedicated serial integration project (vitest.integration.config.ts, 'bun run test:integration'). The 20-session stress test is opt-in via HAPI_RUN_STRESS_TESTS=true. - Add a test-owned process/session registry (processRegistry.ts): every runner, runner-spawned session, and terminal-style child is registered immediately after spawn; afterEach/afterAll run two-stage cleanup (logical stopRunnerSession first, then bounded process-tree kill), followed by a marker sweep for agent grandchildren reparented to PID 1. - Add a per-run HAPI_TEST_MARKER env stamp + identity/secret env neutralization for test children (integrationEnv.ts) so outer HAPI/pi session variables never leak into test processes and the final audit can recognize test-owned processes by env alone. - Final suite audit in globalSetup teardown: reap anything still carrying the run marker and fail with PID/command diagnostics if anything cannot be reaped, before removing the temp home. - Regression coverage: a deliberately failing test registers a detached child and the follow-up audit must find zero test-owned processes. - CI: replace the dead .env.integration-test step with a dedicated integration job running the serial project. * refactor(test): drop unused killByChildProcess import and child field from registry * chore(test): raise integration hookTimeout to 60s for slow teardown hosts * fix(test): fail loudly when the process-table audit cannot scan; assert regression child death Bot review #1521 findings: - A failed `ps` scan (unsupported flags, buffer exhaustion, permissions) previously returned [] and silently disabled both teardown audit layers. It now throws; globalSetup teardown catches the scan error into the audit error (temp home is still removed) so the run fails visibly. - The regression audit test cleaned the leak with the reaper before asserting, and force-killed the fresh marked runner. The failing test's direct child PID is now asserted dead in afterEach right after registry cleanup (before the marker sweep), and the audit test stops its own runner gracefully before reaping. * fix(test): bound the logical cleanup phase so a hung runner cannot stall the hook Bot review #1521: stopRunnerSession carries the worker's 60s HTTP timeout (setup.ts raises HAPI_RUNNER_HTTP_TIMEOUT for the stress test), and the integration hook timeout is also 60s — N sequential stops could exhaust the hook budget before the process-tree fallback and marker sweep ran, recreating the very leak this change prevents. Logical shutdown is now parallel (Promise.allSettled over all tracked sessions) and the whole phase (stops + PID resolution) races against a 15s budget, so stage-2 tree-kill and the marker sweep always get their share of the hook window. * fix(test): bound graceful runner stop in hooks; keep credentials out of audit diagnostics Bot review #1521 (follow-up): - stopRunner()'s HTTP stop can burn the worker-wide 60s timeout on a hung-but-live runner, starving the marker sweep within the hook budget. afterEach/afterAll now race the graceful stop against a 10s bound; a runner that does not stop in time is force-reaped by the sweep (it carries the run marker) and the next beforeEach's alive-PID guard ignores any stale state file. - The env-bearing ps scan (ps eww) was also used for diagnostics, so the first 500 chars of a short-command process could print inherited credentials (CLI_API_TOKEN etc.) into teardown error logs. The scan now only identifies marked PIDs; command lines are fetched separately without 'e', falling back to '(command unavailable)' instead of the env dump. * fix(test): reap runner model-probe orphans before the zero-survivor inspection Bot review #1521 (Minor): inspect-before-reap. Applying it exposed a real race: each test's runner legitimately spawns marker-carrying children at startup (agent acp + agent --list-models model-catalog probes). Stopping the runner orphans them (ppid 1) with the run marker, so the audit test's OWN runner polluted the pure inspection with fresh probes spawned after the failing test's sweep window. - reapTestOwnedProcesses now re-kills every re-scan iteration instead of killing once and only re-scanning, so a process that survived its first SIGKILL (mid-exec) or spawned mid-kill is not given a free pass. - The regression audit test stops its runner, reaps (clearing its own legitimate orphan probes), then inspects: anything still marked is a genuine survivor the bounded reaper could not remove and fails the suite. Killable leaks from the failing test are already asserted dead in afterEach before the sweep runs. * fix(test): strictly bound the marker reaper; make per-test sweep unconditional and verified Bot review #1521 (follow-up): - The 10s reap deadline did not bound the awaited per-tree kills: each killProcessTreeByPid can wait up to 2s per PID, so several stuck processes could still exceed the 60s hook budget. Every process in a test-owned tree carries the marker (env is inherited), so tree-walking is unnecessary: the reaper now SIGKILLs every marked PID found by each scan, fire-and-forget, and re-scans every 250ms — the deadline strictly bounds the function. - The per-test sweep was skipped when the direct-child assertion failed first, and its survivors were ignored. afterEach now snapshots the regression-child state BEFORE the unconditional sweep, then verifies both the registry result and the sweep leftovers. * fix(test): replace it.fails regression with a direct assertion test Bot review #1521 (Minor): Vitest applies the it.fails expected-failure inversion after afterEach, so a broken registry assertion inside the hook would be masked as an expected failure, and the marker sweep would erase the evidence before the follow-up audit ran. The regression is now a normal test that registers a detached child at spawn time, deliberately performs NO per-test teardown, runs only the spawn-time registered cleanup, and asserts the child PID is dead. The afterEach no longer carries the registry-leak assertion (moved into the test body where it cannot be inverted); the per-test sweep assertion and the final audit test are unchanged. * fix(test): bound registry stage-2 tree-kills; require live regression fixture Bot review #1521 (follow-up): - Stage-2 killProcessTreeByPid awaits per descendant serially and can consume the whole 60s hook for a large/stuck tree. Signals are all delivered synchronously (children first) before any waiting, so racing the awaits against a 5s budget bounds the phase without skipping any kill; waitForAllDead still verifies the outcome. - The regression test could pass vacuously if its fixture exited during the startup delay (the registry exit listener would remove it before cleanup). It now asserts the child is alive before running cleanup. * fix(test): kill registered roots with bare synchronous SIGKILL, no pgrep walk Bot review #1521 (follow-up): racing the mapped killProcessTreeByPid calls against a timer does not bound the phase — evaluating the map invokes each call immediately, and each runs the recursive synchronous pgrep walk before its first await, which can consume the hook before the timer, runner stop, or marker sweep run. Stage-2 now SIGKILLs registered roots directly (fire-and-forget, no tree walk, no per-PID waits) and waits a bounded 5s for death. Descendants are reaped by the unconditional marker sweep immediately afterward — every descendant inherits the run marker, so tree-walking is unnecessary. * fix(test): drop duplicate process-death wait in registry cleanup Bot review #1521 (Minor): the duplicated waitForAllDead delayed the authoritative marker sweep by another 5s under the exact stuck-process condition the harness must handle. Keep the single bounded wait; the afterEach marker sweep remains the guarantee. --- .github/workflows/test.yml | 22 ++- cli/package.json | 2 + cli/src/runner/runner.integration.test.ts | 213 ++++++++++++++++++---- cli/src/test/auditTestProcesses.ts | 128 +++++++++++++ cli/src/test/globalSetup.ts | 29 +++ cli/src/test/integrationEnv.ts | 87 +++++++++ cli/src/test/processRegistry.ts | 165 +++++++++++++++++ cli/vitest.config.ts | 8 + cli/vitest.integration.config.ts | 51 ++++++ package.json | 1 + 10 files changed, 657 insertions(+), 49 deletions(-) create mode 100644 cli/src/test/auditTestProcesses.ts create mode 100644 cli/src/test/integrationEnv.ts create mode 100644 cli/src/test/processRegistry.ts create mode 100644 cli/vitest.integration.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b91a3a8f78..1ec7f0e1a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,13 +16,17 @@ jobs: - run: bun typecheck - run: bunx playwright install --with-deps chromium - run: bun run test:e2e -- terminal-wrap-fidelity.spec.ts - - name: Create integration test env - run: | - { - echo "HAPI_HOME=~/.hapi-dev-test" - echo "HAPI_API_URL=http://localhost:3006" - echo "CLI_API_TOKEN=${CLI_API_TOKEN:-dev-test-token}" - echo "HAPI_DAEMON_HTTP_TIMEOUT=60000" - echo "HAPI_DAEMON_HEARTBEAT_INTERVAL=30000" - } > cli/.env.integration-test - run: bun run test + + # Serial runner-integration suite: starts real detached runner/session + # process trees against an isolated temp hub, so it runs in its own job + # (never in the parallel unit-test path). See cli/vitest.integration.config.ts. + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install + - run: bun run test:cli:integration diff --git a/cli/package.json b/cli/package.json index c36d96d87c..6f50374e6f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -44,6 +44,8 @@ "tools:unpack": "bun run scripts/unpack-tools.ts", "update-homebrew-formula": "bun run scripts/update-homebrew-formula.ts", "test": "bun run tools:unpack && vitest run", + "test:integration": "bun run tools:unpack && vitest run --config vitest.integration.config.ts", + "test:integration:stress": "bun run tools:unpack && HAPI_RUN_STRESS_TESTS=true vitest run --config vitest.integration.config.ts", "test:win": "vitest run", "dev": "bun src/index.ts", "dev:local-server": "bun --env-file .env.dev-local-server src/index.ts", diff --git a/cli/src/runner/runner.integration.test.ts b/cli/src/runner/runner.integration.test.ts index 730c9c894f..f0941a4012 100644 --- a/cli/src/runner/runner.integration.test.ts +++ b/cli/src/runner/runner.integration.test.ts @@ -3,19 +3,24 @@ * * Tests the full flow of runner startup, session tracking, and shutdown * - * IMPORTANT: These tests MUST be run with the integration test environment: - * yarn test:integration-test-env + * IMPORTANT: These tests spawn real detached runner/session process trees + * and MUST be run through the dedicated serial integration project: * - * DO NOT run with regular 'npm test' or 'yarn test' - it will use the wrong environment - * and the runner will not work properly! + * bun run test:integration (runner lifecycle coverage) + * bun run test:integration:stress (+ the 20-session stress test) * - * The integration test environment uses .env.integration-test which sets: - * - HAPI_HOME=~/.hapi-dev-test (DIFFERENT from dev's ~/.hapi-dev!) - * - HAPI_API_URL=http://localhost:3006 (local hapi-hub) - * - CLI_API_TOKEN=... (must match the hub) + * They are EXCLUDED from the default parallel unit-test suite + * (`bun run test` / `vitest run`) — see vitest.config.ts and + * vitest.integration.config.ts. Every process the suite spawns is registered + * with testProcessRegistry immediately after spawn, so even a failing or + * interrupted test still reaps its children (two-stage: logical stop first, + * then bounded process-tree termination). The final audit lives in + * globalSetup teardown and fails the run if any test-owned process survives. + * + * The 20-session stress test is opt-in via HAPI_RUN_STRESS_TESTS=true. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { spawn } from 'child_process'; import { existsSync, unlinkSync, readFileSync, writeFileSync, readdirSync } from 'fs'; import path, { join } from 'path'; @@ -33,6 +38,9 @@ import { Metadata } from '@/api/types'; import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; import { getLatestRunnerLog } from '@/ui/logger'; import { isProcessAlive, isWindows, killProcess, killProcessByChildProcess } from '@/utils/process'; +import { buildTestChildEnv, testOwnedMarker } from '@/test/integrationEnv'; +import { trackChildProcess, trackRunnerPid, trackSession, cleanupAllRegisteredProcesses } from '@/test/processRegistry'; +import { findTestOwnedProcesses, reapTestOwnedProcesses } from '@/test/auditTestProcesses'; // Utility to wait for condition async function waitFor( @@ -81,20 +89,38 @@ async function isServerHealthy(): Promise { describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: 20_000 }, () => { let runnerPid: number; + /** Spawn a runner session and register it in the test-owned registry immediately. */ + async function spawnTrackedSession(directory: string, sessionId?: string): Promise { + const response = await spawnRunnerSession(directory, sessionId); + if (response?.sessionId) { + trackSession(response.sessionId, `runner-session:${response.sessionId}`); + } + return response; + } + beforeEach(async () => { // First ensure no runner is running by checking PID in metadata file await stopRunner() // Start fresh runner for this test // This will return and start a background process - we don't need to wait for it - void spawnHappyCLI(['runner', 'start'], { - stdio: 'ignore' + const runnerLauncher = spawnHappyCLI(['runner', 'start'], { + stdio: 'ignore', + // Test-scoped env: neutralizes any outer HAPI/pi session identity and + // stamps every child with the run's unique test marker. + env: buildTestChildEnv() }); + // Register immediately after spawn so cleanup runs even if this test fails + // before reaching its happy-path teardown. + trackChildProcess(runnerLauncher, 'runner-launcher'); // Wait for runner to write its state file (it needs to auth, setup, and start server) + // Also require the PID to actually be alive: a SIGKILLed runner (or a + // crashed one) can leave a stale runner.state.json behind, and reading it + // as "started" would make every control call in the test body fail. await waitFor(async () => { const state = await readRunnerState(); - return state !== null; + return state !== null && isProcessAlive(state.pid); }, 10_000, 250); // Wait up to 10 seconds, checking every 250ms const runnerState = await readRunnerState(); @@ -102,13 +128,45 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: throw new Error('Runner failed to start within timeout'); } runnerPid = runnerState.pid; + trackRunnerPid(runnerPid, 'runner'); console.log(`[TEST] Runner started for test: PID=${runnerPid}`); console.log(`[TEST] Runner log file: ${runnerState?.runnerLogPath}`); }); + /** Bounded wrapper around the graceful runner stop. */ + async function stopRunnerBounded(): Promise { + // stopRunner()'s HTTP stop can burn the worker-wide 60s timeout + // (HAPI_RUNNER_HTTP_TIMEOUT) on a hung-but-live runner, which would + // exhaust the hook budget before the marker sweep runs. Bound it: if the + // runner does not stop in time, the sweep below force-reaps it (it + // carries the run marker) and the next beforeEach's alive-PID guard + // ignores any stale state file. + await Promise.race([stopRunner(), new Promise((resolve) => setTimeout(resolve, 10_000))]) + } + afterEach(async () => { - await stopRunner() + // Two-stage cleanup must run BEFORE stopRunner() so the runner is still + // alive to logically stop tracked sessions and report their PIDs. + await cleanupAllRegisteredProcesses() + // Graceful stop (the runner removes its own state file), bounded so a + // hung runner cannot starve the sweep below. + await stopRunnerBounded() + // Marker sweep: sessions/agents can reparent to PID 1 before the registry + // tree-kill runs, so reap anything still carrying the run's test marker + // (same audit globalSetup teardown performs at the end of the run). This + // runs unconditionally and its survivors are verified, never ignored. + const leftovers = await reapTestOwnedProcesses(testOwnedMarker()) + expect( + leftovers, + 'test-owned processes survived the marker sweep' + ).toEqual([]); + }); + + afterAll(async () => { + await cleanupAllRegisteredProcesses() + await stopRunnerBounded() + await reapTestOwnedProcesses(testOwnedMarker()) }); it('should list sessions (initially empty)', async () => { @@ -143,7 +201,7 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: }); it('should spawn & stop a session via HTTP (not testing RPC route, but similar enough)', async () => { - const response = await spawnRunnerSession('/tmp', 'spawned-test-456'); + const response = await spawnTrackedSession('/tmp', 'spawned-test-456'); expect(response).toHaveProperty('success', true); expect(response).toHaveProperty('sessionId'); @@ -164,28 +222,32 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: expect(await stopRunnerSession('unknown-session-id')).toBe('still_alive'); }); - it('stress test: spawn / stop', { timeout: 60_000 }, async () => { - const promises = []; - const sessionCount = 20; - for (let i = 0; i < sessionCount; i++) { - promises.push(spawnRunnerSession('/tmp')); + it.skipIf(process.env.HAPI_RUN_STRESS_TESTS !== 'true')( + 'stress test: spawn / stop (opt-in via HAPI_RUN_STRESS_TESTS=true)', + { timeout: 60_000 }, + async () => { + const promises = []; + const sessionCount = 20; + for (let i = 0; i < sessionCount; i++) { + promises.push(spawnTrackedSession('/tmp')); + } + + // Wait for all sessions to be spawned + const results = await Promise.all(promises); + const sessionIds = results.map(r => r.sessionId); + + const sessions = await listRunnerSessions(); + expect(sessions).toHaveLength(sessionCount); + + // Stop all sessions + const stopResults = await Promise.all(sessionIds.map(sessionId => stopRunnerSession(sessionId))); + expect(stopResults.every(r => r === 'stopped' || r === 'already_gone'), 'Not all sessions reported stopped').toBe(true); + + // Verify all sessions are stopped + const emptySessions = await listRunnerSessions(); + expect(emptySessions).toHaveLength(0); } - - // Wait for all sessions to be spawned - const results = await Promise.all(promises); - const sessionIds = results.map(r => r.sessionId); - - const sessions = await listRunnerSessions(); - expect(sessions).toHaveLength(sessionCount); - - // Stop all sessions - const stopResults = await Promise.all(sessionIds.map(sessionId => stopRunnerSession(sessionId))); - expect(stopResults.every(r => r === 'stopped' || r === 'already_gone'), 'Not all sessions reported stopped').toBe(true); - - // Verify all sessions are stopped - const emptySessions = await listRunnerSessions(); - expect(emptySessions).toHaveLength(0); - }); + ); it('should handle runner stop request gracefully', async () => { await stopRunnerHttp(); @@ -202,8 +264,11 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: ], { cwd: '/tmp', detached: true, - stdio: 'ignore' + stdio: 'ignore', + env: buildTestChildEnv() }); + // Register immediately after spawn so cleanup runs even on failure. + trackChildProcess(terminalHappyProcess, 'terminal-session'); if (!terminalHappyProcess || !terminalHappyProcess.pid) { throw new Error('Failed to spawn terminal hapi process'); } @@ -211,7 +276,7 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: await new Promise(resolve => setTimeout(resolve, 5_000)); // Spawn a runner session - const spawnResponse = await spawnRunnerSession('/tmp', 'runner-session-bbb'); + const spawnResponse = await spawnTrackedSession('/tmp', 'runner-session-bbb'); // List all sessions const sessions = await listRunnerSessions(); @@ -245,7 +310,7 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: it('should update session metadata when webhook is called', async () => { // Spawn a session - const spawnResponse = await spawnRunnerSession('/tmp'); + const spawnResponse = await spawnTrackedSession('/tmp'); // Verify webhook was processed (session ID updated) const sessions = await listRunnerSessions(); @@ -261,9 +326,11 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: // Try to start another runner const secondChild = spawn('bun', ['src/index.ts', 'runner', 'start-sync'], { cwd: process.cwd(), - env: process.env, + env: buildTestChildEnv(), stdio: ['ignore', 'pipe', 'pipe'] }); + // Register immediately so the registry can reap it if the test fails. + trackChildProcess(secondChild, 'second-runner'); let output = ''; secondChild.stdout?.on('data', (data) => { @@ -287,7 +354,7 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: const promises = []; for (let i = 0; i < 3; i++) { promises.push( - spawnRunnerSession('/tmp') + spawnTrackedSession('/tmp') ); } @@ -463,6 +530,72 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: } }); + /** + * Regression coverage for issue #1515: a test that never reaches its own + * happy-path cleanup must not leak the detached children it registered at + * spawn time. The child is tracked immediately after spawn; the test body + * then runs ONLY the spawn-time registered cleanup and asserts the child is + * gone. + * + * A deliberately-failing (`it.fails`) variant would be weaker here: Vitest + * applies the expected-failure inversion after afterEach, so a broken + * registry assertion inside the hook would be masked as "expected". A + * normal test asserts directly. + */ + it('regression: registered detached child is reaped even when the test body never reaches its own cleanup', async () => { + const child = spawnHappyCLI([ + '--hapi-starting-mode', 'remote', + '--started-by', 'terminal' + ], { + cwd: '/tmp', + detached: true, + stdio: 'ignore', + env: buildTestChildEnv() + }); + // Register immediately after spawn — cleanup must run even though this + // test deliberately performs no per-test teardown of its own. + trackChildProcess(child, 'regression-terminal'); + if (!child.pid) { + throw new Error('Failed to spawn regression terminal hapi process'); + } + + // Give the detached child time to fully start (including its agent + // probes), so a leak would be real and observable. Require the fixture to + // actually be alive: if it exited on its own, the registry exit listener + // would remove it and the dead assertion below would pass vacuously. + await new Promise(resolve => setTimeout(resolve, 2_000)); + expect( + isProcessAlive(child.pid), + 'regression fixture exited before cleanup — test is vacuous' + ).toBe(true); + + // Simulate the failure path: only the spawn-time registered cleanup runs. + await cleanupAllRegisteredProcesses() + expect( + isProcessAlive(child.pid), + 'registered detached child survived registry cleanup' + ).toBe(false); + }); + + it('regression: final audit finds zero test-owned processes after the reaping regression test', async () => { + // The runner from this test's beforeEach legitimately spawns model-catalog + // probe children (agent --list-models / agent acp) at startup; stopping + // it orphans them with the run marker. Reap first to clear that noise, + // then INSPECT: anything still marked at this point is a genuine survivor + // the reaper could not remove within its bounded window and must fail the + // suite (same audit globalSetup teardown performs at the end of the run). + // Killable leaks from the reaping regression test are already gone here: + // its direct child is asserted dead in the test body, and its afterEach + // sweep re-kills for its full bounded window. + await stopRunnerBounded() + await reapTestOwnedProcesses(testOwnedMarker()) + const leftovers = findTestOwnedProcesses(testOwnedMarker()); + expect( + leftovers, + 'test-owned processes survived the bounded reaper — cleanup guarantee broken' + ).toEqual([]); + }); + // TODO: Add a test to see if a corrupted file will work // TODO: Test npm uninstall scenario - runner should gracefully handle when hapi is uninstalled diff --git a/cli/src/test/auditTestProcesses.ts b/cli/src/test/auditTestProcesses.ts new file mode 100644 index 0000000000..a0aaa4976a --- /dev/null +++ b/cli/src/test/auditTestProcesses.ts @@ -0,0 +1,128 @@ +/** + * Final audit for test-owned processes. + * + * The runner integration suite spawns real detached process trees. Even with + * the per-test registry (see `processRegistry.ts`), an orphan whose runner was + * already killed, or a child that escaped a crashing test, can survive the + * suite. This module is the last-resort backstop: it scans the live process + * table for the run's unique marker (`HAPI_TEST_MARKER=`, injected by + * `integrationEnv.ts` into every test child) and force-reaps whatever remains. + * + * The marker lives in the process environment, which survives reparenting to + * PID 1, so orphaned grandchildren are still recognized. Production processes + * never carry the marker and are never touched. + */ + +import { execFileSync } from 'node:child_process' + +export interface TestOwnedProcess { + pid: number + ppid: number + rssKb: number + command: string +} + +/** + * Scans for live processes whose environment dump contains `marker`. + * Returns an empty array on platforms without `ps eww` (Windows); THROWS on + * scan failure (unsupported flags, buffer exhaustion, permission errors) so + * the audit can never silently report "zero survivors" while detached + * test-owned processes remain alive. + * + * The environment-bearing scan is used ONLY to identify marked PIDs. + * Diagnostics (the `command` field) are fetched with a separate `ps` call + * WITHOUT `e`, so inherited credentials in the env dump never reach logs. + */ +export function findTestOwnedProcesses(marker: string): TestOwnedProcess[] { + if (process.platform === 'win32') return [] + + let output: string + try { + // `-eo` (not `-axo`): procps-ng 4.x rejects `-x` with "must set + // personality" on some Linux builds. `e` shows the environment after + // the command; `ww` removes width truncation so the env dump is not + // cut off. + output = execFileSync('ps', ['eww', '-eo', 'pid=,ppid=,rss=,command='], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (error) { + throw new Error( + `[test process audit] failed to inspect process table: ${error instanceof Error ? error.message : String(error)}` + ) + } + + const matchedPids: number[] = [] + const ppidByPid = new Map() + const rssByPid = new Map() + for (const line of output.split('\n')) { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/) + if (!match) continue + if (match[4].includes(marker)) { + const pid = Number(match[1]) + matchedPids.push(pid) + ppidByPid.set(pid, Number(match[2])) + rssByPid.set(pid, Number(match[3])) + } + } + if (matchedPids.length === 0) return [] + + // Fetch clean command lines (no environment) for diagnostics. + const commandByPid = new Map() + try { + const clean = execFileSync( + 'ps', + ['-p', matchedPids.join(','), '-o', 'pid=,command='], + { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + } + ) + for (const line of clean.split('\n')) { + const match = line.match(/^\s*(\d+)\s+(.*)$/) + if (match) { + commandByPid.set(Number(match[1]), match[2].trim()) + } + } + } catch { + // Diagnostics are best-effort; never fall back to the env dump. + } + + return matchedPids.map((pid) => ({ + pid, + ppid: ppidByPid.get(pid) ?? 0, + rssKb: rssByPid.get(pid) ?? 0, + command: (commandByPid.get(pid) ?? '(command unavailable)').slice(0, 500), + })) +} + +/** + * Force-reaps every process carrying `marker`, waiting a bounded window for + * them to disappear, and returns whatever still remains. + * + * Every process in a test-owned tree carries the marker (env is inherited), + * so there is no need to tree-walk: each scan finds the whole marked set and + * SIGKILLs it directly. Kills are fire-and-forget — no per-PID wait — so the + * 10s deadline strictly bounds this function even with many stuck processes. + * The loop re-runs on every re-scan so a process that survived its first + * SIGKILL (e.g. mid-exec, D-state) or spawned after the previous scan is + * never given a free pass. + */ +export async function reapTestOwnedProcesses(marker: string): Promise { + const deadline = Date.now() + 10_000 + let found = findTestOwnedProcesses(marker) + while (found.length > 0 && Date.now() < deadline) { + for (const { pid } of found) { + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already dead or racing exit; re-scan below decides. + } + } + await new Promise((resolve) => setTimeout(resolve, 250)) + found = findTestOwnedProcesses(marker) + } + return findTestOwnedProcesses(marker) +} diff --git a/cli/src/test/globalSetup.ts b/cli/src/test/globalSetup.ts index ed117b86d4..7bee0d46de 100644 --- a/cli/src/test/globalSetup.ts +++ b/cli/src/test/globalSetup.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url' import net from 'node:net' import { spawn, execSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' +import { reapTestOwnedProcesses } from './auditTestProcesses' +import { TEST_OWNED_MARKER_KEY } from './integrationEnv' // Workers can't inherit process.env from globalSetup, so we write config to a file // and let setupFile.ts read it in each worker. @@ -110,7 +112,34 @@ async function stopHubProcess(): Promise { export async function teardown() { await stopHubProcess() try { rmSync(TEST_CONFIG_FILE) } catch {} + + // Final audit: test children carry `HAPI_TEST_MARKER=` in their + // environment (see integrationEnv.ts). Anything still alive after the + // suites ran is a test-owned leak — reap it, then fail the run with + // PID/command diagnostics if something could not be reaped. The temp home + // is always removed so a leak cannot also accumulate DB rows on disk. + let auditError: Error | null = null + if (tmpHome && process.platform !== 'win32') { + try { + const leftovers = await reapTestOwnedProcesses(`${TEST_OWNED_MARKER_KEY}=${tmpHome}`) + if (leftovers.length > 0) { + const detail = leftovers + .map((p) => ` pid=${p.pid} ppid=${p.ppid} rss=${p.rssKb}KB ${p.command}`) + .join('\n') + auditError = new Error( + `[globalSetup] ${leftovers.length} test-owned process(es) survived teardown:\n${detail}` + ) + } + } catch (error) { + // A failed process-table scan must fail the run, never pass as a + // "clean" audit. + auditError = error instanceof Error ? error : new Error(String(error)) + } + } if (tmpHome) { rmSync(tmpHome, { recursive: true, force: true }) } + if (auditError) { + throw auditError + } } diff --git a/cli/src/test/integrationEnv.ts b/cli/src/test/integrationEnv.ts new file mode 100644 index 0000000000..f71213f07e --- /dev/null +++ b/cli/src/test/integrationEnv.ts @@ -0,0 +1,87 @@ +/** + * Test-child environment builder for the runner integration suite. + * + * Every real CLI child the suite spawns must run with this environment so + * that: + * + * 1. Identity variables of the outer HAPI/pi session (PI_SESSION_ID, + * HAPI_SESSION_ID, PM2 metadata, ...) never leak into test children. + * These are blanked rather than dropped because `spawnHappyCLI` merges + * `{ ...process.env, ...options.env }` — a blank value still wins over the + * inherited one, while a missing key would let the parent value through. + * 2. Every child carries a unique per-run marker (`HAPI_TEST_MARKER=`) + * that the final audit (see `auditTestProcesses.ts`) can use to recognize + * test-owned processes even after they have been orphaned/reparented to + * PID 1. + * + * The worker env already points at the isolated temporary hub (see + * `setup.ts`), so the hub credentials stay intact while session identity and + * well-known secrets are neutralized. + */ + +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const TEST_CONFIG_FILE = join(tmpdir(), 'hapi-test-config.json') + +/** Marker env key injected into every test child. Value is the run's tmpHome. */ +export const TEST_OWNED_MARKER_KEY = 'HAPI_TEST_MARKER' + +/** Keys/prefixes that identify the outer session and must never reach children. */ +const IDENTITY_ENV_PATTERNS: RegExp[] = [ + /^PI_/i, + /^HAPI_SESSION_/i, + /^PM2_/i, + /^pm_/, + /^PM_/, + /^HAPI_CLI_EXECUTABLE$/, +] + +/** Well-known secrets that must not leak from the dev environment into children. */ +const SECRET_ENV_PATTERNS: RegExp[] = [ + /^DB_PATH$/, + /^TELEGRAM_BOT_TOKEN$/, + /^SERVERCHAN_/i, + /^ELEVENLABS_/i, +] + +function isNeutralizedKey(key: string): boolean { + return ( + IDENTITY_ENV_PATTERNS.some((pattern) => pattern.test(key)) || + SECRET_ENV_PATTERNS.some((pattern) => pattern.test(key)) + ) +} + +/** + * Builds the environment for a test-spawned CLI child. + * + * Starts from `baseEnv` (defaults to the worker env, which already carries the + * isolated hub credentials injected by `setup.ts`), blanks identity/secret + * keys, and injects the per-run test marker. + */ +export function buildTestChildEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + for (const [key, value] of Object.entries(baseEnv)) { + if (value === undefined) continue + env[key] = isNeutralizedKey(key) ? '' : value + } + + const tmpHome = baseEnv.HAPI_HOME + if (!tmpHome) { + throw new Error('[test env] Missing HAPI_HOME — setup.ts must point the worker at the temp hub home first') + } + env[TEST_OWNED_MARKER_KEY] = tmpHome + return env +} + +/** + * The audit marker string for this run: `HAPI_TEST_MARKER=`. + * Matches the env dump produced by `ps eww`, which the final audit greps. + */ +export function testOwnedMarker(tmpHome?: string): string { + const home = tmpHome ?? process.env.HAPI_HOME + if (!home) { + throw new Error('[test env] Missing HAPI_HOME — cannot build test-owned marker') + } + return `${TEST_OWNED_MARKER_KEY}=${home}` +} diff --git a/cli/src/test/processRegistry.ts b/cli/src/test/processRegistry.ts new file mode 100644 index 0000000000..9dd812fe70 --- /dev/null +++ b/cli/src/test/processRegistry.ts @@ -0,0 +1,165 @@ +/** + * Test-owned process/session registry for the runner integration suite. + * + * The production runner intentionally starts sessions with `detached: true` + * so they survive runner restarts — that means stopping the runner (or a + * failing test) never reaps its session children by itself. This registry is + * the suite's ownership record: every runner, runner-spawned session, and + * terminal-style process created by a test must be registered **immediately + * after spawn** (not after happy-path assertions), so cleanup runs even when + * the test body fails, times out, or is interrupted. + * + * Cleanup is two-stage: + * 1. Logical shutdown — `stopRunnerSession` per tracked session id, which + * asks the runner to terminate the session's process tree. + * 2. Bounded fallback — any tracked process (or session pid still reported + * by the runner) that is still alive is force tree-killed. + * + * The runner itself is NOT killed here — the suite stops it gracefully via + * `stopRunner()` (which also removes its state file). The final safety net + * lives in `auditTestProcesses.ts`: the suite hooks sweep every process + * carrying the run's unique marker after each test, and the globalSetup + * teardown audit reaps anything that still escaped (e.g. an agent tree that + * reparented to PID 1 before the registry tree-kill ran). + */ + +import type { ChildProcess } from 'node:child_process' +import { isProcessAlive } from '../utils/process' +import { listRunnerSessions, stopRunnerSession } from '../runner/controlClient' + +export interface RegisteredProcess { + /** Human-readable label for diagnostics, e.g. `runner-launcher`, `terminal-session`. */ + label: string + kind: 'child' | 'runner' | 'session' + pid?: number + sessionId?: string +} + +const registered: RegisteredProcess[] = [] + +/** Registers a ChildProcess immediately after spawn; auto-removes on exit. */ +export function trackChildProcess(child: ChildProcess, label: string): ChildProcess { + if (!child.pid) return child + const entry: RegisteredProcess = { label, kind: 'child', pid: child.pid } + registered.push(entry) + child.once('exit', () => { + const index = registered.indexOf(entry) + if (index >= 0) registered.splice(index, 1) + }) + return child +} + +/** Registers a runner PID (from runner.state.json) for tree-cleanup. */ +export function trackRunnerPid(pid: number, label: string): void { + if (Number.isFinite(pid) && pid > 0) { + registered.push({ label, kind: 'runner', pid }) + } +} + +/** + * Registers a runner-spawned session by its HAPI session id, immediately when + * the spawn response arrives (the child PID is only known to the runner). + */ +export function trackSession(sessionId: string, label: string): void { + if (sessionId) { + registered.push({ label, kind: 'session', sessionId }) + } +} + +export function trackedEntries(): readonly RegisteredProcess[] { + return registered +} + +function waitForAllDead(pids: number[], timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + return new Promise((resolve) => { + const poll = () => { + const alive = pids.filter((pid) => isProcessAlive(pid)) + if (alive.length === 0 || Date.now() >= deadline) { + resolve() + return + } + setTimeout(poll, 100) + } + poll() + }) +} + +/** + * Two-stage cleanup of every registered resource. Safe to call repeatedly + * (afterEach + afterAll) — already-dead entries are skipped and pruned. + */ +export async function cleanupAllRegisteredProcesses(): Promise { + // The runner control API carries a long HTTP timeout (setup.ts raises + // HAPI_RUNNER_HTTP_TIMEOUT for the stress test), so the whole logical + // phase is bounded: a hung-but-live runner must not exhaust the hook + // budget before the process-tree fallback and marker sweep run. + const LOGICAL_PHASE_BUDGET_MS = 15_000 + + // Stage 1: logical shutdown through the runner control API (all sessions + // in parallel) + resolve any surviving session PIDs from the runner's + // own tracking for the fallback below. + const sessionEntries = registered.filter((entry) => entry.kind === 'session' && entry.sessionId) + const pidsToKill = new Set() + await Promise.race([ + (async () => { + await Promise.allSettled( + sessionEntries.map((entry) => stopRunnerSession(entry.sessionId!)) + ) + + const trackedSessionIds = new Set(sessionEntries.map((entry) => entry.sessionId)) + try { + const sessions = await listRunnerSessions() + for (const session of sessions) { + if ( + session?.happySessionId && + trackedSessionIds.has(session.happySessionId) && + typeof session.pid === 'number' && + isProcessAlive(session.pid) + ) { + pidsToKill.add(session.pid) + } + } + } catch { + // Runner unreachable — orphaned sessions are caught by the + // final audit. + } + })(), + new Promise((resolve) => setTimeout(resolve, LOGICAL_PHASE_BUDGET_MS)), + ]) + + // Stage 2: bounded termination for anything still alive. The runner + // itself is deliberately NOT killed here: it is always stopped + // via `stopRunner()` (graceful HTTP stop, which also removes its state + // file). SIGKILLing the runner would leave a stale runner.state.json that + // the next test's beforeEach can mistake for a live runner. + for (const entry of registered) { + if (entry.kind === 'session' || entry.kind === 'runner') continue + if (entry.pid && isProcessAlive(entry.pid)) { + pidsToKill.add(entry.pid) + } + } + // Kill registered roots with a bare synchronous SIGKILL: no recursive + // pgrep tree walk (unbounded under a large tree, and it would run before + // any await could race it) and no per-PID waits. Descendants are reaped + // by the unconditional marker sweep the suite runs right after this + // cleanup — every descendant inherits the run marker. + for (const pid of pidsToKill) { + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already dead or racing exit; the wait below re-checks. + } + } + + await waitForAllDead([...pidsToKill], 5_000) + + // Prune dead entries so the registry does not grow across tests. + for (let i = registered.length - 1; i >= 0; i--) { + const entry = registered[i] + const pid = entry.pid + if (!pid || !isProcessAlive(pid)) { + registered.splice(i, 1) + } + } +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index 70469ff5d3..89c9432a85 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -6,6 +6,14 @@ export default defineConfig({ globals: false, environment: 'node', include: ['src/**/*.test.ts'], + exclude: [ + // Runner integration tests spawn real detached runner/session + // process trees and must run serially through the dedicated + // integration project (`bun run test:integration`, see + // vitest.integration.config.ts), not inside the parallel + // unit-test suite. + '**/runner.integration.test.ts', + ], globalSetup: './src/test/globalSetup.ts', setupFiles: './src/test/setup.ts', coverage: { diff --git a/cli/vitest.integration.config.ts b/cli/vitest.integration.config.ts new file mode 100644 index 0000000000..59806a3876 --- /dev/null +++ b/cli/vitest.integration.config.ts @@ -0,0 +1,51 @@ +/** + * Dedicated, serial project for the runner integration suite. + * + * `runner.integration.test.ts` starts real detached runner/session process + * trees against the isolated temporary hub. It must never run inside the + * default parallel unit-test suite (see the exclude in `vitest.config.ts`); + * run it explicitly with: + * + * bun run test:integration # serial runner lifecycle coverage + * bun run test:integration:stress # + the 20-session stress test + * + * The whole file runs in a single worker (`fileParallelism: false`) so + * resource ownership stays unambiguous and the suite-level registry cleanup + * (see `src/test/processRegistry.ts`) is authoritative. + */ +import { defineConfig } from 'vitest/config' +import { resolve } from 'node:path' + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['src/runner/runner.integration.test.ts'], + globalSetup: './src/test/globalSetup.ts', + setupFiles: './src/test/setup.ts', + // Real detached process trees: never parallelize this suite. + fileParallelism: false, + // beforeEach starts a real runner (state-file wait can exceed the + // default 5s hook budget on slow machines); afterEach runs the + // two-stage cleanup + marker sweep, which can take longer on hosts + // with slow process teardown. + testTimeout: 20_000, + hookTimeout: 60_000, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/**', + 'dist/**', + '**/*.d.ts', + '**/*.config.*', + '**/mockData/**', + ], + }, + }, + resolve: { + alias: { + '@': resolve('./src'), + }, + }, +}) diff --git a/package.json b/package.json index 0b1bfc5f9e..3b269a041d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "typecheck:web": "cd web && bun run typecheck", "test": "bun run test:cli && bun run test:hub && bun run test:web && bun run test:shared", "test:cli": "cd cli && bun run test", + "test:cli:integration": "cd cli && bun run test:integration", "test:hub": "cd hub && bun run test", "test:web": "cd web && bun run test", "test:shared": "cd shared && bun run test", From c1ceb83ec21c31d990fbea66718bdf2327edc62c Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:30:37 +0100 Subject: [PATCH 074/142] fix(web): fail-closed scheme-less markdown file links (#1519) * fix(web): fail-closed scheme-less markdown file links Never paint a blue SPA dead-end for path-like hrefs after #1142. Route workspace file targets (relative, abs, ~/ when expandable) through FilePathAnchor; keep real app routes navigable; render everything else path-like as inert text. Defense in plus expanded remark rewrite. Refs #1452 Co-authored-by: Cursor * test(web): fixture for fail-closed markdown file-link dogfood Visual cases for #1452: preview blues vs inert dead paths vs SPA routes. Co-authored-by: Cursor * fix(web): harden fail-closed markdown href policy for review findings Stop rewriting POSIX abs in remark so can workspace-check; tighten SPA allowlist; resolve .. before containment. * fix(web): fail-closed Windows absolute markdown file links Drive paths looked scheme-bearing and skipped containment; leave them for with workspace checks. * fix(web): autolink Windows paths as raw hrefs for containment Bare/inline Windows abs become anchors without hapi-file rewrite so can classify; compare containment case-insensitively. * fix(web): encode Windows file links as hapi-file-candidate Backslash paths were URI-normalized to %5C before ; candidate encoding preserves the path for workspace classification. * fix(web): satisfy InertMarkdownHref href type for candidate paths * fix(web): resolve ~/ against /root workspaces in markdown hrefs * fix(web): reject non-Windows hapi-file-candidate payloads * fix(web): decode percent-encoded markdown paths before containment * fix(web): fail-closed empty hapi-file-candidate hrefs * fix(web): honor Vite BASE_URL and normalize candidate scheme detection --------- Co-authored-by: Cursor --- ...markdown-file-link-failclosed-fixture.html | 19 ++ .../markdown-file-link-failclosed-fixture.tsx | 79 ++++++ .../assistant-ui/markdown-a.test.tsx | 198 +++++++++++++-- .../components/assistant-ui/markdown-text.tsx | 95 +++++-- web/src/lib/markdown-href-policy.test.ts | 233 ++++++++++++++++++ web/src/lib/markdown-href-policy.ts | 227 +++++++++++++++++ web/src/lib/remark-file-path-links.test.ts | 42 +++- web/src/lib/remark-file-path-links.ts | 85 ++++++- 8 files changed, 913 insertions(+), 65 deletions(-) create mode 100644 web/e2e-fixtures/markdown-file-link-failclosed-fixture.html create mode 100644 web/e2e-fixtures/markdown-file-link-failclosed-fixture.tsx create mode 100644 web/src/lib/markdown-href-policy.test.ts create mode 100644 web/src/lib/markdown-href-policy.ts diff --git a/web/e2e-fixtures/markdown-file-link-failclosed-fixture.html b/web/e2e-fixtures/markdown-file-link-failclosed-fixture.html new file mode 100644 index 0000000000..94404a88cf --- /dev/null +++ b/web/e2e-fixtures/markdown-file-link-failclosed-fixture.html @@ -0,0 +1,19 @@ + + + + + + HAPI markdown file-link fail-closed fixture (#1452) + + + +
+ + + diff --git a/web/e2e-fixtures/markdown-file-link-failclosed-fixture.tsx b/web/e2e-fixtures/markdown-file-link-failclosed-fixture.tsx new file mode 100644 index 0000000000..481fbc3dd0 --- /dev/null +++ b/web/e2e-fixtures/markdown-file-link-failclosed-fixture.tsx @@ -0,0 +1,79 @@ +/* + * Visual fixture for #1452 fail-closed markdown file links. + * Chat-mode MarkdownRenderer (+ HappyChatContext) so FilePathAnchor can paint. + */ + +import React from 'react' +import ReactDOM from 'react-dom/client' +import { createMemoryHistory, createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router' +import '../src/index.css' +import { I18nProvider } from '../src/lib/i18n-context' +import { MarkdownRenderer } from '../src/components/MarkdownRenderer' +import { HappyChatProvider, type HappyChatContextValue } from '../src/components/AssistantChat/context' +import type { ApiClient } from '../src/api/client' + +const SAMPLE = `## Fail-closed markdown file links (#1452) + +Allowlisted relative (preview): [docs](docs/foo.md) + +Absolute in workspace (preview): [abs](/home/ada/coding/hapi/docs/a.md) + +Tilde in workspace (preview): [tilde](~/coding/hapi/docs/a.md) + +Fragment (preview): [frag](docs/foo.md#section) + +Outside workspace (inert, not blue): [etc](/etc/passwd.sh) + +No extension (inert): [bare](docs/foo) + +Parent escape (inert): [up](../escape.md) + +Real app route (SPA): [settings](/settings) + +Hash / query (SPA): [hash](#section) · [query](?q=1) +` + +function chatValue(): HappyChatContextValue { + return { + api: {} as ApiClient, + sessionId: 'fixture-session', + metadata: { path: '/home/ada/coding/hapi', host: 'local' }, + terminalToolDisplayMode: 'compact', + disabled: false, + onRefresh: () => {}, + hasMoreMessages: false, + isSyncingTail: false, + isLoadingMoreMessages: false, + loadOlderMessagesPreservingScroll: async () => 'loaded', + } +} + +function FixtureBody() { + return ( +
+
+

Chat surface (HappyChatContext + workspace path)

+ + + +
+
+ ) +} + +const rootRoute = createRootRoute({ component: FixtureBody }) +const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), +}) + +const rootEl = document.getElementById('root') +if (rootEl) { + ReactDOM.createRoot(rootEl).render( + + + + + + ) +} diff --git a/web/src/components/assistant-ui/markdown-a.test.tsx b/web/src/components/assistant-ui/markdown-a.test.tsx index 8ab2216815..d2053f0c7d 100644 --- a/web/src/components/assistant-ui/markdown-a.test.tsx +++ b/web/src/components/assistant-ui/markdown-a.test.tsx @@ -13,7 +13,18 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, cleanup, act, waitFor } from '@testing-library/react' import React from 'react' import { defaultComponents, classifyScheme, denyOnlyTransform, UriConfirmProvider } from '@/components/assistant-ui/markdown-text' +import { HappyChatProvider, type HappyChatContextValue } from '@/components/AssistantChat/context' import { I18nProvider } from '@/lib/i18n-context' +import type { ApiClient } from '@/api/client' + +const navigate = vi.fn() +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router') + return { + ...actual, + useNavigate: () => navigate, + } +}) // defaultComponents.a is the memoized A component. const AnchorComponent = (defaultComponents as Record).a as React.ComponentType< @@ -24,14 +35,33 @@ const AnchorComponent = (defaultComponents as Record).a as Reac // Previously
had a localHook fallback for bare renders, but that fallback // added a storage listener per link (N links → N+1 listeners). The fallback is // removed; tests must provide the context instead. -function renderA(props: React.ComponentPropsWithoutRef<'a'>) { - return render( +function renderA(props: React.ComponentPropsWithoutRef<'a'>, chat?: HappyChatContextValue) { + const tree = ( ) + return render( + chat ? {tree} : tree + ) +} + +function chatContext(overrides: Partial = {}): HappyChatContextValue { + return { + api: {} as ApiClient, + sessionId: 'session-1', + metadata: { path: '/home/ada/coding/hapi', host: 'local' }, + terminalToolDisplayMode: 'compact', + disabled: false, + onRefresh: () => {}, + hasMoreMessages: false, + isSyncingTail: false, + isLoadingMoreMessages: false, + loadOlderMessagesPreservingScroll: async () => 'loaded', + ...overrides, + } } const STORAGE_KEY = 'hapi-allowed-schemes' @@ -216,20 +246,17 @@ describe('markdown component — click handler', () => { }) }) -// ── relative / no-scheme hrefs — regression guard ──────────────────────────── +// ── relative / no-scheme hrefs — fail-closed (#1452) ───────────────────────── // -// Finding 2: denyOnlyTransform passes relative hrefs through unchanged (no colon -// → not a scheme URL), but the onClick handler called classifyScheme(href) -// which returned 'deny' for inputs with no valid scheme → preventDefault was -// called → relative/internal links were silently blocked. +// Finding 2 (historical): denyOnlyTransform passes relative hrefs through, but +// classifyScheme returned 'deny' for no-scheme inputs → preventDefault blocked +// internal links. Fixed by treating scheme-less as 'iana' when SPA-safe. // -// Fix: must detect hrefs that have no scheme and treat them as 'iana' so the -// browser/router can navigate normally. - -describe('markdown component — relative / no-scheme hrefs navigate normally', () => { - // Each of these hrefs has no URL scheme. Clicks must NOT be prevented. - // We verify by checking that preventDefault is NOT called on the click event. +// #1452: scheme-less path-like hrefs that are NOT real app routes must not +// remain clickable SPA dead-ends. They become inert s (or FilePathAnchor +// when they resolve to a workspace file). +describe('markdown component — SPA-safe no-scheme hrefs still navigate', () => { function clickAndCheckNotPrevented(href: string) { renderA({ href, children: 'link' }) const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) @@ -243,10 +270,6 @@ describe('markdown component — relative / no-scheme hrefs navigate normall clickAndCheckNotPrevented('/settings') }) - it('./foo → click not prevented (relative-path link)', () => { - clickAndCheckNotPrevented('./foo') - }) - it('#section → click not prevented (hash fragment link)', () => { clickAndCheckNotPrevented('#section') }) @@ -255,14 +278,7 @@ describe('markdown component — relative / no-scheme hrefs navigate normall clickAndCheckNotPrevented('?q=1') }) - it('/path:colon → click not prevented (path with colon, no scheme)', () => { - // "/" appears before ":" so this is a path, not a scheme. - clickAndCheckNotPrevented('/path:colon') - }) - it('//example.com → click not prevented (protocol-relative URL, no colon)', () => { - // Protocol-relative URLs have no colon; browsers navigate them as the - // current origin's protocol, same as any other relative href. clickAndCheckNotPrevented('//example.com/path') }) @@ -271,6 +287,140 @@ describe('markdown component — relative / no-scheme hrefs navigate normall }) }) +describe('markdown component — fail-closed path-like hrefs (#1452)', () => { + it('renders ./foo as inert text (not a navigable )', () => { + renderA({ href: './foo', children: 'dead' }) + expect(document.querySelector('a')).toBeNull() + const inert = document.querySelector('.aui-md-a-inert') + expect(inert).not.toBeNull() + expect(inert!.getAttribute('title')).toBe('./foo') + expect(inert!.textContent).toBe('dead') + }) + + it('renders /home/... absolute file href as inert without chat context', () => { + renderA({ href: '/home/ada/proj/docs/a.md', children: 'abs' }) + expect(document.querySelector('a')).toBeNull() + expect(document.querySelector('.aui-md-a-inert')?.textContent).toBe('abs') + }) + + it('renders ~/... as inert without workspace metadata', () => { + renderA({ href: '~/proj/docs/a.md', children: 'tilde' }) + expect(document.querySelector('a')).toBeNull() + expect(document.querySelector('.aui-md-a-inert')?.textContent).toBe('tilde') + }) + + it('renders /path:colon as inert (path-like, not an app route)', () => { + renderA({ href: '/path:colon', children: 'weird' }) + expect(document.querySelector('a')).toBeNull() + expect(document.querySelector('.aui-md-a-inert')).not.toBeNull() + }) + + it('routes in-workspace absolute file href to FilePathAnchor when chat is present', () => { + renderA( + { href: '/home/ada/coding/hapi/docs/a.md', children: 'abs' }, + chatContext() + ) + const link = document.querySelector('a') + expect(link).not.toBeNull() + expect(link!.getAttribute('href')).toContain('/sessions/session-1/file?') + expect(document.querySelector('.aui-md-a-inert')).toBeNull() + }) + + it('keeps outside-workspace absolute file href inert even with chat present', () => { + renderA( + { href: '/etc/passwd.sh', children: 'etc' }, + chatContext() + ) + expect(document.querySelector('a')).toBeNull() + expect(document.querySelector('.aui-md-a-inert')?.textContent).toBe('etc') + }) + + it('keeps outside-workspace Windows absolute href inert despite drive colon looking like a scheme', () => { + renderA( + { href: 'D:/outside/secret.ts#L1', children: 'win' }, + chatContext() + ) + expect(document.querySelector('a')).toBeNull() + expect(document.querySelector('.aui-md-a-inert')?.textContent).toBe('win') + }) + + it('routes hapi-file-candidate Windows href through containment to FilePathAnchor', () => { + const path = 'C:\\Users\\ada\\coding\\hapi\\docs\\a.md' + renderA( + { + href: 'hapi-file-candidate:' + encodeURIComponent(path), + children: 'win', + }, + chatContext({ + metadata: { path: 'C:\\Users\\ada\\coding\\hapi', host: 'local' }, + }) + ) + const link = document.querySelector('a') + expect(link).not.toBeNull() + expect(link!.getAttribute('href')).toContain('/sessions/session-1/file?') + }) + + it('renders non-Windows hapi-file-candidate payloads as inert (no SPA navigate bypass)', () => { + renderA( + { + href: 'hapi-file-candidate:' + encodeURIComponent('/settings'), + children: 'spoof', + }, + chatContext() + ) + expect(document.querySelector('a')).toBeNull() + expect(document.querySelector('.aui-md-a-inert')?.textContent).toBe('spoof') + }) + + it('renders empty hapi-file-candidate payload as inert (no custom-scheme confirm)', () => { + renderA({ href: 'hapi-file-candidate:', children: 'empty' }, chatContext()) + expect(document.querySelector('a')).toBeNull() + expect(document.querySelector('.aui-md-a-inert')?.textContent).toBe('empty') + }) + + it('renders uppercase hapi-file-candidate scheme as inert when payload is empty', () => { + renderA({ href: 'HAPI-FILE-CANDIDATE:', children: 'upper' }, chatContext()) + expect(document.querySelector('a')).toBeNull() + expect(document.querySelector('.aui-md-a-inert')?.textContent).toBe('upper') + }) + + it('renders percent-encoded hapi-file-candidate scheme as inert when payload is empty', () => { + renderA({ href: 'hapi%2Dfile%2Dcandidate:', children: 'enc' }, chatContext()) + expect(document.querySelector('a')).toBeNull() + expect(document.querySelector('.aui-md-a-inert')?.textContent).toBe('enc') + }) + + it('treats percent-encoded backslash Windows href as inert when outside workspace', () => { + renderA( + { href: 'D:%5Coutside%5Csecret.ts', children: 'win' }, + chatContext() + ) + expect(document.querySelector('a')).toBeNull() + expect(document.querySelector('.aui-md-a-inert')?.textContent).toBe('win') + }) + + it('expands ~/ and routes to FilePathAnchor when workspace metadata is present', () => { + renderA( + { href: '~/coding/hapi/docs/a.md', children: 'tilde' }, + chatContext() + ) + const link = document.querySelector('a') + expect(link).not.toBeNull() + expect(link!.getAttribute('href')).toContain('/sessions/session-1/file?') + }) + + it('keeps allowlisted relative file href as FilePathAnchor with chat', () => { + renderA({ href: 'docs/foo.md', children: 'rel' }, chatContext()) + expect(document.querySelector('a')!.getAttribute('href')).toContain('/sessions/session-1/file?') + }) + + it('still renders /settings as a real navigable link with chat present', () => { + renderA({ href: '/settings', children: 'settings' }, chatContext()) + expect(document.querySelector('a')!.getAttribute('href')).toBe('/settings') + expect(document.querySelector('.aui-md-a-inert')).toBeNull() + }) +}) + // ── intra-tab cross-provider sync (schemeListeners emitter) ────────────────── // // P7e.1 added a module-level `schemeListeners: Set` so that diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 78129c3c26..2a201511ca 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -25,7 +25,8 @@ import { useCodeWrap } from '@/hooks/useCodeWrap' import { CopyIcon, CheckIcon, WrapIcon } from '@/components/icons' import { useTranslation } from '@/lib/use-translation' import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' -import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links' +import { decodeFilePathCandidateHref, decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links' +import { classifyNoSchemeHref } from '@/lib/markdown-href-policy' import { remarkSessionPathLinks } from '@/lib/remark-session-path-links' import { buildSessionReferencePath, parseSessionPathHref } from '@/lib/sessionReference' import { UriConfirmDialog } from '@/components/UriConfirmDialog' @@ -494,25 +495,23 @@ function Code(props: ComponentPropsWithoutRef<'code'>) { } function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: string; sessionId: string }) { + const { filePath, sessionId, ...anchorProps } = props const navigate = useNavigate() - const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel - const search = new URLSearchParams({ - path: encodeBase64(props.filePath), - origin: 'chat', - }).toString() - const href = `/sessions/${encodeURIComponent(props.sessionId)}/file?${search}` + const rel = anchorProps.target === '_blank' ? (anchorProps.rel ?? 'noreferrer') : anchorProps.rel + const search = new URLSearchParams({ path: encodeBase64(filePath), origin: 'chat' }).toString() + const href = `/sessions/${encodeURIComponent(sessionId)}/file?${search}` const handleClick = (event: MouseEvent) => { - props.onClick?.(event) + anchorProps.onClick?.(event) if (event.defaultPrevented) return if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return event.preventDefault() void navigate({ to: '/sessions/$sessionId/file', - params: { sessionId: props.sessionId }, + params: { sessionId }, search: { - path: encodeBase64(props.filePath), + path: encodeBase64(filePath), origin: 'chat', } }) @@ -520,11 +519,11 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin return ( ) } @@ -561,8 +560,10 @@ function SessionPathAnchor(props: ComponentPropsWithoutRef<'a'> & { targetSessio /** * Anchor component with URI scheme policy enforcement. * - * - Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1): passed through - * without interception so the browser or SPA router can navigate normally. + * - Scheme-less hrefs (#1452 fail-closed): known app routes (`/settings`, `#`, + * `?`, `/sessions/…`) stay SPA-navigable; workspace file targets open via + * FilePathAnchor; any other path-like href renders as inert (non-clickable) + * text so we never paint a blue link that SPA-404s. * - IANA safe schemes (https/http/mailto/irc/ircs/xmpp): navigate directly. * - Deny schemes (javascript/data/vbscript/file): silently block. denyOnlyTransform * already strips the href to "", so href="" in DOM (belt-and-suspenders onClick @@ -575,6 +576,19 @@ function SessionPathAnchor(props: ComponentPropsWithoutRef<'a'> & { targetSessio * which uses useNavigate for SPA routing. * - Session citation paths (`/sessions/`): SessionPathAnchor SPA navigation. */ +function InertMarkdownHref(props: { href: string; children?: ReactNode; className?: string }) { + // Plain/muted — intentionally not an , so middle-click / copy-link can't + // invent a dead SPA route either. + return ( + + {props.children} + + ) +} + function A(props: ComponentPropsWithoutRef<'a'>) { const chat = useOptionalHappyChatContext() // useContext must be called unconditionally before any early return so that @@ -589,6 +603,8 @@ function A(props: ComponentPropsWithoutRef<'a'>) { // (or supply a mock UriConfirmContext.Provider). const ctx = useContext(UriConfirmContext) const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null + const candidatePath = + typeof props.href === 'string' ? decodeFilePathCandidateHref(props.href) : null const targetSessionId = typeof props.href === 'string' ? parseSessionPathHref(props.href) : null const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel @@ -603,15 +619,60 @@ function A(props: ComponentPropsWithoutRef<'a'>) { return } + const { onClick, href, ...rest } = props + + // Windows candidate (or raw / %5C-normalized drive path): classify with workspace + // before painting FilePathAnchor or treating `C:` as a custom URI scheme. + // Candidates are Windows-only; reject empty / non-drive payloads fail-closed + // (do not fall through to custom-scheme confirmation for this scheme). + const isCandidateHref = href ? normalizedScheme(href) === 'hapi-file-candidate' : false + if (isCandidateHref && (!candidatePath || !/^[A-Za-z]:[\\/]/.test(candidatePath))) { + return ( + + {props.children} + + ) + } + + const windowsPathFromHref = (() => { + if (candidatePath) return candidatePath + if (!href) return null + if (/^[A-Za-z]:[\\/]/.test(href)) return href + // mdast→hast may percent-encode backslashes before props.href arrives. + if (/^[A-Za-z]:(?:%5[Cc]|\/)/.test(href)) { + try { + return decodeURIComponent(href) + } catch { + return null + } + } + return null + })() + + if (windowsPathFromHref || (href && !hasScheme(href))) { + const decision = classifyNoSchemeHref(windowsPathFromHref ?? href!, { + workspacePath: chat?.metadata?.path ?? null, + }) + if (decision.action === 'file') { + if (!chat) { + return {props.children} + } + return + } + if (decision.action === 'inert') { + return {props.children} + } + // action === 'navigate' → fall through (only for non-Windows scheme-less SPA) + } + const isAllowed = ctx?.isAllowed ?? (() => false) - const { onClick, href, ...rest } = props - // Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) must not be + // Relative / no-scheme hrefs that passed fail-closed as SPA-safe must not be // classified via classifyScheme — it returns 'deny' for inputs with no valid // scheme, which previously caused the onClick handler to preventDefault and // silently break all relative markdown links. Treat them as 'iana' so the // browser or SPA router can navigate normally. - const isRelative = href ? !hasScheme(href) : false + const isRelative = href ? (!hasScheme(href) || Boolean(windowsPathFromHref)) : false const classification = href && !isRelative ? classifyScheme(href) : 'iana' const colonIdx = href ? href.indexOf(':') : -1 const scheme = colonIdx > 0 && !isRelative ? href!.slice(0, colonIdx).toLowerCase() : '' diff --git a/web/src/lib/markdown-href-policy.test.ts b/web/src/lib/markdown-href-policy.test.ts new file mode 100644 index 0000000000..5aff5b11cf --- /dev/null +++ b/web/src/lib/markdown-href-policy.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest' +import { + classifyNoSchemeHref, + expandTildePath, + isKnownSpaHref, + splitHrefMeta, +} from '@/lib/markdown-href-policy' + +describe('splitHrefMeta', () => { + it('strips #fragment for file targets', () => { + expect(splitHrefMeta('docs/foo.md#section')).toEqual({ + path: 'docs/foo.md', + suffix: '#section', + }) + }) + + it('strips query before fragment when both present', () => { + expect(splitHrefMeta('docs/foo.md?x=1#y')).toEqual({ + path: 'docs/foo.md', + suffix: '?x=1#y', + }) + }) +}) + +describe('isKnownSpaHref', () => { + it.each([ + '/settings', + '/settings/general', + '/sessions', + '/sessions/abc-def', + '/sessions/abc/file', + '/sessions/abc/files', + '/sessions/abc/terminal', + '/browse', + '/share', + '#section', + '?q=1', + '/', + ])('treats %s as SPA', (href) => { + expect(isKnownSpaHref(href)).toBe(true) + }) + + it.each([ + '/home/user/proj/docs/a.md', + '~/proj/docs/a.md', + 'docs/foo.md', + './foo', + '../escape.md', + '//example.com/path', + '/settings/typo', + '/browse/extra', + '/sessions/id/unknown', + '/share/extra', + ])('does not treat %s as SPA', (href) => { + expect(isKnownSpaHref(href)).toBe(false) + }) + + it('strips Vite BASE_URL prefix before SPA allowlist checks', () => { + expect(isKnownSpaHref('/hapi/settings', { baseUrl: '/hapi/' })).toBe(true) + expect(isKnownSpaHref('/hapi/sessions/abc/file', { baseUrl: '/hapi/' })).toBe(true) + expect(isKnownSpaHref('/hapi/settings/typo', { baseUrl: '/hapi/' })).toBe(false) + }) +}) + +describe('expandTildePath', () => { + it('expands ~/ against /home/ workspace', () => { + expect(expandTildePath('~/coding/hapi/docs/a.md', '/home/ada/coding/hapi')).toBe( + '/home/ada/coding/hapi/docs/a.md' + ) + }) + + it('expands ~/ against /root workspaces', () => { + expect(expandTildePath('~/hapi/docs/a.md', '/root/hapi')).toBe('/root/hapi/docs/a.md') + }) + + it('returns null without workspace metadata', () => { + expect(expandTildePath('~/docs/a.md', null)).toBeNull() + }) +}) + +describe('classifyNoSchemeHref — fail-closed (#1452)', () => { + const workspace = '/home/ada/coding/hapi' + + it('keeps allowlisted relative file targets as file preview', () => { + expect(classifyNoSchemeHref('docs/foo.md')).toEqual({ + action: 'file', + path: 'docs/foo.md', + }) + }) + + it('opens ./prefixed allowlisted files in preview', () => { + expect(classifyNoSchemeHref('./diagram.mmd')).toEqual({ + action: 'file', + path: './diagram.mmd', + }) + }) + + it('strips #fragment and still opens allowlisted relative files', () => { + expect(classifyNoSchemeHref('docs/foo.md#section')).toEqual({ + action: 'file', + path: 'docs/foo.md', + }) + }) + + it('routes in-workspace absolute paths to file preview', () => { + expect(classifyNoSchemeHref('/home/ada/coding/hapi/docs/a.md', { workspacePath: workspace })).toEqual({ + action: 'file', + path: '/home/ada/coding/hapi/docs/a.md', + }) + }) + + it('expands in-workspace ~/ paths to absolute file preview targets', () => { + expect(classifyNoSchemeHref('~/coding/hapi/docs/a.md', { workspacePath: workspace })).toEqual({ + action: 'file', + path: '/home/ada/coding/hapi/docs/a.md', + }) + }) + + it('expands ~/ for root-owned workspaces', () => { + expect( + classifyNoSchemeHref('~/hapi/docs/a.md', { workspacePath: '/root/hapi' }) + ).toEqual({ + action: 'file', + path: '/root/hapi/docs/a.md', + }) + }) + + it('decodes percent-encoded spaces before workspace containment', () => { + const workspace = '/home/ada/My Project' + expect( + classifyNoSchemeHref('/home/ada/My%20Project/docs/a.md', { + workspacePath: workspace, + }) + ).toEqual({ + action: 'file', + path: '/home/ada/My Project/docs/a.md', + }) + expect( + classifyNoSchemeHref('~/My%20Project/docs/a.md', { + workspacePath: workspace, + }) + ).toEqual({ + action: 'file', + path: '/home/ada/My Project/docs/a.md', + }) + }) + + it('renders absolute paths outside the workspace as inert', () => { + expect(classifyNoSchemeHref('/etc/passwd.sh', { workspacePath: workspace })).toEqual({ + action: 'inert', + }) + }) + + it('does not treat Windows absolute paths as repo-relative (containment required)', () => { + expect(classifyNoSchemeHref('D:\\outside\\secret.ts')).toEqual({ action: 'inert' }) + expect(classifyNoSchemeHref('D:/outside/secret.ts#L1')).toEqual({ action: 'inert' }) + }) + + it('routes in-workspace Windows absolute paths to file preview', () => { + const winWorkspace = 'C:\\Users\\ada\\coding\\hapi' + expect( + classifyNoSchemeHref('C:\\Users\\ada\\coding\\hapi\\docs\\a.md', { + workspacePath: winWorkspace, + }) + ).toEqual({ + action: 'file', + path: 'C:\\Users\\ada\\coding\\hapi\\docs\\a.md', + }) + }) + + it('compares Windows workspace containment case-insensitively', () => { + expect( + classifyNoSchemeHref('c:\\users\\ada\\coding\\hapi\\docs\\a.md', { + workspacePath: 'C:\\Users\\Ada\\coding\\hapi', + }) + ).toEqual({ + action: 'file', + path: 'c:\\users\\ada\\coding\\hapi\\docs\\a.md', + }) + }) + + it('renders absolute paths without workspace metadata as inert (fail closed)', () => { + expect(classifyNoSchemeHref('/home/ada/coding/hapi/docs/a.md')).toEqual({ action: 'inert' }) + }) + + it('rejects tilde paths that lexically escape the workspace via ..', () => { + expect(classifyNoSchemeHref('~/coding/hapi/../secret.ts', { workspacePath: workspace })).toEqual({ + action: 'inert', + }) + }) + + it('treats nonexistent SPA children as inert, not navigate', () => { + expect(classifyNoSchemeHref('/settings/typo')).toEqual({ action: 'inert' }) + expect(classifyNoSchemeHref('/browse/extra')).toEqual({ action: 'inert' }) + expect(classifyNoSchemeHref('/sessions/id/unknown')).toEqual({ action: 'inert' }) + }) + + it('renders unresolvable ~/ without workspace as inert (never SPA)', () => { + expect(classifyNoSchemeHref('~/coding/hapi/docs/a.md')).toEqual({ action: 'inert' }) + }) + + it('renders no-extension path-like hrefs as inert', () => { + expect(classifyNoSchemeHref('docs/foo')).toEqual({ action: 'inert' }) + expect(classifyNoSchemeHref('README')).toEqual({ action: 'inert' }) + expect(classifyNoSchemeHref('./relative-route')).toEqual({ action: 'inert' }) + }) + + it('renders parent-relative escape attempts as inert', () => { + expect(classifyNoSchemeHref('../escape.md')).toEqual({ action: 'inert' }) + }) + + it('keeps real app routes navigable', () => { + expect(classifyNoSchemeHref('/settings')).toEqual({ action: 'navigate' }) + expect(classifyNoSchemeHref('/settings/general')).toEqual({ action: 'navigate' }) + expect(classifyNoSchemeHref('#section')).toEqual({ action: 'navigate' }) + expect(classifyNoSchemeHref('?q=1')).toEqual({ action: 'navigate' }) + }) + + it('keeps protocol-relative URLs navigable', () => { + expect(classifyNoSchemeHref('//example.com/path')).toEqual({ action: 'navigate' }) + }) + + it('never classifies /home/... absolute file hrefs as SPA navigate', () => { + const decision = classifyNoSchemeHref('/home/ada/coding/hapi/docs/a.md', { + workspacePath: workspace, + }) + expect(decision.action).not.toBe('navigate') + expect(decision).toEqual({ + action: 'file', + path: '/home/ada/coding/hapi/docs/a.md', + }) + }) +}) diff --git a/web/src/lib/markdown-href-policy.ts b/web/src/lib/markdown-href-policy.ts new file mode 100644 index 0000000000..4eeb78e52e --- /dev/null +++ b/web/src/lib/markdown-href-policy.ts @@ -0,0 +1,227 @@ +/** + * Fail-closed policy for scheme-less markdown hrefs in chat. + * + * Product rule (#1452): never paint a clickable control that SPA-404s. + * Prefer session file preview when the target looks like a workspace file; + * known in-app routes stay navigable; everything else path-like is inert text. + */ + +import { COMMON_FILE_EXTENSIONS } from '@/lib/remark-file-path-links' + +export type MarkdownHrefDecision = + | { action: 'navigate' } + | { action: 'file'; path: string } + | { action: 'inert' } + +const STATIC_SPA_PATHS = new Set([ + '/', + '/browse', + '/share', + '/sessions', + '/settings', + '/settings/general', + '/settings/display', + '/settings/chat', + '/settings/voice', + '/settings/voice/voices', + '/settings/voice/advanced', + '/settings/machines', + '/settings/about', + '/settings/storage', + '/settings/usage', +]) + +// /sessions/ plus known children only (files | file | terminal). +const SESSION_SPA_PATH = + /^\/sessions\/[^/]+(?:\/(?:files|file|terminal))?\/?$/ + +export function splitHrefMeta(href: string): { path: string; suffix: string } { + const hashIdx = href.indexOf('#') + const queryIdx = href.indexOf('?') + let cut = -1 + if (hashIdx >= 0 && queryIdx >= 0) cut = Math.min(hashIdx, queryIdx) + else if (hashIdx >= 0) cut = hashIdx + else if (queryIdx >= 0) cut = queryIdx + if (cut < 0) return { path: href, suffix: '' } + return { path: href.slice(0, cut), suffix: href.slice(cut) } +} + +function stripLineSuffix(value: string): string { + return value.replace(/:\d+(?::\d+)?$/, '') +} + +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) +} + +export function hasKnownFileExtension(value: string): boolean { + const path = stripLineSuffix(value).toLowerCase() + const dot = path.lastIndexOf('.') + if (dot < 0 || dot === path.length - 1) return false + const ext = path.slice(dot + 1) + return COMMON_FILE_EXTENSIONS.has(ext) +} + +export function isKnownSpaHref( + href: string, + options: { baseUrl?: string } = {} +): boolean { + if (href.startsWith('#') || href.startsWith('?')) return true + if (href.startsWith('//')) return false + const { path: raw } = splitHrefMeta(href) + const path = raw.replace(/\/+$/, '') || '/' + const rawBase = options.baseUrl ?? (import.meta.env.BASE_URL as string | undefined) ?? '/' + const base = rawBase === '/' ? '' : rawBase.replace(/\/+$/, '') + const routePath = + base && (path === base || path.startsWith(`${base}/`)) + ? path.slice(base.length) || '/' + : path + if (STATIC_SPA_PATHS.has(routePath)) return true + return SESSION_SPA_PATH.test(routePath) +} + +export function inferHomeDir(workspacePath: string): string | null { + if (workspacePath === '/root' || workspacePath.startsWith('/root/')) return '/root' + const posix = workspacePath.match(/^(\/(?:home|Users)\/[^/]+)/) + if (posix) return posix[1] + const win = workspacePath.match(/^([A-Za-z]:[\\/]Users[\\/][^\\/]+)/i) + if (win) return win[1] + return null +} + +export function expandTildePath(path: string, workspacePath: string | null | undefined): string | null { + if (path !== '~' && !path.startsWith('~/')) return null + if (!workspacePath) return null + const home = inferHomeDir(workspacePath) + if (!home) return null + if (path === '~') return home + const sep = home.includes('\\') && !home.includes('/') ? '\\' : '/' + const rest = path.slice(2).replace(/\\/g, '/') + if (sep === '\\') return `${home}\\${rest.replace(/\//g, '\\')}` + return `${home}/${rest}` +} + +/** Lexically resolve `.` / `..`; return null if `..` escapes above the root. */ +export function resolveLexicalPath(absPath: string): string | null { + const norm = absPath.replace(/\\/g, '/') + const absolute = norm.startsWith('/') + const drive = /^[A-Za-z]:/.exec(norm) + const parts = norm.split('/') + const out: string[] = [] + for (const part of parts) { + if (part === '' || part === '.') continue + if (drive && part === drive[0]) { + out.push(part) + continue + } + if (part === '..') { + if (out.length === 0) return null + // Do not pop a Windows drive root segment. + if (out.length === 1 && /^[A-Za-z]:$/.test(out[0]!)) return null + out.pop() + continue + } + out.push(part) + } + if (absolute) return `/${out.join('/')}` + if (drive) { + const [root, ...rest] = out + return rest.length === 0 ? `${root}\\` : `${root}\\${rest.join('\\')}` + } + return out.join('/') +} + +export function isWithinWorkspace(absPath: string, workspacePath: string): boolean { + const target = resolveLexicalPath(absPath) + const root = resolveLexicalPath(workspacePath) + if (!target || !root) return false + const normTarget = target.replace(/\\/g, '/').replace(/\/+$/, '') + const normRoot = root.replace(/\\/g, '/').replace(/\/+$/, '') + // Windows filesystems are case-insensitive; compare folded when both sides + // are drive-qualified so `c:\Users\…` matches `C:\Users\…`. + const windows = isWindowsAbsolutePath(normTarget) && isWindowsAbsolutePath(normRoot) + const comparableTarget = windows ? normTarget.toLowerCase() : normTarget + const comparableRoot = windows ? normRoot.toLowerCase() : normRoot + return comparableTarget === comparableRoot || comparableTarget.startsWith(`${comparableRoot}/`) +} + +function isRepoRelativeCandidate(path: string): boolean { + if (path.includes('://')) return false + if (path.startsWith('/') || path.startsWith('~/') || path === '~') return false + if (path.startsWith('../') || path.includes('/../')) return false + // Drive-qualified paths need workspace containment — never treat as relative. + if (isWindowsAbsolutePath(path)) return false + return hasKnownFileExtension(path) +} + +function looksPathLike(path: string): boolean { + if (!path) return false + if (path === '~' || path.startsWith('~/') || path.startsWith('./') || path.startsWith('../')) return true + if (path.startsWith('/') || isWindowsAbsolutePath(path)) return true + if (path.includes('/') || path.includes('\\')) return true + return hasKnownFileExtension(path) +} + +/** + * Classify a scheme-less markdown href for the chat renderer. + * + * @param workspacePath session metadata.path when available (enables ~/ expansion + containment) + */ +export function classifyNoSchemeHref( + href: string, + options: { workspacePath?: string | null } = {} +): MarkdownHrefDecision { + const trimmed = href.trim() + if (!trimmed) return { action: 'inert' } + + // Protocol-relative URLs keep browser navigation (existing policy). + if (trimmed.startsWith('//')) return { action: 'navigate' } + + if (isKnownSpaHref(trimmed)) return { action: 'navigate' } + + const { path: rawPath } = splitHrefMeta(trimmed) + // mdast→hast percent-encodes spaces etc.; compare against literal workspace. + let decodedPath: string + try { + decodedPath = decodeURIComponent(rawPath) + } catch { + return { action: 'inert' } + } + const path = stripLineSuffix(decodedPath) + const workspacePath = options.workspacePath ?? null + + if (isRepoRelativeCandidate(path)) { + return { action: 'file', path } + } + + // Absolute / tilde targets need workspace metadata so we can fail closed on + // out-of-tree paths (remark deliberately does not rewrite POSIX abs). + if (isWindowsAbsolutePath(path) && hasKnownFileExtension(path)) { + if (!workspacePath || !isWithinWorkspace(path, workspacePath)) { + return { action: 'inert' } + } + return { action: 'file', path } + } + + if (path.startsWith('/') && hasKnownFileExtension(path)) { + if (!workspacePath || !isWithinWorkspace(path, workspacePath)) { + return { action: 'inert' } + } + return { action: 'file', path } + } + + if (path.startsWith('~/') || path === '~') { + if (!hasKnownFileExtension(path)) return { action: 'inert' } + const expanded = expandTildePath(path, workspacePath) + if (!expanded) return { action: 'inert' } + if (!workspacePath || !isWithinWorkspace(expanded, workspacePath)) { + return { action: 'inert' } + } + return { action: 'file', path: expanded } + } + + if (looksPathLike(path)) return { action: 'inert' } + + // Non-path leftovers (rare bare tokens) — do not invent SPA routes. + return { action: 'inert' } +} diff --git a/web/src/lib/remark-file-path-links.test.ts b/web/src/lib/remark-file-path-links.test.ts index b4203d1802..8b74879fa9 100644 --- a/web/src/lib/remark-file-path-links.test.ts +++ b/web/src/lib/remark-file-path-links.test.ts @@ -48,14 +48,17 @@ describe('remarkFilePathLinks', () => { expect(links.map(linkedPath)).toEqual(['screenshot.png', 'README.md']) }) - it('links Windows absolute paths so the session host can validate and read them', () => { + it('autolinks Windows absolute paths as hapi-file-candidate (backslash-safe through hast)', () => { const nodes = transform('Open C:\\Users\\dev\\project\\handoff.md and D:/work/app/src/main.ts:12') const links = nodes.filter((node) => node.type === 'link') - expect(links.map(linkedPath)).toEqual([ - 'C:\\Users\\dev\\project\\handoff.md', - 'D:/work/app/src/main.ts' + expect(links.map((n) => n.url)).toEqual([ + 'hapi-file-candidate:' + encodeURIComponent('C:\\Users\\dev\\project\\handoff.md'), + 'hapi-file-candidate:' + encodeURIComponent('D:/work/app/src/main.ts'), ]) + for (const link of links) { + expect(decodeFilePathHref(link.url as string)).toBeNull() + } }) it('does not link other absolute or parent paths', () => { @@ -119,11 +122,12 @@ describe('remarkFilePathLinks — inlineCode', () => { it.each([ 'C:\\Users\\dev\\project\\handoff.md', 'D:/work/app/src/main.ts:12' - ])('links Windows absolute inlineCode path %s', (value) => { + ])('autolinks Windows absolute inlineCode path %s as hapi-file-candidate', (value) => { const nodes = transformNodes([{ type: 'inlineCode', value }]) const link = nodes.find((node) => node.type === 'link')! - - expect(linkedPath(link)).toBe(value.replace(/:\d+(?::\d+)?$/, '')) + const expected = value.replace(/:\d+(?::\d+)?$/, '') + expect(link.url).toBe('hapi-file-candidate:' + encodeURIComponent(expected)) + expect(decodeFilePathHref(link.url as string)).toBeNull() expect(link.children?.[0]?.type).toBe('inlineCode') expect(link.children?.[0]?.value).toBe(value) }) @@ -175,14 +179,29 @@ describe('remarkFilePathLinks — explicit markdown links', () => { expect(linkedPath(nodes.find((n) => n.type === 'link')!)).toBe('./diagram.mmd') }) + it('rewrites a relative link with a #fragment, stripping it from the target', () => { + const nodes = transformNodes([linkNode('docs/foo.md#section')]) + expect(linkedPath(nodes.find((n) => n.type === 'link')!)).toBe('docs/foo.md') + }) + + it('does not rewrite POSIX absolute file links (containment needs session cwd in )', () => { + const nodes = transformNodes([linkNode('/home/ada/coding/hapi/docs/a.md')]) + const link = nodes.find((n) => n.type === 'link')! + expect(decodeFilePathHref(link.url as string)).toBeNull() + expect(link.url).toBe('/home/ada/coding/hapi/docs/a.md') + }) + it.each([ 'C:\\Users\\dev\\project\\handoff.md', - 'D:/work/app/src/main.ts:12' - ])('rewrites a Windows absolute file link %s', (url) => { + 'D:/work/app/src/main.ts:12', + 'D:/outside/secret.ts#L1', + ])('rewrites Windows absolute file link %s to hapi-file-candidate (not premature hapi-file)', (url) => { const nodes = transformNodes([linkNode(url)]) const link = nodes.find((node) => node.type === 'link')! - - expect(linkedPath(link)).toBe(url.replace(/:\d+(?::\d+)?$/, '')) + const withoutMeta = url.replace(/#.*$/, '').replace(/\?.*$/, '') + const expectedPath = withoutMeta.replace(/:\d+(?::\d+)?$/, '') + expect(decodeFilePathHref(link.url as string)).toBeNull() + expect(link.url).toBe('hapi-file-candidate:' + encodeURIComponent(expectedPath)) }) it.each([ @@ -190,6 +209,7 @@ describe('remarkFilePathLinks — explicit markdown links', () => { 'mailto:dev@example.com', 'obsidian://open?file=a.md', '/abs/path.md', + '/etc/passwd.sh', '~/home.md', '../escape.md', 'foo:bar.md', diff --git a/web/src/lib/remark-file-path-links.ts b/web/src/lib/remark-file-path-links.ts index 66121cba63..21e3e2aa30 100644 --- a/web/src/lib/remark-file-path-links.ts +++ b/web/src/lib/remark-file-path-links.ts @@ -1,4 +1,7 @@ const FILE_PATH_HREF_PREFIX = 'hapi-file:' +// Encoded Windows abs handoff: raw `C:\…` is URI-normalized to `%5C` before , +// which breaks drive detection. Candidate scheme preserves the path through hast. +const FILE_PATH_CANDIDATE_HREF_PREFIX = 'hapi-file-candidate:' const PATH_PATTERN = /(?:[A-Za-z]:[\\/]|\.\/|[A-Za-z0-9_.-]+\/)[^\s`"\'<>]*?\.(?:[A-Za-z0-9]{1,12}|lock)(?::\d+(?::\d+)?)?|(?:[A-Za-z0-9_.-]+\.(?:[A-Za-z0-9]{1,12}|lock))(?::\d+(?::\d+)?)?/g @@ -10,7 +13,7 @@ const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?']) // tabular data (csv/tsv), config/schema (ini/conf/env/proto/graphql/prisma), // and common languages not already covered. TLD-lookalikes (org/com/io/dev/co) // are deliberately excluded so URLs like "example.org" don't autolink. -const COMMON_FILE_EXTENSIONS = new Set([ +export const COMMON_FILE_EXTENSIONS = new Set([ 'adoc', 'astro', 'avif', 'bat', 'bmp', 'c', 'cfg', 'cjs', 'conf', 'cpp', 'css', 'csv', 'env', 'gif', 'go', 'gql', 'gradle', 'graphql', 'h', 'hpp', 'html', 'ico', 'ini', 'java', 'jpeg', 'jpg', 'js', 'json', 'jsx', 'kt', 'lock', 'md', 'mdx', 'mjs', 'mmd', 'php', 'png', @@ -40,6 +43,35 @@ export function decodeFilePathHref(href: string): string | null { } } +export function decodeFilePathCandidateHref(href: string): string | null { + // Decode scheme bypass spellings (`HAPI-FILE-CANDIDATE:`, percent-encoded) + // before extracting the payload. Empty payload → null (caller fails closed). + let value = href.trimStart() + for (let i = 0; i < 2; i++) { + try { + const next = decodeURIComponent(value) + if (next === value) break + value = next + } catch { + break + } + } + const match = /^hapi-file-candidate:(.*)$/i.exec(value) + if (!match) return null + const payload = match[1] + if (!payload) return null + try { + // Payload may already be decoded by the loop above; retry is a no-op. + return decodeURIComponent(payload) + } catch { + return payload + } +} + +function createFileCandidateHref(path: string): string { + return `${FILE_PATH_CANDIDATE_HREF_PREFIX}${encodeURIComponent(path)}` +} + function splitTrailingPunctuation(value: string): { path: string; trailing: string } { let path = value let trailing = '' @@ -87,11 +119,19 @@ function shouldLinkPath(value: string): boolean { if (path.length < 3) return false if (path.startsWith('/') || path.startsWith('~/')) return false if (path.startsWith('../') || path.includes('/../')) return false + // Windows abs: autolink with the raw path (not hapi-file:) so can + // apply workspace containment before painting FilePathAnchor. if (isWindowsAbsolutePath(path)) return hasKnownFileExtension(path) if (path.includes('/')) return hasKnownFileExtension(path) return hasKnownFileExtension(path) } +/** Autolink href: Windows abs uses candidate encoding so backslashes survive hast. */ +function createAutolinkHref(filePath: string): string { + if (isWindowsAbsolutePath(filePath)) return createFileCandidateHref(filePath) + return createFileHref(filePath) +} + function linkTextNode(node: MarkdownNode): MarkdownNode[] { const value = node.value ?? '' const parts: MarkdownNode[] = [] @@ -117,7 +157,7 @@ function linkTextNode(node: MarkdownNode): MarkdownNode[] { } parts.push({ type: 'link', - url: createFileHref(filePath), + url: createAutolinkHref(filePath), title: null, children: [{ type: 'text', value: displayPath }] }) @@ -157,29 +197,48 @@ function linkInlineCodeNode(node: MarkdownNode): MarkdownNode | null { return { type: 'link', - url: createFileHref(filePath), + url: createAutolinkHref(filePath), title: null, children: [{ type: 'inlineCode', value: trimmed }] } } -// Rewrite an explicit markdown link `[label](relative/file.ext)` whose target is -// a repo-relative allowlisted file path into a `hapi-file:` href so it opens the -// session file viewer instead of dead-ending in the SPA router. +// Rewrite an explicit markdown link `[label](…file.ext)` into a `hapi-file:` href +// so it opens the session file viewer instead of dead-ending in the SPA router. +// +// Accepts: +// - repo-relative allowlisted paths (including `./` and `#fragment` / `:line` stripped) // -// Security: reuses shouldLinkPath (rejects POSIX abs / `~/` / `../` / `scheme://`) -// and rejects residual colons for non-Windows targets after the line-suffix strip, -// so scheme-bearing urls (mailto:, obsidian://, foo:bar.md) are left for the -// deny-scheme layer. Windows absolute paths are routed through the session file -// viewer; the CLI still enforces that they stay inside the session workspace. +// Still rejects: POSIX/Windows abs / `~/` (need session cwd — handled fail-closed in ), +// `../`, scheme-bearing URLs, and non-file targets (`/settings`, `#section`). function rewriteFileLinkNode(node: MarkdownNode): void { if (node.type !== 'link') return const url = node.url if (!url) return if (url.startsWith(FILE_PATH_HREF_PREFIX)) return - const target = stripLineSuffix(url) - if (!isWindowsAbsolutePath(target) && target.includes(':')) return + // Strip #fragment / ?query so `file.md#section` can still rewrite. + const hashIdx = url.indexOf('#') + const queryIdx = url.indexOf('?') + let cut = -1 + if (hashIdx >= 0 && queryIdx >= 0) cut = Math.min(hashIdx, queryIdx) + else if (hashIdx >= 0) cut = hashIdx + else if (queryIdx >= 0) cut = queryIdx + const withoutMeta = cut >= 0 ? url.slice(0, cut) : url + + const target = stripLineSuffix(withoutMeta) + + // Absolute paths (POSIX or Windows) need chat workspace metadata for + // containment — leave POSIX for ; encode Windows as candidate so + // backslashes survive mdast→hast URI normalization. + if (isWindowsAbsolutePath(target)) { + if (!hasKnownFileExtension(target)) return + node.url = createFileCandidateHref(target) + return + } + if (target.startsWith('/') && !target.startsWith('//')) return + if (target.includes(':')) return + if (!shouldLinkPath(target)) return node.url = createFileHref(target) From 7c4b3ab17b119826529ef32c87da0440a7b84a67 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:54:37 +0100 Subject: [PATCH 075/142] fix(cursor): stop session-list spinner flicker from ACP state_update (#1503) * fix(cursor): stop session-list spinner flicker from ACP state_update #1487 mapped Cursor ACP state_update running/idle onto hub thinking. Cursor chatters those states while HAPI is queue-idle, so keepAlive flipped thinking every ~1-2s and the session list spinner danced. Ignore state_update for thinking; only bump on real activity, clear via prompt finally/abort. Co-authored-by: Cursor * fix(cursor): clear thinking on ACP idle without bumping on running Refine #1502 hotfix: ignore state_update running/requires_action (Cursor chatter caused spinner flicker) but still clear on idle so mid-idle harness wakes do not stick thinking=true forever. Co-authored-by: Cursor * fix(cursor): stop ACP background updates from flickering thinking Ignore tool/content session updates for hub thinking (ACP allows them while idle). Drive thinking from state_update only, debounce running 750ms, and skip idle clears during an in-flight HAPI prompt so #1502 residual flicker dies. --------- Co-authored-by: Cursor --- .../agent/backends/acp/AcpSdkBackend.test.ts | 52 ++++++++++++++++++- cli/src/agent/backends/acp/AcpSdkBackend.ts | 49 ++++++++++++++--- ...houldBumpThinkingFromSessionUpdate.test.ts | 14 +++-- .../shouldBumpThinkingFromSessionUpdate.ts | 47 ++++++++--------- cli/src/cursor/cursorAcpRemoteLauncher.ts | 5 +- 5 files changed, 128 insertions(+), 39 deletions(-) diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index 051e26ec26..4d17fa7b4a 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -1413,7 +1413,8 @@ describe('AcpSdkBackend', () => { ]); }); - it('notifies agent-activity listener for harness-wake activity, not usage noise (#1470)', () => { + it('notifies agent-activity listener for sustained running + idle, not content/usage noise (#1470/#1502)', () => { + vi.useFakeTimers(); const backend = new AcpSdkBackend({ command: 'agent' }); const activity: boolean[] = []; backend.setAgentActivityListener((thinking) => { @@ -1431,6 +1432,14 @@ describe('AcpSdkBackend', () => { content: { type: 'text', text: 'resumed' } } }); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: 'tc-bg', + status: 'in_progress' + } + }); backendInternal.handleSessionUpdate({ sessionId: 'session-1', update: { sessionUpdate: 'usage_update', used: 1_000, size: 200_000 } @@ -1442,16 +1451,55 @@ describe('AcpSdkBackend', () => { title: 'noise' } }); + // Chatter: running then idle before debounce → no true bump (idle may clear) + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { sessionUpdate: 'state_update', state: 'running' } + }); + vi.advanceTimersByTime(200); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { sessionUpdate: 'state_update', state: 'idle' } + }); + expect(activity.filter((v) => v === true)).toEqual([]); + + // Sustained running commits after debounce backendInternal.handleSessionUpdate({ sessionId: 'session-1', update: { sessionUpdate: 'state_update', state: 'running' } }); + vi.advanceTimersByTime(750); + expect(activity.filter((v) => v === true)).toEqual([true]); backendInternal.handleSessionUpdate({ sessionId: 'session-1', update: { sessionUpdate: 'state_update', state: 'idle' } }); + expect(activity.at(-1)).toBe(false); + vi.useRealTimers(); + }); - expect(activity).toEqual([true, true, false]); + it('ignores idle clears while a HAPI prompt turn is still draining', () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const activity: boolean[] = []; + backend.setAgentActivityListener((thinking) => { + activity.push(thinking); + }); + const backendInternal = backend as unknown as { + handleSessionUpdate: (params: unknown) => void; + isProcessingMessage: boolean; + }; + backendInternal.isProcessingMessage = true; + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { sessionUpdate: 'state_update', state: 'idle' } + }); + expect(activity).toEqual([]); + backendInternal.isProcessingMessage = false; + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { sessionUpdate: 'state_update', state: 'idle' } + }); + expect(activity).toEqual([false]); }); it('notifies agent-activity listener when a permission request arrives (#1470)', async () => { diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index d583460b52..fbe0441cbc 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -83,8 +83,10 @@ export class AcpSdkBackend implements AgentBackend { private promptUsageCallback: ((msg: AgentMessage) => void) | null = null; private usageUpdateListener: ((msg: AgentMessage) => void) | null = null; private sessionInfoUpdateListener: ((update: AcpSessionInfoUpdate) => void) | null = null; - /** Fired on real agent activity so launchers can bump hub thinking (#1470). */ + /** Fired on foreground ACP state / permission so launchers can bump hub thinking (#1470). */ private agentActivityListener: ((thinking: boolean) => void) | null = null; + /** Debounce timer for state_update running → thinking (#1502 chatter). */ + private runningThinkingTimer: ReturnType | null = null; private lastForwardedUsageUpdate: AcpUsageUpdate | null = null; private sessionUpdateQueue: Promise = Promise.resolve(); @@ -99,6 +101,8 @@ export class AcpSdkBackend implements AgentBackend { private static readonly PRE_PROMPT_UPDATE_QUIET_PERIOD_MS = 200; private static readonly PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS = 1200; private static readonly SESSION_TITLE_REFRESH_DELAYS_MS = [1000, 3000]; + /** Cursor chatters running↔idle ~1–2s; require sustained running before bump. */ + private static readonly RUNNING_THINKING_DEBOUNCE_MS = 750; // After the initial post-prompt drain, slow-tailing models (DeepSeek, // GPT-5.5, etc.) can keep sending agentMessageChunk notifications. We poll // drainBuffers() on a short interval so the UI keeps streaming smoothly, @@ -444,10 +448,10 @@ export class AcpSdkBackend implements AgentBackend { } /** - * Called when ACP reports thinking transitions for harness wake (#1470). - * `true` = activity / running / permission; `false` = state_update idle. - * Usage/title noise does not fire. Launchers should ignore no-ops when - * session.thinking already matches. + * Called when ACP reports foreground state / permission for harness wake (#1470 / #1502). + * `true` = sustained `running` (debounced), `requires_action`, or permission. + * `false` = `state_update` idle (skipped while a HAPI prompt turn is still draining). + * Launchers should ignore no-ops when session.thinking already matches. */ setAgentActivityListener(listener: ((thinking: boolean) => void) | null): void { this.agentActivityListener = listener; @@ -755,6 +759,7 @@ export class AcpSdkBackend implements AgentBackend { clearTimeout(timer); } this.sessionInfoRefreshTimers.clear(); + this.clearRunningThinkingTimer(); await this.sessionUpdateQueue; this.messageHandler?.drainBuffers(); this.messageHandler = null; @@ -813,7 +818,39 @@ export class AcpSdkBackend implements AgentBackend { if (hint === null) { return; } - this.agentActivityListener(hint); + + if (hint === false) { + this.clearRunningThinkingTimer(); + // Launcher owns thinking for the duration of prompt(); idle chatter + // mid-drain must not clear the spinner before finally runs. + if (this.isProcessingMessage) { + return; + } + this.agentActivityListener(false); + return; + } + + // Sustained running only — Cursor flaps running↔idle while queue-idle (#1502). + if (update.sessionUpdate === 'state_update' && update.state === 'running') { + if (this.runningThinkingTimer) { + return; + } + this.runningThinkingTimer = setTimeout(() => { + this.runningThinkingTimer = null; + this.agentActivityListener?.(true); + }, AcpSdkBackend.RUNNING_THINKING_DEBOUNCE_MS); + return; + } + + this.clearRunningThinkingTimer(); + this.agentActivityListener(true); + } + + private clearRunningThinkingTimer(): void { + if (this.runningThinkingTimer) { + clearTimeout(this.runningThinkingTimer); + this.runningThinkingTimer = null; + } } private forwardSessionInfoUpdate(sessionId: string | null, update: unknown): void { diff --git a/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.test.ts b/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.test.ts index 8744641f27..8e8f9d0b69 100644 --- a/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.test.ts +++ b/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.test.ts @@ -17,12 +17,12 @@ describe('thinkingHintFromSessionUpdate', () => { 'user_message', 'user_message_chunk', 'tool_call_content_chunk', - ] as const)('returns true for activity type %s', (sessionUpdate) => { - expect(thinkingHintFromSessionUpdate({ sessionUpdate })).toBe(true) - expect(shouldBumpThinkingFromSessionUpdate({ sessionUpdate })).toBe(true) + ] as const)('ignores background/content type %s (not foreground state)', (sessionUpdate) => { + expect(thinkingHintFromSessionUpdate({ sessionUpdate })).toBeNull() + expect(shouldBumpThinkingFromSessionUpdate({ sessionUpdate })).toBe(false) }) - it('returns true for ACP v2 state_update running / requires_action', () => { + it('returns true for state_update running/requires_action (debounced in backend)', () => { expect(thinkingHintFromSessionUpdate({ sessionUpdate: 'state_update', state: 'running', @@ -31,9 +31,13 @@ describe('thinkingHintFromSessionUpdate', () => { sessionUpdate: 'state_update', state: 'requires_action', })).toBe(true) + expect(shouldBumpThinkingFromSessionUpdate({ + sessionUpdate: 'state_update', + state: 'running', + })).toBe(true) }) - it('returns false for ACP v2 state_update idle', () => { + it('returns false for state_update idle so mid-idle wakes can clear', () => { expect(thinkingHintFromSessionUpdate({ sessionUpdate: 'state_update', state: 'idle', diff --git a/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.ts b/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.ts index 0313c8f690..d8becdd811 100644 --- a/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.ts +++ b/cli/src/agent/backends/acp/shouldBumpThinkingFromSessionUpdate.ts @@ -1,10 +1,19 @@ /** - * Gate for harness/ACP resume → hub thinking (#1470). + * Gate for harness/ACP resume → hub thinking (#1470 / #1502 / #1503). * * Returns: - * - `true` — real agent activity / foreground running (bump thinking) + * - `true` — foreground ACP state (`running` / `requires_action`) * - `false` — ACP v2 `state_update: idle` (clear thinking) - * - `null` — noise / unknown (do not touch thinking) + * - `null` — noise / background updates (do not touch) + * + * ACP allows `tool_call*` / message chunks while the agent reports `idle` + * (background activity). Mapping those onto hub thinking races idle clears and + * flickers the session-list spinner (#1502 residual on long-lived Cursor ACP). + * Foreground work is `state_update` only; permission bumps go through + * `setAgentActivityListener(true)` directly. + * + * `running` chatter is debounced in `AcpSdkBackend.notifyAgentActivity` so + * rapid running↔idle edges do not flip the spinner. */ export type SessionUpdateThinkingHint = boolean | null @@ -15,29 +24,17 @@ export function thinkingHintFromSessionUpdate( return null } - switch (update.sessionUpdate) { - case 'agent_message_chunk': - case 'agent_message': - case 'agent_thought_chunk': - case 'agent_thought': - case 'tool_call': - case 'tool_call_update': - case 'tool_call_content_chunk': - case 'plan': - case 'user_message': - case 'user_message_chunk': - return true - case 'state_update': - if (update.state === 'running' || update.state === 'requires_action') { - return true - } - if (update.state === 'idle') { - return false - } - return null - default: - return null + if (update.sessionUpdate !== 'state_update') { + return null + } + + if (update.state === 'idle') { + return false + } + if (update.state === 'running' || update.state === 'requires_action') { + return true } + return null } /** @deprecated Prefer thinkingHintFromSessionUpdate; kept for call-site clarity in tests. */ diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index f4e38159be..0e49a7b0c3 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -563,7 +563,10 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { }); } - /** #1470: ACP activity after idle → hub thinking via existing keepalive. */ + /** + * #1470 / #1502: ACP foreground state → hub thinking via keepalive. + * Background tool/content updates are ignored; running is debounced in the backend. + */ private wireAgentActivityThinking(backend: AcpSdkBackend, session: CursorSession): void { backend.setAgentActivityListener((thinking) => { if (session.thinking !== thinking) { From c69a88afaeed454df482c283cdccfa1be2c00cc2 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:55:26 +0100 Subject: [PATCH 076/142] fix(cursor): close ACP list-models race and false exit 143 window (#1518) * fix(cursor): close ACP list-models race and false exit 143 window Register the agent-acp-active guard before spawn, hold it until stdio close (not bare exit), record the ACP child PID, and align lock/cache home with resolveHapiHomeDir so runner and session children agree. Richer exit attribution distinguishes live-PID transport disruption from confirmed child death. Fixes residual #1472 after #835. Co-authored-by: Cursor * test(cursor): isolate ACP guard teardown from ~/.hapi Reset afterEach under the temp HAPI_HOME only, and restore the isolated home before teardown in the unset-HAPI_HOME case, so tests cannot wipe a live agent-acp-active lock. Use distinct child PIDs in registration tests. Co-authored-by: Cursor * fix(cursor): publish ACP lock pid before count Fail-closed reservation order: write pids/ before count so concurrent reconcile cannot treat a mid-register lock as stale and clear it for list-models. Keep a short mtime grace only when pids/ is missing (mkdir gap). Regression covers mid-publish readers. Co-authored-by: Cursor * fix(cursor): keep empty pids/ ACP reservation fail-closed Between mkdir(pids) and the host pid writeFile, reconcile could see liveCount=0 and clear the lock. Keep that window when count is still absent and the lock is fresh; re-scan for pids published mid-reconcile. Regression hooks the mkdir/write gap. Co-authored-by: Cursor * fix(cursor): keep ACP lock across last-unregister publish race Write a short-lived registering marker before pids/count so empty pids with leftover count cannot erase a concurrent mid-addLockPid reservation. Co-authored-by: Cursor * fix(cursor): use per-pid ACP registering markers Crash/reboot must not pin list-models forever on a bare registering file; prune dead owners and only keep live registrar PIDs. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../backends/acp/AcpStdioTransport.test.ts | 75 ++++- .../agent/backends/acp/AcpStdioTransport.ts | 65 ++++- .../agent/backends/acp/agentCliGuard.test.ts | 182 +++++++++++- cli/src/agent/backends/acp/agentCliGuard.ts | 265 +++++++++++++++++- .../modules/common/cursorModelsSharedCache.ts | 4 +- 5 files changed, 564 insertions(+), 27 deletions(-) diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.test.ts b/cli/src/agent/backends/acp/AcpStdioTransport.test.ts index 3fb915fd25..ba1749cd28 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.test.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.test.ts @@ -2,7 +2,17 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; const guard = vi.hoisted(() => ({ register: vi.fn(), - unregister: vi.fn() + unregister: vi.fn(), + recordChildPid: vi.fn(), + getLockDir: vi.fn(() => '/tmp/test-hapi/locks/agent-acp-active'), + isActive: vi.fn(() => true), + describeState: vi.fn((childPid?: number | null) => ({ + lockDir: '/tmp/test-hapi/locks/agent-acp-active', + inProcessCount: 1, + childPid: childPid ?? null, + childAlive: false, + guardActive: true + })) })); const spawnState = vi.hoisted(() => ({ @@ -12,12 +22,21 @@ const spawnState = vi.hoisted(() => ({ stdinEnd: vi.fn(), stdinWrite: vi.fn<(chunk: string) => boolean>(() => true), kill: vi.fn(), - exitCode: null as number | null + exitCode: null as number | null, + pid: 424242 as number | undefined, + spawnCallOrder: [] as string[] })); vi.mock('./agentCliGuard', () => ({ - registerActiveAcpTransport: guard.register, - unregisterActiveAcpTransport: guard.unregister + registerActiveAcpTransport: (...args: unknown[]) => { + spawnState.spawnCallOrder.push('register'); + return guard.register(...args); + }, + unregisterActiveAcpTransport: guard.unregister, + recordActiveAcpChildPid: guard.recordChildPid, + getAgentAcpLockDir: guard.getLockDir, + isAgentAcpTransportActive: guard.isActive, + describeAgentAcpGuardState: guard.describeState })); vi.mock('@/utils/process', () => ({ @@ -26,11 +45,15 @@ vi.mock('@/utils/process', () => ({ vi.mock('node:child_process', () => ({ spawn: vi.fn(() => { + spawnState.spawnCallOrder.push('spawn'); spawnState.exitHandlers = []; spawnState.closeHandlers = []; spawnState.stdoutDataHandlers = []; const handlers = new Map void>>(); const proc = { + get pid() { + return spawnState.pid; + }, get exitCode() { return spawnState.exitCode; }, @@ -81,12 +104,25 @@ describe('AcpStdioTransport agent CLI guard', () => { afterEach(() => { guard.register.mockClear(); guard.unregister.mockClear(); + guard.recordChildPid.mockClear(); + guard.getLockDir.mockClear(); + guard.isActive.mockClear(); + guard.describeState.mockClear(); + guard.describeState.mockImplementation((childPid?: number | null) => ({ + lockDir: '/tmp/test-hapi/locks/agent-acp-active', + inProcessCount: 1, + childPid: childPid ?? null, + childAlive: false, + guardActive: true + })); spawnState.stdinWrite.mockReset(); spawnState.stdinWrite.mockReturnValue(true); spawnState.stdinEnd.mockClear(); spawnState.kill.mockClear(); vi.mocked(killProcessByChildProcess).mockClear(); spawnState.exitCode = null; + spawnState.pid = 424242; + spawnState.spawnCallOrder = []; spawnState.exitHandlers = []; spawnState.closeHandlers = []; spawnState.stdoutDataHandlers = []; @@ -95,16 +131,45 @@ describe('AcpStdioTransport agent CLI guard', () => { test('registers cross-process guard only for Cursor agent command', async () => { const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); expect(guard.register).toHaveBeenCalledTimes(1); + expect(guard.recordChildPid).toHaveBeenCalledWith(424242); await transport.close(); expect(guard.unregister).toHaveBeenCalledTimes(1); + expect(guard.unregister).toHaveBeenCalledWith({ childPid: 424242 }); + }); + + test('registers the ACP guard before spawn so list-models cannot race the new child', () => { + spawnState.spawnCallOrder = []; + new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + expect(spawnState.spawnCallOrder.indexOf('register')).toBeGreaterThanOrEqual(0); + expect(spawnState.spawnCallOrder.indexOf('spawn')).toBeGreaterThan( + spawnState.spawnCallOrder.indexOf('register') + ); + }); + + test('keeps the ACP guard held across exit until close drains stdio', () => { + new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + guard.unregister.mockClear(); + + for (const handler of spawnState.exitHandlers) { + handler(143, null); + } + expect(guard.unregister).not.toHaveBeenCalled(); + + for (const handler of spawnState.closeHandlers) { + handler(143, null); + } + expect(guard.unregister).toHaveBeenCalledTimes(1); + expect(guard.unregister).toHaveBeenCalledWith({ childPid: 424242 }); }); test('does not register guard for non-agent ACP backends', () => { for (const command of ['gemini', 'opencode', 'kimi']) { guard.register.mockClear(); guard.unregister.mockClear(); + guard.recordChildPid.mockClear(); new AcpStdioTransport({ command }); expect(guard.register).not.toHaveBeenCalled(); + expect(guard.recordChildPid).not.toHaveBeenCalled(); expect(guard.unregister).not.toHaveBeenCalled(); } }); @@ -258,7 +323,7 @@ describe('AcpStdioTransport closed stdin writes', () => { } await expect(transport.sendRequest('session/load')).rejects.toThrow( - /ACP process exited \(code=1, signal=null\)\. stderr: Cannot use this model: grok-4\.5\[fast=true\]/ + /ACP process exited \(code=1, signal=null(?:, childPid=\d+, lock=[^)]+)?\)\. stderr: Cannot use this model: grok-4\.5\[fast=true\]/ ); }); diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.ts b/cli/src/agent/backends/acp/AcpStdioTransport.ts index c8807180f8..c576412c38 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.ts @@ -2,7 +2,13 @@ import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptions } from 'n import { logger } from '@/ui/logger'; import { killProcessByChildProcess } from '@/utils/process'; import { GEMINI_MODEL_PRESETS } from '@hapi/protocol'; -import { registerActiveAcpTransport, unregisterActiveAcpTransport } from './agentCliGuard'; +import { + describeAgentAcpGuardState, + getAgentAcpLockDir, + recordActiveAcpChildPid, + registerActiveAcpTransport, + unregisterActiveAcpTransport +} from './agentCliGuard'; import { matchesAcpHttp2Cancel, matchesAcpRetryBackoff } from './acpStderrErrors'; interface JsonRpcRequest { @@ -73,6 +79,8 @@ export class AcpStdioTransport { /** True after process 'exit'; blocks new writes until 'close' drains stderr. */ private exited = false; private exitError: Error | null = null; + /** ACP child PID when known (for lock attribution / exit logs). */ + private childPid: number | null = null; /** Rolling join window for stderr before close-time classification. */ private static readonly RECENT_STDERR_WINDOW = 8_000; @@ -85,6 +93,12 @@ export class AcpStdioTransport { env?: Record; }) { this.shouldGuardAgentCli = options.command === 'agent'; + // Register before spawn so runner/list-models cannot observe an unlocked + // window between process creation and lock write (#1472). + if (this.shouldGuardAgentCli) { + registerActiveAcpTransport(); + } + this.process = spawn( options.command, options.args ?? [], @@ -92,7 +106,12 @@ export class AcpStdioTransport { ) as ChildProcessWithoutNullStreams; if (this.shouldGuardAgentCli) { - registerActiveAcpTransport(); + const childPid = typeof this.process.pid === 'number' ? this.process.pid : null; + this.childPid = childPid; + if (childPid !== null) { + recordActiveAcpChildPid(childPid); + } + logger.debug('[ACP] agent CLI guard armed', describeAgentAcpGuardState(childPid)); } this.process.stdout.setEncoding('utf8'); @@ -128,12 +147,24 @@ export class AcpStdioTransport { // Block new stdin writes as soon as the process exits, but defer markClosed // until 'close' so final stderr chunks can still enrich the failure. + // Do NOT release the agent CLI guard here — exit→close is exactly when + // list-models can race another `agent` and SIGTERM remaining ACP children. this.process.on('exit', (code, signal) => { - this.releaseAgentCliGuard(); this.exited = true; - this.exitError = new Error( - `ACP process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})` - ); + const attribution = this.formatExitAttribution(code, signal); + const guardState = describeAgentAcpGuardState(this.childPid); + logger.debug(`[ACP] process exit ${attribution}`, guardState); + if (guardState.childAlive === true) { + // Node reported exit, but the recorded ACP PID is still alive — + // likely a Cursor-internal worker/stdio quirk. Do not claim a + // definitive process death in the error string operators grep. + this.exitError = new Error( + `ACP transport reported exit (${attribution}) but OS PID ${this.childPid} is still alive ` + + `(lock=${getAgentAcpLockDir()}); treating as transport disruption, not confirmed child death` + ); + } else { + this.exitError = new Error(`ACP process exited (${attribution})`); + } }); // Use 'close' (not only 'exit') so final stderr chunks are drained before we @@ -141,12 +172,17 @@ export class AcpStdioTransport { this.process.on('close', (code, signal) => { this.releaseAgentCliGuard(); this.flushStderrParseBuffer(); + const attribution = this.formatExitAttribution(code, signal); + const guardState = describeAgentAcpGuardState(this.childPid); const stderr = this.stderrForCloseError(); - let message = `ACP process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`; + let message = guardState.childAlive === true + ? `ACP transport closed (${attribution}) but OS PID ${this.childPid} is still alive ` + + `(lock=${getAgentAcpLockDir()})` + : `ACP process exited (${attribution})`; if (stderr) { message = `${message}. stderr: ${stderr}`; } - logger.debug(message); + logger.debug(message, guardState); const error = new Error(message); if (stderr) { (error as Error & { stderr?: string }).stderr = stderr; @@ -249,12 +285,23 @@ export class AcpStdioTransport { this.markClosed(new Error('ACP transport closed')); } + private formatExitAttribution(code: number | null, signal: NodeJS.Signals | null): string { + const base = `code=${code ?? 'null'}, signal=${signal ?? 'null'}`; + if (!this.shouldGuardAgentCli) { + return base; + } + const child = this.childPid ?? this.process.pid ?? 'unknown'; + return `${base}, childPid=${child}, lock=${getAgentAcpLockDir()}`; + } + private releaseAgentCliGuard(): void { if (!this.shouldGuardAgentCli || this.guardReleased) { return; } this.guardReleased = true; - unregisterActiveAcpTransport(); + unregisterActiveAcpTransport( + this.childPid !== null ? { childPid: this.childPid } : undefined + ); } private handleStdout(chunk: string): void { diff --git a/cli/src/agent/backends/acp/agentCliGuard.test.ts b/cli/src/agent/backends/acp/agentCliGuard.test.ts index e9c783321d..8f54379fc4 100644 --- a/cli/src/agent/backends/acp/agentCliGuard.test.ts +++ b/cli/src/agent/backends/acp/agentCliGuard.test.ts @@ -1,10 +1,15 @@ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, readdirSync, utimesSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { afterEach, describe, expect, test } from 'vitest'; import { _resetAgentCliGuardForTests, + _setActiveAcpTransportCountForTests, + _setAddLockPidHookForTests, + _setRegisterPublishHookForTests, + getAgentAcpLockDir, isAgentAcpTransportActive, + recordActiveAcpChildPid, registerActiveAcpTransport, unregisterActiveAcpTransport } from './agentCliGuard'; @@ -34,6 +39,9 @@ describe('agentCliGuard', () => { const previousHome = process.env.HAPI_HOME; afterEach(() => { + // Always tear down under the isolated test home — never while HAPI_HOME + // is unset (that would resolve ~/.hapi and could wipe a live ACP guard). + process.env.HAPI_HOME = testHome; _resetAgentCliGuardForTests(); if (previousHome === undefined) { delete process.env.HAPI_HOME; @@ -98,11 +106,25 @@ describe('agentCliGuard', () => { const dir = lockDir(); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'count'), '1', 'utf8'); + // Age the lock past the pre-spawn grace so missing pids is truly stale. + const aged = Date.now() - 60_000; + utimesSync(dir, aged / 1000, aged / 1000); expect(isAgentAcpTransportActive()).toBe(false); expect(existsSync(dir)).toBe(false); }); + test('keeps a fresh count-without-pids lock fail-closed during pre-spawn grace', () => { + process.env.HAPI_HOME = testHome; + const dir = lockDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'count'), '1', 'utf8'); + _setActiveAcpTransportCountForTests(0); + + expect(isAgentAcpTransportActive()).toBe(true); + expect(existsSync(dir)).toBe(true); + }); + test('clears refcount lock when all pid entries are stale', () => { process.env.HAPI_HOME = testHome; writeTestAcpLock({ count: 2, pids: [99999998, 99999999] }); @@ -118,4 +140,160 @@ describe('agentCliGuard', () => { expect(isAgentAcpTransportActive()).toBe(true); expect(existsSync(lockDir())).toBe(true); }); + + test('records the ACP child PID when provided, not only the HAPI host PID', () => { + process.env.HAPI_HOME = testHome; + // Distinct from process.pid so host + child markers are both asserted. + const childPid = process.pid + 1_000_000; + registerActiveAcpTransport({ childPid }); + + const dir = lockDir(); + expect(existsSync(join(dir, 'pids', String(process.pid)))).toBe(true); + expect(existsSync(join(dir, 'pids', String(childPid)))).toBe(true); + expect(readFileSync(join(dir, 'child-pid'), 'utf8').trim()).toBe(String(childPid)); + + unregisterActiveAcpTransport({ childPid }); + expect(existsSync(dir)).toBe(false); + }); + + test('recordActiveAcpChildPid upgrades a pre-spawn reservation to the real child PID', () => { + process.env.HAPI_HOME = testHome; + registerActiveAcpTransport(); + const childPid = process.pid + 1_000_001; + recordActiveAcpChildPid(childPid); + + const dir = lockDir(); + expect(existsSync(join(dir, 'pids', String(process.pid)))).toBe(true); + expect(existsSync(join(dir, 'pids', String(childPid)))).toBe(true); + expect(readFileSync(join(dir, 'child-pid'), 'utf8').trim()).toBe(String(childPid)); + expect(isAgentAcpTransportActive()).toBe(true); + + unregisterActiveAcpTransport({ childPid }); + expect(isAgentAcpTransportActive()).toBe(false); + }); + + test('uses ~/.hapi lock home when HAPI_HOME is unset (not /tmp/hapi)', () => { + delete process.env.HAPI_HOME; + try { + const expected = join(homedir(), '.hapi', 'locks', 'agent-acp-active'); + expect(getAgentAcpLockDir()).toBe(expected); + expect(getAgentAcpLockDir()).not.toContain(join(tmpdir(), 'hapi')); + } finally { + // Restore isolated home before afterEach reset (belt + suspenders). + process.env.HAPI_HOME = testHome; + } + }); + + test('publishes host pid marker before count so mid-register readers stay active', () => { + process.env.HAPI_HOME = testHome; + const steps: string[] = []; + _setRegisterPublishHookForTests((step) => { + steps.push(step); + if (step === 'after-host-pid') { + // Cross-process reader: no in-process reservation yet for them. + _setActiveAcpTransportCountForTests(0); + expect(existsSync(join(lockDir(), 'pids', String(process.pid)))).toBe(true); + expect(existsSync(join(lockDir(), 'count'))).toBe(false); + expect(isAgentAcpTransportActive()).toBe(true); + expect(existsSync(lockDir())).toBe(true); + } + if (step === 'after-mkdir') { + _setActiveAcpTransportCountForTests(0); + // Grace keeps the mkdir-only reservation fail-closed. + expect(isAgentAcpTransportActive()).toBe(true); + expect(existsSync(lockDir())).toBe(true); + } + }); + + registerActiveAcpTransport(); + expect(steps).toEqual(['after-mkdir', 'after-host-pid', 'after-count']); + expect(isAgentAcpTransportActive()).toBe(true); + _setRegisterPublishHookForTests(null); + unregisterActiveAcpTransport(); + }); + + test('host-pid-without-count reservation is not cleared as stale by reconcile', () => { + process.env.HAPI_HOME = testHome; + const dir = lockDir(); + mkdirSync(join(dir, 'pids'), { recursive: true }); + writeFileSync(join(dir, 'pids', String(process.pid)), String(process.pid), 'utf8'); + // No count file — the old race window after count-before-pids, inverted. + _setActiveAcpTransportCountForTests(0); + + expect(isAgentAcpTransportActive()).toBe(true); + expect(existsSync(dir)).toBe(true); + expect(existsSync(join(dir, 'pids', String(process.pid)))).toBe(true); + }); + + test('empty pids/ mid-addLockPid stays active for concurrent readers', () => { + process.env.HAPI_HOME = testHome; + let sawEmptyPids = false; + _setAddLockPidHookForTests((phase) => { + if (phase !== 'after-pids-mkdir') { + return; + } + sawEmptyPids = true; + _setActiveAcpTransportCountForTests(0); + const dir = lockDir(); + expect(existsSync(join(dir, 'pids'))).toBe(true); + expect(existsSync(join(dir, 'count'))).toBe(false); + expect(readdirSync(join(dir, 'pids'))).toEqual([]); + expect(existsSync(join(dir, 'registering', String(process.pid)))).toBe(true); + expect(isAgentAcpTransportActive()).toBe(true); + expect(existsSync(dir)).toBe(true); + }); + + registerActiveAcpTransport(); + expect(sawEmptyPids).toBe(true); + _setAddLockPidHookForTests(null); + unregisterActiveAcpTransport(); + expect(isAgentAcpTransportActive()).toBe(false); + }); + + test('last unregister does not erase concurrent mid-addLockPid registration', () => { + process.env.HAPI_HOME = testHome; + registerActiveAcpTransport(); + expect(readFileSync(join(lockDir(), 'count'), 'utf8')).toBe('1'); + + let sawRace = false; + _setAddLockPidHookForTests((phase) => { + if (phase !== 'after-pids-mkdir') { + return; + } + sawRace = true; + // Prior transport's last unregister while the new registrar has + // empty-or-about-to-rewrite pids/ and a live `registering/`. + // Force last-unregister semantics (in-process count → 0). + _setActiveAcpTransportCountForTests(1); + unregisterActiveAcpTransport(); + expect(existsSync(join(lockDir(), 'registering', String(process.pid)))).toBe(true); + _setActiveAcpTransportCountForTests(0); + expect(isAgentAcpTransportActive()).toBe(true); + expect(existsSync(lockDir())).toBe(true); + }); + + registerActiveAcpTransport(); + expect(sawRace).toBe(true); + _setAddLockPidHookForTests(null); + _setActiveAcpTransportCountForTests(1); + expect(existsSync(join(lockDir(), 'pids', String(process.pid)))).toBe(true); + expect(existsSync(join(lockDir(), 'registering', String(process.pid)))).toBe(false); + expect(isAgentAcpTransportActive()).toBe(true); + unregisterActiveAcpTransport(); + expect(isAgentAcpTransportActive()).toBe(false); + }); + + test('prunes crash-stale registering/ so list-models is not pinned', () => { + process.env.HAPI_HOME = testHome; + const dir = lockDir(); + mkdirSync(join(dir, 'registering'), { recursive: true }); + mkdirSync(join(dir, 'pids'), { recursive: true }); + writeFileSync(join(dir, 'count'), '1', 'utf8'); + // Unlikely-to-be-alive PID — marker left by SIGKILL mid-publish. + writeFileSync(join(dir, 'registering', '999999'), '1', 'utf8'); + _setActiveAcpTransportCountForTests(0); + + expect(isAgentAcpTransportActive()).toBe(false); + expect(existsSync(dir)).toBe(false); + }); }); diff --git a/cli/src/agent/backends/acp/agentCliGuard.ts b/cli/src/agent/backends/acp/agentCliGuard.ts index b4937ef004..5540c0fbf2 100644 --- a/cli/src/agent/backends/acp/agentCliGuard.ts +++ b/cli/src/agent/backends/acp/agentCliGuard.ts @@ -4,10 +4,11 @@ import { readdirSync, readFileSync, rmSync, + statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; +import { resolveHapiHomeDir } from '@/configuration'; /** * Cursor's `agent` CLI appears to allow only one active process at a time. @@ -16,18 +17,115 @@ import { tmpdir } from 'node:os'; * * In-process ref counting covers RPC handlers in the same process; a HAPI_HOME * lock directory covers runner vs session child processes. + * + * Prefer recording the ACP child PID (not only the HAPI host PID) so stale + * cleanup and logs attribute the real `agent` process. Register the lock + * before spawn, and keep it held until stdio `close` — releasing on bare + * `exit` opens a window where list-models can start another `agent`. + * + * Filesystem publish order is fail-closed: host PID marker under `pids/` is + * written before `count`, so concurrent reconcile never sees a lock with no + * pids and clears it mid-reservation. Per-host `registering/` markers + * cover the mkdir→pid gap even when a prior transport left a positive + * `count` (last-unregister vs concurrent register); dead-owner markers are + * pruned so a crash cannot pin list-models forever. Mtime grace is a + * backstop for the tiny window before that marker lands. */ let activeAcpTransportCount = 0; +/** @internal Test hook fired between register publish steps. */ +let registerPublishHook: ((step: 'after-mkdir' | 'after-host-pid' | 'after-count') => void) | null = null; + +/** @internal Test hook inside addLockPid (mkdir vs write gap). */ +let addLockPidHook: ((phase: 'after-pids-mkdir' | 'after-pid-write') => void) | null = null; + +/** Fail-closed window while mkdir → first pid file is in flight. */ +const PRESPAWN_RESERVATION_GRACE_MS = 5_000; + +const REGISTERING_MARKER = 'registering'; + +export type AgentAcpGuardPidOptions = { + /** Spawned `agent` child PID when known. */ + childPid?: number; +}; + +function normalizePid(pid: number | undefined): number | null { + if (pid === undefined || !Number.isInteger(pid) || pid <= 0) { + return null; + } + return pid; +} + +export function getAgentAcpLockDir(): string { + return join(resolveHapiHomeDir(), 'locks', 'agent-acp-active'); +} + function getAcpLockDir(): string { - const home = process.env.HAPI_HOME?.trim() || join(tmpdir(), 'hapi'); - return join(home, 'locks', 'agent-acp-active'); + return getAgentAcpLockDir(); } function getPidsDir(lockDir: string): string { return join(lockDir, 'pids'); } +function getRegisteringDir(lockDir: string): string { + return join(lockDir, REGISTERING_MARKER); +} + +function beginRegistering(lockDir: string): void { + const dir = getRegisteringDir(lockDir); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, String(process.pid)), String(Date.now()), 'utf8'); +} + +function endRegistering(lockDir: string): void { + try { + rmSync(join(getRegisteringDir(lockDir), String(process.pid)), { force: true }); + } catch { + // Best effort. + } +} + +/** True if any live host still holds a mid-publish reservation marker. */ +function isRegistering(lockDir: string): boolean { + const dir = getRegisteringDir(lockDir); + if (!existsSync(dir)) { + return false; + } + + let anyLive = false; + for (const entry of readdirSync(dir)) { + const pid = Number(entry); + if (!Number.isInteger(pid) || pid <= 0) { + try { + rmSync(join(dir, entry), { force: true }); + } catch { + // Best effort. + } + continue; + } + if (isProcessAlive(pid)) { + anyLive = true; + continue; + } + try { + rmSync(join(dir, entry), { force: true }); + } catch { + // Best effort — crash/reboot left a dead registrar marker. + } + } + return anyLive; +} + +function isFreshPrespawnReservation(lockDir: string): boolean { + try { + return Date.now() - statSync(lockDir).mtimeMs < PRESPAWN_RESERVATION_GRACE_MS; + } catch { + // Fail closed — prefer keeping a disputed lock over list-models SIGTERM. + return true; + } +} + function readLockPid(lockDir: string): number | null { const pidPath = join(lockDir, 'pid'); if (!existsSync(pidPath)) { @@ -68,10 +166,38 @@ function writeLockCount(lockDir: string, count: number): void { writeFileSync(join(lockDir, 'count'), String(Math.max(0, count)), 'utf8'); } +function writeChildPidHint(lockDir: string, childPid: number): void { + writeFileSync(join(lockDir, 'child-pid'), String(childPid), 'utf8'); +} + +function clearChildPidHint(lockDir: string): void { + try { + rmSync(join(lockDir, 'child-pid'), { force: true }); + } catch { + // Best effort. + } +} + function addLockPid(lockDir: string, pid: number): void { const pidsDir = getPidsDir(lockDir); - mkdirSync(pidsDir, { recursive: true }); - writeFileSync(join(pidsDir, String(pid)), String(pid), 'utf8'); + const pidPath = join(pidsDir, String(pid)); + // Retry once if a concurrent last-unregister deleted the lock mid-publish. + for (let attempt = 0; attempt < 2; attempt++) { + mkdirSync(lockDir, { recursive: true }); + mkdirSync(pidsDir, { recursive: true }); + addLockPidHook?.('after-pids-mkdir'); + try { + writeFileSync(pidPath, String(pid), { encoding: 'utf8', flag: 'w' }); + addLockPidHook?.('after-pid-write'); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (attempt === 0 && (code === 'ENOENT' || code === 'ENOTDIR')) { + continue; + } + throw error; + } + } } function removeLockPid(lockDir: string, pid: number): void { @@ -112,6 +238,10 @@ function removeAcpLockDir(): void { function reconcileRefcountLock(lockDir: string): boolean { const pidsDir = getPidsDir(lockDir); if (!existsSync(pidsDir)) { + // Registrar mid-publish, or grace before `registering` / first pid. + if (isRegistering(lockDir) || isFreshPrespawnReservation(lockDir)) { + return true; + } removeAcpLockDir(); return false; } @@ -141,6 +271,34 @@ function reconcileRefcountLock(lockDir: string): boolean { } if (liveCount <= 0) { + // Re-read: registrar may have published a pid during our scan, or we + // are between mkdir(pids) and writeFile (empty dir — fail closed). + // A live `registering` marker covers overlap with leftover count>0 + // from a concurrent last-unregister. + let entries: string[] = []; + try { + entries = readdirSync(pidsDir); + } catch { + entries = []; + } + const liveAgain = entries.filter((entry) => { + const pid = Number(entry); + return Number.isInteger(pid) && pid > 0 && isProcessAlive(pid); + }); + if (liveAgain.length > 0) { + writeLockCount(lockDir, liveAgain.length); + return true; + } + if (isRegistering(lockDir)) { + return true; + } + if ( + entries.length === 0 + && readLockCount(lockDir) <= 0 + && (isFreshPrespawnReservation(pidsDir) || isFreshPrespawnReservation(lockDir)) + ) { + return true; + } removeAcpLockDir(); return false; } @@ -167,19 +325,59 @@ function clearStaleAcpLockIfNeeded(): void { reconcileRefcountLock(lockDir); } -export function registerActiveAcpTransport(): void { +/** + * Reserve / register the ACP lock. Call before spawn (no childPid) so + * list-models cannot race the new `agent` process, then call + * {@link recordActiveAcpChildPid} once the child PID is known. + * + * Publish order is fail-closed: `pids/` (and optional child) land + * before `count`, so concurrent reconcile never treats the reservation as + * a lock with no pids. + */ +export function registerActiveAcpTransport(options?: AgentAcpGuardPidOptions): void { activeAcpTransportCount += 1; const lockDir = getAcpLockDir(); + const childPid = normalizePid(options?.childPid); try { mkdirSync(lockDir, { recursive: true }); - writeLockCount(lockDir, readLockCount(lockDir) + 1); + beginRegistering(lockDir); + registerPublishHook?.('after-mkdir'); + // Always keep the HAPI host PID for crash/stale cleanup of the session + // process; also record the ACP child when known — before count. addLockPid(lockDir, process.pid); + if (childPid !== null) { + addLockPid(lockDir, childPid); + writeChildPidHint(lockDir, childPid); + } + registerPublishHook?.('after-host-pid'); + writeLockCount(lockDir, readLockCount(lockDir) + 1); + registerPublishHook?.('after-count'); } catch { // Another process may have created the lock; in-process guard still applies. + } finally { + endRegistering(lockDir); } } -export function unregisterActiveAcpTransport(): void { +/** Upgrade a pre-spawn reservation with the real ACP child PID. */ +export function recordActiveAcpChildPid(childPid: number): void { + const pid = normalizePid(childPid); + if (pid === null) { + return; + } + const lockDir = getAcpLockDir(); + if (!existsSync(lockDir)) { + return; + } + try { + addLockPid(lockDir, pid); + writeChildPidHint(lockDir, pid); + } catch { + // Best effort. + } +} + +export function unregisterActiveAcpTransport(options?: AgentAcpGuardPidOptions): void { activeAcpTransportCount = Math.max(0, activeAcpTransportCount - 1); const lockDir = getAcpLockDir(); @@ -195,8 +393,13 @@ export function unregisterActiveAcpTransport(): void { } try { + const childPid = normalizePid(options?.childPid); + if (childPid !== null) { + removeLockPid(lockDir, childPid); + } if (activeAcpTransportCount <= 0) { removeLockPid(lockDir, process.pid); + clearChildPidHint(lockDir); } reconcileRefcountLock(lockDir); } catch { @@ -219,10 +422,54 @@ export function isAgentAcpTransportActive(): boolean { return pid !== null && isProcessAlive(pid); } - return readLockCount(lockDir) > 0; + if (readLockCount(lockDir) > 0) { + return true; + } + // Mid-publish: registering marker or mtime grace without a count yet. + if (isRegistering(lockDir)) { + return true; + } + return isFreshPrespawnReservation(lockDir); +} + +/** Debug attribution for exit / list-models races (PID, lock dir, activity). */ +export function describeAgentAcpGuardState(childPid?: number | null): { + lockDir: string; + inProcessCount: number; + childPid: number | null; + childAlive: boolean | null; + guardActive: boolean; +} { + const pid = normalizePid(childPid ?? undefined); + return { + lockDir: getAgentAcpLockDir(), + inProcessCount: activeAcpTransportCount, + childPid: pid, + childAlive: pid === null ? null : isProcessAlive(pid), + guardActive: isAgentAcpTransportActive() + }; +} + +export function _setRegisterPublishHookForTests( + hook: ((step: 'after-mkdir' | 'after-host-pid' | 'after-count') => void) | null +): void { + registerPublishHook = hook; +} + +export function _setAddLockPidHookForTests( + hook: ((phase: 'after-pids-mkdir' | 'after-pid-write') => void) | null +): void { + addLockPidHook = hook; +} + +/** Simulate a cross-process reader (no in-process reservation). */ +export function _setActiveAcpTransportCountForTests(count: number): void { + activeAcpTransportCount = Math.max(0, count); } export function _resetAgentCliGuardForTests(): void { activeAcpTransportCount = 0; + registerPublishHook = null; + addLockPidHook = null; removeAcpLockDir(); } diff --git a/cli/src/modules/common/cursorModelsSharedCache.ts b/cli/src/modules/common/cursorModelsSharedCache.ts index 6a3d5fce5d..b7e508497d 100644 --- a/cli/src/modules/common/cursorModelsSharedCache.ts +++ b/cli/src/modules/common/cursorModelsSharedCache.ts @@ -1,10 +1,10 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { tmpdir } from 'node:os'; import type { CursorModelsResponse } from '@hapi/protocol/apiTypes'; +import { resolveHapiHomeDir } from '@/configuration'; function getHapiHomeDir(): string { - return process.env.HAPI_HOME?.trim() || join(tmpdir(), 'hapi'); + return resolveHapiHomeDir(); } function getSharedCachePath(): string { From 51ae260a3fbe699a1af7a44a89afa4b27fc02493 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:10:36 +0100 Subject: [PATCH 077/142] feat(web): settings for AGENT_NOTIFY_SUMMARY chat display (default hide) (#1477) * feat(web): render AGENT_NOTIFY_SUMMARY as compact metadata * fix(web): defer summary rendering until completion * fix(shared): preserve indentation when splitting summaries * fix(web): guard unknown summary statuses * feat(web): settings for AGENT_NOTIFY_SUMMARY chat display (default hide) Add hub setting sibling to emit (#1376): show compact NotifySummaryText when on; strip footer from chat/copy when off. Store/parse/FCM unchanged. Co-authored-by: Cursor * fix(web): address #1477 Majors for display setting Allow any namespace to GET hub-settings (PUT stays owner-only) so sessionSummaryInChat applies hub-wide. Reject whitespace-delimited AGENT_NOTIFY_SUMMARY examples so default-hide does not eat prose. Co-authored-by: Cursor * fix(shared): allow indented AGENT_NOTIFY_SUMMARY footers Keep rejecting whitespace-delimited prose examples, but accept a standalone footer whose only prefix is leading indentation. Co-authored-by: Cursor * fix(web): poll hub display setting; hide empty notify footers Refetch sessionSummaryInChat so open clients pick up owner toggles. When display is on, recognized footers without status/summary/action still strip raw JSON instead of falling back to MarkdownText. Co-authored-by: Cursor * fix(web): poll notify display once in chat shell Move hub-settings refetchInterval off per-message hooks into HappyThread context. Strip well-formed footers while streaming when display is off. Co-authored-by: Cursor * fix(web,hub): QueryClient for HappyThread tests; atomic hub-settings Wrap HappyThread mobile-scroll tests in QueryClientProvider after the chat-shell hub-settings poll. Read/write both hub setting flags in one settings.json snapshot under the shared lock. Co-authored-by: Cursor --------- Co-authored-by: Ananovo <78636812+techotaku39@users.noreply.github.com> Co-authored-by: Cursor --- hub/src/config/sessionSummaryInChat.ts | 36 +++++ hub/src/config/settings.ts | 5 + hub/src/web/routes/hubSettings.test.ts | 77 ++++++++-- hub/src/web/routes/hubSettings.ts | 43 ++++-- shared/src/apiTypes.ts | 16 ++- shared/src/messages.test.ts | 81 +++++++++++ shared/src/messages.ts | 127 ++++++++++++---- web/src/api/client.ts | 3 +- .../HappyThread.mobile-scroll.test.tsx | 58 ++++---- .../components/AssistantChat/HappyThread.tsx | 12 ++ web/src/components/AssistantChat/context.tsx | 2 + .../messages/AssistantMessage.tsx | 10 +- .../messages/NotifySummaryText.test.tsx | 116 +++++++++++++++ .../messages/NotifySummaryText.tsx | 135 ++++++++++++++++++ .../ToolMessage.generatedMedia.test.tsx | 1 + .../messages/assistantCopyText.test.ts | 12 ++ .../messages/assistantCopyText.ts | 12 +- .../ToolCard/ToolGroupCard.test.tsx | 5 + web/src/hooks/useSessionSummaryInChat.ts | 12 ++ web/src/lib/locales/en.ts | 10 ++ web/src/lib/locales/zh-CN.ts | 10 ++ web/src/routes/settings/general.tsx | 33 +++-- web/src/routes/settings/index.test.tsx | 9 +- 23 files changed, 719 insertions(+), 106 deletions(-) create mode 100644 hub/src/config/sessionSummaryInChat.ts create mode 100644 web/src/components/AssistantChat/messages/NotifySummaryText.test.tsx create mode 100644 web/src/components/AssistantChat/messages/NotifySummaryText.tsx create mode 100644 web/src/hooks/useSessionSummaryInChat.ts diff --git a/hub/src/config/sessionSummaryInChat.ts b/hub/src/config/sessionSummaryInChat.ts new file mode 100644 index 0000000000..8f4187fcde --- /dev/null +++ b/hub/src/config/sessionSummaryInChat.ts @@ -0,0 +1,36 @@ +import { + getSettingsFile, + readSettingsOrThrow, + updateSettings, + type Settings +} from './settings' + +/** + * Hub-persisted opt-in to show AGENT_NOTIFY_SUMMARY in chat UI. + * Default is off (undefined / false): render/copy strip the footer; + * store, parse, FCM, and ledger capture keep the raw line. + */ +export function isSessionSummaryInChatSettingEnabled(settings: Settings): boolean { + return settings.sessionSummaryInChat === true +} + +export async function readSessionSummaryInChatEnabled(dataDir: string): Promise { + const settings = await readSettingsOrThrow(getSettingsFile(dataDir)) + return isSessionSummaryInChatSettingEnabled(settings) +} + +export async function writeSessionSummaryInChatEnabled( + dataDir: string, + enabled: boolean +): Promise { + return updateSettings(getSettingsFile(dataDir), (current) => { + const settings = { + ...current, + sessionSummaryInChat: enabled + } + return { + settings, + result: settings.sessionSummaryInChat === true + } + }) +} diff --git a/hub/src/config/settings.ts b/hub/src/config/settings.ts index eaa2384f6e..76c5230a38 100644 --- a/hub/src/config/settings.ts +++ b/hub/src/config/settings.ts @@ -29,6 +29,11 @@ export interface Settings { * into supported flavor system / developer instructions. Default off. */ sessionSummaryContract?: boolean + /** + * When true, web chat shows a compact AGENT_NOTIFY_SUMMARY row. + * Default off: render/copy strip the footer; store stays raw. + */ + sessionSummaryInChat?: boolean /** * Hub-side provider API keys / endpoints managed from Settings. * Env vars still win when set at process start (ops override). diff --git a/hub/src/web/routes/hubSettings.test.ts b/hub/src/web/routes/hubSettings.test.ts index 9b3f54903c..10ddfd6ae6 100644 --- a/hub/src/web/routes/hubSettings.test.ts +++ b/hub/src/web/routes/hubSettings.test.ts @@ -6,6 +6,7 @@ import { Hono } from 'hono' import type { WebAppEnv } from '../middleware/auth' import { createHubSettingsRoutes } from './hubSettings' import { writeSessionSummaryContractEnabled } from '../../config/sessionSummaryContract' +import { writeSessionSummaryInChatEnabled } from '../../config/sessionSummaryInChat' const directories: string[] = [] @@ -26,15 +27,18 @@ describe('GET/PUT /api/hub-settings', () => { return { app, dataDir } } - it('returns default off', async () => { + it('returns default off for emit and chat display', async () => { const { app } = await createApp() const response = await app.request('/api/hub-settings') expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') - expect(await response.json()).toEqual({ sessionSummaryContract: false }) + expect(await response.json()).toEqual({ + sessionSummaryContract: false, + sessionSummaryInChat: false + }) }) - it('persists toggle for owner', async () => { + it('persists emit toggle for owner without changing display', async () => { const { app } = await createApp() const put = await app.request('/api/hub-settings', { method: 'PUT', @@ -42,10 +46,42 @@ describe('GET/PUT /api/hub-settings', () => { body: JSON.stringify({ sessionSummaryContract: true }) }) expect(put.status).toBe(200) - expect(await put.json()).toEqual({ sessionSummaryContract: true }) + expect(await put.json()).toEqual({ + sessionSummaryContract: true, + sessionSummaryInChat: false + }) const get = await app.request('/api/hub-settings') - expect(await get.json()).toEqual({ sessionSummaryContract: true }) + expect(await get.json()).toEqual({ + sessionSummaryContract: true, + sessionSummaryInChat: false + }) + }) + + it('persists chat display toggle for owner without changing emit', async () => { + const { app, dataDir } = await createApp() + await writeSessionSummaryContractEnabled(dataDir, true) + + const put = await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionSummaryInChat: true }) + }) + expect(put.status).toBe(200) + expect(await put.json()).toEqual({ + sessionSummaryContract: true, + sessionSummaryInChat: true + }) + }) + + it('rejects empty body', async () => { + const { app } = await createApp() + const response = await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}) + }) + expect(response.status).toBe(400) }) it('rejects invalid body', async () => { @@ -58,12 +94,25 @@ describe('GET/PUT /api/hub-settings', () => { expect(response.status).toBe(400) }) - it('rejects non-default namespaces', async () => { - const { app } = await createApp('tenant') - const get = await app.request('/api/hub-settings') - expect(get.status).toBe(403) + it('rejects non-default namespaces for PUT but allows GET', async () => { + const { app, dataDir } = await createApp('default') + await writeSessionSummaryInChatEnabled(dataDir, true) - const put = await app.request('/api/hub-settings', { + const tenantApp = new Hono() + tenantApp.use('*', async (c, next) => { + c.set('namespace', 'tenant') + await next() + }) + tenantApp.route('/api', createHubSettingsRoutes(dataDir)) + + const get = await tenantApp.request('/api/hub-settings') + expect(get.status).toBe(200) + expect(await get.json()).toEqual({ + sessionSummaryContract: false, + sessionSummaryInChat: true + }) + + const put = await tenantApp.request('/api/hub-settings', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionSummaryContract: true }) @@ -71,10 +120,14 @@ describe('GET/PUT /api/hub-settings', () => { expect(put.status).toBe(403) }) - it('survives a prior write via settings helper', async () => { + it('survives a prior write via settings helpers', async () => { const { app, dataDir } = await createApp() await writeSessionSummaryContractEnabled(dataDir, true) + await writeSessionSummaryInChatEnabled(dataDir, true) const response = await app.request('/api/hub-settings') - expect(await response.json()).toEqual({ sessionSummaryContract: true }) + expect(await response.json()).toEqual({ + sessionSummaryContract: true, + sessionSummaryInChat: true + }) }) }) diff --git a/hub/src/web/routes/hubSettings.ts b/hub/src/web/routes/hubSettings.ts index f641aa7ac7..3d2a087789 100644 --- a/hub/src/web/routes/hubSettings.ts +++ b/hub/src/web/routes/hubSettings.ts @@ -1,24 +1,31 @@ import { Hono } from 'hono' import { UpdateHubSettingsRequestSchema, type HubSettingsResponse } from '@hapi/protocol' import { - readSessionSummaryContractEnabled, - writeSessionSummaryContractEnabled -} from '../../config/sessionSummaryContract' + getSettingsFile, + readSettingsOrThrow, + updateSettings, + type Settings +} from '../../config/settings' import type { WebAppEnv } from '../middleware/auth' const OWNER_ONLY_ERROR = 'Hub settings are only available to the hub owner' +function toHubSettings(settings: Settings): HubSettingsResponse { + return { + sessionSummaryContract: settings.sessionSummaryContract === true, + sessionSummaryInChat: settings.sessionSummaryInChat === true + } +} + export function createHubSettingsRoutes(dataDir: string): Hono { const app = new Hono() + // Authenticated readers (any namespace) can observe hub-wide display/emit + // flags. Mutations stay owner-only below. app.get('/hub-settings', async (c) => { - if (c.get('namespace') !== 'default') { - return c.json({ error: OWNER_ONLY_ERROR }, 403) - } c.header('Cache-Control', 'no-store') - const enabled = await readSessionSummaryContractEnabled(dataDir) - const response: HubSettingsResponse = { sessionSummaryContract: enabled } - return c.json(response) + const settings = await readSettingsOrThrow(getSettingsFile(dataDir)) + return c.json(toHubSettings(settings)) }) app.put('/hub-settings', async (c) => { @@ -30,12 +37,20 @@ export function createHubSettingsRoutes(dataDir: string): Hono { if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } - const enabled = await writeSessionSummaryContractEnabled( - dataDir, - parsed.data.sessionSummaryContract - ) + const response = await updateSettings(getSettingsFile(dataDir), (current) => { + const settings: Settings = { ...current } + if (parsed.data.sessionSummaryContract !== undefined) { + settings.sessionSummaryContract = parsed.data.sessionSummaryContract + } + if (parsed.data.sessionSummaryInChat !== undefined) { + settings.sessionSummaryInChat = parsed.data.sessionSummaryInChat + } + return { + settings, + result: toHubSettings(settings) + } + }) c.header('Cache-Control', 'no-store') - const response: HubSettingsResponse = { sessionSummaryContract: enabled } return c.json(response) }) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index ca90e63527..14ec99b003 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -58,14 +58,22 @@ export const CreateSessionResponseSchema = z.object({ export type CreateSessionResponse = z.infer export const HubSettingsResponseSchema = z.object({ - sessionSummaryContract: z.boolean() + sessionSummaryContract: z.boolean(), + /** Show compact AGENT_NOTIFY_SUMMARY in chat (default off / hide). */ + sessionSummaryInChat: z.boolean() }) export type HubSettingsResponse = z.infer -export const UpdateHubSettingsRequestSchema = z.object({ - sessionSummaryContract: z.boolean() -}) +export const UpdateHubSettingsRequestSchema = z + .object({ + sessionSummaryContract: z.boolean().optional(), + sessionSummaryInChat: z.boolean().optional() + }) + .refine( + (data) => data.sessionSummaryContract !== undefined || data.sessionSummaryInChat !== undefined, + { message: 'At least one hub setting field is required' } + ) export type UpdateHubSettingsRequest = z.infer diff --git a/shared/src/messages.test.ts b/shared/src/messages.test.ts index bc1bf6cd40..7533feac3f 100644 --- a/shared/src/messages.test.ts +++ b/shared/src/messages.test.ts @@ -3,6 +3,8 @@ import { extractAssistantPlainText, extractNotifySummary, isRedundantGoalStatusEventContent, + splitNotifySummary, + stripNotifySummaryFooter, type NotifySummary } from './messages' @@ -147,6 +149,21 @@ describe('extractNotifySummary', () => { expect(r?.summary).toBe('ok') }) + test('rejects whitespace-delimited contract examples on the last line', () => { + const example = 'Example: AGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done"}' + expect(extractNotifySummary(example)).toBeNull() + expect(splitNotifySummary(example)).toBeNull() + expect(stripNotifySummaryFooter(example)).toBe(example) + }) + + test('accepts a standalone footer with leading indentation', () => { + const indented = ' AGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done"}' + const r = extractNotifySummary(indented) + expect(r?.summary).toBe('Done') + expect(r?.status).toBe('done') + expect(stripNotifySummaryFooter(`Prose.\n${indented}`)).toBe('Prose.') + }) + test('parses glued token after multi-line prose (token still on last line)', () => { const text = `Did the work.\n\nOwnership session pinged.AGENT_NOTIFY_SUMMARY {"version":1,"status":"done","summary":"ok"}` const r = extractNotifySummary(text) @@ -225,6 +242,70 @@ describe('extractNotifySummary', () => { expect(r?.summary).toBe('mentions AGENT_NOTIFY_SUMMARY here') expect(r?.status).toBe('done') }) + + test('splits a clean footer into visible prose and metadata', () => { + const text = 'Did the work.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done","action":"Review it"}' + const result = splitNotifySummary(text) + + expect(result?.visibleText).toBe('Did the work.') + expect(result?.summary).toEqual({ summary: 'Done', status: 'done', action: 'Review it' }) + }) + + test('splits a footer glued to prose on the last line', () => { + const text = 'Did the work.\nOwnership session pinged.AGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done"}' + const result = splitNotifySummary(text) + + expect(result?.visibleText).toBe('Did the work.\nOwnership session pinged.') + expect(result?.summary.summary).toBe('Done') + }) + + test('preserves leading indentation when a footer is glued to Markdown prose', () => { + const text = '- item\n nested line.AGENT_NOTIFY_SUMMARY {"summary":"Done"}' + const result = splitNotifySummary(text) + + expect(result?.visibleText).toBe('- item\n nested line.') + }) + + test('returns null when the footer is not a compliant final line', () => { + expect(splitNotifySummary('AGENT_NOTIFY_SUMMARY {"summary":"Done"}\nMore prose')).toBeNull() + expect(splitNotifySummary('Plain prose')).toBeNull() + }) +}) + +describe('stripNotifySummaryFooter', () => { + const FOOTER = 'AGENT_NOTIFY_SUMMARY {"version":1,"status":"done","summary":"ok","action":"Ship it"}' + + test('removes a trailing well-formed footer and keeps prose', () => { + expect(stripNotifySummaryFooter(`Here is the answer.\n\n${FOOTER}`)).toBe('Here is the answer.') + }) + + test('keeps glued last-line prose when stripping the footer', () => { + expect(stripNotifySummaryFooter(`Ownership session pinged.${FOOTER}`)).toBe( + 'Ownership session pinged.' + ) + }) + + test('tolerates trailing whitespace after the footer line', () => { + expect(stripNotifySummaryFooter(`Done.\n${FOOTER}\n\n`)).toBe('Done.') + }) + + test('leaves malformed or truncated footers untouched', () => { + const truncated = 'Done.\nAGENT_NOTIFY_SUMMARY {"summary":' + const bogus = 'Done.\nAGENT_NOTIFY_SUMMARY {bogus}' + expect(stripNotifySummaryFooter(truncated)).toBe(truncated) + expect(stripNotifySummaryFooter(bogus)).toBe(bogus) + }) + + test('leaves mid-body mentions and non-final footers untouched', () => { + const mid = 'See AGENT_NOTIFY_SUMMARY {"status":"done","summary":"mid"} for the contract.' + const nonFinal = `${FOOTER}\nMore prose` + expect(stripNotifySummaryFooter(mid)).toBe(mid) + expect(stripNotifySummaryFooter(nonFinal)).toBe(nonFinal) + }) + + test('returns empty string when the message is only a footer', () => { + expect(stripNotifySummaryFooter(FOOTER)).toBe('') + }) }) describe('extractNotifySummary + extractAssistantPlainText (integration)', () => { diff --git a/shared/src/messages.ts b/shared/src/messages.ts index d60fe6efe1..cb231b0643 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -150,22 +150,33 @@ export type NotifySummary = { /** * Match a well-formed `AGENT_NOTIFY_SUMMARY {...}` footer on a single line. * - * Allows an optional prose prefix on the same line (agents sometimes glue - * trailing text and the token without a newline). Scans left-to-right and - * returns the first token occurrence whose remainder is valid JSON through - * end of line - so a literal `AGENT_NOTIFY_SUMMARY ` inside a JSON string - * value does not steal the match from the real footer. + * Allows an optional *glued* prose prefix on the same line (agents sometimes + * omit the newline: `Done.AGENT_NOTIFY_SUMMARY {...}`). Whitespace-delimited + * mentions (`Example: AGENT_NOTIFY_SUMMARY {...}`) are not treated as footers. + * Scans left-to-right and returns the first token occurrence whose remainder + * is valid JSON through end of line - so a literal `AGENT_NOTIFY_SUMMARY ` + * inside a JSON string value does not steal the match from the real footer. */ -function matchNotifySummaryLine(line: string): string | null { +type NotifySummaryLineMatch = { + jsonPart: string + start: number +} + +function matchNotifySummaryLine(line: string): NotifySummaryLineMatch | null { for ( let idx = line.indexOf(NOTIFY_SUMMARY_PREFIX); idx >= 0; idx = line.indexOf(NOTIFY_SUMMARY_PREFIX, idx + NOTIFY_SUMMARY_PREFIX.length) ) { + // Keep glued footers (`Done.AGENT_NOTIFY_SUMMARY ...`) and indented + // standalone footers, but reject ordinary prose-delimited mentions + // (`Example: AGENT_NOTIFY_SUMMARY ...`). + const prefix = line.slice(0, idx) + if (prefix.trim().length > 0 && /\s/.test(line[idx - 1]!)) continue const jsonPart = line.slice(idx + NOTIFY_SUMMARY_PREFIX.length).trim() if (!jsonPart.startsWith('{') || !jsonPart.endsWith('}')) continue try { - if (isObject(JSON.parse(jsonPart))) return jsonPart + if (isObject(JSON.parse(jsonPart))) return { jsonPart, start: idx } } catch { // Try the next occurrence (e.g. token mentioned inside a value). } @@ -173,15 +184,56 @@ function matchNotifySummaryLine(line: string): string | null { return null } +function parseNotifySummaryJson(jsonPart: string): NotifySummary | null { + try { + const parsed: unknown = JSON.parse(jsonPart) + if (!isObject(parsed)) return null + const result: NotifySummary = {} + if (typeof parsed.version === 'number') result.version = parsed.version + if (typeof parsed.agent === 'string') result.agent = parsed.agent + if (typeof parsed.project === 'string') result.project = parsed.project + if (typeof parsed.status === 'string') result.status = parsed.status + if (typeof parsed.action === 'string') result.action = parsed.action + if (typeof parsed.summary === 'string') result.summary = parsed.summary + return result + } catch { + return null + } +} + +type NotifySummaryMatch = { + lines: string[] + lastIdx: number + line: string + match: NotifySummaryLineMatch + summary: NotifySummary +} + +function findNotifySummary(text: string): NotifySummaryMatch | null { + const lines = text.split('\n') + let lastIdx = lines.length - 1 + while (lastIdx >= 0 && lines[lastIdx].trim() === '') lastIdx -= 1 + if (lastIdx < 0) return null + + const line = lines[lastIdx].trimEnd() + const match = matchNotifySummaryLine(line) + if (match === null) return null + + const summary = parseNotifySummaryJson(match.jsonPart) + if (summary === null) return null + + return { lines, lastIdx, line, match, summary } +} + /** * Look for an `AGENT_NOTIFY_SUMMARY {...json...}` footer as the **last * non-empty line** of an agent's plain-text message. * * End-anchor: trailing blank lines are fine, but prose on a later * non-empty line is non-compliant and returns null. Mid-body quotes of - * the token are ignored for the same reason. An optional prose prefix on - * the last line itself is tolerated when the line still ends with a - * well-formed `AGENT_NOTIFY_SUMMARY {…}` payload. + * the token are ignored for the same reason. An optional *glued* prose prefix + * on the last line itself is tolerated (`Done.AGENT_NOTIFY_SUMMARY {…}`); + * whitespace-delimited examples on that line are not. * * Returns the parsed object on success, `null` on any deviation. The * shape is intentionally loose - we only trust `summary`, `action`, and @@ -191,28 +243,45 @@ function matchNotifySummaryLine(line: string): string | null { export function extractNotifySummary(text: unknown): NotifySummary | null { if (typeof text !== 'string' || text.length === 0) return null - const lines = text.split('\n') - let lastIdx = lines.length - 1 - while (lastIdx >= 0 && lines[lastIdx].trim() === '') lastIdx -= 1 - if (lastIdx < 0) return null + return findNotifySummary(text)?.summary ?? null +} - const jsonPart = matchNotifySummaryLine(lines[lastIdx].trim()) - if (jsonPart === null) return null +export type NotifySummaryDisplay = { + /** Agent prose with the machine-readable footer removed. */ + visibleText: string + summary: NotifySummary +} - try { - const parsed: unknown = JSON.parse(jsonPart) - if (!isObject(parsed)) return null - const result: NotifySummary = {} - if (typeof parsed.version === 'number') result.version = parsed.version - if (typeof parsed.agent === 'string') result.agent = parsed.agent - if (typeof parsed.project === 'string') result.project = parsed.project - if (typeof parsed.status === 'string') result.status = parsed.status - if (typeof parsed.action === 'string') result.action = parsed.action - if (typeof parsed.summary === 'string') result.summary = parsed.summary - return result - } catch { - return null +/** + * Split a valid trailing summary footer into user-facing prose and metadata. + * + * The original message remains untouched; callers can use `visibleText` only + * for presentation while retaining the raw text for copy/export/notifications. + */ +export function splitNotifySummary(text: unknown): NotifySummaryDisplay | null { + if (typeof text !== 'string' || text.length === 0) return null + + const found = findNotifySummary(text) + if (found === null) return null + + const prefix = found.line.slice(0, found.match.start).trimEnd() + const visibleLines = found.lines.slice(0, found.lastIdx) + if (prefix.length > 0) visibleLines.push(prefix) + + return { + visibleText: visibleLines.join('\n').trimEnd(), + summary: found.summary } } +/** + * Render/copy helper: remove a valid trailing AGENT_NOTIFY_SUMMARY footer. + * Leaves malformed, mid-body, and non-final occurrences unchanged. Store and + * parse/FCM paths must keep using the raw text. + */ +export function stripNotifySummaryFooter(text: string): string { + if (typeof text !== 'string' || text.length === 0) return text + return splitNotifySummary(text)?.visibleText ?? text +} + export type { RoleWrappedRecord } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 471fd635fd..5b8234d812 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -49,6 +49,7 @@ import type { ReopenSessionResponse, SqliteStorageUsageResponse, HubSettingsResponse, + UpdateHubSettingsRequest, UsageSummaryResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' @@ -737,7 +738,7 @@ export class ApiClient { return await this.request('/api/hub-settings') } - async updateHubSettings(settings: HubSettingsResponse): Promise { + async updateHubSettings(settings: UpdateHubSettingsRequest): Promise { return await this.request('/api/hub-settings', { method: 'PUT', body: JSON.stringify(settings) diff --git a/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx b/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx index c47c7d739b..1e4e14c854 100644 --- a/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx +++ b/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx @@ -1,4 +1,5 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { PropsWithChildren } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' @@ -32,33 +33,38 @@ import type { Session } from '@/types/api' const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTo') function renderThread(onViewModeChange = vi.fn()) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }) const renderHappyThread = (forceScrollToken: number) => ( - - - + + + + + ) const result = render(renderHappyThread(0)) const viewport = result.container.querySelector('.chat-scroll-y') diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 756c04008d..92d5036b43 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ThreadPrimitive, useAuiState } from '@assistant-ui/react' +import { useQuery } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' import type { HappyRuntimeExtras } from '@/lib/assistant-runtime' import type { Session, SessionMetadataSummary } from '@/types/api' @@ -31,6 +32,7 @@ import { formatRelativeTime } from '@/lib/relativeTime' import { formatSessionHeaderTimestamp } from '@/lib/sessionHeaderTimestamp' import { getShareTurnReasoningLabel, selectShareTurnMetadata } from '@/lib/shareTurnMetadata' import { useMinuteTick } from '@/hooks/useMinuteTick' +import { queryKeys } from '@/lib/query-keys' type ScrollAnchor = { id: string @@ -515,6 +517,15 @@ export function HappyThread(props: { }) }, [headerMetadata, locale, machineLabelsById, props.serviceTier, props.session, shareDialogOpen, shareRelativeTimeTick, t]) const { terminalToolDisplayMode } = useTerminalToolDisplayMode() + const hubSettingsQuery = useQuery({ + queryKey: queryKeys.hubSettings, + queryFn: async () => props.api.getHubSettings(), + enabled: Boolean(props.api), + staleTime: 30_000, + refetchInterval: 30_000, + retry: false, + }) + const showSessionSummaryInChat = hubSettingsQuery.data?.sessionSummaryInChat === true const runtimeExtras = useAuiState((s) => s.thread.extras) as HappyRuntimeExtras | undefined const appliedMessagesVersion = runtimeExtras?.messagesVersion ?? props.messagesVersion const appliedHistoryVersion = runtimeExtras?.historyVersion ?? props.historyVersion @@ -1569,6 +1580,7 @@ export function HappyThread(props: { sessionId: props.sessionId, metadata: props.metadata, terminalToolDisplayMode, + showSessionSummaryInChat, disabled: props.disabled, onRefresh: props.onRefresh, onRetryMessage: props.onRetryMessage, diff --git a/web/src/components/AssistantChat/context.tsx b/web/src/components/AssistantChat/context.tsx index 375c1710d1..dd1a4c1907 100644 --- a/web/src/components/AssistantChat/context.tsx +++ b/web/src/components/AssistantChat/context.tsx @@ -11,6 +11,8 @@ export type HappyChatContextValue = { sessionId: string metadata: SessionMetadataSummary | null terminalToolDisplayMode: TerminalToolDisplayMode + /** Hub-wide AGENT_NOTIFY_SUMMARY chat display; polled once at chat shell. */ + showSessionSummaryInChat: boolean disabled: boolean onRefresh: () => void onRetryMessage?: (localId: string) => void diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index f89d0e9b72..7186cbe7d5 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -1,5 +1,4 @@ import { MessagePrimitive, useAuiState, type TextMessagePart } from '@assistant-ui/react' -import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning' import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage' import { CliOutputBlock } from '@/components/CliOutputBlock' @@ -9,13 +8,15 @@ import { getConversationMessageAnchorId } from '@/chat/outline' import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard' import { MessageActions } from '@/components/AssistantChat/messages/MessageActions' import { useHappyChatContext } from '@/components/AssistantChat/context' +import { NotifySummaryText } from '@/components/AssistantChat/messages/NotifySummaryText' +import { useSessionSummaryInChat } from '@/hooks/useSessionSummaryInChat' const TOOL_COMPONENTS = { Fallback: HappyToolMessage } as const const MESSAGE_PART_COMPONENTS = { - Text: MarkdownText, + Text: NotifySummaryText, Reasoning: Reasoning, ReasoningGroup: ReasoningGroup, tools: TOOL_COMPONENTS @@ -23,6 +24,7 @@ const MESSAGE_PART_COMPONENTS = { export function HappyAssistantMessage() { const ctx = useHappyChatContext() + const showSessionSummaryInChat = useSessionSummaryInChat() const messageId = useAuiState((s) => s.message.id) const elementId = getConversationMessageAnchorId(messageId) const isCliOutput = useAuiState((s) => { @@ -45,7 +47,9 @@ export function HappyAssistantMessage() { }) const copyText = useAuiState((s) => { if (s.message.role !== 'assistant') return '' - return getAssistantCopyText(s.message.content) + return getAssistantCopyText(s.message.content, { + stripNotifySummary: !showSessionSummaryInChat + }) }) const durationMs = useAuiState(({ message }) => (message.metadata.custom as Partial | undefined)?.durationMs) diff --git a/web/src/components/AssistantChat/messages/NotifySummaryText.test.tsx b/web/src/components/AssistantChat/messages/NotifySummaryText.test.tsx new file mode 100644 index 0000000000..953759948b --- /dev/null +++ b/web/src/components/AssistantChat/messages/NotifySummaryText.test.tsx @@ -0,0 +1,116 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { NotifySummaryText } from './NotifySummaryText' + +const { mockUseSessionSummaryInChat } = vi.hoisted(() => ({ + mockUseSessionSummaryInChat: vi.fn(() => true) +})) + +vi.mock('@/hooks/useSessionSummaryInChat', () => ({ + useSessionSummaryInChat: () => mockUseSessionSummaryInChat() +})) + +vi.mock('@/components/assistant-ui/markdown-text', () => ({ + MarkdownText: () =>
raw assistant text
+})) + +vi.mock('@/components/MarkdownRenderer', () => ({ + MarkdownRenderer: ({ content }: { content: string }) => ( +
{content}
+ ) +})) + +function renderText(text: string, statusType: 'complete' | 'running' = 'complete') { + return render( + + + + ) +} + +describe('NotifySummaryText', () => { + beforeEach(() => { + mockUseSessionSummaryInChat.mockReturnValue(true) + }) + + it('renders the prose and compact summary footer instead of raw JSON', () => { + renderText('Did the work.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done","action":"Review it"}') + + expect(screen.getByTestId('visible-markdown')).toHaveTextContent('Did the work.') + expect(screen.getByTestId('notify-summary-footer')).toHaveTextContent('Done') + expect(screen.getByTestId('notify-summary-footer')).toHaveTextContent('→Review it') + expect(screen.getByTestId('notify-summary-status')).toHaveAttribute('aria-label', 'Done') + expect(screen.getByTestId('notify-summary-status')).not.toHaveTextContent('Done') + expect(screen.getByTestId('notify-summary-status').querySelector('svg')).toBeInTheDocument() + expect(screen.queryByText(/AGENT_NOTIFY_SUMMARY/)).toBeNull() + }) + + it('strips the footer when chat display is off', () => { + mockUseSessionSummaryInChat.mockReturnValue(false) + renderText('Did the work.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done"}') + + expect(screen.getByTestId('visible-markdown')).toHaveTextContent('Did the work.') + expect(screen.queryByTestId('notify-summary-footer')).toBeNull() + expect(screen.queryByText(/AGENT_NOTIFY_SUMMARY/)).toBeNull() + }) + + it('keeps a status label and dot for non-complete summaries', () => { + renderText('Needs input.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Waiting","status":"needs_review"}') + + expect(screen.getByTestId('notify-summary-status')).toHaveTextContent('Needs review') + expect(screen.getByTestId('notify-summary-status').querySelector('svg')).toBeNull() + }) + + it('humanizes unknown prototype-named statuses instead of using inherited presentations', () => { + const view = renderText('Finished.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Done","status":"constructor"}') + + expect(screen.getByTestId('notify-summary-status')).toHaveTextContent('Constructor') + expect(screen.getByTestId('notify-summary-status').querySelector('svg')).toBeNull() + + view.unmount() + renderText('Finished.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Done","status":"__proto__"}') + + expect(screen.getByTestId('notify-summary-status')).toHaveTextContent('Proto') + expect(screen.getByTestId('notify-summary-status').querySelector('svg')).toBeNull() + }) + + it('keeps prose glued to the footer in the visible message body', () => { + renderText('Ownership session pinged.AGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done"}') + + expect(screen.getByTestId('visible-markdown')).toHaveTextContent('Ownership session pinged.') + expect(screen.getByTestId('notify-summary-footer')).toHaveTextContent('Done') + expect(screen.queryByText(/AGENT_NOTIFY_SUMMARY/)).toBeNull() + }) + + it('uses the normal markdown renderer when there is no valid footer', () => { + renderText('Plain assistant prose.') + + expect(screen.getByTestId('raw-markdown')).toBeInTheDocument() + expect(screen.queryByTestId('notify-summary-footer')).toBeNull() + }) + + it('hides a recognized footer with no displayable fields instead of raw JSON', () => { + renderText('Did the work.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"agent":"codex"}') + + expect(screen.getByTestId('visible-markdown')).toHaveTextContent('Did the work.') + expect(screen.queryByTestId('notify-summary-footer')).toBeNull() + expect(screen.queryByText(/AGENT_NOTIFY_SUMMARY/)).toBeNull() + }) + + it('keeps a complete-looking footer in markdown while the message is streaming', () => { + mockUseSessionSummaryInChat.mockReturnValue(true) + renderText('Still working.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done"}', 'running') + + expect(screen.getByTestId('raw-markdown')).toBeInTheDocument() + expect(screen.queryByTestId('notify-summary-footer')).toBeNull() + }) + + it('strips a well-formed footer while streaming when display is off', () => { + mockUseSessionSummaryInChat.mockReturnValue(false) + renderText('Still working.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done"}', 'running') + + expect(screen.getByTestId('visible-markdown')).toHaveTextContent('Still working.') + expect(screen.queryByText(/AGENT_NOTIFY_SUMMARY/)).toBeNull() + }) +}) diff --git a/web/src/components/AssistantChat/messages/NotifySummaryText.tsx b/web/src/components/AssistantChat/messages/NotifySummaryText.tsx new file mode 100644 index 0000000000..73d0818b8a --- /dev/null +++ b/web/src/components/AssistantChat/messages/NotifySummaryText.tsx @@ -0,0 +1,135 @@ +import type { TextMessagePartComponent } from '@assistant-ui/react' +import { splitNotifySummary, stripNotifySummaryFooter, type NotifySummary } from '@hapi/protocol/messages' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { MarkdownText } from '@/components/assistant-ui/markdown-text' +import { CheckIcon } from '@/components/icons' +import { useSessionSummaryInChat } from '@/hooks/useSessionSummaryInChat' +import { useTranslation } from '@/lib/use-translation' + +type SummaryStatusPresentation = { + labelKey: string + marker: 'check' | 'dot' + markerClassName: string +} + +const SUMMARY_STATUS_PRESENTATIONS: Record = { + done: { labelKey: 'session.summary.status.done', marker: 'check', markerClassName: 'text-[var(--app-badge-success-text)]' }, + blocked: { labelKey: 'session.summary.status.blocked', marker: 'dot', markerClassName: 'bg-[var(--app-badge-warning-text)]' }, + needs_review: { labelKey: 'session.summary.status.needsReview', marker: 'dot', markerClassName: 'bg-[var(--app-badge-warning-text)]' }, + needs_decision: { labelKey: 'session.summary.status.needsDecision', marker: 'dot', markerClassName: 'bg-[var(--app-badge-warning-text)]' }, + failed: { labelKey: 'session.summary.status.failed', marker: 'dot', markerClassName: 'bg-[var(--app-badge-error-text)]' }, + stalled: { labelKey: 'session.summary.status.stalled', marker: 'dot', markerClassName: 'bg-[var(--app-badge-warning-text)]' } +} + +function normalizeStatus(status: string | undefined): string { + return status?.trim().toLowerCase() ?? '' +} + +function humanizeStatus(status: string): string { + return status + .replaceAll('_', ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()) +} + +function SummaryStatusIndicator({ summary }: { summary: NotifySummary }) { + const { t } = useTranslation() + const normalizedStatus = normalizeStatus(summary.status) + const presentation = Object.hasOwn(SUMMARY_STATUS_PRESENTATIONS, normalizedStatus) + ? SUMMARY_STATUS_PRESENTATIONS[normalizedStatus] + : undefined + const statusLabel = presentation + ? t(presentation.labelKey) + : normalizedStatus + ? humanizeStatus(normalizedStatus) + : t('session.summary.label') + + return ( + + {presentation?.marker === 'check' ? ( + + ) : ( + <> + + ) +} + +export function NotifySummaryFooter({ summary }: { summary: NotifySummary }) { + const { t } = useTranslation() + const summaryText = summary.summary?.trim() ?? '' + const actionText = summary.action?.trim() ?? '' + + if (!summaryText && !actionText && !summary.status?.trim()) return null + + return ( +
+ + {summaryText ? ( + + {summaryText} + + ) : null} + {actionText && actionText !== summaryText ? ( + <> + + + + + {actionText} + + + ) : null} +
+ ) +} + +/** Render the machine footer as a compact row when display is on; otherwise strip it. */ +export const NotifySummaryText: TextMessagePartComponent = ({ text, status }) => { + const showInChat = useSessionSummaryInChat() + + if (!showInChat) { + const stripped = stripNotifySummaryFooter(text) + if (!stripped) return null + if (stripped === text) return + return + } + + if (status.type !== 'complete') return + + const display = splitNotifySummary(text) + if (!display) return + + const hasDisplayableSummary = Boolean( + display.summary.summary?.trim() + || display.summary.action?.trim() + || display.summary.status?.trim() + ) + if (!hasDisplayableSummary) { + if (!display.visibleText) return null + return + } + + return ( + <> + {display.visibleText ? : null} + + + ) +} diff --git a/web/src/components/AssistantChat/messages/ToolMessage.generatedMedia.test.tsx b/web/src/components/AssistantChat/messages/ToolMessage.generatedMedia.test.tsx index 4703f864f2..f1719b93a2 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.generatedMedia.test.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.generatedMedia.test.tsx @@ -16,6 +16,7 @@ function renderCard(options: { sessionId: 'session-1', metadata: null, terminalToolDisplayMode: 'compact', + showSessionSummaryInChat: false, disabled: false, onRefresh: () => {}, hasMoreMessages: false, diff --git a/web/src/components/AssistantChat/messages/assistantCopyText.test.ts b/web/src/components/AssistantChat/messages/assistantCopyText.test.ts index 407eeaba72..fa1642bb77 100644 --- a/web/src/components/AssistantChat/messages/assistantCopyText.test.ts +++ b/web/src/components/AssistantChat/messages/assistantCopyText.test.ts @@ -22,4 +22,16 @@ describe('getAssistantCopyText', () => { expect(getAssistantCopyText(parts)).toBe('') }) + + it('strips trailing AGENT_NOTIFY_SUMMARY when stripNotifySummary is on', () => { + const parts = [ + { + type: 'text', + text: 'Did the work.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done"}' + } + ] satisfies ThreadAssistantMessagePart[] + + expect(getAssistantCopyText(parts, { stripNotifySummary: true })).toBe('Did the work.') + expect(getAssistantCopyText(parts)).toContain('AGENT_NOTIFY_SUMMARY') + }) }) diff --git a/web/src/components/AssistantChat/messages/assistantCopyText.ts b/web/src/components/AssistantChat/messages/assistantCopyText.ts index 141f60e950..512a29fbd0 100644 --- a/web/src/components/AssistantChat/messages/assistantCopyText.ts +++ b/web/src/components/AssistantChat/messages/assistantCopyText.ts @@ -1,9 +1,17 @@ import type { ThreadAssistantMessagePart } from '@assistant-ui/react' +import { stripNotifySummaryFooter } from '@hapi/protocol/messages' -export function getAssistantCopyText(parts: readonly ThreadAssistantMessagePart[]): string { +export function getAssistantCopyText( + parts: readonly ThreadAssistantMessagePart[], + options?: { stripNotifySummary?: boolean } +): string { + const strip = options?.stripNotifySummary === true return parts .filter((part) => part.type === 'text') - .map((part) => part.text.trim()) + .map((part) => { + const trimmed = part.text.trim() + return strip ? stripNotifySummaryFooter(trimmed) : trimmed + }) .filter((text) => text.length > 0) .join('\n\n') } diff --git a/web/src/components/ToolCard/ToolGroupCard.test.tsx b/web/src/components/ToolCard/ToolGroupCard.test.tsx index 30496ed726..9fa7dd395d 100644 --- a/web/src/components/ToolCard/ToolGroupCard.test.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx @@ -89,6 +89,7 @@ function renderCard(block: ToolGroupBlock, options?: { sessionId: 'session-1', metadata: { path: 'repo', host: 'local' }, terminalToolDisplayMode: 'detailed', + showSessionSummaryInChat: false, disabled: false, onRefresh: vi.fn(), hasMoreMessages: options?.hasMore ?? false, @@ -308,6 +309,7 @@ describe('ToolGroupCard', () => { sessionId: 'session-1', metadata: { path: 'repo', host: 'local' }, terminalToolDisplayMode: 'detailed', + showSessionSummaryInChat: false, disabled: false, onRefresh: vi.fn(), hasMoreMessages: true, @@ -367,6 +369,7 @@ describe('ToolGroupCard', () => { sessionId: 'session-1', metadata: { path: 'repo', host: 'local' }, terminalToolDisplayMode: 'detailed', + showSessionSummaryInChat: false, disabled: false, onRefresh: vi.fn(), hasMoreMessages: true, @@ -435,6 +438,7 @@ describe('ToolGroupCard', () => { sessionId: 'session-1', metadata: { path: 'repo', host: 'local' }, terminalToolDisplayMode: 'detailed', + showSessionSummaryInChat: false, disabled: false, onRefresh: vi.fn(), hasMoreMessages: hasMore, @@ -491,6 +495,7 @@ describe('ToolGroupCard', () => { sessionId: 'session-1', metadata: { path: 'repo', host: 'local' }, terminalToolDisplayMode: 'detailed', + showSessionSummaryInChat: false, disabled: false, onRefresh: vi.fn(), hasMoreMessages: hasMore, diff --git a/web/src/hooks/useSessionSummaryInChat.ts b/web/src/hooks/useSessionSummaryInChat.ts new file mode 100644 index 0000000000..0795c20cb5 --- /dev/null +++ b/web/src/hooks/useSessionSummaryInChat.ts @@ -0,0 +1,12 @@ +import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' + +/** + * Hub opt-in to show compact AGENT_NOTIFY_SUMMARY in chat. + * Default false (hide/strip). Value is polled once in the chat shell and + * exposed via HappyChatContext — message renderers must not install their + * own refetch intervals. + */ +export function useSessionSummaryInChat(): boolean { + const ctx = useOptionalHappyChatContext() + return ctx?.showSessionSummaryInChat === true +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 2559844de7..e378584bf8 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -762,6 +762,8 @@ export default { 'settings.general.agents.description': 'Hub-wide defaults for how agents behave in new and resumed sessions.', 'settings.general.sessionSummaryContract': 'Ask agents to emit session status summary', 'settings.general.sessionSummaryContract.desc': 'When on, Claude, Codex, OpenCode, and remote Grok sessions are asked to end each turn with an AGENT_NOTIFY_SUMMARY line for denser ready notifications. Off by default. Local Grok and Cursor are not covered yet. Applies to new/resumed sessions.', + 'settings.general.sessionSummaryInChat': 'Show session status summary in chat', + 'settings.general.sessionSummaryInChat.desc': 'When on, assistant messages show a compact status row instead of raw AGENT_NOTIFY_SUMMARY JSON. Off by default (hidden from chat and copy). Stored messages, notifications, and capture are unchanged.', 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', @@ -1129,4 +1131,12 @@ export default { 'share.searchResults': 'Matching sessions', 'share.noSearchResults': 'No sessions match your search.', 'share.searchForMore': '{n} more active sessions — search to find them.', + 'session.summary.label': 'Session summary', + 'session.summary.ariaLabel': 'Session status summary', + 'session.summary.status.done': 'Done', + 'session.summary.status.blocked': 'Blocked', + 'session.summary.status.needsReview': 'Needs review', + 'session.summary.status.needsDecision': 'Needs decision', + 'session.summary.status.failed': 'Failed', + 'session.summary.status.stalled': 'Stalled', } as const diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index d1deb621fb..abad7b0ea4 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -761,6 +761,8 @@ export default { 'settings.general.agents.description': '适用于新建与恢复会话的中心级智能体默认行为。', 'settings.general.sessionSummaryContract': '要求智能体输出会话状态摘要', 'settings.general.sessionSummaryContract.desc': '开启后,Claude、Codex、OpenCode 以及远程 Grok 会话会在每轮结束时追加 AGENT_NOTIFY_SUMMARY 行,便于更清晰的就绪通知。默认关闭。本地 Grok 与 Cursor 暂不覆盖。对新开/恢复的会话生效。', + 'settings.general.sessionSummaryInChat': '在聊天中显示会话状态摘要', + 'settings.general.sessionSummaryInChat.desc': '开启后,助手消息以紧凑状态行显示,而不是原始 AGENT_NOTIFY_SUMMARY JSON。默认关闭(聊天与复制中隐藏)。已存储消息、通知与采集路径不受影响。', 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', @@ -1128,4 +1130,12 @@ export default { 'share.searchResults': '匹配的会话', 'share.noSearchResults': '没有匹配的会话。', 'share.searchForMore': '还有 {n} 个活跃会话 — 搜索以查找。', + 'session.summary.label': '会话摘要', + 'session.summary.ariaLabel': '会话状态摘要', + 'session.summary.status.done': '已完成', + 'session.summary.status.blocked': '已阻塞', + 'session.summary.status.needsReview': '需要审阅', + 'session.summary.status.needsDecision': '需要决策', + 'session.summary.status.failed': '失败', + 'session.summary.status.stalled': '已停滞', } as const diff --git a/web/src/routes/settings/general.tsx b/web/src/routes/settings/general.tsx index e4180d8ba3..ac281e3950 100644 --- a/web/src/routes/settings/general.tsx +++ b/web/src/routes/settings/general.tsx @@ -41,9 +41,9 @@ export default function SettingsGeneralPage() { }) const hubSettingsMutation = useMutation({ - mutationFn: async (sessionSummaryContract: boolean) => { + mutationFn: async (patch: { sessionSummaryContract?: boolean; sessionSummaryInChat?: boolean }) => { if (!api) throw new Error('API unavailable') - return await api.updateHubSettings({ sessionSummaryContract }) + return await api.updateHubSettings(patch) }, onSuccess: (data) => { queryClient.setQueryData(queryKeys.hubSettings, data) @@ -58,15 +58,26 @@ export default function SettingsGeneralPage() { {isOwner ? ( {hubSettingsQuery.data ? ( - { - if (hubSettingsMutation.isPending) return - hubSettingsMutation.mutate(checked) - }} - /> + <> + { + if (hubSettingsMutation.isPending) return + hubSettingsMutation.mutate({ sessionSummaryContract: checked }) + }} + /> + { + if (hubSettingsMutation.isPending) return + hubSettingsMutation.mutate({ sessionSummaryInChat: checked }) + }} + /> + ) : null} ) : null} diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index c50b2707dc..881786f4e3 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -23,8 +23,8 @@ const { context, navigate, setAppearance, setColorTheme, setFontScale, setTermin setVoice: vi.fn(), })) -const getHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: false }) -const updateHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: true }) +const getHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false }) +const updateHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: true, sessionSummaryInChat: false }) vi.mock('@/hooks/useColorTheme', () => ({ useColorTheme: () => ({ colorTheme: 'default', setColorTheme }), @@ -216,8 +216,8 @@ describe('responsive settings pages', () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() - getHubSettings.mockResolvedValue({ sessionSummaryContract: false }) - updateHubSettings.mockResolvedValue({ sessionSummaryContract: true }) + getHubSettings.mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false }) + updateHubSettings.mockResolvedValue({ sessionSummaryContract: true, sessionSummaryInChat: false }) context.token = `x.${btoa(JSON.stringify({ ns: 'default' }))}.x` }) @@ -246,6 +246,7 @@ describe('responsive settings pages', () => { expect(screen.getByText('Companion')).toBeInTheDocument() expect(screen.getByText('Companion pairing')).toBeInTheDocument() expect(await screen.findByRole('checkbox', { name: 'Ask agents to emit session status summary' })).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Show session status summary in chat' })).toBeInTheDocument() fireEvent.click(screen.getByRole('radio', { name: '简体中文' })) expect(localStorage.getItem('hapi-lang')).toBe('zh-CN') }) From b9d1abed1d824d80f0a094cb5d304fed0da3a2a8 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Wed, 12 Aug 2026 10:10:48 +0800 Subject: [PATCH 078/142] fix(web): restore scroll chaining from reasoning panel to chat viewport (#1501) The reasoning panel carried overscroll-y-contain, which disables native scroll chaining: after scrolling the panel to its bottom, the outer chat viewport could not keep scrolling. #1264 removed the containment for exactly this reason; #1398 re-added it while adding nested follow-tail coordination, silently reverting #1264. The coordination (onNestedScrollFollowChange pauses the outer auto-follow while the user scrolls inside the panel) already resolves the #1397 fighting; containment is unnecessary. Drop it and add a regression test guarding the class list. Fixes #1500 --- web/src/components/assistant-ui/reasoning.test.tsx | 11 +++++++++++ web/src/components/assistant-ui/reasoning.tsx | 8 +++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/web/src/components/assistant-ui/reasoning.test.tsx b/web/src/components/assistant-ui/reasoning.test.tsx index 569e0ddae1..e85ff45fc3 100644 --- a/web/src/components/assistant-ui/reasoning.test.tsx +++ b/web/src/components/assistant-ui/reasoning.test.tsx @@ -240,4 +240,15 @@ describe('ReasoningGroup', () => { expect(onNestedScrollFollowChange.mock.calls).toEqual([[false], [true]]) }) + + it('does not contain overscroll so the outer chat keeps scrolling past the panel boundary', () => { + // Scroll chaining is native browser behavior: once the panel reaches + // its bottom, the next wheel gesture must keep scrolling the outer + // chat viewport. overscroll-behavior-y: contain (Tailwind + // `overscroll-y-contain`) blocks exactly that — it was removed in + // #1264 and must not come back (regression from #1398). + const { container } = renderGroup() + const scroll = container.querySelector('.aui-reasoning-scroll') as HTMLDivElement + expect(scroll.className).not.toContain('overscroll-y-contain') + }) }) diff --git a/web/src/components/assistant-ui/reasoning.tsx b/web/src/components/assistant-ui/reasoning.tsx index 8fc2772992..f4d31a895b 100644 --- a/web/src/components/assistant-ui/reasoning.tsx +++ b/web/src/components/assistant-ui/reasoning.tsx @@ -202,7 +202,13 @@ export const ReasoningGroup: FC = ({ children }) => { onPointerDown={claimNestedPointerScroll} onWheel={claimNestedWheelScroll} onKeyDown={claimNestedKeyboardScroll} - className="aui-reasoning-scroll max-h-[60vh] overflow-y-auto overscroll-y-contain border-t border-[var(--app-divider)] px-3.5 py-3" + // No overscroll containment: native scroll chaining must pass + // to the outer chat viewport once the panel reaches its + // bottom (see #1264). The follow-tail coordination above + // (onNestedScrollFollowChange) already pauses the outer + // auto-follow while the user scrolls inside this panel, so + // contain is not needed to stop the two from fighting. + className="aui-reasoning-scroll max-h-[60vh] overflow-y-auto border-t border-[var(--app-divider)] px-3.5 py-3" > {children}
From fea42212ea945ff2df68cf2dad3ff1a4b508f053 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 12 Aug 2026 10:26:06 +0800 Subject: [PATCH 079/142] Release version 0.27.3 --- bun.lock | 22 ++++++++++------------ cli/package.json | 12 ++++++------ shared/src/buildInfo.ts | 2 +- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/bun.lock b/bun.lock index 45607bc6f5..8ac0d2ec4e 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "cli": { "name": "@twsxtd/hapi", - "version": "0.27.2", + "version": "0.27.3", "bin": { "hapi": "bin/hapi.cjs", }, @@ -47,11 +47,11 @@ "vitest": "^4.0.16", }, "optionalDependencies": { - "@twsxtd/hapi-darwin-arm64": "0.27.2", - "@twsxtd/hapi-darwin-x64": "0.27.2", - "@twsxtd/hapi-linux-arm64": "0.27.2", - "@twsxtd/hapi-linux-x64": "0.27.2", - "@twsxtd/hapi-win32-x64": "0.27.2", + "@twsxtd/hapi-darwin-arm64": "0.27.3", + "@twsxtd/hapi-darwin-x64": "0.27.3", + "@twsxtd/hapi-linux-arm64": "0.27.3", + "@twsxtd/hapi-linux-x64": "0.27.3", + "@twsxtd/hapi-win32-x64": "0.27.3", }, }, "docs": { @@ -1098,15 +1098,13 @@ "@twsxtd/hapi": ["@twsxtd/hapi@workspace:cli"], - "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-+8PS3ZEtCcncWBGW4SOz9KB++DqtrZJuvLEFrN+hYZFA5qux2FnNfKv28C1Dd1U8+F6R7zPO/ztReRnizkCm1g=="], + "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-YGjyrzBL5vIaJTrx0SlrZgyVqjXvLX0VmJBympvnpEa9SKaDUhmSfT7BD/zqakKmJySZXL/aTM49OUzLvlYsdw=="], - "@twsxtd/hapi-darwin-x64": ["@twsxtd/hapi-darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-7+fBzRn+5LNU3cTIqRx8eZw3VJ5gYZKvwoHXDbv8FLZ1CtsyzPmA1rx6RH7dWG2/+A061nfRH9Lkg5KJ8n7RhQ=="], + "@twsxtd/hapi-darwin-x64": ["@twsxtd/hapi-darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-NT43dR1n5TuaHsCbilICLG7+U0S0hatTY+kHZUy0VTfzmjnn40TSaEAIXd0vvxxtLNk3oKNeuXxJcghZZ4kvrg=="], - "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-GmsYsZjDCApiaURXYpgdxFQjJ1rbgDNp1Ohu1xX+Pv5CBpM6vWoBgeR4pgnvYCbVbOKPqSEx5lZpbG4bmjsWeQ=="], + "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-4haIjX9OwZ+vTtIQcx0CsH2y2ZzBefprhQBYAPrdgfDWHvaMtOm0uEaX+ThfDsi1uNfdDq3z3E5sIeXviGdPYQ=="], - "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-IqjfbOUucPX8RpKHc/4dOx/j1mIkKG+eb3Y6CiJE9cdAp8Br48fHHvuI+8yWeqv3brP+3ZPB/YgRBxyMjyMkkQ=="], - - "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-koeAhuz9Ato9MbXdi45KHoPvV6Qo8MXn0jMHKpXqVMn3KShZeXk7cq5iO2UUATwr1IRK+bMi1OXjE8EcULOSoQ=="], + "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-xezXLDF60bMPdyRBzzZjdBL0yC8zOH3EoznJwzh3RsW1lzc3vd5XWLP+TvY1UU4/MWsMM0mp9/U6IivTlQjcHw=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], diff --git a/cli/package.json b/cli/package.json index 6f50374e6f..2c2e78646e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@twsxtd/hapi", - "version": "0.27.2", + "version": "0.27.3", "description": "App for agentic coding - access coding agent anywhere", "author": "Kirill Dubovitskiy & weishu", "license": "AGPL-3.0-only", @@ -26,11 +26,11 @@ } }, "optionalDependencies": { - "@twsxtd/hapi-darwin-arm64": "0.27.2", - "@twsxtd/hapi-darwin-x64": "0.27.2", - "@twsxtd/hapi-linux-arm64": "0.27.2", - "@twsxtd/hapi-linux-x64": "0.27.2", - "@twsxtd/hapi-win32-x64": "0.27.2" + "@twsxtd/hapi-darwin-arm64": "0.27.3", + "@twsxtd/hapi-darwin-x64": "0.27.3", + "@twsxtd/hapi-linux-arm64": "0.27.3", + "@twsxtd/hapi-linux-x64": "0.27.3", + "@twsxtd/hapi-win32-x64": "0.27.3" }, "scripts": { "postinstall": "node -e \"try{require('fs').chmodSync(require('path').join(__dirname,'bin','hapi.cjs'),0o755)}catch(e){}\"", diff --git a/shared/src/buildInfo.ts b/shared/src/buildInfo.ts index 7d235e3643..0889798d8c 100644 --- a/shared/src/buildInfo.ts +++ b/shared/src/buildInfo.ts @@ -1 +1 @@ -export const APP_VERSION = '0.27.2' +export const APP_VERSION = '0.27.3' From 08abd05251d7c4de77cf510c91b37f0968dafe9d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:05:27 +0000 Subject: [PATCH 080/142] feat(doctor): add cross-machine provenance audit subcommand Hub exposes GET /api/doctor/provenance; CLI `hapi doctor provenance` flags active sessions missing killSession RPC and other #1203 skew signals. Co-authored-by: Cursor --- cli/src/commands/doctor.ts | 5 + cli/src/ui/doctor.ts | 1 + cli/src/ui/doctorProvenance.test.ts | 68 +++++++++ cli/src/ui/doctorProvenance.ts | 157 ++++++++++++++++++++ hub/src/sync/provenanceDiagnostics.test.ts | 162 +++++++++++++++++++++ hub/src/sync/provenanceDiagnostics.ts | 123 ++++++++++++++++ hub/src/sync/rpcGateway.ts | 9 ++ hub/src/sync/syncEngine.ts | 11 ++ hub/src/web/routes/doctor.test.ts | 41 ++++++ hub/src/web/routes/doctor.ts | 20 +++ hub/src/web/server.ts | 2 + shared/package.json | 1 + shared/src/provenanceDiagnostics.ts | 56 +++++++ 13 files changed, 656 insertions(+) create mode 100644 cli/src/ui/doctorProvenance.test.ts create mode 100644 cli/src/ui/doctorProvenance.ts create mode 100644 hub/src/sync/provenanceDiagnostics.test.ts create mode 100644 hub/src/sync/provenanceDiagnostics.ts create mode 100644 hub/src/web/routes/doctor.test.ts create mode 100644 hub/src/web/routes/doctor.ts create mode 100644 shared/src/provenanceDiagnostics.ts diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 17fab1748e..e113b8687c 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -1,6 +1,7 @@ import { killRunawayHappyProcesses } from '@/runner/doctor' import { runDoctorCommand } from '@/ui/doctor' import { runDoctorInlineMedia } from '@/ui/doctorInlineMedia' +import { runDoctorProvenance } from '@/ui/doctorProvenance' import type { CommandDefinition } from './types' export const doctorCommand: CommandDefinition = { @@ -19,6 +20,10 @@ export const doctorCommand: CommandDefinition = { const code = await runDoctorInlineMedia() process.exit(code) } + if (commandArgs[0] === 'provenance') { + const code = await runDoctorProvenance() + process.exit(code) + } await runDoctorCommand() } } diff --git a/cli/src/ui/doctor.ts b/cli/src/ui/doctor.ts index 4daae9948c..b4c544db62 100644 --- a/cli/src/ui/doctor.ts +++ b/cli/src/ui/doctor.ts @@ -228,6 +228,7 @@ export async function runDoctorCommand(filter?: 'all' | 'runner'): Promise if (filter === 'all' && allProcesses.length > 1) { // More than just current process console.log(chalk.bold('\n💡 Process Management')); console.log(chalk.gray('To clean up runaway processes: hapi doctor clean')); + console.log(chalk.gray('Cross-machine provenance audit: hapi doctor provenance')); } } catch (error) { console.log(chalk.red('❌ Error checking runner status')); diff --git a/cli/src/ui/doctorProvenance.test.ts b/cli/src/ui/doctorProvenance.test.ts new file mode 100644 index 0000000000..67c5d1283b --- /dev/null +++ b/cli/src/ui/doctorProvenance.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + formatProvenanceReport, + provenanceDiagnosticsHasIssues, +} from './doctorProvenance' +import type { ProvenanceDiagnostics } from '@hapi/protocol/provenanceDiagnostics' + +const cleanDiagnostics: ProvenanceDiagnostics = { + generatedAt: 100, + sessions: [{ + sessionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + name: 'Peer #1', + active: true, + lifecycleState: null, + machineId: 'machine-1', + hostPid: 42, + flavor: 'claude', + hasKillSessionRpc: true, + issues: [], + }], + machines: [{ + machineId: 'machine-1', + displayName: 'gc-oos-linux', + host: 'gc-oos-linux', + active: true, + hasSpawnRpc: true, + hasRunnerProof: true, + capabilitySkew: false, + cliBinaryStale: false, + happyCliVersion: '0.1.0', + issues: [], + }], + summary: { + activeSessions: 1, + unprovenActiveSessions: 0, + archivedButActiveSessions: 0, + onlineMachines: 1, + machinesWithIssues: 0, + }, +} + +describe('doctorProvenance', () => { + it('formatProvenanceReport includes session and machine rows', () => { + const report = formatProvenanceReport(cleanDiagnostics) + expect(report).toContain('Peer #1') + expect(report).toContain('gc-oos-linux') + expect(report).toContain('active sessions: 1') + }) + + it('provenanceDiagnosticsHasIssues is false when all rows are clean', () => { + expect(provenanceDiagnosticsHasIssues(cleanDiagnostics)).toBe(false) + }) + + it('provenanceDiagnosticsHasIssues is true for unproven active sessions', () => { + expect(provenanceDiagnosticsHasIssues({ + ...cleanDiagnostics, + sessions: [{ + ...cleanDiagnostics.sessions[0]!, + hasKillSessionRpc: false, + issues: ['active_unproven'], + }], + summary: { + ...cleanDiagnostics.summary, + unprovenActiveSessions: 1, + }, + })).toBe(true) + }) +}) diff --git a/cli/src/ui/doctorProvenance.ts b/cli/src/ui/doctorProvenance.ts new file mode 100644 index 0000000000..4e43393e7b --- /dev/null +++ b/cli/src/ui/doctorProvenance.ts @@ -0,0 +1,157 @@ +/** + * Cross-machine peer provenance diagnostics (#1203 operator tooling). + */ + +import chalk from 'chalk' +import type { + MachineProvenanceRow, + ProvenanceDiagnostics, + ProvenanceIssueCode, + SessionProvenanceRow, +} from '@hapi/protocol/provenanceDiagnostics' +import { configuration } from '@/configuration' +import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' +import { readSettings } from '@/persistence' + +const ISSUE_LABELS: Record = { + active_unproven: 'active but missing killSession RPC (unproven CLI)', + archived_but_active: 'lifecycle archived but still heartbeating', + machine_no_spawn_rpc: 'online machine missing spawn-happy-session RPC', + machine_no_runner_proof: 'machine has no runner proof hash bound', + machine_capability_skew: 'runner missing required machine capabilities', + machine_cli_stale: 'runner started from older CLI binary than installed', +} + +async function hubJwt(): Promise { + const settings = await readSettings() + const token = process.env.CLI_API_TOKEN ?? settings.cliApiToken + if (!token) { + return null + } + const res = await fetch(`${configuration.apiUrl}/api/auth`, { + method: 'POST', + headers: buildHubRequestHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ accessToken: token }), + }) + if (!res.ok) { + return null + } + const body = (await res.json()) as { token?: string } + return body.token ?? null +} + +export async function fetchProvenanceDiagnostics(jwt: string): Promise { + const res = await fetch(`${configuration.apiUrl}/api/doctor/provenance`, { + headers: buildHubRequestHeaders({ Authorization: `Bearer ${jwt}` }), + }) + if (!res.ok) { + throw new Error(`provenance diagnostics failed: HTTP ${res.status}`) + } + return await res.json() as ProvenanceDiagnostics +} + +function formatIssues(issues: ProvenanceIssueCode[]): string { + if (issues.length === 0) { + return chalk.green('ok') + } + return issues.map((issue) => chalk.red(ISSUE_LABELS[issue])).join('; ') +} + +function formatSessionRow(row: SessionProvenanceRow): string { + const label = row.name ?? row.sessionId.slice(0, 8) + const pid = row.hostPid !== null ? ` pid=${row.hostPid}` : '' + const machine = row.machineId ? ` machine=${row.machineId.slice(0, 8)}` : '' + const lifecycle = row.lifecycleState ? ` lifecycle=${row.lifecycleState}` : '' + const kill = row.hasKillSessionRpc ? chalk.green('killSession') : chalk.red('no-kill') + const active = row.active ? chalk.yellow('active') : chalk.gray('idle') + return [ + ` ${active} ${chalk.cyan(label)}`, + ` id=${row.sessionId}`, + ` flavor=${row.flavor ?? '(unknown)'}${machine}${pid}${lifecycle}`, + ` rpc=${kill} ${formatIssues(row.issues)}`, + ].join('\n') +} + +function formatMachineRow(row: MachineProvenanceRow): string { + const label = row.displayName ?? row.host ?? row.machineId.slice(0, 8) + const spawn = row.hasSpawnRpc ? chalk.green('spawn') : chalk.red('no-spawn') + const proof = row.hasRunnerProof ? chalk.green('proof') : chalk.red('no-proof') + const version = row.happyCliVersion ? ` cli=${row.happyCliVersion}` : '' + return [ + ` ${chalk.blue(label)} (${row.machineId.slice(0, 8)})`, + ` host=${row.host ?? '(unknown)'}${version}`, + ` rpc=${spawn} proof=${proof} ${formatIssues(row.issues)}`, + ].join('\n') +} + +export function formatProvenanceReport(diagnostics: ProvenanceDiagnostics): string { + const lines: string[] = [ + chalk.bold('Summary'), + ` active sessions: ${diagnostics.summary.activeSessions}`, + ` unproven active: ${diagnostics.summary.unprovenActiveSessions}`, + ` archived-but-active: ${diagnostics.summary.archivedButActiveSessions}`, + ` online machines: ${diagnostics.summary.onlineMachines}`, + ` machines with issues: ${diagnostics.summary.machinesWithIssues}`, + ] + + const flaggedSessions = diagnostics.sessions.filter((row) => row.active || row.issues.length > 0) + lines.push('', chalk.bold('Sessions')) + if (flaggedSessions.length === 0) { + lines.push(' (no active or flagged sessions)') + } else { + for (const row of flaggedSessions) { + lines.push(formatSessionRow(row)) + } + } + + lines.push('', chalk.bold('Machines')) + if (diagnostics.machines.length === 0) { + lines.push(' (no online machines)') + } else { + for (const row of diagnostics.machines) { + lines.push(formatMachineRow(row)) + } + } + + return lines.join('\n') +} + +export function provenanceDiagnosticsHasIssues(diagnostics: ProvenanceDiagnostics): boolean { + return diagnostics.sessions.some((row) => row.issues.length > 0) + || diagnostics.machines.some((row) => row.issues.length > 0) +} + +export async function runDoctorProvenance(): Promise { + console.log(chalk.bold.cyan('\n🔎 hapi provenance doctor\n')) + console.log(`Hub: ${chalk.blue(configuration.apiUrl)}`) + + const jwt = await hubJwt() + if (!jwt) { + console.log(chalk.red('❌ CLI_API_TOKEN missing or auth failed')) + console.log(chalk.gray(' Run `hapi auth login` or set CLI_API_TOKEN, then retry.')) + return 1 + } + + let diagnostics: ProvenanceDiagnostics + try { + diagnostics = await fetchProvenanceDiagnostics(jwt) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.log(chalk.red(`❌ ${message}`)) + return 1 + } + + console.log('') + console.log(formatProvenanceReport(diagnostics)) + console.log('') + + if (provenanceDiagnosticsHasIssues(diagnostics)) { + console.log(chalk.yellow('⚠️ Provenance issues found.')) + console.log(chalk.gray(' Unproven active sessions cannot be archived cleanly; restart the CLI on that machine.')) + console.log(chalk.gray(' Inspect one session: hapi inspect-peer ')) + return 1 + } + + console.log(chalk.green('✅ No provenance issues detected.')) + return 0 +} diff --git a/hub/src/sync/provenanceDiagnostics.test.ts b/hub/src/sync/provenanceDiagnostics.test.ts new file mode 100644 index 0000000000..8e189b4ac8 --- /dev/null +++ b/hub/src/sync/provenanceDiagnostics.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'bun:test' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { buildProvenanceDiagnostics } from './provenanceDiagnostics' +import type { Machine, Session } from './syncEngine' + +function makeSession(overrides?: Partial): Session { + return { + id: 'session-1', + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + pinned: false, + globalPinned: false, + metadata: { + name: 'Peer #1', + path: '/tmp', + host: 'gc-oos-linux', + machineId: 'machine-1', + flavor: 'claude', + hostPid: 4242, + }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 1, + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null, + active: true, + activeAt: 1, + thinking: false, + ...overrides, + } as Session +} + +function makeMachine(overrides?: Partial): Machine { + return { + id: 'machine-1', + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: { + host: 'gc-oos-linux', + platform: 'linux', + happyCliVersion: '0.1.0', + capabilities: ['cursor-chat-store-status'], + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 1, + ...overrides, + } +} + +describe('buildProvenanceDiagnostics', () => { + it('flags active sessions without killSession RPC as unproven', () => { + const report = buildProvenanceDiagnostics({ + sessions: [makeSession()], + machines: [makeMachine()], + getStoredMachine: () => ({ + id: 'machine-1', + namespace: 'default', + tag: 'tag', + runnerProofHash: 'hash', + createdAt: 1, + updatedAt: 1, + metadata: null, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 1, + active: true, + activeAt: 1, + seq: 1, + }), + hasLiveRpcHandler: (method) => method === `machine-1:${RPC_METHODS.SpawnHappySession}`, + now: () => 100, + }) + + expect(report.summary.unprovenActiveSessions).toBe(1) + expect(report.sessions[0]?.issues).toEqual(['active_unproven']) + expect(report.sessions[0]?.hasKillSessionRpc).toBe(false) + }) + + it('marks proven active sessions when killSession RPC is live', () => { + const report = buildProvenanceDiagnostics({ + sessions: [makeSession()], + machines: [], + getStoredMachine: () => null, + hasLiveRpcHandler: (method) => method === `session-1:${RPC_METHODS.KillSession}`, + now: () => 100, + }) + + expect(report.sessions[0]?.issues).toEqual([]) + expect(report.sessions[0]?.hasKillSessionRpc).toBe(true) + }) + + it('flags archived-but-active split brain', () => { + const report = buildProvenanceDiagnostics({ + sessions: [makeSession({ + metadata: { + name: 'zombie', + path: '/tmp', + host: 'gc-oos-linux', + lifecycleState: 'archived', + }, + })], + machines: [], + getStoredMachine: () => null, + hasLiveRpcHandler: () => false, + now: () => 100, + }) + + expect(report.summary.archivedButActiveSessions).toBe(1) + expect(report.sessions[0]?.issues).toContain('archived_but_active') + expect(report.sessions[0]?.issues).toContain('active_unproven') + }) + + it('flags machine spawn/proof/capability/cli issues', () => { + const report = buildProvenanceDiagnostics({ + sessions: [], + machines: [makeMachine({ + metadata: { + host: 'gc-oos-linux', + platform: 'linux', + happyCliVersion: '0.1.0', + startedCliMtimeMs: 1, + installedCliMtimeMs: 2, + capabilities: [], + }, + })], + getStoredMachine: () => ({ + id: 'machine-1', + namespace: 'default', + tag: 'tag', + runnerProofHash: null, + createdAt: 1, + updatedAt: 1, + metadata: null, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 1, + active: true, + activeAt: 1, + seq: 1, + }), + hasLiveRpcHandler: () => false, + now: () => 100, + }) + + expect(report.machines[0]?.issues).toEqual([ + 'machine_no_spawn_rpc', + 'machine_no_runner_proof', + 'machine_capability_skew', + 'machine_cli_stale', + ]) + expect(report.summary.machinesWithIssues).toBe(1) + }) +}) diff --git a/hub/src/sync/provenanceDiagnostics.ts b/hub/src/sync/provenanceDiagnostics.ts new file mode 100644 index 0000000000..796a800b0f --- /dev/null +++ b/hub/src/sync/provenanceDiagnostics.ts @@ -0,0 +1,123 @@ +import { + cliBinaryUpdatedOnDisk, + isMachineCapabilitySkewed, +} from '@hapi/protocol/runnerCapabilities' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import type { + MachineProvenanceRow, + ProvenanceDiagnostics, + ProvenanceIssueCode, + SessionProvenanceRow, +} from '@hapi/protocol/provenanceDiagnostics' +import type { Machine, Session } from './syncEngine' +import type { StoredMachine } from '../store/types' + +type BuildProvenanceDiagnosticsInput = { + sessions: Session[] + machines: Machine[] + getStoredMachine: (machineId: string) => StoredMachine | null + hasLiveRpcHandler: (method: string) => boolean + now?: () => number +} + +function metadataRecord(metadata: Session['metadata']): Record | null { + return metadata !== null && typeof metadata === 'object' ? metadata as Record : null +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function numberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function buildSessionRow( + session: Session, + hasLiveRpcHandler: (method: string) => boolean +): SessionProvenanceRow { + const meta = metadataRecord(session.metadata) + const lifecycleState = stringOrNull(meta?.lifecycleState) + const hasKillSessionRpc = hasLiveRpcHandler(`${session.id}:${RPC_METHODS.KillSession}`) + const issues: ProvenanceIssueCode[] = [] + + if (session.active && !hasKillSessionRpc) { + issues.push('active_unproven') + } + if (session.active && lifecycleState === 'archived') { + issues.push('archived_but_active') + } + + return { + sessionId: session.id, + name: stringOrNull(meta?.name), + active: session.active, + lifecycleState, + machineId: stringOrNull(meta?.machineId) ?? stringOrNull(session.metadata?.machineId), + hostPid: numberOrNull(meta?.hostPid), + flavor: stringOrNull(meta?.flavor), + hasKillSessionRpc, + issues, + } +} + +function buildMachineRow( + machine: Machine, + stored: StoredMachine | null, + hasLiveRpcHandler: (method: string) => boolean +): MachineProvenanceRow { + const hasSpawnRpc = hasLiveRpcHandler(`${machine.id}:${RPC_METHODS.SpawnHappySession}`) + const hasRunnerProof = Boolean(stored?.runnerProofHash) + const capabilitySkew = isMachineCapabilitySkewed(machine.metadata?.capabilities) + const cliBinaryStale = cliBinaryUpdatedOnDisk(machine.metadata) + const issues: ProvenanceIssueCode[] = [] + + if (machine.active && !hasSpawnRpc) { + issues.push('machine_no_spawn_rpc') + } + if (machine.active && !hasRunnerProof) { + issues.push('machine_no_runner_proof') + } + if (machine.active && capabilitySkew) { + issues.push('machine_capability_skew') + } + if (machine.active && cliBinaryStale) { + issues.push('machine_cli_stale') + } + + return { + machineId: machine.id, + displayName: stringOrNull(machine.metadata?.displayName), + host: stringOrNull(machine.metadata?.host), + active: machine.active, + hasSpawnRpc, + hasRunnerProof, + capabilitySkew, + cliBinaryStale, + happyCliVersion: stringOrNull(machine.metadata?.happyCliVersion), + issues, + } +} + +export function buildProvenanceDiagnostics(input: BuildProvenanceDiagnosticsInput): ProvenanceDiagnostics { + const now = input.now ?? Date.now + const sessions = input.sessions.map((session) => buildSessionRow(session, input.hasLiveRpcHandler)) + const machines = input.machines.map((machine) => buildMachineRow( + machine, + input.getStoredMachine(machine.id), + input.hasLiveRpcHandler + )) + + return { + generatedAt: now(), + sessions, + machines, + summary: { + activeSessions: sessions.filter((row) => row.active).length, + unprovenActiveSessions: sessions.filter((row) => row.issues.includes('active_unproven')).length, + archivedButActiveSessions: sessions.filter((row) => row.issues.includes('archived_but_active')).length, + onlineMachines: machines.filter((row) => row.active).length, + machinesWithIssues: machines.filter((row) => row.issues.length > 0).length, + }, + } +} diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 177807bd6c..53ad27df74 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -486,6 +486,15 @@ export class RpcGateway { return await this.rpcCall(`${machineId}:${method}`, params, timeoutMs) } + /** True when a live /cli socket owns the RPC method (hub-side provenance signal). */ + hasLiveHandler(method: string): boolean { + const socketId = this.rpcRegistry.getSocketIdForMethod(method) + if (!socketId) { + return false + } + return this.io.of('/cli').sockets.has(socketId) + } + private async rpcCall(method: string, params: unknown, timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS): Promise { const socketId = this.rpcRegistry.getSocketIdForMethod(method) if (!socketId) { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 4a7a6fa07b..4b39e72279 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -58,6 +58,8 @@ import { import { SessionCache } from './sessionCache' import { ingestNotifySummaryFromMessage } from './workGraphNotifyIngest' import { armResumePeerMint, clearResumePeerMint } from '../web/pendingResumePeerMint' +import { buildProvenanceDiagnostics } from './provenanceDiagnostics' +import type { ProvenanceDiagnostics } from '@hapi/protocol/provenanceDiagnostics' type PiResumeAttempt = NonNullable['piResumeAttempt']> type PtyResumeAttempt = NonNullable['ptyResumeAttempt']> @@ -392,6 +394,15 @@ export class SyncEngine { return this.machineCache.getOnlineMachinesByNamespace(namespace) } + getProvenanceDiagnostics(namespace: string): ProvenanceDiagnostics { + return buildProvenanceDiagnostics({ + sessions: this.getSessionsByNamespace(namespace), + machines: this.getOnlineMachinesByNamespace(namespace), + getStoredMachine: (machineId) => this.store.machines.getMachineByNamespace(machineId, namespace), + hasLiveRpcHandler: (method) => this.rpcGateway.hasLiveHandler(method), + }) + } + async renameMachine(machineId: string, displayName: string): Promise { return this.machineCache.renameMachine(machineId, displayName) } diff --git a/hub/src/web/routes/doctor.test.ts b/hub/src/web/routes/doctor.test.ts new file mode 100644 index 0000000000..242826e559 --- /dev/null +++ b/hub/src/web/routes/doctor.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { ProvenanceDiagnostics } from '@hapi/protocol/provenanceDiagnostics' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createDoctorRoutes } from './doctor' + +function createApp(diagnostics: ProvenanceDiagnostics) { + const engine = { + getProvenanceDiagnostics: () => diagnostics, + } as unknown as SyncEngine + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createDoctorRoutes(() => engine)) + return app +} + +describe('doctor routes', () => { + it('GET /doctor/provenance returns hub diagnostics', async () => { + const diagnostics: ProvenanceDiagnostics = { + generatedAt: 100, + sessions: [], + machines: [], + summary: { + activeSessions: 0, + unprovenActiveSessions: 0, + archivedButActiveSessions: 0, + onlineMachines: 0, + machinesWithIssues: 0, + }, + } + const app = createApp(diagnostics) + const response = await app.request('/api/doctor/provenance') + expect(response.status).toBe(200) + expect(await response.json()).toEqual(diagnostics) + }) +}) diff --git a/hub/src/web/routes/doctor.ts b/hub/src/web/routes/doctor.ts new file mode 100644 index 0000000000..ffa0976d15 --- /dev/null +++ b/hub/src/web/routes/doctor.ts @@ -0,0 +1,20 @@ +import { Hono } from 'hono' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSyncEngine } from './guards' + +export function createDoctorRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + app.get('/doctor/provenance', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const namespace = c.get('namespace') + return c.json(engine.getProvenanceDiagnostics(namespace)) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 52a6b3cdb6..315f90197e 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -31,6 +31,7 @@ import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' import { createHubSettingsRoutes } from './routes/hubSettings' import { createWorkGraphRoutes } from './routes/workGraph' +import { createDoctorRoutes } from './routes/doctor' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer, ServerWebSocket } from 'bun' @@ -303,6 +304,7 @@ function createWebApp(options: { app.route('/api', createVoiceRoutes({ dataDir: configuration.dataDir })) // Path is intentionally NOT `/api/events` — that route is the SSE stream. app.route('/api', createWorkGraphRoutes(options.store)) + app.route('/api', createDoctorRoutes(options.getSyncEngine)) // Skip static serving in relay mode, show helpful message on root if (options.relayMode) { diff --git a/shared/package.json b/shared/package.json index 760d72d22f..8aac70b7bb 100644 --- a/shared/package.json +++ b/shared/package.json @@ -13,6 +13,7 @@ "./buildInfo": "./src/buildInfo.ts", "./conversationHistory": "./src/conversationHistory.ts", "./modes": "./src/modes.ts", + "./provenanceDiagnostics": "./src/provenanceDiagnostics.ts", "./rpcMethods": "./src/rpcMethods.ts", "./runnerCapabilities": "./src/runnerCapabilities.ts", "./schemas": "./src/schemas.ts", diff --git a/shared/src/provenanceDiagnostics.ts b/shared/src/provenanceDiagnostics.ts new file mode 100644 index 0000000000..8e141a0b03 --- /dev/null +++ b/shared/src/provenanceDiagnostics.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' + +export const ProvenanceIssueCodeSchema = z.enum([ + 'active_unproven', + 'archived_but_active', + 'machine_no_spawn_rpc', + 'machine_no_runner_proof', + 'machine_capability_skew', + 'machine_cli_stale', +]) + +export type ProvenanceIssueCode = z.infer + +export const SessionProvenanceRowSchema = z.object({ + sessionId: z.string(), + name: z.string().nullable(), + active: z.boolean(), + lifecycleState: z.string().nullable(), + machineId: z.string().nullable(), + hostPid: z.number().nullable(), + flavor: z.string().nullable(), + hasKillSessionRpc: z.boolean(), + issues: z.array(ProvenanceIssueCodeSchema), +}) + +export type SessionProvenanceRow = z.infer + +export const MachineProvenanceRowSchema = z.object({ + machineId: z.string(), + displayName: z.string().nullable(), + host: z.string().nullable(), + active: z.boolean(), + hasSpawnRpc: z.boolean(), + hasRunnerProof: z.boolean(), + capabilitySkew: z.boolean(), + cliBinaryStale: z.boolean(), + happyCliVersion: z.string().nullable(), + issues: z.array(ProvenanceIssueCodeSchema), +}) + +export type MachineProvenanceRow = z.infer + +export const ProvenanceDiagnosticsSchema = z.object({ + generatedAt: z.number(), + sessions: z.array(SessionProvenanceRowSchema), + machines: z.array(MachineProvenanceRowSchema), + summary: z.object({ + activeSessions: z.number(), + unprovenActiveSessions: z.number(), + archivedButActiveSessions: z.number(), + onlineMachines: z.number(), + machinesWithIssues: z.number(), + }), +}) + +export type ProvenanceDiagnostics = z.infer From e819571bd7410575be32c10e0c389fd9e7112d8c Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:05:38 +0000 Subject: [PATCH 081/142] fix(web): add showSessionSummaryInChat to chat context test mocks Upstream #1477 made the field required on HappyChatContextValue; merge ref typecheck failed on markdown-a.test chatContext helper. Co-authored-by: Cursor --- web/e2e-fixtures/markdown-file-link-failclosed-fixture.tsx | 1 + web/src/components/assistant-ui/markdown-a.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/web/e2e-fixtures/markdown-file-link-failclosed-fixture.tsx b/web/e2e-fixtures/markdown-file-link-failclosed-fixture.tsx index 481fbc3dd0..1d05792545 100644 --- a/web/e2e-fixtures/markdown-file-link-failclosed-fixture.tsx +++ b/web/e2e-fixtures/markdown-file-link-failclosed-fixture.tsx @@ -39,6 +39,7 @@ function chatValue(): HappyChatContextValue { sessionId: 'fixture-session', metadata: { path: '/home/ada/coding/hapi', host: 'local' }, terminalToolDisplayMode: 'compact', + showSessionSummaryInChat: false, disabled: false, onRefresh: () => {}, hasMoreMessages: false, diff --git a/web/src/components/assistant-ui/markdown-a.test.tsx b/web/src/components/assistant-ui/markdown-a.test.tsx index d2053f0c7d..94c7ed9daa 100644 --- a/web/src/components/assistant-ui/markdown-a.test.tsx +++ b/web/src/components/assistant-ui/markdown-a.test.tsx @@ -54,6 +54,7 @@ function chatContext(overrides: Partial = {}): HappyChatC sessionId: 'session-1', metadata: { path: '/home/ada/coding/hapi', host: 'local' }, terminalToolDisplayMode: 'compact', + showSessionSummaryInChat: false, disabled: false, onRefresh: () => {}, hasMoreMessages: false, From df1a56e1db13361bd97cb09b2fdd560935f4721d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:42:43 +0100 Subject: [PATCH 082/142] fix(cursor): exclusive agent spawn lease for list-models vs ACP (#1529) * fix(cursor): exclusive agent spawn lease for list-models vs ACP (#1520) Add a proper-lockfile spawn lease beside agent-acp-active so model probes and ACP transport acquire mutual exclusion atomically before spawning agent children, closing the post-#1518 check-then-act overlap window. Fixes #1520 Co-authored-by: Cursor * chore(web): fix markdown-a test HappyChatContext mock for typecheck Adds showSessionSummaryInChat to chatContext() so CI typecheck passes on the PR branch (pre-existing main breakage unrelated to #1520). Co-authored-by: Cursor * Revert "chore(web): fix markdown-a test HappyChatContext mock for typecheck" This reverts commit 4973f321a31fda772e8990ea6cda20517ca906aa. * fix(cursor): scope spawn lease to agent spawn window only (#1520) Hold agent-cli.spawn only around spawn('agent') in AcpStdioTransport, not for the full ACP session. Restores N concurrent cursor sessions per host; list-models probe lease unchanged. Co-authored-by: Cursor * fix(cursor): tighten spawn lease lifecycle for babysit (#1520) Acquire spawn lease before ACP marker publish; unregister on spawn failure. Hold list-models probe lease until child exit on timeout. Add missing showSessionSummaryInChat to markdown-a test mock (unblocks CI typecheck). Co-authored-by: Cursor * fix(cursor): re-check ACP marker after spawn lease acquire (#1520) Close check-then-act window where ACP could publish its marker between the inactive guard read and list-models spawn. Add regression test. Co-authored-by: Cursor * test(cursor): fix ACP-after-acquire mock call order (#1520) Co-authored-by: Cursor * fix(cursor): async spawn lease + force-kill probe timeout (#1520) Add acquireAgentCliSpawnLease (setTimeout yields) for ACP create path; AcpStdioTransport.create() async factory. Probe timeout uses killProcessByChildProcess(force) while holding lease until child exit. Addresses Bugbot Majors: session-lifetime mutex (fe07b708d), probe lease release, post-acquire re-check (137baa779), sync loop starvation, timeout escalation. Co-authored-by: Cursor * fix(acp): coalesce concurrent initialize() transport spawns (#1520) Await shared bootstrapTransport promise so overlapping initialize calls do not spawn duplicate ACP children while create() is in flight. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- bun.lock | 2 + .../acp/AcpSdkBackend.initialize.test.ts | 11 +- cli/src/agent/backends/acp/AcpSdkBackend.ts | 25 ++- .../backends/acp/AcpStdioTransport.test.ts | 58 +++--- .../agent/backends/acp/AcpStdioTransport.ts | 45 ++++- .../agent/backends/acp/agentCliGuard.test.ts | 13 ++ cli/src/agent/backends/acp/agentCliGuard.ts | 12 +- cli/src/modules/common/cursorModels.test.ts | 35 +++- cli/src/modules/common/cursorModels.ts | 65 +++++-- cli/src/modules/common/grokModels.ts | 2 +- cli/src/modules/common/opencodeModels.test.ts | 22 +-- cli/src/modules/common/opencodeModels.ts | 2 +- shared/package.json | 3 +- shared/src/agentCliSpawnLease.test.ts | 86 +++++++++ shared/src/agentCliSpawnLease.ts | 170 ++++++++++++++++++ .../assistant-ui/markdown-a.test.tsx | 1 + 16 files changed, 470 insertions(+), 82 deletions(-) create mode 100644 shared/src/agentCliSpawnLease.test.ts create mode 100644 shared/src/agentCliSpawnLease.ts diff --git a/bun.lock b/bun.lock index 8ac0d2ec4e..4e1f93d12e 100644 --- a/bun.lock +++ b/bun.lock @@ -1106,6 +1106,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-xezXLDF60bMPdyRBzzZjdBL0yC8zOH3EoznJwzh3RsW1lzc3vd5XWLP+TvY1UU4/MWsMM0mp9/U6IivTlQjcHw=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-Rtnz6WwRVqqt3qVcM6awHA/LUU7lLB35YAkfTeloNf+ARuANslZUeMyXHIi6/xoNEVX5S9+mRJyfv80EOmwSkw=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts index 6dc851b794..040e590b22 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts @@ -4,9 +4,11 @@ const transportState = vi.hoisted(() => ({ calls: [] as Array<{ method: string; params?: unknown }> })); -vi.mock('./AcpStdioTransport', () => ({ - AcpStdioTransport: class { - constructor(_options: unknown) {} +vi.mock('./AcpStdioTransport', () => { + class MockAcpStdioTransport { + static async create(_options: unknown) { + return new MockAcpStdioTransport(); + } onNotification = vi.fn(); onStderrError = vi.fn(); registerRequestHandler = vi.fn(); @@ -19,7 +21,8 @@ vi.mock('./AcpStdioTransport', () => ({ }); close = vi.fn(async () => {}); } -})); + return { AcpStdioTransport: MockAcpStdioTransport }; +}); import { AcpSdkBackend } from './AcpSdkBackend'; diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index fbe0441cbc..f686a9ddff 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -74,6 +74,7 @@ export class AcpSdkBackend implements AgentBackend { private messageHandler: AcpMessageHandler | null = null; private activeSessionId: string | null = null; private initializeResult: AcpInitializeResult | null = null; + private initializeInFlight: Promise | null = null; private setModeSupported: boolean | undefined = undefined; private isProcessingMessage = false; private promptRequestInFlight = false; @@ -133,13 +134,35 @@ export class AcpSdkBackend implements AgentBackend { async initialize(): Promise { if (this.transport) return; + if (this.initializeInFlight) { + await this.initializeInFlight; + return; + } + + this.initializeInFlight = this.bootstrapTransport(); + try { + await this.initializeInFlight; + } finally { + this.initializeInFlight = null; + } + } - this.transport = new AcpStdioTransport({ + private async bootstrapTransport(): Promise { + if (this.transport) return; + + const transport = await AcpStdioTransport.create({ command: this.options.command, args: this.options.args, env: this.options.env }); + if (this.transport) { + await transport.close(); + return; + } + + this.transport = transport; + this.transport.onNotification((method, params) => { if (method === 'session/update') { this.handleSessionUpdate(params); diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.test.ts b/cli/src/agent/backends/acp/AcpStdioTransport.test.ts index ba1749cd28..33a94d5486 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.test.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.test.ts @@ -129,7 +129,7 @@ describe('AcpStdioTransport agent CLI guard', () => { }); test('registers cross-process guard only for Cursor agent command', async () => { - const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + const transport = await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); expect(guard.register).toHaveBeenCalledTimes(1); expect(guard.recordChildPid).toHaveBeenCalledWith(424242); await transport.close(); @@ -137,17 +137,17 @@ describe('AcpStdioTransport agent CLI guard', () => { expect(guard.unregister).toHaveBeenCalledWith({ childPid: 424242 }); }); - test('registers the ACP guard before spawn so list-models cannot race the new child', () => { + test('registers the ACP guard before spawn so list-models cannot race the new child', async () => { spawnState.spawnCallOrder = []; - new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); expect(spawnState.spawnCallOrder.indexOf('register')).toBeGreaterThanOrEqual(0); expect(spawnState.spawnCallOrder.indexOf('spawn')).toBeGreaterThan( spawnState.spawnCallOrder.indexOf('register') ); }); - test('keeps the ACP guard held across exit until close drains stdio', () => { - new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + test('keeps the ACP guard held across exit until close drains stdio', async () => { + await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); guard.unregister.mockClear(); for (const handler of spawnState.exitHandlers) { @@ -162,12 +162,12 @@ describe('AcpStdioTransport agent CLI guard', () => { expect(guard.unregister).toHaveBeenCalledWith({ childPid: 424242 }); }); - test('does not register guard for non-agent ACP backends', () => { + test('does not register guard for non-agent ACP backends', async () => { for (const command of ['gemini', 'opencode', 'kimi']) { guard.register.mockClear(); guard.unregister.mockClear(); guard.recordChildPid.mockClear(); - new AcpStdioTransport({ command }); + await AcpStdioTransport.create({ command }); expect(guard.register).not.toHaveBeenCalled(); expect(guard.recordChildPid).not.toHaveBeenCalled(); expect(guard.unregister).not.toHaveBeenCalled(); @@ -188,7 +188,7 @@ describe('AcpStdioTransport plain-text stdout', () => { }); test('ignores Cursor worktree banner and keeps JSON-RPC session alive', async () => { - const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + const transport = await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); const notifications: Array<{ method: string; params: unknown }> = []; transport.onNotification((method, params) => { notifications.push({ method, params }); @@ -221,7 +221,7 @@ describe('AcpStdioTransport plain-text stdout', () => { }); test('ignores non-object JSON lines without killing the session', async () => { - const transport = new AcpStdioTransport({ command: 'gemini' }); + const transport = await AcpStdioTransport.create({ command: 'gemini' }); const pending = transport.sendRequest('initialize'); emitStdout('42\n'); @@ -239,7 +239,7 @@ describe('AcpStdioTransport plain-text stdout', () => { }); test('treats unknown non-JSON stdout as a fatal protocol error', async () => { - const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + const transport = await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); const pending = transport.sendRequest('initialize'); expect(spawnState.stdoutDataHandlers.length).toBeGreaterThan(0); @@ -266,7 +266,7 @@ describe('AcpStdioTransport closed stdin writes', () => { }); test('rejects new requests after process exit before close without writing stdin', async () => { - const transport = new AcpStdioTransport({ command: 'gemini' }); + const transport = await AcpStdioTransport.create({ command: 'gemini' }); spawnState.exitCode = 1; spawnState.stdinWrite.mockClear(); @@ -287,7 +287,7 @@ describe('AcpStdioTransport closed stdin writes', () => { }); test('rejects new requests after the ACP process exits instead of throwing from stdin.write', async () => { - const transport = new AcpStdioTransport({ command: 'gemini' }); + const transport = await AcpStdioTransport.create({ command: 'gemini' }); spawnState.exitCode = 1; spawnState.stdinWrite.mockImplementation(() => { throw new Error('WritableIterable is closed'); @@ -304,7 +304,7 @@ describe('AcpStdioTransport closed stdin writes', () => { }); test('includes recent stderr on process close so callers can classify model rejection', async () => { - const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + const transport = await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); const proc = (transport as unknown as { process: { stderr: { on: ReturnType }; } }).process; @@ -328,7 +328,7 @@ describe('AcpStdioTransport closed stdin writes', () => { }); test('accumulates split stderr chunks so Cannot use this model survives a catalog follow-up chunk', async () => { - const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + const transport = await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); const proc = (transport as unknown as { process: { stderr: { on: ReturnType }; } }).process; @@ -352,7 +352,7 @@ describe('AcpStdioTransport closed stdin writes', () => { }); test('preserves Cannot use this model when the keyword itself is split across chunks', async () => { - const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + const transport = await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); const proc = (transport as unknown as { process: { stderr: { on: ReturnType }; } }).process; @@ -382,7 +382,7 @@ describe('AcpStdioTransport closed stdin writes', () => { }); test('waits for the model id before emitting Cannot use this model via onStderrError', async () => { - const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + const transport = await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); const proc = (transport as unknown as { process: { stderr: { on: ReturnType }; } }).process; @@ -415,7 +415,7 @@ describe('AcpStdioTransport closed stdin writes', () => { }); test('pins Cannot use this model head when Available models catalog exceeds the rolling window', async () => { - const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + const transport = await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); const proc = (transport as unknown as { process: { stderr: { on: ReturnType }; } }).process; @@ -439,7 +439,7 @@ describe('AcpStdioTransport closed stdin writes', () => { }); test('keeps the head of long stderr so Cannot use this model survives Available models lists', async () => { - const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + const transport = await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); const proc = (transport as unknown as { process: { stderr: { on: ReturnType }; } }).process; @@ -462,8 +462,8 @@ describe('AcpStdioTransport closed stdin writes', () => { ); }); - test('reports Cannot use this model stderr via onStderrError with Cursor text intact', () => { - const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + test('reports Cannot use this model stderr via onStderrError with Cursor text intact', async () => { + const transport = await AcpStdioTransport.create({ command: 'agent', args: ['acp'] }); const seen: Array<{ type: string; message: string; raw: string }> = []; transport.onStderrError((error) => { seen.push(error); @@ -492,8 +492,8 @@ describe('AcpStdioTransport closed stdin writes', () => { ['status 404', 'model_not_found'], ['Cannot use this model: stale-id', 'model_not_found'], ['unexpected error', 'unknown'] - ])('reports newline-free %s stderr immediately', (chunk, type) => { - const transport = new AcpStdioTransport({ command: 'agent' }); + ])('reports newline-free %s stderr immediately', async (chunk, type) => { + const transport = await AcpStdioTransport.create({ command: 'agent' }); const seen: Array<{ type: string }> = []; transport.onStderrError((error) => seen.push(error)); const proc = (transport as unknown as { process: { @@ -508,8 +508,8 @@ describe('AcpStdioTransport closed stdin writes', () => { expect(seen.map((error) => error.type)).toEqual([type]); }); - test('reports a completed non-HTTP/2 cancellation record', () => { - const transport = new AcpStdioTransport({ command: 'agent' }); + test('reports a completed non-HTTP/2 cancellation record', async () => { + const transport = await AcpStdioTransport.create({ command: 'agent' }); const seen: Array<{ type: string; message: string; raw: string }> = []; transport.onStderrError((error) => seen.push(error)); const proc = (transport as unknown as { process: { @@ -528,8 +528,8 @@ describe('AcpStdioTransport closed stdin writes', () => { }]); }); - test('parses stall signatures split across stderr chunks without waiting for close', () => { - const transport = new AcpStdioTransport({ command: 'opencode' }); + test('parses stall signatures split across stderr chunks without waiting for close', async () => { + const transport = await AcpStdioTransport.create({ command: 'opencode' }); const seen: Array<{ type: string; message: string; raw: string }> = []; transport.onStderrError((error) => { seen.push(error); @@ -579,8 +579,8 @@ describe('AcpStdioTransport closed stdin writes', () => { ]); }); - test('bounds newline-free unclassified stderr tails', () => { - const transport = new AcpStdioTransport({ command: 'agent' }); + test('bounds newline-free unclassified stderr tails', async () => { + const transport = await AcpStdioTransport.create({ command: 'agent' }); const proc = (transport as unknown as { process: { stderr: { on: ReturnType }; } }).process; @@ -599,7 +599,7 @@ describe('AcpStdioTransport closed stdin writes', () => { throw new Error('WritableIterable is closed'); }); - const transport = new AcpStdioTransport({ command: 'gemini' }); + const transport = await AcpStdioTransport.create({ command: 'gemini' }); await expect(transport.sendRequest('initialize')).rejects.toThrow('WritableIterable is closed'); await expect(transport.sendRequest('session/new')).rejects.toThrow('WritableIterable is closed'); }); diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.ts b/cli/src/agent/backends/acp/AcpStdioTransport.ts index c576412c38..d5bee14cdf 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.ts @@ -1,4 +1,9 @@ import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptions } from 'node:child_process'; +import { + acquireAgentCliSpawnLease, + releaseAgentCliSpawnLeaseFromAcpRegisterSync +} from '@hapi/protocol/agentCliSpawnLease'; +import { resolveHapiHomeDir } from '@/configuration'; import { logger } from '@/ui/logger'; import { killProcessByChildProcess } from '@/utils/process'; import { GEMINI_MODEL_PRESETS } from '@hapi/protocol'; @@ -58,6 +63,7 @@ export function buildAcpStdioSpawnOptions(env?: Record): SpawnOp export class AcpStdioTransport { /** Only Cursor's `agent` CLI is single-process; other ACP backends must not block model probes. */ private readonly shouldGuardAgentCli: boolean; + private readonly command: string; private readonly process: ChildProcessWithoutNullStreams; private readonly pending = new Map void; @@ -87,23 +93,44 @@ export class AcpStdioTransport { /** Max stderr attached to the close Error (prefer model-rejection head). */ private static readonly CLOSE_STDERR_CAP = 4_000; - constructor(options: { + static async create(options: { command: string; args?: string[]; env?: Record; - }) { - this.shouldGuardAgentCli = options.command === 'agent'; - // Register before spawn so runner/list-models cannot observe an unlocked - // window between process creation and lock write (#1472). - if (this.shouldGuardAgentCli) { - registerActiveAcpTransport(); + }): Promise { + const shouldGuardAgentCli = options.command === 'agent'; + if (shouldGuardAgentCli) { + await acquireAgentCliSpawnLease(resolveHapiHomeDir()); + try { + registerActiveAcpTransport(); + try { + const process = spawn( + options.command, + options.args ?? [], + buildAcpStdioSpawnOptions(options.env) + ) as ChildProcessWithoutNullStreams; + return new AcpStdioTransport(process, true, options.command); + } catch (error) { + unregisterActiveAcpTransport(); + throw error; + } + } finally { + releaseAgentCliSpawnLeaseFromAcpRegisterSync(); + } } - this.process = spawn( + const process = spawn( options.command, options.args ?? [], buildAcpStdioSpawnOptions(options.env) ) as ChildProcessWithoutNullStreams; + return new AcpStdioTransport(process, false, options.command); + } + + private constructor(process: ChildProcessWithoutNullStreams, shouldGuardAgentCli: boolean, command: string) { + this.shouldGuardAgentCli = shouldGuardAgentCli; + this.command = command; + this.process = process; if (this.shouldGuardAgentCli) { const childPid = typeof this.process.pid === 'number' ? this.process.pid : null; @@ -195,7 +222,7 @@ export class AcpStdioTransport { logger.debug('[ACP] Process error', error); const message = error instanceof Error ? error.message : String(error); this.markClosed(new Error( - `Failed to spawn ${options.command}: ${message}. Is it installed and on PATH?`, + `Failed to spawn ${this.command}: ${message}. Is it installed and on PATH?`, { cause: error } )); }); diff --git a/cli/src/agent/backends/acp/agentCliGuard.test.ts b/cli/src/agent/backends/acp/agentCliGuard.test.ts index 8f54379fc4..f885d5c566 100644 --- a/cli/src/agent/backends/acp/agentCliGuard.test.ts +++ b/cli/src/agent/backends/acp/agentCliGuard.test.ts @@ -13,6 +13,11 @@ import { registerActiveAcpTransport, unregisterActiveAcpTransport } from './agentCliGuard'; +import { + releaseAgentCliSpawnLeaseFromAcpRegisterSync, + releaseAgentCliSpawnLeaseSync, + tryAcquireAgentCliSpawnLeaseSync +} from '@hapi/protocol/agentCliSpawnLease'; const testHome = join(tmpdir(), `hapi-agent-cli-guard-${process.pid}`); @@ -58,6 +63,14 @@ describe('agentCliGuard', () => { expect(isAgentAcpTransportActive()).toBe(false); }); + test('does not hold spawn lease for the full register lifetime', () => { + process.env.HAPI_HOME = testHome; + registerActiveAcpTransport(); + expect(tryAcquireAgentCliSpawnLeaseSync(testHome)).toBe(true); + releaseAgentCliSpawnLeaseSync(); + unregisterActiveAcpTransport(); + }); + test('keeps cross-process lock until the last transport unregisters', () => { process.env.HAPI_HOME = testHome; registerActiveAcpTransport(); diff --git a/cli/src/agent/backends/acp/agentCliGuard.ts b/cli/src/agent/backends/acp/agentCliGuard.ts index 5540c0fbf2..f76cd65115 100644 --- a/cli/src/agent/backends/acp/agentCliGuard.ts +++ b/cli/src/agent/backends/acp/agentCliGuard.ts @@ -8,6 +8,10 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { + releaseAgentCliSpawnLeaseFromAcpRegisterSync, + _resetAgentCliSpawnLeaseForTests +} from '@hapi/protocol/agentCliSpawnLease'; import { resolveHapiHomeDir } from '@/configuration'; /** @@ -16,7 +20,10 @@ import { resolveHapiHomeDir } from '@/configuration'; * child (SIGTERM / exit 143) and crashes the remote session. * * In-process ref counting covers RPC handlers in the same process; a HAPI_HOME - * lock directory covers runner vs session child processes. + * lock directory covers runner vs session child processes. The proper-lockfile + * spawn lease (`locks/agent-cli.spawn`) is held only around `spawn('agent')` + * in AcpStdioTransport and during list-models probes — not for the full session + * (#1520; multi-session ACP must remain possible). * * Prefer recording the ACP child PID (not only the HAPI host PID) so stale * cleanup and logs attribute the real `agent` process. Register the lock @@ -468,8 +475,11 @@ export function _setActiveAcpTransportCountForTests(count: number): void { } export function _resetAgentCliGuardForTests(): void { + const home = process.env.HAPI_HOME; activeAcpTransportCount = 0; registerPublishHook = null; addLockPidHook = null; + releaseAgentCliSpawnLeaseFromAcpRegisterSync(); + _resetAgentCliSpawnLeaseForTests(home); removeAcpLockDir(); } diff --git a/cli/src/modules/common/cursorModels.test.ts b/cli/src/modules/common/cursorModels.test.ts index 0fe8171ee3..496a20caec 100644 --- a/cli/src/modules/common/cursorModels.test.ts +++ b/cli/src/modules/common/cursorModels.test.ts @@ -404,20 +404,41 @@ describe('listCursorModels', () => { }); }); - test('skips CLI slug probe when ACP lock is active after ACP probe', async () => { + test('skips CLI slug probe when spawn lease is held after guard reads inactive', async () => { + const { acquireAgentCliSpawnLeaseSync, _resetAgentCliSpawnLeaseForTests } = await import( + '@hapi/protocol/agentCliSpawnLease' + ) + acquireAgentCliSpawnLeaseSync(testHapiHome) + vi.mocked(isAgentAcpTransportActive).mockReturnValueOnce(false) + acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({ + success: false, + error: 'no wires' + }) + + const result = await listCursorModels() + + expect(spawnMock).not.toHaveBeenCalled() + expect(result.success).toBe(false) + expect(result.error).toContain('ACP transport is active') + _resetAgentCliSpawnLeaseForTests(testHapiHome) + }) + + test('does not spawn list-models when ACP marker appears after lease acquire', async () => { vi.mocked(isAgentAcpTransportActive) .mockReturnValueOnce(false) - .mockReturnValueOnce(true); + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({ success: false, error: 'no wires' - }); + }) - const result = await listCursorModels(); + const result = await listCursorModels() - expect(spawnMock).not.toHaveBeenCalled(); - expect(result).toEqual({ success: false, error: 'no wires' }); - }); + expect(spawnMock).not.toHaveBeenCalled() + expect(result.success).toBe(false) + expect(result.error).toContain('ACP transport is active') + }) test('prefers live ACP snapshot over cache while ACP transport is active', async () => { vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) diff --git a/cli/src/modules/common/cursorModels.ts b/cli/src/modules/common/cursorModels.ts index 8629b62402..ae9e0f6414 100644 --- a/cli/src/modules/common/cursorModels.ts +++ b/cli/src/modules/common/cursorModels.ts @@ -1,6 +1,12 @@ import { spawn } from 'node:child_process'; import type { CursorModelsResponse, CursorModelSummary } from '@hapi/protocol/apiTypes'; +import { + releaseAgentCliSpawnLeaseSync, + tryAcquireAgentCliSpawnLeaseSync +} from '@hapi/protocol/agentCliSpawnLease'; import { isAgentAcpTransportActive } from '@/agent/backends/acp/agentCliGuard'; +import { resolveHapiHomeDir } from '@/configuration'; +import { killProcessByChildProcess } from '@/utils/process'; import { getCursorAcpModelsSnapshot } from '@/cursor/utils/cursorAcpModelsBridge'; import { getErrorMessage } from './rpcResponses'; import { @@ -202,10 +208,23 @@ export function parseCursorModelsOutput(output: string): { } async function runCursorModelProbe(): Promise { + if (!tryAcquireAgentCliSpawnLeaseSync(resolveHapiHomeDir())) { + throw new Error('Cursor ACP transport is active'); + } if (isAgentAcpTransportActive()) { + releaseAgentCliSpawnLeaseSync(); throw new Error('Cursor ACP transport is active'); } + let leaseReleased = false; + const releaseLeaseOnce = (): void => { + if (leaseReleased) { + return; + } + leaseReleased = true; + releaseAgentCliSpawnLeaseSync(); + }; + return await new Promise((resolve, reject) => { const child = spawn('agent', ['--list-models'], { env: process.env, @@ -217,11 +236,21 @@ async function runCursorModelProbe(): Promise { let stderr = ''; let settled = false; - const timeout = setTimeout(() => { - if (settled) return; + let timeoutError: Error | null = null; + + const finish = (handler: () => void): void => { + if (settled) { + return; + } settled = true; - child.kill('SIGTERM'); - reject(new Error('Cursor model discovery timed out')); + clearTimeout(timeout); + releaseLeaseOnce(); + handler(); + }; + + const timeout = setTimeout(() => { + timeoutError = new Error('Cursor model discovery timed out'); + void killProcessByChildProcess(child, true); }, PROBE_TIMEOUT_MS); child.stdout?.on('data', (chunk) => { @@ -231,23 +260,23 @@ async function runCursorModelProbe(): Promise { stderr += chunk.toString(); }); child.on('error', (error) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - reject(error); + finish(() => reject(error)); }); child.on('exit', (code) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - if (code !== 0) { - reject(new Error(stderr.trim() || `agent --list-models exited with code ${code}`)); - return; - } + finish(() => { + if (timeoutError) { + reject(timeoutError); + return; + } + if (code !== 0) { + reject(new Error(stderr.trim() || `agent --list-models exited with code ${code}`)); + return; + } - resolve({ - success: true, - ...parseCursorModelsOutput(stdout) + resolve({ + success: true, + ...parseCursorModelsOutput(stdout) + }); }); }); }); diff --git a/cli/src/modules/common/grokModels.ts b/cli/src/modules/common/grokModels.ts index 2a4d5747ff..65d8063bf8 100644 --- a/cli/src/modules/common/grokModels.ts +++ b/cli/src/modules/common/grokModels.ts @@ -152,7 +152,7 @@ async function runGrokModelsCliProbe(cwd: string): Promise { // The primary ACP probe also uses shell mode on Windows through AcpStdioTransport. assertSafeWindowsShellArg(cwd, 'cwd') - const transport = new AcpStdioTransport({ + const transport = await AcpStdioTransport.create({ command: 'grok', args: ['--cwd', cwd, 'agent', '--reasoning-effort', 'low', 'stdio'], env: Object.fromEntries( diff --git a/cli/src/modules/common/opencodeModels.test.ts b/cli/src/modules/common/opencodeModels.test.ts index f966bbab38..8910ac3b29 100644 --- a/cli/src/modules/common/opencodeModels.test.ts +++ b/cli/src/modules/common/opencodeModels.test.ts @@ -2,17 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sendRequestMock = vi.fn() const closeMock = vi.fn().mockResolvedValue(undefined) -const transportConstructor = vi.fn() +const transportCreate = vi.fn() -vi.mock('@/agent/backends/acp/AcpStdioTransport', () => ({ - AcpStdioTransport: class { +vi.mock('@/agent/backends/acp/AcpStdioTransport', () => { + class MockAcpStdioTransport { sendRequest = sendRequestMock close = closeMock - constructor(opts: { command: string; args?: string[] }) { - transportConstructor(opts) + static async create(opts: { command: string; args?: string[] }) { + transportCreate(opts) + return new MockAcpStdioTransport() } } -})) + return { AcpStdioTransport: MockAcpStdioTransport } +}) import { listOpencodeModelsForCwd, _resetOpencodeModelsCacheForTests } from './opencodeModels' @@ -21,7 +23,7 @@ describe('listOpencodeModelsForCwd', () => { _resetOpencodeModelsCacheForTests() sendRequestMock.mockReset() closeMock.mockClear() - transportConstructor.mockClear() + transportCreate.mockClear() }) afterEach(() => { @@ -50,7 +52,7 @@ describe('listOpencodeModelsForCwd', () => { const result = await listOpencodeModelsForCwd('/home/user/project') - expect(transportConstructor).toHaveBeenCalledWith( + expect(transportCreate).toHaveBeenCalledWith( expect.objectContaining({ command: 'opencode', args: ['acp'] }) ) expect(sendRequestMock).toHaveBeenNthCalledWith( @@ -131,7 +133,7 @@ describe('listOpencodeModelsForCwd', () => { await listOpencodeModelsForCwd('/cache/cwd') await listOpencodeModelsForCwd('/cache/cwd') - expect(transportConstructor).toHaveBeenCalledTimes(1) + expect(transportCreate).toHaveBeenCalledTimes(1) expect(sendRequestMock).toHaveBeenCalledTimes(2) }) @@ -152,7 +154,7 @@ describe('listOpencodeModelsForCwd', () => { const [r1, r2] = await Promise.all([inflight1, inflight2]) - expect(transportConstructor).toHaveBeenCalledTimes(1) + expect(transportCreate).toHaveBeenCalledTimes(1) expect(r1).toEqual(r2) expect(r1.success).toBe(true) }) diff --git a/cli/src/modules/common/opencodeModels.ts b/cli/src/modules/common/opencodeModels.ts index 5d06518831..e837daeb2d 100644 --- a/cli/src/modules/common/opencodeModels.ts +++ b/cli/src/modules/common/opencodeModels.ts @@ -84,7 +84,7 @@ function extractModelsFromResponse(response: unknown): { } async function runOpencodeProbe(cwd: string): Promise { - const transport = new AcpStdioTransport({ + const transport = await AcpStdioTransport.create({ command: 'opencode', args: ['acp'] }); diff --git a/shared/package.json b/shared/package.json index 760d72d22f..a8371503b2 100644 --- a/shared/package.json +++ b/shared/package.json @@ -23,7 +23,8 @@ "./voicePickerCatalog": "./src/voicePickerCatalog.ts", "./voice-personality": "./src/voicePersonality.ts", "./usage": "./src/usage.ts", - "./settingsFileLock": "./src/settingsFileLock.ts" + "./settingsFileLock": "./src/settingsFileLock.ts", + "./agentCliSpawnLease": "./src/agentCliSpawnLease.ts" }, "sideEffects": false, "scripts": { diff --git a/shared/src/agentCliSpawnLease.test.ts b/shared/src/agentCliSpawnLease.test.ts new file mode 100644 index 0000000000..0fdd332e10 --- /dev/null +++ b/shared/src/agentCliSpawnLease.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test, afterEach } from 'bun:test' +import { existsSync, mkdtempSync, mkdirSync, utimesSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + acquireAgentCliSpawnLease, + acquireAgentCliSpawnLeaseSync, + getAgentCliSpawnLockTarget, + releaseAgentCliSpawnLeaseFromAcpRegisterSync, + releaseAgentCliSpawnLeaseSync, + tryAcquireAgentCliSpawnLeaseSync, + _resetAgentCliSpawnLeaseForTests, +} from './agentCliSpawnLease' + +describe('agentCliSpawnLease', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-agent-cli-spawn-lease-')) + + afterEach(() => { + _resetAgentCliSpawnLeaseForTests(dir) + }) + + test('exclusive lease blocks a second non-blocking acquirer', () => { + expect(tryAcquireAgentCliSpawnLeaseSync(dir)).toBe(true) + expect(tryAcquireAgentCliSpawnLeaseSync(dir)).toBe(false) + releaseAgentCliSpawnLeaseSync() + expect(tryAcquireAgentCliSpawnLeaseSync(dir)).toBe(true) + releaseAgentCliSpawnLeaseSync() + }) + + test('ACP register depth shares one lease until the last unregister', () => { + acquireAgentCliSpawnLeaseSync(dir) + acquireAgentCliSpawnLeaseSync(dir) + expect(tryAcquireAgentCliSpawnLeaseSync(dir)).toBe(false) + releaseAgentCliSpawnLeaseFromAcpRegisterSync() + expect(tryAcquireAgentCliSpawnLeaseSync(dir)).toBe(false) + releaseAgentCliSpawnLeaseFromAcpRegisterSync() + expect(tryAcquireAgentCliSpawnLeaseSync(dir)).toBe(true) + releaseAgentCliSpawnLeaseSync() + }) + + test('ACP blocking acquire succeeds after probe releases the lease', () => { + expect(tryAcquireAgentCliSpawnLeaseSync(dir)).toBe(true) + expect(tryAcquireAgentCliSpawnLeaseSync(dir)).toBe(false) + releaseAgentCliSpawnLeaseSync() + acquireAgentCliSpawnLeaseSync(dir) + releaseAgentCliSpawnLeaseFromAcpRegisterSync() + }) + + test('async blocking acquire yields while same-process probe holds lease', async () => { + expect(tryAcquireAgentCliSpawnLeaseSync(dir)).toBe(true) + const acquirePromise = acquireAgentCliSpawnLease(dir) + let probeReleased = false + setTimeout(() => { + releaseAgentCliSpawnLeaseSync() + probeReleased = true + }, 50) + await acquirePromise + expect(probeReleased).toBe(true) + releaseAgentCliSpawnLeaseFromAcpRegisterSync() + }) + + test('anchors spawn lease beside the agent-acp-active marker directory', () => { + const target = getAgentCliSpawnLockTarget(dir) + expect(target.endsWith(join('locks', 'agent-cli.spawn'))).toBe(true) + acquireAgentCliSpawnLeaseSync(dir) + expect(existsSync(join(dir, 'locks', 'agent-acp-active'))).toBe(false) + expect(existsSync(target)).toBe(true) + releaseAgentCliSpawnLeaseFromAcpRegisterSync() + }) + + test('reclaims a stale spawn lease left by a crashed holder', () => { + const target = getAgentCliSpawnLockTarget(dir) + const lockDir = spawnLockfilePath(target) + mkdirSync(lockDir) + const past = new Date(Date.now() - 300_000) + utimesSync(lockDir, past, past) + + expect(tryAcquireAgentCliSpawnLeaseSync(dir)).toBe(true) + releaseAgentCliSpawnLeaseSync() + expect(existsSync(lockDir)).toBe(false) + }) +}) + +function spawnLockfilePath(lockTarget: string): string { + return `${lockTarget}.hapi.lock` +} diff --git a/shared/src/agentCliSpawnLease.ts b/shared/src/agentCliSpawnLease.ts new file mode 100644 index 0000000000..95ee1487ed --- /dev/null +++ b/shared/src/agentCliSpawnLease.ts @@ -0,0 +1,170 @@ +/** + * Cross-process exclusive lease for Cursor `agent` child processes. + * ACP transport and `agent --list-models` probes must not overlap — Cursor + * SIGTERMs the other child (exit 143). Uses proper-lockfile beside the + * agent-acp-active marker dir so acquisition is atomic (no check-then-act). + */ + +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import lockfile from 'proper-lockfile' + +const SPAWN_LOCK_STALE_MS = 120_000 +const SPAWN_LOCK_UPDATE_MS = 30_000 +const SPAWN_LOCK_RETRY_INTERVAL_MS = 100 +const SPAWN_LOCK_MAX_ATTEMPTS = 300 + +/** Cross-process lease release fn when this process holds the spawn lease. */ +let leaseRelease: (() => void) | null = null +/** Nested ACP registerActiveAcpTransport calls sharing one lease. */ +let acpRegisterLeaseDepth = 0 + +function sleepMsSync(ms: number): void { + const bun = (globalThis as { Bun?: { sleepSync?: (duration: number) => void } }).Bun + if (bun?.sleepSync) { + bun.sleepSync(ms) + return + } + const end = Date.now() + ms + while (Date.now() < end) { + // Node vitest fallback when Bun.sleepSync is unavailable. + } +} + +function spawnLockfilePath(lockTarget: string): string { + return `${lockTarget}.hapi.lock` +} + +function lockOptions(lockTarget: string): { + realpath: boolean + lockfilePath: string + stale: number + update: number + retries: number +} { + return { + realpath: false, + lockfilePath: spawnLockfilePath(lockTarget), + stale: SPAWN_LOCK_STALE_MS, + update: SPAWN_LOCK_UPDATE_MS, + retries: 0, + } +} + +/** Lease anchor file colocated with the agent-acp-active marker directory. */ +export function getAgentCliSpawnLockTarget(hapiHome: string): string { + const locksDir = join(hapiHome, 'locks') + mkdirSync(locksDir, { recursive: true }) + const target = join(locksDir, 'agent-cli.spawn') + if (!existsSync(target)) { + writeFileSync(target, '', { flag: 'a' }) + } + return target +} + +function claimSpawnLeaseSync(hapiHome: string): boolean { + if (leaseRelease !== null) { + return false + } + const lockTarget = getAgentCliSpawnLockTarget(hapiHome) + try { + leaseRelease = lockfile.lockSync(lockTarget, lockOptions(lockTarget)) + return true + } catch { + return false + } +} + +/** + * Non-blocking exclusive lease for model-list probes. Returns false when + * another holder (ACP or probe) already owns the spawn lease. + */ +export function tryAcquireAgentCliSpawnLeaseSync(hapiHome: string): boolean { + if (leaseRelease !== null || acpRegisterLeaseDepth > 0) { + return false + } + return claimSpawnLeaseSync(hapiHome) +} + +function sleepMs(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** Blocking exclusive lease for ACP transport startup (sync — tests only). */ +export function acquireAgentCliSpawnLeaseSync(hapiHome: string): void { + if (acpRegisterLeaseDepth > 0) { + acpRegisterLeaseDepth += 1 + return + } + + for (let attempt = 0; attempt < SPAWN_LOCK_MAX_ATTEMPTS; attempt++) { + if (claimSpawnLeaseSync(hapiHome)) { + acpRegisterLeaseDepth = 1 + return + } + sleepMsSync(SPAWN_LOCK_RETRY_INTERVAL_MS) + } + + throw new Error('agent CLI spawn lease held by another process') +} + +/** Blocking exclusive lease for ACP transport startup (yields event loop between retries). */ +export async function acquireAgentCliSpawnLease(hapiHome: string): Promise { + if (acpRegisterLeaseDepth > 0) { + acpRegisterLeaseDepth += 1 + return + } + + for (let attempt = 0; attempt < SPAWN_LOCK_MAX_ATTEMPTS; attempt++) { + if (claimSpawnLeaseSync(hapiHome)) { + acpRegisterLeaseDepth = 1 + return + } + await sleepMs(SPAWN_LOCK_RETRY_INTERVAL_MS) + } + + throw new Error('agent CLI spawn lease held by another process') +} + +/** Release after a list-models probe child exits. */ +export function releaseAgentCliSpawnLeaseSync(): void { + if (acpRegisterLeaseDepth > 0 || leaseRelease === null) { + return + } + leaseRelease() + leaseRelease = null +} + +/** Release after the last ACP transport unregisters in this process. */ +export function releaseAgentCliSpawnLeaseFromAcpRegisterSync(): void { + if (acpRegisterLeaseDepth <= 0) { + return + } + acpRegisterLeaseDepth -= 1 + if (acpRegisterLeaseDepth > 0 || leaseRelease === null) { + return + } + leaseRelease() + leaseRelease = null +} + +/** @internal test-only */ +export function _resetAgentCliSpawnLeaseForTests(hapiHome?: string): void { + acpRegisterLeaseDepth = 0 + if (leaseRelease) { + leaseRelease() + leaseRelease = null + } + if (!hapiHome) { + return + } + const lockTarget = getAgentCliSpawnLockTarget(hapiHome) + try { + lockfile.unlockSync(lockTarget, { + realpath: false, + lockfilePath: spawnLockfilePath(lockTarget), + }) + } catch { + // Best effort — lock may not exist. + } +} diff --git a/web/src/components/assistant-ui/markdown-a.test.tsx b/web/src/components/assistant-ui/markdown-a.test.tsx index d2053f0c7d..d5c161313a 100644 --- a/web/src/components/assistant-ui/markdown-a.test.tsx +++ b/web/src/components/assistant-ui/markdown-a.test.tsx @@ -59,6 +59,7 @@ function chatContext(overrides: Partial = {}): HappyChatC hasMoreMessages: false, isSyncingTail: false, isLoadingMoreMessages: false, + showSessionSummaryInChat: false, loadOlderMessagesPreservingScroll: async () => 'loaded', ...overrides, } From febbf8ff58005e6c54ccd00967bc01f71792892f Mon Sep 17 00:00:00 2001 From: Ananovo Date: Thu, 13 Aug 2026 10:14:55 +0800 Subject: [PATCH 083/142] fix(web): improve session search UI (#1545) * fix(web): improve session search UI * fix(web): reserve space for search clear control --- .../SessionList.directory-action.test.tsx | 53 ++++++++++--------- .../SessionList.machine-filter.test.tsx | 7 ++- web/src/components/SessionList.tsx | 14 +++-- web/src/lib/locales/en.ts | 4 +- web/src/lib/locales/zh-CN.ts | 4 +- 5 files changed, 47 insertions(+), 35 deletions(-) diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 9000abfe8e..0e1357e5bd 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -7,6 +7,9 @@ import { I18nProvider } from '@/lib/i18n-context' import { ToastProvider } from '@/lib/toast-context' import { SessionList } from './SessionList' +const SEARCH_LABEL = 'Search sessions (title, path, Agent, machine name, ID, and more)' +const SEARCH_PLACEHOLDER = 'Search title/path/Agent/machine/ID…' + afterEach(() => { cleanup() localStorage.removeItem('hapi-session-preview-limit') @@ -124,8 +127,8 @@ describe('SessionList directory action', () => { const listContent = projectHeader.parentElement?.parentElement expect(listContent).not.toHaveClass('pt-1') - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - const searchInput = screen.getByPlaceholderText(/Search sessions/) + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER) const headerRow = searchInput.parentElement?.parentElement expect(headerRow).toHaveClass('px-2') expect(headerRow).toHaveClass('py-1') @@ -188,12 +191,12 @@ describe('SessionList time filter', () => { expect(screen.getByRole('button', { name: /Recent session/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Old session/ })).toBeInTheDocument() - const searchButton = screen.getByRole('button', { name: 'Search sessions' }) + const searchButton = screen.getByRole('button', { name: SEARCH_LABEL }) const filterButton = screen.getByRole('button', { name: 'Filter sessions by last activity' }) expect(searchButton.nextElementSibling).toBe(filterButton) expect(searchButton.parentElement).toBe(filterButton.parentElement) expect(searchButton.parentElement).toHaveClass('relative', 'gap-1') - expect(screen.queryByPlaceholderText('Search sessions')).toBeNull() + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull() fireEvent.click(filterButton) const emptyDate = screen.getByRole('button', { name: new Date(2026, 6, 17).toLocaleDateString() }) @@ -206,7 +209,7 @@ describe('SessionList time filter', () => { expect(screen.getByRole('button', { name: /Recent session/ })).toBeInTheDocument() expect(screen.queryByRole('button', { name: /Old session/ })).toBeNull() - expect(screen.queryByPlaceholderText('Search sessions')).toBeNull() + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull() expect(filterButton).toHaveAttribute('title', '2026-07-17 – 2026-07-18') expect(filterButton).toHaveAccessibleName('Filter sessions by last activity: 2026-07-17 – 2026-07-18') expect(filterButton).toHaveFocus() @@ -232,7 +235,7 @@ describe('SessionList time filter', () => { /> ) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by last activity' })) const today = screen.getByRole('button', { name: new Date(2026, 6, 18).toLocaleDateString() }) const anotherDay = screen.getByRole('button', { name: new Date(2026, 6, 17).toLocaleDateString() }) @@ -262,7 +265,7 @@ describe('SessionList time filter', () => { /> ) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) const filterButton = screen.getByRole('button', { name: 'Filter sessions by last activity' }) fireEvent.click(filterButton) const startDate = screen.getByRole('button', { name: new Date(2026, 6, 1).toLocaleDateString() }) @@ -296,8 +299,8 @@ describe('SessionList time filter', () => { /> ) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - const input = screen.getByPlaceholderText('Search sessions') + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + const input = screen.getByPlaceholderText(SEARCH_PLACEHOLDER) const filterButton = screen.getByRole('button', { name: 'Filter sessions by last activity' }) fireEvent.click(filterButton) fireEvent.click(screen.getByRole('button', { name: new Date(2026, 6, 1).toLocaleDateString() })) @@ -683,8 +686,8 @@ describe('SessionList collapse behavior', () => { expect(runningPanel()?.getAttribute('data-open')).toBeNull() expect(screen.getByTitle('In progress').getAttribute('aria-expanded')).toBe('false') - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - fireEvent.change(screen.getByPlaceholderText('Search sessions'), { + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.change(screen.getByPlaceholderText(SEARCH_PLACEHOLDER), { target: { value: 'Running' }, }) @@ -755,8 +758,8 @@ describe('SessionList collapse behavior', () => { })) render(renderSessionList(sessions, null)) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - fireEvent.change(screen.getByPlaceholderText('Search sessions'), { + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.change(screen.getByPlaceholderText(SEARCH_PLACEHOLDER), { target: { value: 'Matching task' }, }) @@ -901,10 +904,10 @@ describe('SessionList search toggle', () => { ) // Collapsed by default: only the toggle icon is rendered. - expect(screen.queryByPlaceholderText('Search sessions')).toBeNull() + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - const input = screen.getByPlaceholderText('Search sessions') + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + const input = screen.getByPlaceholderText(SEARCH_PLACEHOLDER) expect(input).toHaveFocus() fireEvent.change(input, { target: { value: 'Matching' } }) @@ -913,7 +916,7 @@ describe('SessionList search toggle', () => { // Blur collapses back to the icon; the query stays applied. fireEvent.blur(input) - expect(screen.queryByPlaceholderText('Search sessions')).toBeNull() + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull() expect(screen.getByRole('button', { name: /Search sessions/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Matching task/ })).toBeInTheDocument() expect(screen.queryByRole('button', { name: /Other task/ })).toBeNull() @@ -946,12 +949,12 @@ describe('SessionList search toggle', () => { /> ) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - const input = screen.getByPlaceholderText('Search sessions') + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + const input = screen.getByPlaceholderText(SEARCH_PLACEHOLDER) fireEvent.change(input, { target: { value: 'jellybot' } }) fireEvent.blur(input) - expect(screen.queryByPlaceholderText('Search sessions')).toBeNull() + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull() const collapsed = screen.getByRole('button', { name: /Search sessions/ }) expect(collapsed).toHaveTextContent('jellybot') expect(collapsed.className).toContain('bg-[var(--app-chat-user-chip-bg)]') @@ -978,8 +981,8 @@ describe('SessionList search toggle', () => { /> ) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - const input = screen.getByPlaceholderText('Search sessions') + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + const input = screen.getByPlaceholderText(SEARCH_PLACEHOLDER) fireEvent.change(input, { target: { value: 'Task' } }) // The clear button unmounts itself; focus must return to the input so a @@ -988,7 +991,7 @@ describe('SessionList search toggle', () => { expect(input).toHaveFocus() expect(input).toHaveValue('') - expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument() + expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument() }) it('keeps header actions visible when sessions become empty while search is expanded', () => { @@ -1024,12 +1027,12 @@ describe('SessionList search toggle', () => { }), ])) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull() rerender(renderList([])) expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Search sessions' })).toBeNull() + expect(screen.queryByRole('button', { name: SEARCH_LABEL })).toBeNull() }) }) diff --git a/web/src/components/SessionList.machine-filter.test.tsx b/web/src/components/SessionList.machine-filter.test.tsx index eebb11644a..f786202fd9 100644 --- a/web/src/components/SessionList.machine-filter.test.tsx +++ b/web/src/components/SessionList.machine-filter.test.tsx @@ -7,6 +7,9 @@ import { I18nProvider } from '@/lib/i18n-context' import { ToastProvider } from '@/lib/toast-context' import { SessionList } from './SessionList' +const SEARCH_LABEL = 'Search sessions (title, path, Agent, machine name, ID, and more)' +const SEARCH_PLACEHOLDER = 'Search title/path/Agent/machine/ID…' + afterEach(() => cleanup()) function makeSession(overrides: Partial & { id: string }): SessionSummary { @@ -145,8 +148,8 @@ describe('SessionList machine filter', () => { }) ]) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - fireEvent.change(screen.getByPlaceholderText('Search sessions'), { target: { value: 'alpha' } }) + fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL })) + fireEvent.change(screen.getByPlaceholderText(SEARCH_PLACEHOLDER), { target: { value: 'alpha' } }) fireEvent.click(screen.getByRole('button', { name: /Teemo \(1\)/ })) expect(screen.getByText('No sessions match your filters.')).toBeTruthy() diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 6ab3bf9d72..7e87f48cd9 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -736,7 +736,7 @@ export function SessionListSearch(props: { 'relative shrink-0 transition-colors hover:bg-[var(--app-subtle-bg)]', variant === 'standalone' ? 'rounded-full p-1.5 hover:text-[var(--app-fg)]' - : 'flex items-center rounded-r-lg rounded-l-md px-2', + : 'flex items-center rounded-r-lg rounded-l-md px-1', hasDateRange ? 'text-[var(--app-link)]' : 'text-[var(--app-hint)]' )} title={hasDateRange ? `${props.customStart} – ${props.customEnd}` : t('sessions.timeFilter.label')} @@ -787,10 +787,11 @@ export function SessionListSearch(props: { ) } + const searchLabel = t('sessions.search.open') + if (!props.expanded) { const hasTextQuery = props.value.length > 0 - const openLabel = t('sessions.search.open') - const collapsedLabel = hasTextQuery ? `${openLabel}: ${props.value}` : openLabel + const collapsedLabel = hasTextQuery ? `${searchLabel}: ${props.value}` : searchLabel return (
) : null} -
+
- +
+ {onSuggestTitle ? ( + + ) : null} + +
diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 2d313572a9..51f553e63e 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -396,6 +396,7 @@ function hasAbortableAgentRun(blocks: readonly ChatBlock[]): boolean { type SessionChatProps = { api: ApiClient + titleSuggestionAvailable?: boolean session: Session cursorChatOnDisk?: boolean reopenDisabledReason?: string @@ -1591,6 +1592,7 @@ function SessionChatInner(props: SessionChatProps) { onToggleTerminal={canViewAgentTerminal ? () => setTerminalVisible(v => !v) : undefined} terminalActive={terminalVisible} api={props.api} + titleSuggestionAvailable={props.titleSuggestionAvailable} canReopen={inactiveCanResume} reopenDisabledReason={props.reopenDisabledReason} reopenHint={props.reopenHint} diff --git a/web/src/components/SessionDialogTitleAlignment.test.tsx b/web/src/components/SessionDialogTitleAlignment.test.tsx index edbe97af7b..98550f6716 100644 --- a/web/src/components/SessionDialogTitleAlignment.test.tsx +++ b/web/src/components/SessionDialogTitleAlignment.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, within } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' import { ToastProvider } from '@/lib/toast-context' import { RenameSessionDialog } from './RenameSessionDialog' @@ -14,6 +14,8 @@ function renderWithProviders(content: React.ReactNode) { ) } +afterEach(() => cleanup()) + function expectCenteredTitle(name: string) { const dialog = screen.getByRole('dialog') const title = within(dialog).getByRole('heading', { name }) @@ -38,6 +40,181 @@ describe('session dialog title alignment', () => { expectCenteredTitle('Rename Session') }) + it('saves an untouched generated draft as metadata.summary.text', async () => { + const onRename = vi.fn(async () => {}) + const onUpdateSummary = vi.fn(async () => {}) + + renderWithProviders( + 'Generated title'} + onUpdateSummary={onUpdateSummary} + isPending={false} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('Generated title')) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onUpdateSummary).toHaveBeenCalledWith('Generated title')) + expect(onRename).not.toHaveBeenCalled() + }) + + it('treats any edit to a generated draft as a manual metadata.name rename', async () => { + const onRename = vi.fn(async () => {}) + const onUpdateSummary = vi.fn(async () => {}) + + renderWithProviders( + 'Generated title'} + onUpdateSummary={onUpdateSummary} + isPending={false} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('Generated title')) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'My own title' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onRename).toHaveBeenCalledWith('My own title')) + expect(onUpdateSummary).not.toHaveBeenCalled() + }) + + it('keeps a generated draft manual when the user edits it back to the current title', async () => { + const onRename = vi.fn(async () => {}) + const onUpdateSummary = vi.fn(async () => {}) + + renderWithProviders( + 'Generated title'} + onUpdateSummary={onUpdateSummary} + isPending={false} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('Generated title')) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Edited title' } }) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Session' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onRename).toHaveBeenCalledWith('Session')) + expect(onUpdateSummary).not.toHaveBeenCalled() + }) + + it('ignores a generated result after the dialog closes and reopens', async () => { + let resolveSuggestion: ((title: string) => void) | undefined + const onSuggestTitle = vi.fn(() => new Promise((resolve) => { + resolveSuggestion = resolve + })) + const onClose = vi.fn() + const { rerender } = renderWithProviders( + {})} + onSuggestTitle={onSuggestTitle} + onUpdateSummary={vi.fn(async () => {})} + isPending={false} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(onSuggestTitle).toHaveBeenCalledOnce()) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + rerender( + + + {})} + onSuggestTitle={onSuggestTitle} + onUpdateSummary={vi.fn(async () => {})} + isPending={false} + /> + + + ) + rerender( + + + {})} + onSuggestTitle={onSuggestTitle} + onUpdateSummary={vi.fn(async () => {})} + isPending={false} + /> + + + ) + + resolveSuggestion?.('Stale title') + await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('Session')) + expect(screen.getByRole('textbox')).not.toHaveValue('Stale title') + }) + + it('uses metadata.name for direct manual input and does not save on cancel', async () => { + const onRename = vi.fn(async () => {}) + const onUpdateSummary = vi.fn(async () => {}) + const onClose = vi.fn() + + const { rerender } = renderWithProviders( + 'Generated title'} + onUpdateSummary={onUpdateSummary} + isPending={false} + /> + ) + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Manual title' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(onRename).toHaveBeenCalledWith('Manual title')) + expect(onUpdateSummary).not.toHaveBeenCalled() + + rerender( + + + 'Generated title'} + onUpdateSummary={onUpdateSummary} + isPending={false} + /> + + + ) + fireEvent.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('Generated title')) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onUpdateSummary).not.toHaveBeenCalled() + }) + it('centers the export dialog title on the close button centerline', () => { renderWithProviders( = {}): Session { } } -function renderHeader(session: Session, extra?: { serviceTier?: string | null }) { +function renderHeader(session: Session, extra?: { serviceTier?: string | null; titleSuggestionAvailable?: boolean }) { return render( @@ -48,6 +48,7 @@ function renderHeader(session: Session, extra?: { serviceTier?: string | null }) @@ -82,6 +83,33 @@ describe('resolveSessionHeaderMachineLabel', () => { }) describe('SessionHeader', () => { + it('hides title generation when the Hub does not advertise the capability', () => { + const api = { + getMachines: vi.fn().mockResolvedValue({ machines: [] }), + getScratchlist: vi.fn().mockResolvedValue({ entries: [] }) + } as unknown as ApiClient + + render( + + + + + + + + ) + + fireEvent.click(screen.getByTitle('More actions')) + fireEvent.click(screen.getByRole('menuitem', { name: /Rename/ })) + + expect(screen.queryByRole('button', { name: 'Generate' })).not.toBeInTheDocument() + }) + it('manually syncs an inactive Pi session through its owning machine', async () => { const importPiSessions = vi.fn().mockResolvedValue({ success: true, diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 140e7e0ce9..381de6c7b4 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -146,6 +146,7 @@ export function SessionHeader(props: { onToggleTerminal?: () => void terminalActive?: boolean api: ApiClient | null + titleSuggestionAvailable?: boolean canReopen?: boolean reopenDisabledReason?: string reopenHint?: string @@ -222,7 +223,7 @@ export function SessionHeader(props: { const [isSyncingCodex, setIsSyncingCodex] = useState(false) const [isSyncingPi, setIsSyncingPi] = useState(false) - const { archiveSession, reopenSession, renameSession, setPinMode, deleteSession, isPending } = useSessionActions( + const { archiveSession, reopenSession, renameSession, suggestSessionTitle, updateSessionSummary, setPinMode, deleteSession, isPending } = useSessionActions( api, session.id, session.metadata?.flavor ?? null @@ -550,6 +551,8 @@ export function SessionHeader(props: { onClose={() => setRenameOpen(false)} currentName={title} onRename={renameSession} + onSuggestTitle={api && props.titleSuggestionAvailable ? suggestSessionTitle : undefined} + onUpdateSummary={api && props.titleSuggestionAvailable ? updateSessionSummary : undefined} isPending={isPending} /> diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 7e87f48cd9..ee3cd54630 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -870,6 +870,7 @@ function SessionItem(props: { onSelect: (sessionId: string) => void showPath?: boolean api: ApiClient | null + titleSuggestionAvailable?: boolean selected?: boolean showDetailedStatus?: boolean inRunningSection?: boolean @@ -878,7 +879,18 @@ function SessionItem(props: { }) { const { t } = useTranslation() const { addToast } = useToast() - const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false, inRunningSection = false, projectLabel, machineLabel } = props + const { + session: s, + onSelect, + showPath = true, + api, + titleSuggestionAvailable = false, + selected = false, + showDetailedStatus = false, + inRunningSection = false, + projectLabel, + machineLabel + } = props const { haptic } = usePlatform() const [menuOpen, setMenuOpen] = useState(false) const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) @@ -911,7 +923,7 @@ function SessionItem(props: { ? t('session.action.reopenCursorUnverified') : undefined - const { archiveSession, reopenSession, renameSession, deleteSession, setPinMode, isPending } = useSessionActions( + const { archiveSession, reopenSession, renameSession, suggestSessionTitle, updateSessionSummary, deleteSession, setPinMode, isPending } = useSessionActions( api, s.id, s.metadata?.flavor ?? null @@ -1042,6 +1054,8 @@ function SessionItem(props: { onClose={() => setRenameOpen(false)} currentName={sessionName} onRename={renameSession} + onSuggestTitle={api && titleSuggestionAvailable ? suggestSessionTitle : undefined} + onUpdateSummary={api && titleSuggestionAvailable ? updateSessionSummary : undefined} isPending={isPending} /> ) : null} @@ -1132,12 +1146,21 @@ export function SessionList(props: { renderHeader?: boolean headerActions?: React.ReactNode api: ApiClient | null + titleSuggestionAvailable?: boolean machineLabelsById?: Record machinesById?: Record selectedSessionId?: string | null }) { const { t } = useTranslation() - const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, machinesById = {}, onNewSessionInDirectory } = props + const { + renderHeader = true, + api, + titleSuggestionAvailable = false, + selectedSessionId, + machineLabelsById = {}, + machinesById = {}, + onNewSessionInDirectory + } = props const { sessionPreviewLimit } = useSessionPreviewLimit() const { sessionListStatusMode } = useSessionListStatusMode() const { showActiveSessionsOnly } = useShowActiveSessionsOnly() @@ -1447,6 +1470,7 @@ export function SessionList(props: { onSelect={props.onSelect} showPath={false} api={api} + titleSuggestionAvailable={titleSuggestionAvailable} selected={s.id === selectedSessionId} showDetailedStatus={showDetailedStatus} /> @@ -1808,6 +1832,7 @@ export function SessionList(props: { onSelect={props.onSelect} showPath={false} api={api} + titleSuggestionAvailable={titleSuggestionAvailable} selected={s.id === selectedSessionId} showDetailedStatus={showDetailedStatus} inRunningSection @@ -1869,6 +1894,7 @@ export function SessionList(props: { onSelect={props.onSelect} showPath={false} api={api} + titleSuggestionAvailable={titleSuggestionAvailable} selected={s.id === selectedSessionId} showDetailedStatus={showDetailedStatus} inRunningSection diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 448a567d11..4c9ce03b7b 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -27,6 +27,8 @@ export function useSessionActions( setEffort: (effort: string | null) => Promise setServiceTier: (serviceTier: string | null) => Promise renameSession: (name: string) => Promise + suggestSessionTitle: () => Promise + updateSessionSummary: (text: string) => Promise setPinMode: (mode: 'none' | 'project' | 'global') => Promise deleteSession: () => Promise isPending: boolean @@ -235,6 +237,26 @@ export function useSessionActions( onSuccess: () => void invalidateSession(), }) + const titleSuggestionMutation = useMutation({ + mutationFn: async () => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + const response = await api.suggestSessionTitle(sessionId) + return response.title + } + }) + + const summaryMutation = useMutation({ + mutationFn: async (text: string) => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + await api.updateSessionSummary(sessionId, text) + }, + onSuccess: () => void invalidateSession(), + }) + const pinMutation = useMutation({ mutationFn: async (mode: 'none' | 'project' | 'global') => { if (!api || !sessionId) throw new Error('Session unavailable') @@ -271,6 +293,8 @@ export function useSessionActions( setEffort: effortMutation.mutateAsync, setServiceTier: serviceTierMutation.mutateAsync, renameSession: renameMutation.mutateAsync, + suggestSessionTitle: titleSuggestionMutation.mutateAsync, + updateSessionSummary: summaryMutation.mutateAsync, setPinMode: pinMutation.mutateAsync, deleteSession: deleteMutation.mutateAsync, isPending: abortMutation.isPending @@ -285,6 +309,8 @@ export function useSessionActions( || effortMutation.isPending || serviceTierMutation.isPending || renameMutation.isPending + || titleSuggestionMutation.isPending + || summaryMutation.isPending || pinMutation.isPending || deleteMutation.isPending, } diff --git a/web/src/lib/app-context.tsx b/web/src/lib/app-context.tsx index 477d9d9197..6d5609b1be 100644 --- a/web/src/lib/app-context.tsx +++ b/web/src/lib/app-context.tsx @@ -5,6 +5,7 @@ type AppContextValue = { api: ApiClient token: string baseUrl: string + titleSuggestionAvailable?: boolean } const AppContext = createContext(null) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index de9c307d92..7e1e5ff1a3 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -275,6 +275,9 @@ export default { 'dialog.rename.save': 'Save', 'dialog.rename.saving': 'Saving…', 'dialog.rename.error': 'Failed to rename. Please try again.', + 'dialog.rename.generate': 'Generate', + 'dialog.rename.generating': 'Generating…', + 'dialog.rename.generateError': 'Failed to generate a title. Please try again.', 'dialog.archive.title': 'Archive Session', 'dialog.archive.description': 'Are you sure you want to archive "{name}"? This will disconnect active session.', 'dialog.archive.confirm': 'Archive', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 8642c15a89..fbec31985d 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -275,6 +275,9 @@ export default { 'dialog.rename.save': '保存', 'dialog.rename.saving': '保存中…', 'dialog.rename.error': '重命名失败,请重试。', + 'dialog.rename.generate': '生成', + 'dialog.rename.generating': '生成中…', + 'dialog.rename.generateError': '标题生成失败,请重试。', 'dialog.archive.title': '归档会话', 'dialog.archive.description': '确定要归档 "{name}" 吗?这将断开活动会话。', diff --git a/web/src/router.tsx b/web/src/router.tsx index ebb3e6e2d7..fa058d13e3 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -151,7 +151,7 @@ function SettingsIcon(props: { className?: string }) { } function SessionsPage() { - const { api, baseUrl } = useAppContext() + const { api, baseUrl, titleSuggestionAvailable = false } = useAppContext() const navigate = useNavigate() const pathname = useLocation({ select: location => location.pathname }) const matchRoute = useMatchRoute() @@ -277,6 +277,7 @@ function SessionsPage() {
)} api={api} + titleSuggestionAvailable={titleSuggestionAvailable} machineLabelsById={machineLabelsById} machinesById={machinesById} /> @@ -329,7 +330,7 @@ function classifySendError( } function SessionPage() { - const { api } = useAppContext() + const { api, titleSuggestionAvailable = false } = useAppContext() const { t } = useTranslation() const goBack = useAppGoBack() const navigate = useNavigate() @@ -775,6 +776,7 @@ function SessionPage() { return ( { await transferComposerDraftThenNavigate( diff --git a/web/src/types/api.ts b/web/src/types/api.ts index f2e40455eb..d7a9a4ddbd 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -45,6 +45,7 @@ export type { SlashCommand, SlashCommandsResponse, SessionResponse, + SessionTitleSuggestionResponse, SessionsResponse, SpawnResponse, UploadFileResponse @@ -105,6 +106,15 @@ export type SessionMetadataSummary = { worktree?: WorktreeMetadata } +export type HubHealthResponse = { + status: string + protocolVersion: number + capabilities?: { + workGraph?: boolean + titleSuggestion?: boolean + } +} + export type MessageStatus = 'queued' | 'sending' | 'sent' | 'failed' export type DecryptedMessage = ProtocolDecryptedMessage & { From e0354b09c0e1d9a135afc2fd18685ddca2b6bb89 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Sat, 15 Aug 2026 11:14:12 +0800 Subject: [PATCH 090/142] fix(web): align new-session and settings controls (#1578) --- .../components/NewSession/ActionButtons.tsx | 2 +- web/src/components/NewSession/index.tsx | 2 +- .../components/settings/CompanionPairing.tsx | 2 +- .../components/settings/SettingsPrimitives.tsx | 2 +- .../settings/TranscriptionProviderOnboard.tsx | 2 +- .../settings/VoiceAdvancedControls.tsx | 16 ++++++++-------- web/src/routes/settings/storage.tsx | 18 ++++++++++-------- 7 files changed, 23 insertions(+), 21 deletions(-) diff --git a/web/src/components/NewSession/ActionButtons.tsx b/web/src/components/NewSession/ActionButtons.tsx index 00b6b9f81e..44728fa66c 100644 --- a/web/src/components/NewSession/ActionButtons.tsx +++ b/web/src/components/NewSession/ActionButtons.tsx @@ -13,7 +13,7 @@ export function ActionButtons(props: { const { t } = useTranslation() return ( -
+
diff --git a/web/src/components/settings/SettingsPrimitives.tsx b/web/src/components/settings/SettingsPrimitives.tsx index b695cdc273..4bc32f91c4 100644 --- a/web/src/components/settings/SettingsPrimitives.tsx +++ b/web/src/components/settings/SettingsPrimitives.tsx @@ -29,7 +29,7 @@ export function CheckIcon(props: { className?: string }) { export function SettingsPageContent(props: { description?: string; children: ReactNode }) { return ( -
+
{props.description ?

{props.description}

: null}
diff --git a/web/src/components/settings/TranscriptionProviderOnboard.tsx b/web/src/components/settings/TranscriptionProviderOnboard.tsx index d1ce8aeb56..a374011b90 100644 --- a/web/src/components/settings/TranscriptionProviderOnboard.tsx +++ b/web/src/components/settings/TranscriptionProviderOnboard.tsx @@ -291,7 +291,7 @@ export function TranscriptionProviderOnboard(props: { {error ?

{error}

: null} {message ?

{message}

: null} -
+
diff --git a/web/src/components/settings/VoiceAdvancedControls.tsx b/web/src/components/settings/VoiceAdvancedControls.tsx index b3c4f8cd70..1eded3cb0e 100644 --- a/web/src/components/settings/VoiceAdvancedControls.tsx +++ b/web/src/components/settings/VoiceAdvancedControls.tsx @@ -130,14 +130,14 @@ export function VoicePersonaControls(props: { onChange={(e) => setIdentity(e.target.value === DEFAULT_VOICE_IDENTITY ? '' : e.target.value)} rows={6} maxLength={VOICE_IDENTITY_MAX_LENGTH} spellCheck={false} className="w-full resize-y rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-2 font-mono text-xs leading-relaxed text-[var(--app-fg)]" /> -
+
+ + {prefs.identity.trim() ? props.t('settings.voice.identity.customized') : props.t('settings.voice.identity.default')} + - - {prefs.identity.trim() ? props.t('settings.voice.identity.customized') : props.t('settings.voice.identity.default')} -
)} @@ -156,7 +156,7 @@ export function VoicePersonaControls(props: { onChange={(e) => setCharacter(e.target.value === DEFAULT_VOICE_CHARACTER ? '' : e.target.value)} rows={8} maxLength={VOICE_CHARACTER_MAX_LENGTH} spellCheck={false} className="w-full resize-y rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-2 font-mono text-xs leading-relaxed text-[var(--app-fg)]" /> -
+
{deliveryOpen && ( -
-

+

+

{props.t('settings.voice.character.presetSlidersHint')}

-