mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-06 18:39:47 +00:00
* feat(pi): auto-title Pi sessions via bundled hapi_change_title extension Pi sessions never got automatic titles: HAPI set PI_RPC_EMIT_TITLE=1 but Pi does not implement it, and unlike the Claude/Codex/OpenCode launchers the Pi bridge neither registers a change_title tool nor injects a title instruction. This materializes a bundled Pi extension at launch and passes it via --extension, giving Pi sessions the same titling flow: - hapi_change_title tool (namespaced to avoid collisions with user extensions, mirroring the OpenCode launcher naming) - first-turn system-prompt instruction matching the Claude/Codex wording - title lands via ctx.ui.setTitle(), which the existing extension UI bridge already syncs into session metadata Removes the dead PI_RPC_EMIT_TITLE env var. Requires pi >= 0.35.0 (when --extension landed, 2026-01). Closes #1669 by giving sessions titles from the session model itself, with zero extra title-provider calls. * fix(pi): publish title extension atomically and keep retitle rule Review findings from the HAPI PR bot: - [Major] Publish the generated extension atomically. All Pi sessions of a HAPI version share the same versioned path and Pi treats extension-load errors as fatal, so a plain writeFile could break a concurrent launch with a half-written file. The extension is now written to a unique temp file and moved into place with rename; if rename loses a race against another launcher that already published the file, the existing copy is kept and the temp file is cleaned up. - [Minor] Inject the title instruction on every before_agent_start instead of stopping after the first title, preserving the objective-change retitle rule from the persistent Claude/Codex instruction. Adds a concurrent-materialization test and a handler test that executes the title tool and asserts a later turn still carries the instruction. * test(pi): fix handler return type in title extension test * fix(pi): keep bundled title extension compatible with older Pi releases Follow-up review finding: the embedded extension assumed three behaviors that only exist in recent Pi releases, while HAPI has no Pi minimum-version floor, so accepted older installations either failed to load the extension or reached the tool without setting a title: - import TypeBox via '@sinclair/typebox', the specifier both the legacy extension loaders and the current one (which aliases it to the bundled 'typebox') resolve; - normalize the execute() context positionally: current Pi passes (toolCallId, params, signal, onUpdate, ctx), legacy releases passed (toolCallId, params, onUpdate, ctx, signal); - call ctx.ui.setTitle() directly instead of gating on ctx.hasUI, which older RPC releases report as false even though setTitle emits the extension UI event (this extension only runs under HAPI's RPC bridge). Tests now exercise both call orders, including hasUI: false. --------- Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>
127 lines
5.8 KiB
TypeScript
127 lines
5.8 KiB
TypeScript
import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { materializePiTitleExtension, PI_TITLE_EXTENSION_SOURCE } from './titleExtension';
|
|
|
|
describe('pi title extension source', () => {
|
|
it('registers a namespaced hapi_change_title tool and applies it via setTitle', () => {
|
|
expect(PI_TITLE_EXTENSION_SOURCE).toContain("name: TOOL_NAME");
|
|
expect(PI_TITLE_EXTENSION_SOURCE).toContain("'hapi_change_title'");
|
|
expect(PI_TITLE_EXTENSION_SOURCE).toContain('ctx.ui.setTitle');
|
|
});
|
|
|
|
it('uses the typebox specifier that old and new Pi loaders both resolve', () => {
|
|
expect(PI_TITLE_EXTENSION_SOURCE).toContain("import { Type } from '@sinclair/typebox';");
|
|
});
|
|
|
|
it('injects the instruction on every turn, matching the persistent Claude/Codex rule', () => {
|
|
expect(PI_TITLE_EXTENSION_SOURCE).toContain('## Session title');
|
|
expect(PI_TITLE_EXTENSION_SOURCE).toContain('tool once');
|
|
expect(PI_TITLE_EXTENSION_SOURCE).toContain('before_agent_start');
|
|
expect(PI_TITLE_EXTENSION_SOURCE).not.toContain('titled');
|
|
});
|
|
});
|
|
|
|
describe('materializePiTitleExtension', () => {
|
|
it('writes the extension into the target dir and returns its path', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'hapi-pi-title-'));
|
|
const file = await materializePiTitleExtension(dir);
|
|
|
|
expect(file).toBe(join(dir, 'hapi-title-extension.ts'));
|
|
const content = await readFile(file, 'utf8');
|
|
expect(content).toContain('hapiTitleExtension');
|
|
expect(content).toContain('hapi_change_title');
|
|
});
|
|
|
|
it('creates missing directories and is idempotent', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hapi-pi-title-'));
|
|
const dir = join(root, 'runtime', '0.0.0-test', 'pi');
|
|
|
|
const first = await materializePiTitleExtension(dir);
|
|
const second = await materializePiTitleExtension(dir);
|
|
|
|
expect(first).toBe(second);
|
|
});
|
|
|
|
it('publishes atomically under concurrent launches', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'hapi-pi-title-'));
|
|
|
|
const paths = await Promise.all(
|
|
Array.from({ length: 8 }, () => materializePiTitleExtension(dir)),
|
|
);
|
|
|
|
expect(new Set(paths).size).toBe(1);
|
|
const content = await readFile(join(dir, 'hapi-title-extension.ts'), 'utf8');
|
|
expect(content).toBe(PI_TITLE_EXTENSION_SOURCE);
|
|
const entries = await readdir(dir);
|
|
expect(entries.filter((name) => name.endsWith('.tmp'))).toEqual([]);
|
|
});
|
|
|
|
it('keeps injecting the instruction on later turns so renames stay possible', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'hapi-pi-title-'));
|
|
const file = await materializePiTitleExtension(dir);
|
|
|
|
// The materialized source only imports typebox, which cannot be
|
|
// resolved from an arbitrary temp dir; stub it so the module loads as ESM.
|
|
const source = await readFile(file, 'utf8');
|
|
const testable = source.replace(
|
|
"import { Type } from '@sinclair/typebox';",
|
|
'const Type = { Object: (properties) => ({ properties }), String: (options) => options };',
|
|
);
|
|
const moduleFile = join(dir, 'hapi-title-extension.mjs');
|
|
await writeFile(moduleFile, testable, 'utf8');
|
|
const mod = await import(pathToFileURL(moduleFile).href);
|
|
|
|
const handlers: Record<string, Array<(event: { systemPrompt: string }) => unknown>> = {};
|
|
const registered: Array<{
|
|
name: string;
|
|
execute: (id: string, params: { title?: string }, signal?: unknown, onUpdate?: unknown, ctx?: unknown) => Promise<{ content: Array<{ text: string }> }>;
|
|
}> = [];
|
|
mod.default({
|
|
on: (event: string, handler: (event: { systemPrompt: string }) => unknown) => {
|
|
(handlers[event] ??= []).push(handler);
|
|
},
|
|
registerTool: (definition: (typeof registered)[number]) => registered.push(definition),
|
|
});
|
|
|
|
const beforeAgentStart = handlers.before_agent_start[0] as (
|
|
event: { systemPrompt: string },
|
|
) => { systemPrompt: string };
|
|
expect(beforeAgentStart({ systemPrompt: 'base' }).systemPrompt).toContain('## Session title');
|
|
|
|
const setTitle = vi.fn();
|
|
const tool = registered.find((definition) => definition.name === 'hapi_change_title');
|
|
expect(tool).toBeDefined();
|
|
|
|
// Current Pi call order: (toolCallId, params, signal, onUpdate, ctx).
|
|
const result = await tool!.execute('call-1', { title: 'Fix login bug' }, undefined, undefined, {
|
|
hasUI: true,
|
|
ui: { setTitle },
|
|
});
|
|
expect(setTitle).toHaveBeenCalledWith('Fix login bug');
|
|
expect(result.content[0].text).toContain('Fix login bug');
|
|
|
|
// Legacy Pi call order (id, params, onUpdate, ctx, signal) with RPC
|
|
// reporting hasUI: false — the context must still be normalized and
|
|
// setTitle must still fire.
|
|
const setTitleLegacy = vi.fn();
|
|
const legacyCtx = { hasUI: false, ui: { setTitle: setTitleLegacy } };
|
|
await tool!.execute(
|
|
'call-2',
|
|
{ title: 'Legacy goal' },
|
|
() => {},
|
|
legacyCtx,
|
|
new AbortController().signal,
|
|
);
|
|
expect(setTitleLegacy).toHaveBeenCalledWith('Legacy goal');
|
|
|
|
// A later turn (after the title tool executed) still carries the
|
|
// instruction, preserving the objective-change retitle rule.
|
|
const later = beforeAgentStart({ systemPrompt: 'base' });
|
|
expect(later.systemPrompt).toContain('## Session title');
|
|
expect(later.systemPrompt).toContain('Rename only when');
|
|
});
|
|
});
|