Files
hapi/cli/src/pi/titleExtension.test.ts
T
905d89e03a feat(pi): auto-title Pi sessions via bundled hapi_change_title extension (#1719)
* 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>
2026-09-09 09:31:08 +08:00

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