Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions apps/cli/src/lib/message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ import {
isManagedBuiltinAgentType,
sanitizeLodyInternalInstructions,
usesAcpProvidedSessionTitle,
resolveSessionAgentIdentity,
SessionCreateResponse,
SessionChatResponse,
SessionStatusFactory,
type MachineLegacyMetaFields,
type MachineMeta,
type MachineResourceInfo,
SessionMeta,
type SessionTitleSource,
LocalProjectId,
type NeedToDeleteSessionQueueItem,
getMachineRoomId,
Expand Down Expand Up @@ -8912,10 +8914,14 @@ export class MessageHandler {
}

/**
* Stores a session title pushed by the agent via ACP session_info_update
* Stores a session title pushed by the agent via ACP session_info_update.
* Builtin Claude skips the isolated local generator, so the pushed title is
* its only generated source. Never overwrites a user-set title; the conditional
* write guards against renames racing in via sync.
* its only generated source and may replace an earlier generated title.
* Codex (and other isolated-generator agents) already have a generated title
* from title-generator.ts; an explicit Codex thread name must not replace it
* — that path used to retitle a resumed conversation from the latest prompt.
* Never overwrites a user-set title; the conditional write also guards
* against renames racing in via sync.
*/
private async maybeStoreAgentSessionTitle(sessionId: SessionId, title: string): Promise<void> {
try {
Expand All @@ -8928,10 +8934,15 @@ export class MessageHandler {
if (meta?.title?.trim() === sanitized) {
return;
}
const applied = await sessionDoc.setTitleIfSourceIn(sanitized, 'generated', [
'draft',
'generated',
]);
const allowedSources: SessionTitleSource[] = ['draft'];
// getMetaState() is a raw cast: resume-time docs can still store
// agentType-only or cliType=claude|codex. Normalize before the Claude
// title predicate. Do not look up the agent-config catalog.
const identity = resolveSessionAgentIdentity(meta?.cliType, meta?.agentType);
if (usesAcpProvidedSessionTitle(identity?.cliType, identity?.agentType)) {
allowedSources.push('generated');
}
const applied = await sessionDoc.setTitleIfSourceIn(sanitized, 'generated', allowedSources);
if (applied) {
this.logger.debug(`[${sessionId}] Session title updated from agent: ${sanitized}`);
}
Expand Down
167 changes: 165 additions & 2 deletions apps/cli/tests/message-handler-title.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ const createHandler = async (
latestMeta?: { title?: string; titleSource?: SessionTitleSource },
options?: {
agentConfigId?: string;
agentConfigMeta?: { titleGeneration?: { configOptionValues: Record<string, string> } } | null;
sessionCliType?: string;
sessionAgentType?: string;
agentConfigMeta?: {
titleGeneration?: { configOptionValues: Record<string, string> };
cliType?: string;
agentType?: string;
} | null;
}
) => {
const logger = createSilentLogger();
Expand All @@ -48,6 +54,8 @@ const createHandler = async (
title: metaTitle ?? undefined,
titleSource,
agentConfigId: options?.agentConfigId,
cliType: options?.sessionCliType,
agentType: options?.sessionAgentType,
})
.mockResolvedValue(latestMeta ?? { title: metaTitle ?? undefined, titleSource }),
setTitle: vi.fn(async () => {}),
Expand Down Expand Up @@ -365,7 +373,6 @@ describe('MessageHandler title generation', () => {

expect(sessionDoc.setTitleIfSourceIn).toHaveBeenCalledWith('Fix flaky login', 'generated', [
'draft',
'generated',
]);
});

Expand Down Expand Up @@ -407,4 +414,160 @@ describe('MessageHandler title generation', () => {
expect.objectContaining({ titleConfig: undefined })
);
});

it('does not let a Codex explicit title replace an already generated title', async () => {
const { handler, sessionDoc, workspaceDocument } = await createHandler(
'Exact ping5 reply',
'generated',
undefined,
{ sessionCliType: 'builtin', sessionAgentType: 'codex' }
);
const titleHost = handler as unknown as {
maybeStoreAgentSessionTitle: (sessionId: SessionId, title: string) => Promise<void>;
};

await titleHost.maybeStoreAgentSessionTitle(
's-codex-resume' as SessionId,
'Single-word ping5b response'
);

expect(workspaceDocument.getAgentConfigById).not.toHaveBeenCalled();
expect(sessionDoc.setTitleIfSourceIn).toHaveBeenCalledWith(
'Single-word ping5b response',
'generated',
['draft']
);
expect(await sessionDoc.setTitleIfSourceIn.mock.results[0]?.value).toBe(false);
});

it('still lets a Codex explicit title name a draft new session', async () => {
const { handler, sessionDoc } = await createHandler(undefined, 'draft', undefined, {
sessionCliType: 'builtin',
sessionAgentType: 'codex',
});
const titleHost = handler as unknown as {
maybeStoreAgentSessionTitle: (sessionId: SessionId, title: string) => Promise<void>;
};

await titleHost.maybeStoreAgentSessionTitle(
's-codex-new' as SessionId,
'Single-word alpha7k reply'
);

expect(sessionDoc.setTitleIfSourceIn).toHaveBeenCalledWith(
'Single-word alpha7k reply',
'generated',
['draft']
);
expect(await sessionDoc.setTitleIfSourceIn.mock.results[0]?.value).toBe(true);
});

it('does not let an agent title replace a user rename', async () => {
const { handler, sessionDoc } = await createHandler('KeepPong7k', 'user');
const titleHost = handler as unknown as {
maybeStoreAgentSessionTitle: (sessionId: SessionId, title: string) => Promise<void>;
};

await titleHost.maybeStoreAgentSessionTitle(
's-user' as SessionId,
'Exact pong7k response request'
);

expect(sessionDoc.setTitleIfSourceIn).toHaveBeenCalledWith(
'Exact pong7k response request',
'generated',
['draft']
);
expect(await sessionDoc.setTitleIfSourceIn.mock.results[0]?.value).toBe(false);
});

it('still lets builtin Claude replace a generated title', async () => {
const { handler, sessionDoc, workspaceDocument } = await createHandler(
'Earlier generated title',
'generated',
undefined,
{
agentConfigId: 'agent-config-1',
sessionCliType: 'builtin',
sessionAgentType: 'claude',
agentConfigMeta: { cliType: 'builtin', agentType: 'claude' },
}
);
const titleHost = handler as unknown as {
maybeStoreAgentSessionTitle: (sessionId: SessionId, title: string) => Promise<void>;
};

await titleHost.maybeStoreAgentSessionTitle('s-claude' as SessionId, 'Fix login bug');

expect(workspaceDocument.getAgentConfigById).not.toHaveBeenCalled();
expect(sessionDoc.setTitleIfSourceIn).toHaveBeenCalledWith('Fix login bug', 'generated', [
'draft',
'generated',
]);
expect(await sessionDoc.setTitleIfSourceIn.mock.results[0]?.value).toBe(true);
});

it('still lets a legacy Claude session without agentConfigId replace a generated title', async () => {
const { handler, sessionDoc, workspaceDocument } = await createHandler(
'Earlier generated title',
'generated',
undefined,
{ sessionCliType: 'builtin', sessionAgentType: 'claude' }
);
const titleHost = handler as unknown as {
maybeStoreAgentSessionTitle: (sessionId: SessionId, title: string) => Promise<void>;
};

await titleHost.maybeStoreAgentSessionTitle('s-claude-legacy' as SessionId, 'Fix login bug');

expect(workspaceDocument.getAgentConfigById).not.toHaveBeenCalled();
expect(sessionDoc.setTitleIfSourceIn).toHaveBeenCalledWith('Fix login bug', 'generated', [
'draft',
'generated',
]);
expect(await sessionDoc.setTitleIfSourceIn.mock.results[0]?.value).toBe(true);
});

it('still lets a legacy Claude session with only agentType replace a generated title', async () => {
const { handler, sessionDoc } = await createHandler(
'Earlier generated title',
'generated',
undefined,
{ sessionAgentType: 'claude' }
);
const titleHost = handler as unknown as {
maybeStoreAgentSessionTitle: (sessionId: SessionId, title: string) => Promise<void>;
};

await titleHost.maybeStoreAgentSessionTitle(
's-claude-agent-only' as SessionId,
'Fix login bug'
);

expect(sessionDoc.setTitleIfSourceIn).toHaveBeenCalledWith('Fix login bug', 'generated', [
'draft',
'generated',
]);
expect(await sessionDoc.setTitleIfSourceIn.mock.results[0]?.value).toBe(true);
});

it('still lets a legacy Claude session with cliType claude and no agentType replace a generated title', async () => {
const { handler, sessionDoc } = await createHandler(
'Earlier generated title',
'generated',
undefined,
{ sessionCliType: 'claude' }
);
const titleHost = handler as unknown as {
maybeStoreAgentSessionTitle: (sessionId: SessionId, title: string) => Promise<void>;
};

await titleHost.maybeStoreAgentSessionTitle('s-claude-cli-only' as SessionId, 'Fix login bug');

expect(sessionDoc.setTitleIfSourceIn).toHaveBeenCalledWith('Fix login bug', 'generated', [
'draft',
'generated',
]);
expect(await sessionDoc.setTitleIfSourceIn.mock.results[0]?.value).toBe(true);
});
});
34 changes: 34 additions & 0 deletions packages/shared/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,40 @@ export const usesAcpProvidedSessionTitle = (
agentType: AgentType | null | undefined
): boolean => cliType === 'builtin' && agentType === 'claude';

const isLegacyBuiltinAgentName = (value: string): value is 'claude' | 'codex' =>
value === 'claude' || value === 'codex';

/**
* Resume-time session meta can still carry pre-#1221 encodings: only
* `agentType: claude|codex`, or `cliType: claude|codex` with no agentType.
* `getMetaState()` returns that raw shape. Same cases as
* `normalizeAcpTarget` in local-session-control.
*/
export const resolveSessionAgentIdentity = (
cliTypeValue: AgentConfigCliType | string | null | undefined,
agentTypeValue: AgentType | string | null | undefined
): { cliType: AgentConfigCliType; agentType: string } | null => {
const cliType = typeof cliTypeValue === 'string' ? cliTypeValue.trim() : '';
const agentType = typeof agentTypeValue === 'string' ? agentTypeValue.trim() : '';

if (
(cliType === 'builtin' || cliType === 'registry' || cliType === 'custom') &&
agentType.length > 0
) {
return { cliType, agentType };
}
if (!cliType && isLegacyBuiltinAgentName(agentType)) {
return { cliType: 'builtin', agentType };
}
if (isLegacyBuiltinAgentName(cliType) && !agentType) {
return { cliType: 'builtin', agentType: cliType };
}
if (isLegacyBuiltinAgentName(cliType) && isLegacyBuiltinAgentName(agentType)) {
return { cliType: 'builtin', agentType };
}
return null;
};

/**
* User-defined ACP launch spec for `cliType: 'custom'` providers: the exact
* executable + args the CLI spawns on the owning machine. Env vars come from
Expand Down
49 changes: 49 additions & 0 deletions packages/shared/tests/title-generation-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
computeTitleGenerationDefaults,
getBuiltinTitleGenerationDefaults,
resolveSessionAgentIdentity,
usesAcpProvidedSessionTitle,
type AcpConfigOptionSummary,
} from '../src/ai';
Expand Down Expand Up @@ -130,3 +131,51 @@ describe('usesAcpProvidedSessionTitle', () => {
expect(usesAcpProvidedSessionTitle('custom', 'claude')).toBe(false);
});
});

describe('resolveSessionAgentIdentity', () => {
it('keeps current builtin/registry/custom encodings', () => {
expect(resolveSessionAgentIdentity('builtin', 'claude')).toEqual({
cliType: 'builtin',
agentType: 'claude',
});
expect(resolveSessionAgentIdentity('registry', 'codex')).toEqual({
cliType: 'registry',
agentType: 'codex',
});
});

it('maps pre-#1221 agentType-only Claude/Codex to builtin', () => {
expect(resolveSessionAgentIdentity(undefined, 'claude')).toEqual({
cliType: 'builtin',
agentType: 'claude',
});
expect(resolveSessionAgentIdentity('', 'codex')).toEqual({
cliType: 'builtin',
agentType: 'codex',
});
});

it('maps transitional cliType=claude|codex with no agentType to builtin', () => {
expect(resolveSessionAgentIdentity('claude', undefined)).toEqual({
cliType: 'builtin',
agentType: 'claude',
});
expect(resolveSessionAgentIdentity('codex', '')).toEqual({
cliType: 'builtin',
agentType: 'codex',
});
});

it('maps both-legacy encodings to builtin using agentType', () => {
expect(resolveSessionAgentIdentity('claude', 'codex')).toEqual({
cliType: 'builtin',
agentType: 'codex',
});
});

it('returns null for unknown or incomplete identities', () => {
expect(resolveSessionAgentIdentity(undefined, undefined)).toBeNull();
expect(resolveSessionAgentIdentity('builtin', '')).toBeNull();
expect(resolveSessionAgentIdentity('kimi', undefined)).toBeNull();
});
});
Loading