mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-23 22:09:56 +00:00
feat: rich composer session @-mentions + inspect_peer (#1228)
* feat(web): feature-flagged rich composer for inline session @ mentions Custom segmented contenteditable (not TipTap) inserts caret-local session atoms from the existing @ picker and serializes to markdown links on send. Textarea path remains default until flag parity dogfood. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): rich composer mention boundary + #1215 refs Treat U+FFFC mirror atoms as word boundaries so @ after a session token still opens autocomplete. Point comments at Fixes #1215. Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): peer-stack e2e for rich composer session @ mentions (#1215) Smoke: flag on, @ picker inserts inline session atom chip (not prose dump). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): preserve newlines in rich composer Enter-newline mode Chromium splits contenteditable on Enter into block divs; serialize those as \\n and insert <br> when parent leaves Enter unhandled (Shift+Enter / enter-inserts-newline). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): show @ badge when rich composer mentions flag is on Dogfood was invisible: flag-off looks like a normal textarea, and flag-on had no chrome. Surface a small @ badge when enabled. * fix(web): rich session composer on by default (not a user setting) The plan dual-path was an engineering kill-switch, not an opt-in. Default to the segmented composer; only richMentions=0 disables. Drop the flag badge and record a peer-stack motion proof covering chips + baseline UX. * fix(web): make rich composer Shift+Enter create a visible newline Trailing <br>+empty text node was a silent no-op at EOL. Use insertLineBreak (ZWSP pad fallback), assert real \\n in peer e2e. * feat(web): hover tooltips on rich composer session chips Show full title, status, short id, and path on chip hover via a portal bubble fed by live useSessions lookup (drafts fall back to title + id). * fix(web): dismiss rich composer chip tooltips on mouse leave contenteditable pointerout/relatedTarget was flaky so tips stuck after leaving the chip. Hit-test on pointermove, clear on prose/input/leave. * fix(web): address cold-review Blocker/Majors on rich composer Exclude peer e2e from default Playwright; force plain-text paste; restore newline hard-stop in findActiveWord; fix root-anchored selection mapping and nested-block serialize; cover with unit tests. * chore: drop accidental .cursor files from rich-composer tip * fix(web): close remaining cold-review gaps on rich composer Drop absolute peer e2e tooling imports, prove chip→markdown send, and harden paste/EOL/focus/tooltip/Enter edges before Meta rematerialize. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: absorb soup playwright.config union for clean remat Keep fork peer-stack timeouts/annotated-video wiring and add testIgnore for e2e/peer so the next driver rematerialize does not conflict. Co-authored-by: Cursor <cursoragent@cursor.com> * revert: drop fork playwright tooling from upstreamable tip Peer-stack annotated-video + HAPI_PEER wiring stay on fork main / soup. Product tip only needs testIgnore for e2e/peer (see docs/tooling/peer-stack.md). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): fix rich composer Shift+Enter double newline and paste space Prefer manual newline+pad over execCommand insertLineBreak, and stop applying autocomplete trailing-space on paste/drop paths. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): pad EOL Shift+Enter after Range.insertNode split insertNode always leaves an empty text sibling, so !nextSibling never saw EOL; detect meaningful trailing content and cover with jsdom tests. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): drop custom onDrop from rich composer Intercepting drop without caretRangeFromPoint landed text at EOF or no-oped in-editor moves. Native CE drop is enough for #1215; paste still forces plain text. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): sidebar-parity tooltips on rich composer session chips Reuse SessionRowSummary (flavor, thinking/attention, schedule, todos, relative ago, path) for chip hover so the tip matches the session list. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: keep peer-stack e2e off the upstreamable tip Peer specs and playwright.peer.config stay on fork main per docs/tooling/peer-stack.md; default config still testIgnore's e2e/peer. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: cite sessions with UUID wire + inspect_peer for agent/overseer Rich composer chips already serialize to [title](/sessions/<id>); flush before send so the agent prompt never gets title-only chip text. Add inspect_peer (MCP + hapi inspect-peer) as the read twin of ping_peer so that same id is immediately usable for overseer/agent peer lookup, with system-prompt glue from citations to inspect/ping. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): gate inspect_peer behind permission approval Cross-session history reads need the same prompt path as ping_peer: keep inspect_peer off Claude --allowedTools and treat it as sensitive in ACP/OpenCode read-only mode so prompt injection cannot silently enumerate peer transcripts. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: clarify playwright peer testIgnore is upstream-safe Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep session UUIDs on rich composer copy/cut/paste Copy/cut write wire markdown so chips do not collapse to @title-only clipboard text; paste reparses session links back into atoms. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -53,13 +53,14 @@ describe('runHappyMcpStdioBridge tool forwarding', () => {
|
||||
'--url',
|
||||
'http://127.0.0.1:43006',
|
||||
'--tools',
|
||||
'change_title,display_image,ping_peer,skill_lookup'
|
||||
'change_title,display_image,ping_peer,inspect_peer,skill_lookup'
|
||||
])
|
||||
|
||||
expect([...harness.tools.keys()]).toEqual([
|
||||
'change_title',
|
||||
'display_image',
|
||||
'ping_peer',
|
||||
'inspect_peer',
|
||||
'skill_lookup'
|
||||
])
|
||||
|
||||
@@ -80,9 +81,14 @@ describe('runHappyMcpStdioBridge tool forwarding', () => {
|
||||
'--url',
|
||||
'http://127.0.0.1:43006',
|
||||
'--tools',
|
||||
'change_title,display_image,ping_peer'
|
||||
'change_title,display_image,ping_peer,inspect_peer'
|
||||
])
|
||||
|
||||
expect([...harness.tools.keys()]).toEqual(['change_title', 'display_image', 'ping_peer'])
|
||||
expect([...harness.tools.keys()]).toEqual([
|
||||
'change_title',
|
||||
'display_image',
|
||||
'ping_peer',
|
||||
'inspect_peer'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'ping_peer'];
|
||||
const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'ping_peer', 'inspect_peer'];
|
||||
|
||||
function parseArgs(argv: string[]): { url: string | null; toolNames: Set<string> } {
|
||||
let url: string | null = null;
|
||||
@@ -135,7 +135,7 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
|
||||
|
||||
const pingPeerInputSchema: z.ZodTypeAny = z.object({
|
||||
sessionIdPrefix: z.string().trim().min(1).describe(
|
||||
'Target HAPI session id or unique id prefix (another session - not this chat)'
|
||||
'Target HAPI session id or unique id prefix (another session - not this chat). Prefer the full UUID from a [title](/sessions/<id>) citation.'
|
||||
),
|
||||
message: z.string().min(1).describe('Message text to deliver to the target session'),
|
||||
});
|
||||
@@ -144,7 +144,7 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
|
||||
server.registerTool<any, any>(
|
||||
'ping_peer',
|
||||
{
|
||||
description: 'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs the message on the same hub/namespace. Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat.',
|
||||
description: 'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs the message on the same hub/namespace. Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat. When the user cites [title](/sessions/<id>), pass that <id> as sessionIdPrefix.',
|
||||
title: 'Ping Peer Session',
|
||||
inputSchema: pingPeerInputSchema,
|
||||
},
|
||||
@@ -165,6 +165,40 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const inspectPeerInputSchema: z.ZodTypeAny = z.object({
|
||||
sessionIdPrefix: z.string().trim().min(1).describe(
|
||||
'Target HAPI session id or unique id prefix. Prefer the full UUID from a [title](/sessions/<id>) citation.'
|
||||
),
|
||||
messageLimit: z.number().int().min(1).max(100).optional().describe(
|
||||
'Recent message page size (default 30, max 100). Text snippets only.'
|
||||
),
|
||||
});
|
||||
|
||||
if (toolNames.has('inspect_peer')) {
|
||||
server.registerTool<any, any>(
|
||||
'inspect_peer',
|
||||
{
|
||||
description: 'Read another HAPI session (metadata + recent message text). Resolves by session id / prefix on the same hub/namespace. Read-only: does not resume. Prefer this (or `hapi inspect-peer`) over JWT+curl. When the user cites [title](/sessions/<id>), pass that <id> as sessionIdPrefix.',
|
||||
title: 'Inspect Peer Session',
|
||||
inputSchema: inspectPeerInputSchema,
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
try {
|
||||
const client = await ensureHttpClient();
|
||||
const response = await client.callTool({ name: 'inspect_peer', arguments: args });
|
||||
return response as any;
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Failed to inspect peer: ${error instanceof Error ? error.message : String(error)}` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const skillLookupInputSchema: z.ZodTypeAny = z.object({
|
||||
name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'),
|
||||
});
|
||||
|
||||
@@ -14,8 +14,8 @@ vi.mock('@/claude/utils/startHappyServer', () => ({
|
||||
return {
|
||||
url: 'http://127.0.0.1:43006/',
|
||||
toolNames: options.skillLookup
|
||||
? ['change_title', 'display_image', 'ping_peer', 'skill_lookup']
|
||||
: ['change_title', 'display_image', 'ping_peer'],
|
||||
? ['change_title', 'display_image', 'ping_peer', 'inspect_peer', 'skill_lookup']
|
||||
: ['change_title', 'display_image', 'ping_peer', 'inspect_peer'],
|
||||
stop: vi.fn()
|
||||
}
|
||||
})
|
||||
@@ -71,7 +71,7 @@ describe('buildHapiMcpBridge skill lookup config', () => {
|
||||
'--url',
|
||||
'http://127.0.0.1:43006/',
|
||||
'--tools',
|
||||
'change_title,display_image,ping_peer,skill_lookup'
|
||||
'change_title,display_image,ping_peer,inspect_peer,skill_lookup'
|
||||
])
|
||||
expect(bridge.mcpServers.hapi.tools).toEqual({
|
||||
change_title: { approval_mode: 'approve' },
|
||||
@@ -82,7 +82,7 @@ describe('buildHapiMcpBridge skill lookup config', () => {
|
||||
it('does not expose skill_lookup for native-skill bridge callers', async () => {
|
||||
const bridge = await buildHapiMcpBridge(createClient())
|
||||
|
||||
expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,ping_peer')
|
||||
expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,ping_peer,inspect_peer')
|
||||
expect(bridge.mcpServers.hapi.tools).toEqual({
|
||||
change_title: { approval_mode: 'approve' }
|
||||
})
|
||||
|
||||
@@ -95,8 +95,9 @@ export async function buildHapiMcpBridge(
|
||||
approval_mode: 'approve'
|
||||
};
|
||||
}
|
||||
// ping_peer is registered on the HTTP MCP server / stdio bridge, but is not
|
||||
// auto-approved: it targets another session (resume + inject message).
|
||||
// ping_peer / inspect_peer are registered on the HTTP MCP server / stdio
|
||||
// bridge, but are not auto-approved: they target another session (resume +
|
||||
// inject, or read peer histories).
|
||||
if (options.skillLookup) {
|
||||
tools.skill_lookup = {
|
||||
approval_mode: 'approve'
|
||||
|
||||
@@ -19,7 +19,7 @@ export const TITLE_INSTRUCTION = trimIdent(`
|
||||
Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording.
|
||||
Rename only when the user's primary objective changes substantially and the existing title would be misleading.
|
||||
When you create or find a local image file that the user should see, call functions.hapi__display_image with the image path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_image, mcp__hapi__display_image, or hapi_display_image.
|
||||
To message another HAPI session (peer handoff / nudge), call functions.hapi__ping_peer with sessionIdPrefix and message - do not reinvent JWT+curl. Shell fallback: hapi ping-peer <prefix> <message>.
|
||||
When the user cites another HAPI session as [title](/sessions/<id>) (or a bare /sessions/<id>), extract that <id>. Call functions.hapi__inspect_peer with sessionIdPrefix=<id> to read metadata and recent messages; call functions.hapi__ping_peer with sessionIdPrefix=<id> and a message to nudge or hand off. Prefer these over JWT+curl. Shell fallbacks: hapi inspect-peer <id> / hapi ping-peer <id> <message>.
|
||||
`);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user