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:
HeavyGee
2026-09-21 13:49:53 +01:00
committed by GitHub
co-authored by Cursor
parent b6a27bdc68
commit a259ad3144
17 changed files with 432 additions and 54 deletions
+32 -9
View File
@@ -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');
});
});
+26 -8
View File
@@ -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 };
});
}
}
}
+2 -3
View File
@@ -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');
});