Skip to content
Merged
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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

119 changes: 119 additions & 0 deletions common/__tests__/settings.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'bun:test';
import {
normalizeChatTitleUiSettings,
normalizeCommitMessageUiSettings,
normalizeRemoteSettingsSnapshot,
} from '../settings.js';

describe('generation settings contracts', () => {
it('keeps title settings limited to title generation fields', () => {
expect(normalizeChatTitleUiSettings({
enabled: true,
agentId: 'codex',
model: 'gpt-5.5',
thinkingMode: 'medium',
customPrompt: 'Unsupported title prompt',
useCommonDirPrefix: true,
})).toEqual({
enabled: true,
agentId: 'codex',
model: 'gpt-5.5',
thinkingMode: 'medium',
});
});

it('keeps commit-only prompt fields while dropping title-only enabled state', () => {
expect(normalizeCommitMessageUiSettings({
enabled: false,
agentId: 'codex',
model: 'gpt-5.5',
customPrompt: 'Summarize the diff',
useCommonDirPrefix: true,
})).toEqual({
agentId: 'codex',
model: 'gpt-5.5',
customPrompt: 'Summarize the diff',
useCommonDirPrefix: true,
});
});

it('strips commit-only fields from persisted and effective title snapshots', () => {
const snapshot = normalizeRemoteSettingsSnapshot({
version: 1,
features: { transcriptSearch: { enabled: false } },
ui: {
chatTitle: {
enabled: true,
agentId: 'codex',
model: 'gpt-5.5',
thinkingMode: 'medium',
customPrompt: 'Unsupported title prompt',
useCommonDirPrefix: true,
},
commitMessage: {
agentId: 'codex',
model: 'gpt-5.5',
thinkingMode: 'medium',
customPrompt: 'Summarize the diff',
useCommonDirPrefix: true,
},
},
uiEffective: {
chatTitle: {
enabled: true,
agentId: 'codex',
model: 'gpt-5.5',
thinkingMode: 'medium',
customPrompt: 'Unsupported title prompt',
useCommonDirPrefix: true,
},
commitMessage: {
agentId: 'codex',
model: 'gpt-5.5',
thinkingMode: 'medium',
customPrompt: 'Summarize the diff',
useCommonDirPrefix: true,
},
},
paths: {
pinnedProjectPaths: [],
browseStartPath: '',
recentProjectPaths: [],
},
pinnedChatIds: [],
recentAgentSettings: [],
executionDefaults: {
global: {
permissionMode: 'default',
thinkingMode: 'none',
agentSettingsById: {},
},
byAgent: {},
},
projectBasePath: '',
telegram: {
botTokenAvailable: false,
botUsername: null,
botFirstName: null,
recipientUsername: null,
recipientDisplayName: null,
recipientLinked: false,
pendingLink: false,
linkUrl: null,
},
});

expect(snapshot?.ui.chatTitle).not.toHaveProperty('customPrompt');
expect(snapshot?.ui.chatTitle).not.toHaveProperty('useCommonDirPrefix');
expect(snapshot?.uiEffective.chatTitle).not.toHaveProperty('customPrompt');
expect(snapshot?.uiEffective.chatTitle).not.toHaveProperty('useCommonDirPrefix');
expect(snapshot?.ui.commitMessage).toMatchObject({
customPrompt: 'Summarize the diff',
useCommonDirPrefix: true,
});
expect(snapshot?.uiEffective.commitMessage).toMatchObject({
customPrompt: 'Summarize the diff',
useCommonDirPrefix: true,
});
});
});
76 changes: 55 additions & 21 deletions common/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,24 @@ export type PinnedInsertPosition = 'top' | 'bottom';
export const DEFAULT_APP_TITLE = 'Garcon';
export const APP_TITLE_MAX_LENGTH = 120;

