mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
fix(cli): change_title updates metadata.name for visible renames (#1896)
* fix(cli): change_title updates metadata.name for visible renames Agent change_title was writing metadata.summary only, so spawn --name sessions kept showing the old title. Route explicit renames through metadata.name (web rename semantics) across MCP, Codex, and Pi. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): voice context prefers metadata.name for session title Voice bootstrap/full formatters only read summary.text, so after change_title sets name the spoken context kept a stale title. Use the same name→summary precedence as getSessionTitle. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: polish title rename tests after cold review Rename the Codex root title test to match last-writer-wins behavior, and skip the voice full-context summary header when no title signal exists (avoid literal undefined). Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -31,14 +31,15 @@ function params(item: unknown, turnId = 'turn') { return { threadId: 'thread', t
|
||||
function history(...items: unknown[]) { return { turns: [{ id: 'turn', status: 'completed', items }] }; }
|
||||
|
||||
describe('shared Codex titles', () => {
|
||||
it('persists a successful root tool title while preserving the manual name and transcript', async () => {
|
||||
it('renames via metadata.name so a spawn name does not hide change_title', async () => {
|
||||
const f = fixture();
|
||||
const item = titleItem('title', ' Remote title ');
|
||||
await f.projection.notification('item/started', params(item));
|
||||
expect(f.update).not.toHaveBeenCalled();
|
||||
// Completion may omit the arguments already sent at start.
|
||||
await f.projection.notification('item/completed', params({ ...item, arguments: undefined }));
|
||||
expect(f.metadata()).toMatchObject({ name: 'Manual name', summary: { text: 'Remote title', updatedAt: expect.any(Number) } });
|
||||
expect(f.metadata()).toMatchObject({ name: 'Remote title' });
|
||||
expect(f.metadata().summary).toBeUndefined();
|
||||
expect(f.send.mock.calls.map(([body]) => body.type)).toEqual(['tool-call', 'tool-call-result']);
|
||||
expect(f.update).toHaveBeenCalledTimes(1);
|
||||
await f.projection.notification('item/completed', params(item));
|
||||
@@ -50,10 +51,16 @@ describe('shared Codex titles', () => {
|
||||
|
||||
it('handles a complete item without a start and orders successive titles across deferred writes', async () => {
|
||||
const f = fixture(undefined, true);
|
||||
// Start without a spawn name so successive renames are visible on name.
|
||||
f.update(metadata => {
|
||||
const { name: _ignored, ...rest } = metadata;
|
||||
return rest as Metadata;
|
||||
});
|
||||
f.update.mockClear();
|
||||
await f.projection.notification('item/completed', params(titleItem('first', 'First')));
|
||||
await f.projection.notification('item/completed', params(titleItem('second', 'Second')));
|
||||
f.flush();
|
||||
expect(f.metadata().summary?.text).toBe('Second');
|
||||
expect(f.metadata().name).toBe('Second');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -86,25 +93,31 @@ describe('shared Codex titles', () => {
|
||||
|
||||
it('restores only the last successful historical title and does not replay earlier names', async () => {
|
||||
const f = fixture();
|
||||
f.update(metadata => {
|
||||
const { name: _ignored, ...rest } = metadata;
|
||||
return rest as Metadata;
|
||||
});
|
||||
f.update.mockClear();
|
||||
const first = titleItem('first', 'First');
|
||||
const last = titleItem('last', 'Last');
|
||||
const snapshot = history(first, last, { ...titleItem('failed', 'Failed'), result: { isError: true } });
|
||||
await f.projection.history(snapshot);
|
||||
expect(f.metadata().summary?.text).toBe('Last');
|
||||
expect(f.metadata().name).toBe('Last');
|
||||
expect(f.update).toHaveBeenCalledTimes(1);
|
||||
f.projection.reset();
|
||||
await f.projection.history(snapshot);
|
||||
await f.projection.notification('item/completed', params(first));
|
||||
expect(f.metadata().summary?.text).toBe('Last');
|
||||
expect(f.metadata().name).toBe('Last');
|
||||
expect(f.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never overwrites an existing summary during a cold history replay', async () => {
|
||||
it('never overwrites an existing display title during a cold history replay', async () => {
|
||||
const f = fixture();
|
||||
f.update(metadata => ({ ...metadata, summary: { text: 'Existing', updatedAt: 123 } }));
|
||||
f.update.mockClear();
|
||||
await f.projection.history(history(titleItem('old', 'Old')));
|
||||
expect(f.update).not.toHaveBeenCalled();
|
||||
expect(f.metadata().name).toBe('Manual name');
|
||||
expect(f.metadata().summary).toEqual({ text: 'Existing', updatedAt: 123 });
|
||||
});
|
||||
|
||||
@@ -114,11 +127,16 @@ describe('shared Codex titles', () => {
|
||||
await f.projection.history({ turns: [{ id: 'turn', status: 'inProgress', items: [item] }] });
|
||||
expect(f.update).not.toHaveBeenCalled();
|
||||
await f.projection.notification('item/completed', params({ ...titleItem('title', 'unused'), arguments: undefined }));
|
||||
expect(f.metadata().summary?.text).toBe('Resumed');
|
||||
expect(f.metadata().name).toBe('Resumed');
|
||||
});
|
||||
|
||||
it('does not roll back a live title when a suspended replay or queued metadata callback resumes', async () => {
|
||||
const f = fixture(undefined, true);
|
||||
f.update(metadata => {
|
||||
const { name: _ignored, ...rest } = metadata;
|
||||
return rest as Metadata;
|
||||
});
|
||||
f.update.mockClear();
|
||||
let release!: () => void;
|
||||
f.committed.mockImplementationOnce(() => new Promise<void>(resolve => { release = resolve; }));
|
||||
const replay = f.projection.history(history(
|
||||
@@ -129,12 +147,17 @@ describe('shared Codex titles', () => {
|
||||
await f.projection.notification('item/completed', params(titleItem('live', 'Live'), 'next'));
|
||||
release(); await replay;
|
||||
f.flush();
|
||||
expect(f.metadata().summary?.text).toBe('Live');
|
||||
expect(f.metadata().name).toBe('Live');
|
||||
|
||||
const queued = fixture(undefined, true);
|
||||
queued.update(metadata => {
|
||||
const { name: _ignored, ...rest } = metadata;
|
||||
return rest as Metadata;
|
||||
});
|
||||
queued.update.mockClear();
|
||||
await queued.projection.history(history(titleItem('old', 'Old')));
|
||||
await queued.projection.notification('item/completed', params(titleItem('live', 'Live'), 'next'));
|
||||
queued.flush();
|
||||
expect(queued.metadata().summary?.text).toBe('Live');
|
||||
expect(queued.metadata().name).toBe('Live');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ApiSessionClient } from '@/api/apiSession';
|
||||
import { normalizeSessionDisplayTitle } from '@/agent/sessionDisplayRename';
|
||||
import { registerGeneratedImageFromPath } from '@/modules/common/generatedImages';
|
||||
import { AppServerEventConverter } from '../utils/appServerEventConverter';
|
||||
import { record, string } from './gateway';
|
||||
@@ -16,7 +17,7 @@ export function inputText(input: unknown): string {
|
||||
|
||||
function requestedTitle(item: Record<string, unknown>): string | undefined {
|
||||
if (item.type !== 'mcpToolCall' || item.server !== 'hapi' || item.tool !== 'change_title') return;
|
||||
return string(record(item.arguments).title)?.trim() || undefined;
|
||||
return normalizeSessionDisplayTitle(string(record(item.arguments).title)) ?? undefined;
|
||||
}
|
||||
|
||||
function successfulTitle(item: Record<string, unknown>, pending?: string): string | undefined {
|
||||
@@ -27,6 +28,10 @@ function successfulTitle(item: Record<string, unknown>, pending?: string): strin
|
||||
return requestedTitle(item) ?? pending;
|
||||
}
|
||||
|
||||
function metadataHasDisplayTitle(metadata: { name?: string; summary?: { text: string } } | null | undefined): boolean {
|
||||
return Boolean(metadata?.name?.trim() || metadata?.summary?.text?.trim());
|
||||
}
|
||||
|
||||
/** Canonical V2 stream only. Stable message IDs also deduplicate snapshot replay at the hub. */
|
||||
export class SharedCodexProjection {
|
||||
private converter = new AppServerEventConverter();
|
||||
@@ -54,6 +59,15 @@ export class SharedCodexProjection {
|
||||
} : { ...body, id }, id);
|
||||
}
|
||||
|
||||
private applyDisplayRename(title: string, revision: number): void {
|
||||
this.session.updateMetadata(metadata => {
|
||||
if (revision !== this.titleRevision) return metadata;
|
||||
const normalized = normalizeSessionDisplayTitle(title);
|
||||
if (!normalized || metadata.name?.trim() === normalized) return metadata;
|
||||
return { ...metadata, name: normalized };
|
||||
});
|
||||
}
|
||||
|
||||
async notification(method: string, params: unknown, modelAtReceipt?: string): Promise<void> {
|
||||
const p = record(params);
|
||||
const item = record(p.item);
|
||||
@@ -68,9 +82,7 @@ export class SharedCodexProjection {
|
||||
if (completedTitle) {
|
||||
this.completedTitles.add(key);
|
||||
const revision = ++this.titleRevision;
|
||||
this.session.updateMetadata(metadata => revision !== this.titleRevision ? metadata : {
|
||||
...metadata, summary: { text: completedTitle, updatedAt: Date.now() }
|
||||
});
|
||||
this.applyDisplayRename(completedTitle, revision);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,11 +209,17 @@ export class SharedCodexProjection {
|
||||
}
|
||||
// Repair sessions created while remote title projection was missing.
|
||||
// Recheck inside the metadata lock: live updates may still be queued,
|
||||
// and a replay must never replace an existing or newer title.
|
||||
if (latestTitle && titleRevision === this.titleRevision && !this.session.getMetadata()?.summary?.text?.trim()) {
|
||||
// and a replay must never replace an existing or newer display title.
|
||||
if (latestTitle && titleRevision === this.titleRevision && !metadataHasDisplayTitle(this.session.getMetadata())) {
|
||||
const title = latestTitle;
|
||||
this.session.updateMetadata(metadata => titleRevision !== this.titleRevision || metadata.summary?.text?.trim()
|
||||
? metadata : { ...metadata, summary: { text: title, updatedAt: Date.now() } });
|
||||
this.session.updateMetadata(metadata => {
|
||||
if (titleRevision !== this.titleRevision || metadataHasDisplayTitle(metadata)) {
|
||||
return metadata;
|
||||
}
|
||||
const normalized = normalizeSessionDisplayTitle(title);
|
||||
if (!normalized) return metadata;
|
||||
return { ...metadata, name: normalized };
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,16 +104,15 @@ async function completePlan(f: Awaited<ReturnType<typeof fixture>>, status = 'co
|
||||
}
|
||||
|
||||
describe('shared plan actions', () => {
|
||||
it('persists remote title tools while retaining native terminal rename events', async () => {
|
||||
it('applies remote change_title as metadata.name then lets native terminal rename win', async () => {
|
||||
const f = await fixture();
|
||||
const item = { id: 'title', type: 'mcpToolCall', server: 'hapi', tool: 'change_title',
|
||||
arguments: { title: 'Remote title' }, status: 'completed', result: { content: [], isError: false } };
|
||||
f.native.notify('item/completed', { threadId: 'thread', turnId: 'turn', item });
|
||||
await vi.waitFor(() => expect(f.metadata().summary?.text).toBe('Remote title'));
|
||||
await vi.waitFor(() => expect(f.metadata().name).toBe('Remote title'));
|
||||
f.native.notify('thread/name/updated', { threadId: 'thread', threadName: 'Terminal title' });
|
||||
await vi.waitFor(() => expect(f.metadata().name).toBe('Terminal title'));
|
||||
await f.root.refresh();
|
||||
expect(f.metadata().summary?.text).toBe('Remote title');
|
||||
expect(f.metadata().name).toBe('Terminal title');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user