export interface GenerationUiSettings {
enabled?: boolean;
export interface GenerationSelectionUiSettings {
agentId?: AgentId;
model?: string;
apiProviderId?: string | null;
modelEndpointId?: string | null;
modelProtocol?: ApiProtocol | null;
thinkingMode?: ThinkingMode;
}

export interface ChatTitleUiSettings extends GenerationSelectionUiSettings {
enabled?: boolean;
}

export interface CommitMessageUiSettings extends GenerationSelectionUiSettings {
customPrompt?: string;
useCommonDirPrefix?: boolean;
}

export type CommitMessageUiSettings = Omit<GenerationUiSettings, 'enabled'>;

export interface TelegramNotificationSettings {
enabled?: boolean;
}
Expand All @@ -55,27 +59,30 @@ export interface RemoteTelegramStatus {

export interface RemoteUiSettings {
pinnedInsertPosition?: PinnedInsertPosition;
chatTitle?: GenerationUiSettings;
chatTitle?: ChatTitleUiSettings;
commitMessage?: CommitMessageUiSettings;
appIdentity?: AppIdentityUiSettings;
notifications?: {
telegram?: TelegramNotificationSettings;
};
}

type EffectiveGenerationExtras = {
type EffectiveGenerationSelection = {
apiProviderId?: string | null;
modelEndpointId?: string | null;
modelProtocol?: ApiProtocol | null;
};

type EffectiveCommitMessageExtras = EffectiveGenerationSelection & {
customPrompt?: string;
useCommonDirPrefix?: boolean;
};

export interface RemoteUiEffectiveSettings {
chatTitle?: Required<Pick<GenerationUiSettings, 'enabled' | 'agentId' | 'model' | 'thinkingMode'>> &
EffectiveGenerationExtras;
chatTitle?: Required<Pick<ChatTitleUiSettings, 'enabled' | 'agentId' | 'model' | 'thinkingMode'>> &
EffectiveGenerationSelection;
commitMessage?: Required<Pick<CommitMessageUiSettings, 'agentId' | 'model' | 'thinkingMode'>> &
EffectiveGenerationExtras;
EffectiveCommitMessageExtras;
}

export interface RemotePathSettings {
Expand Down Expand Up @@ -163,23 +170,43 @@ function safeOptionalProtocol(value: unknown): ApiProtocol | null {
return null;
}

function normalizeGenerationUiSettings(
function normalizeGenerationSelection(
value: unknown,
options: { includeEnabled?: boolean } = {},
): GenerationUiSettings | undefined {
): GenerationSelectionUiSettings | undefined {
const raw = asRecord(value);
if (!raw) return undefined;

const includeEnabled = options.includeEnabled ?? true;
const normalized: GenerationUiSettings = {};
if (includeEnabled && typeof raw.enabled === 'boolean') normalized.enabled = raw.enabled;
const normalized: GenerationSelectionUiSettings = {};
if (isAgentId(raw.agentId)) normalized.agentId = raw.agentId;
if (typeof raw.model === 'string') normalized.model = raw.model;
if (raw.apiProviderId !== undefined) normalized.apiProviderId = safeOptionalId(raw.apiProviderId);
if (raw.modelEndpointId !== undefined) normalized.modelEndpointId = safeOptionalId(raw.modelEndpointId);
if (raw.modelProtocol !== undefined) normalized.modelProtocol = safeOptionalProtocol(raw.modelProtocol);
const thinkingMode = coerceThinkingMode(raw.thinkingMode);
if (thinkingMode) normalized.thinkingMode = thinkingMode;
return Object.keys(normalized).length > 0 ? normalized : undefined;
}

export function normalizeChatTitleUiSettings(value: unknown): ChatTitleUiSettings | undefined {
const raw = asRecord(value);
if (!raw) return undefined;

const normalized: ChatTitleUiSettings = {
...normalizeGenerationSelection(raw),
};
if (typeof raw.enabled === 'boolean') normalized.enabled = raw.enabled;
return Object.keys(normalized).length > 0 ? normalized : undefined;
}

export function normalizeCommitMessageUiSettings(
value: unknown,
): CommitMessageUiSettings | undefined {
const raw = asRecord(value);
if (!raw) return undefined;

const normalized: CommitMessageUiSettings = {
...normalizeGenerationSelection(raw),
};
if (typeof raw.customPrompt === 'string') normalized.customPrompt = raw.customPrompt;
if (typeof raw.useCommonDirPrefix === 'boolean') {
normalized.useCommonDirPrefix = raw.useCommonDirPrefix;
Expand All @@ -196,13 +223,20 @@ function normalizeAppIdentityUiSettings(value: unknown): AppIdentityUiSettings |
return { title };
}

function normalizeEffectiveGenerationExtras(
function normalizeEffectiveGenerationSelection(
raw: Record<string, unknown>,
normalized: EffectiveGenerationExtras,
normalized: EffectiveGenerationSelection,
): void {
if (raw.apiProviderId !== undefined) normalized.apiProviderId = safeOptionalId(raw.apiProviderId);
if (raw.modelEndpointId !== undefined) normalized.modelEndpointId = safeOptionalId(raw.modelEndpointId);
if (raw.modelProtocol !== undefined) normalized.modelProtocol = safeOptionalProtocol(raw.modelProtocol);
}

function normalizeEffectiveCommitMessageExtras(
raw: Record<string, unknown>,
normalized: EffectiveCommitMessageExtras,
): void {
normalizeEffectiveGenerationSelection(raw, normalized);
if (typeof raw.customPrompt === 'string') normalized.customPrompt = raw.customPrompt;
if (typeof raw.useCommonDirPrefix === 'boolean') {
normalized.useCommonDirPrefix = raw.useCommonDirPrefix;
Expand All @@ -224,7 +258,7 @@ function normalizeChatTitleUiEffectiveSettings(
model: raw.model,
thinkingMode: normalizeThinkingMode(raw.thinkingMode),
};
normalizeEffectiveGenerationExtras(raw, normalized);
normalizeEffectiveGenerationSelection(raw, normalized);
return normalized;
}

Expand All @@ -241,7 +275,7 @@ function normalizeCommitMessageUiEffectiveSettings(
model: raw.model,
thinkingMode: normalizeThinkingMode(raw.thinkingMode),
};
normalizeEffectiveGenerationExtras(raw, normalized);
normalizeEffectiveCommitMessageExtras(raw, normalized);
return normalized;
}

Expand All @@ -254,10 +288,10 @@ function normalizeRemoteUiSettings(value: unknown): RemoteUiSettings | null {
normalized.pinnedInsertPosition = raw.pinnedInsertPosition;
}

const chatTitle = normalizeGenerationUiSettings(raw.chatTitle);
const chatTitle = normalizeChatTitleUiSettings(raw.chatTitle);
if (chatTitle) normalized.chatTitle = chatTitle;

const commitMessage = normalizeGenerationUiSettings(raw.commitMessage, { includeEnabled: false });
const commitMessage = normalizeCommitMessageUiSettings(raw.commitMessage);
if (commitMessage) normalized.commitMessage = commitMessage;

const appIdentity = normalizeAppIdentityUiSettings(raw.appIdentity);
Expand Down
4 changes: 4 additions & 0 deletions integration-tests/support/garcon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ export class GarconTestClient {
return this.post<GenerateChatTitleResponse>('/api/v1/chats/title/generate', request);
}

updateSessionName(chatId: string, title: string): Promise<{ success: boolean }> {
return this.put('/api/v1/app/session-name', { chatId, title });
}

async getScheduledPrompts(): Promise<ScheduledPromptsSnapshot> {
const response = await this.get<unknown>('/api/v1/scheduled-prompts');
const snapshot = normalizeScheduledPromptsSnapshot(response);
Expand Down
79 changes: 79 additions & 0 deletions integration-tests/tests/server/chat-title-icon-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test } from 'bun:test';
import { withIntegrationFixture } from '../../support/integration-fixture.js';

describe('chat title icon history', () => {
test('shares title icons across chats, records generated titles, and resets on restart', async () => {
await withIntegrationFixture('chat-title-icon-history', async (fixture) => {
const firstChatId = fixture.newChatId();
const secondChatId = fixture.newChatId();

for (const [chatId, content] of [
[firstChatId, 'first-title-history-chat'],
[secondChatId, 'second-title-history-chat'],
]) {
const started = await fixture.client.startDirectChat({
chatId,
content,
projectPath: fixture.dirs.project,
agent: fixture.directAgents.openAi,
});
await fixture.client.waitForTurnTerminal(chatId, started.turnId);
}

await fixture.client.updateSessionName(firstChatId, 'Investigate 🧿 access 🔐');
fixture.fakeProviders.openAiResponses.respondThinkingThenTextNext(
{ lastUserTextIncludes: 'first-history-generation' },
'🧪 Testing and packaging 📦',
);
await fixture.client.generateChatTitle({
chatId: secondChatId,
message: 'first-history-generation',
});

const firstGenerationRequest = fixture.fakeProviders.openAiResponses.requests().find(
(request) => request.lastUserText.includes('first-history-generation'),
);
expect(firstGenerationRequest?.lastUserText).toContain(
'### Recently Used Emojis to Avoid When Another Accurate Emoji Is Available:\n🔐 🧿',
);

fixture.fakeProviders.openAiResponses.respondThinkingThenTextNext(
{ lastUserTextIncludes: 'second-history-generation' },
'🧭 Follow-up title',
);
await fixture.client.generateChatTitle({
chatId: firstChatId,
message: 'second-history-generation',
});

const secondGenerationRequest = fixture.fakeProviders.openAiResponses.requests().find(
(request) => request.lastUserText.includes('second-history-generation'),
);
expect(secondGenerationRequest?.lastUserText).toContain(
'### Recently Used Emojis to Avoid When Another Accurate Emoji Is Available:\n📦 🧪 🔐 🧿',
);

await fixture.restartGarcon();
fixture.fakeProviders.openAiResponses.respondThinkingThenTextNext(
{ lastUserTextIncludes: 'post-restart-generation' },
'🛰️ Restarted title',
);
await fixture.client.generateChatTitle({
chatId: secondChatId,
message: 'post-restart-generation',
});

const postRestartRequest = fixture.fakeProviders.openAiResponses.requests().find(
(request) => request.lastUserText.includes('post-restart-generation'),
);
expect(postRestartRequest?.lastUserText).toContain(
'### Recently Used Emojis to Avoid When Another Accurate Emoji Is Available:\nNone',
);
expect(postRestartRequest?.lastUserText).not.toContain('🧿');
expect(postRestartRequest?.lastUserText).not.toContain('🔐');
expect(postRestartRequest?.lastUserText).not.toContain('🧪');
expect(postRestartRequest?.lastUserText).not.toContain('📦');
expect(postRestartRequest?.lastUserText).not.toContain('🧭');
}, { chatTitleAgent: 'openAiResponses' });
});
});
Loading
Loading