feat(ai-chat): per-role autoStart toggle + custom launchMessage (#149)

Agent role cards always auto-sent a hardcoded "Take a look at the current
document" on pick. Make it configurable per role:
- autoStart (bool, default true): whether picking the role auto-sends a message.
- launchMessage (nullable text): the text sent on auto-start; empty -> the
  built-in default. autoStart=false -> bind the role and send nothing (the user
  types the first message, which still carries the roleId).
Existing roles default to autoStart=true / launchMessage=null => identical old
behavior.

Full-stack:
- migration 20260624T120000 adds `auto_start boolean NOT NULL DEFAULT true` +
  `launch_message text` (additive; down drops both); db.d.ts updated by hand.
- DTO: autoStart (@IsBoolean) + launchMessage (trim @Transform, @MaxLength 2000).
- repo/service: thread + normalize (undefined=unchanged, ""=>null, autoStart??true).
  Both fields exposed in the picker-view for ordinary members (they decide
  whether/what to auto-send); instructions/modelConfig stay ADMIN-ONLY.
- client: IAiRole types, role form (Switch + Textarea, re-hydrated on edit),
  handleRolePick branches on autoStart; i18n en-US + ru-RU.

Review follow-ups folded in: reset the `rolePickedNoSend` flag when the thread
returns to an empty role-less state (the "New chat after autoStart=false pick"
stuck-UI bug — render-phase one-shot reset); made create/update launchMessage
normalization symmetric (raw value, server normalizes ""→null).

Server: 68 role tests pass, tsc clean. Client: tsc clean, role tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-24 06:28:41 +03:00
parent acf6d85b07
commit 0ec0af405a
14 changed files with 374 additions and 8 deletions

View File

@@ -1266,6 +1266,10 @@
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.", "Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini", "e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.", "If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
"Start automatically": "Start automatically",
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.",
"Launch message": "Launch message",
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
"Agent roles": "Agent roles", "Agent roles": "Agent roles",
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.", "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
"No roles configured": "No roles configured", "No roles configured": "No roles configured",

View File

@@ -677,6 +677,10 @@
"Ask AI": "Спросить ИИ", "Ask AI": "Спросить ИИ",
"AI agent": "AI-агент", "AI agent": "AI-агент",
"Take a look at the current document": "Посмотри текущий документ", "Take a look at the current document": "Посмотри текущий документ",
"Start automatically": "Запускать автоматически",
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
"Launch message": "Стартовое сообщение",
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
"AI agent is typing…": "AI-агент печатает…", "AI agent is typing…": "AI-агент печатает…",
"{{name}} is typing…": "{{name}} печатает…", "{{name}} is typing…": "{{name}} печатает…",
"Thinking…": "Думаю…", "Thinking…": "Думаю…",

View File

@@ -315,16 +315,44 @@ export default function ChatThread({
// of a generic "Something went wrong". // of a generic "Something went wrong".
const errorView = error ? describeChatError(error.message ?? "", t) : null; const errorView = error ? describeChatError(error.message ?? "", t) : null;
// Clicking a role card both binds the role to THIS new chat and immediately // A role was picked with autoStart=false: the role is bound but NOTHING was
// starts the conversation. roleIdRef is set synchronously here because the // sent, so chatId stays null and the empty state would keep showing the cards.
// parent's selectedRoleId state update would only reach roleIdRef on the next // This flag hides the cards and reveals the composer (with the role indicated)
// render — after this synchronous sendMessage has already read it. // so the user can type the first message themselves. roleIdRef is already set,
// so that first manual message carries the roleId.
const [rolePickedNoSend, setRolePickedNoSend] = useState(false);
// Clicking a role card always binds the role to THIS new chat. Whether it also
// auto-starts the conversation is per-role (autoStart). roleIdRef is set
// synchronously here because the parent's selectedRoleId state update would
// only reach roleIdRef on the next render — after this synchronous sendMessage
// has already read it.
const handleRolePick = (role: IAiRole): void => { const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id; roleIdRef.current = role.id;
onRolePicked?.(role); onRolePicked?.(role);
sendMessage({ text: t("Take a look at the current document") }); if (role.autoStart) {
// Custom launch message when set; otherwise the built-in default text.
sendMessage({
text: role.launchMessage?.trim() || t("Take a look at the current document"),
});
} else {
// Bind only: hide the cards and show the composer, send nothing.
setRolePickedNoSend(true);
}
}; };
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0; // Reset the "picked, not sent" flag when the thread returns to a truly empty,
// role-less state — e.g. the user hit "New chat" after picking an autoStart=false
// role. That path clears the parent's selectedRoleId (roleId -> null) but leaves
// chatId null, so the thread never remounts and the flag would stay set, hiding
// the cards forever. A picked-and-bound role keeps roleId non-null, so the cards
// correctly stay hidden then. Render-phase reset (React "adjust state on prop
// change"): one-shot — it re-renders with the flag false and the guard no longer
// matches, so it cannot loop. (Review of #149.)
if (chatId === null && roleId == null && rolePickedNoSend) {
setRolePickedNoSend(false);
}
const showRoleCards =
chatId === null && (roles?.length ?? 0) > 0 && !rolePickedNoSend;
const roleCardsEmptyState = showRoleCards ? ( const roleCardsEmptyState = showRoleCards ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} /> <RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined; ) : undefined;

View File

@@ -13,6 +13,8 @@ const roles: IAiRole[] = [
emoji: "🏴‍☠️", emoji: "🏴‍☠️",
description: "Talks like a pirate", description: "Talks like a pirate",
enabled: true, enabled: true,
autoStart: true,
launchMessage: null,
}, },
{ {
id: "r2", id: "r2",
@@ -20,6 +22,8 @@ const roles: IAiRole[] = [
emoji: null, emoji: null,
description: null, description: null,
enabled: true, enabled: true,
autoStart: true,
launchMessage: null,
}, },
]; ];

View File

@@ -53,6 +53,10 @@ export interface IAiRole {
instructions?: string; instructions?: string;
modelConfig?: IAiRoleModelConfig | null; modelConfig?: IAiRoleModelConfig | null;
enabled: boolean; enabled: boolean;
// Whether picking the role auto-sends a launch message and starts the chat.
autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@@ -65,6 +69,8 @@ export interface IAiRoleCreate {
instructions: string; instructions: string;
modelConfig?: IAiRoleModelConfig | null; modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean; enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
} }
/** Admin update payload for a role (partial). */ /** Admin update payload for a role (partial). */
@@ -76,6 +82,8 @@ export interface IAiRoleUpdate {
instructions?: string; instructions?: string;
modelConfig?: IAiRoleModelConfig | null; modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean; enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
} }
/** /**

View File

@@ -53,6 +53,8 @@ const formSchema = z.object({
driver: z.enum(["", ...AI_DRIVER_VALUES]), driver: z.enum(["", ...AI_DRIVER_VALUES]),
chatModel: z.string(), chatModel: z.string(),
enabled: z.boolean(), enabled: z.boolean(),
autoStart: z.boolean(),
launchMessage: z.string(),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -83,6 +85,8 @@ export default function AiAgentRoleForm({
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"], driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
chatModel: role?.modelConfig?.chatModel ?? "", chatModel: role?.modelConfig?.chatModel ?? "",
enabled: role?.enabled ?? true, enabled: role?.enabled ?? true,
autoStart: role?.autoStart ?? true,
launchMessage: role?.launchMessage ?? "",
}, },
}); });
@@ -96,6 +100,8 @@ export default function AiAgentRoleForm({
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"], driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
chatModel: role?.modelConfig?.chatModel ?? "", chatModel: role?.modelConfig?.chatModel ?? "",
enabled: role?.enabled ?? true, enabled: role?.enabled ?? true,
autoStart: role?.autoStart ?? true,
launchMessage: role?.launchMessage ?? "",
}); });
form.resetDirty(); form.resetDirty();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -122,6 +128,8 @@ export default function AiAgentRoleForm({
instructions: values.instructions, instructions: values.instructions,
modelConfig, modelConfig,
enabled: values.enabled, enabled: values.enabled,
autoStart: values.autoStart,
launchMessage: values.launchMessage,
}; };
await updateMutation.mutateAsync(payload); await updateMutation.mutateAsync(payload);
} else { } else {
@@ -132,6 +140,10 @@ export default function AiAgentRoleForm({
instructions: values.instructions, instructions: values.instructions,
modelConfig, modelConfig,
enabled: values.enabled, enabled: values.enabled,
autoStart: values.autoStart,
// Send the raw (trimmed) value like the update path; the server
// normalizes an empty string to null (emptyToNull). Symmetric.
launchMessage: values.launchMessage,
}; };
await createMutation.mutateAsync(payload); await createMutation.mutateAsync(payload);
} }
@@ -195,6 +207,28 @@ export default function AiAgentRoleForm({
)} )}
</Text> </Text>
<Switch
label={t("Start automatically")}
description={t(
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.",
)}
checked={form.values.autoStart}
onChange={(event) =>
form.setFieldValue("autoStart", event.currentTarget.checked)
}
/>
<Textarea
label={t("Launch message")}
description={t(
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
)}
autosize
minRows={2}
maxRows={6}
{...form.getInputProps("launchMessage")}
/>
<Switch <Switch
label={t("Enabled")} label={t("Enabled")}
checked={form.values.enabled} checked={form.values.enabled}

View File

@@ -25,6 +25,8 @@ describe('AiAgentRolesService guards', () => {
instructions: 'be a researcher', instructions: 'be a researcher',
modelConfig: null, modelConfig: null,
enabled: true, enabled: true,
autoStart: true,
launchMessage: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
...over, ...over,
@@ -159,6 +161,8 @@ describe('AiAgentRolesService guards', () => {
instructions: 'updated instructions', instructions: 'updated instructions',
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' }, modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
enabled: false, enabled: false,
autoStart: true,
launchMessage: null,
createdAt, createdAt,
updatedAt, updatedAt,
}); });
@@ -186,6 +190,35 @@ describe('AiAgentRolesService guards', () => {
expect(patch2.emoji).toBeUndefined(); expect(patch2.emoji).toBeUndefined();
expect(patch2.description).toBeUndefined(); expect(patch2.description).toBeUndefined();
}); });
it('autoStart/launchMessage thread through; launchMessage:"" clears to null', async () => {
const { service, repo } = makeService({ existing: makeRow() });
await service.update('ws-1', 'r1', {
autoStart: false,
launchMessage: ' custom ',
} as UpdateAgentRoleDto);
const patch = repo.update.mock.calls[0][2];
expect(patch.autoStart).toBe(false);
expect(patch.launchMessage).toBe('custom');
repo.update.mockClear();
// Explicit empty => clear to null.
await service.update('ws-1', 'r1', {
launchMessage: ' ',
} as UpdateAgentRoleDto);
expect(repo.update.mock.calls[0][2].launchMessage).toBeNull();
});
it('autoStart/launchMessage omitted => undefined (unchanged) in the patch', async () => {
const { service, repo } = makeService({ existing: makeRow() });
await service.update('ws-1', 'r1', {
name: 'Renamed',
} as UpdateAgentRoleDto);
const patch = repo.update.mock.calls[0][2];
expect(patch.autoStart).toBeUndefined();
expect(patch.launchMessage).toBeUndefined();
});
}); });
describe('remove', () => { describe('remove', () => {
@@ -319,6 +352,40 @@ describe('AiAgentRolesService guards', () => {
} as CreateAgentRoleDto), } as CreateAgentRoleDto),
).rejects.toBe(other); ).rejects.toBe(other);
}); });
it('autoStart omitted => defaults to true; launchMessage omitted => null', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
} as CreateAgentRoleDto);
const values = repo.insert.mock.calls[0][0];
expect(values.autoStart).toBe(true);
expect(values.launchMessage).toBeNull();
});
it('autoStart:false + launchMessage round-trip (trimmed) to the repo', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
autoStart: false,
launchMessage: ' do the thing ',
} as CreateAgentRoleDto);
const values = repo.insert.mock.calls[0][0];
expect(values.autoStart).toBe(false);
expect(values.launchMessage).toBe('do the thing');
});
it('empty/whitespace launchMessage normalizes to null', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
launchMessage: ' ',
} as CreateAgentRoleDto);
expect(repo.insert.mock.calls[0][0].launchMessage).toBeNull();
});
}); });
describe('list view (security: non-admin must not see instructions/modelConfig)', () => { describe('list view (security: non-admin must not see instructions/modelConfig)', () => {
@@ -349,19 +416,25 @@ describe('AiAgentRolesService guards', () => {
const list = await service.list('ws-1', false); const list = await service.list('ws-1', false);
expect(list).toHaveLength(1); expect(list).toHaveLength(1);
const item = list[0] as unknown as Record<string, unknown>; const item = list[0] as unknown as Record<string, unknown>;
// The picker fields ARE present... // The picker fields ARE present — INCLUDING the auto-start fields, which
// the client needs to decide whether/what to auto-send on role pick.
expect(item).toEqual({ expect(item).toEqual({
id: 'r1', id: 'r1',
name: 'Researcher', name: 'Researcher',
emoji: '🔬', emoji: '🔬',
description: 'finds things', description: 'finds things',
enabled: true, enabled: true,
autoStart: true,
launchMessage: null,
}); });
// ...and the admin-only fields are absent (not just undefined). // ...and the admin-only fields are absent (not just undefined).
expect('instructions' in item).toBe(false); expect('instructions' in item).toBe(false);
expect('modelConfig' in item).toBe(false); expect('modelConfig' in item).toBe(false);
expect('createdAt' in item).toBe(false); expect('createdAt' in item).toBe(false);
expect('updatedAt' in item).toBe(false); expect('updatedAt' in item).toBe(false);
// autoStart/launchMessage are deliberately NOT admin-only — present here.
expect('autoStart' in item).toBe(true);
expect('launchMessage' in item).toBe(true);
}); });
it('admin (isAdmin=true) gets the full view WITH instructions/modelConfig', async () => { it('admin (isAdmin=true) gets the full view WITH instructions/modelConfig', async () => {

View File

@@ -22,6 +22,8 @@ export interface AgentRoleView {
instructions: string; instructions: string;
modelConfig: RoleModelConfig | null; modelConfig: RoleModelConfig | null;
enabled: boolean; enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -31,6 +33,11 @@ export interface AgentRoleView {
* role picker needs — deliberately WITHOUT `instructions`, `modelConfig`, * role picker needs — deliberately WITHOUT `instructions`, `modelConfig`,
* creator or timestamps, so non-admins never receive the admin-authored prompt * creator or timestamps, so non-admins never receive the admin-authored prompt
* or the model override. * or the model override.
*
* `autoStart` / `launchMessage` ARE included (unlike instructions/modelConfig):
* the client needs them to decide whether and what to auto-send when a role card
* is picked. `launchMessage` is sent verbatim as a normal user message — it is
* not a secret, so exposing it to members is intentional.
*/ */
export interface AgentRolePickerView { export interface AgentRolePickerView {
id: string; id: string;
@@ -38,6 +45,8 @@ export interface AgentRolePickerView {
emoji: string | null; emoji: string | null;
description: string | null; description: string | null;
enabled: boolean; enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
} }
/** /**
@@ -87,6 +96,9 @@ export class AiAgentRolesService {
instructions, instructions,
modelConfig: modelConfig as Record<string, unknown> | null, modelConfig: modelConfig as Record<string, unknown> | null,
enabled: dto.enabled ?? true, enabled: dto.enabled ?? true,
autoStart: dto.autoStart ?? true,
// Empty/whitespace-only => null (client default launch message).
launchMessage: emptyToNull(dto.launchMessage),
}); });
return this.toView(row); return this.toView(row);
} catch (err) { } catch (err) {
@@ -128,6 +140,12 @@ export class AiAgentRolesService {
| Record<string, unknown> | Record<string, unknown>
| null), | null),
enabled: dto.enabled, enabled: dto.enabled,
autoStart: dto.autoStart,
// undefined => unchanged; '' => clear to null.
launchMessage:
dto.launchMessage === undefined
? undefined
: emptyToNull(dto.launchMessage),
}); });
} catch (err) { } catch (err) {
throw rethrowDuplicateName(err, dto.name?.trim() || existing.name); throw rethrowDuplicateName(err, dto.name?.trim() || existing.name);
@@ -156,12 +174,18 @@ export class AiAgentRolesService {
instructions: row.instructions, instructions: row.instructions,
modelConfig: (row.modelConfig ?? null) as RoleModelConfig | null, modelConfig: (row.modelConfig ?? null) as RoleModelConfig | null,
enabled: row.enabled, enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
} }
/** Non-admin picker view: id/name/emoji/description/enabled only. */ /**
* Non-admin picker view: id/name/emoji/description/enabled plus the auto-start
* fields the client needs to decide whether/what to send on role pick. Still
* WITHOUT instructions/modelConfig (admin-only).
*/
private toPickerView(row: AiAgentRole): AgentRolePickerView { private toPickerView(row: AiAgentRole): AgentRolePickerView {
return { return {
id: row.id, id: row.id,
@@ -169,6 +193,8 @@ export class AiAgentRolesService {
emoji: row.emoji ?? null, emoji: row.emoji ?? null,
description: row.description ?? null, description: row.description ?? null,
enabled: row.enabled, enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
}; };
} }
} }

View File

@@ -78,4 +78,32 @@ describe('CreateAgentRoleDto with nested modelConfig', () => {
}); });
expect(errors.length).toBeGreaterThan(0); expect(errors.length).toBeGreaterThan(0);
}); });
it('accepts autoStart:false + a launchMessage', () => {
expect(
validateCreate({ ...base, autoStart: false, launchMessage: 'Go' }),
).toHaveLength(0);
});
it('rejects a non-boolean autoStart', () => {
const errors = validateCreate({ ...base, autoStart: 'yes' });
expect(errors.some((e) => e.property === 'autoStart')).toBe(true);
});
it('rejects a launchMessage longer than 2000 chars', () => {
const errors = validateCreate({
...base,
launchMessage: 'a'.repeat(2001),
});
expect(errors.some((e) => e.property === 'launchMessage')).toBe(true);
});
it('trims surrounding whitespace from launchMessage', () => {
const dto = plainToInstance(CreateAgentRoleDto, {
...base,
launchMessage: ' Look here ',
});
expect(validateSync(dto as object)).toHaveLength(0);
expect(dto.launchMessage).toBe('Look here');
});
}); });

View File

@@ -65,6 +65,22 @@ export class CreateAgentRoleDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
// Whether picking this role auto-sends a launch message and starts the chat.
// Omitted => default true (preserves the previous always-auto-start behavior).
@IsOptional()
@IsBoolean()
autoStart?: boolean;
// Optional custom auto-start text. Trimmed at the boundary (like chatModel);
// empty/whitespace-only => the client falls back to its default launch message.
@IsOptional()
@Transform(({ value }: TransformFnParams) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MaxLength(2000)
launchMessage?: string;
} }
/** Admin update payload for an agent role (all fields optional). */ /** Admin update payload for an agent role (all fields optional). */
@@ -98,4 +114,19 @@ export class UpdateAgentRoleDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
// Whether picking this role auto-sends a launch message and starts the chat.
@IsOptional()
@IsBoolean()
autoStart?: boolean;
// Optional custom auto-start text. Trimmed at the boundary (like chatModel);
// empty/whitespace-only => the client falls back to its default launch message.
@IsOptional()
@Transform(({ value }: TransformFnParams) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MaxLength(2000)
launchMessage?: string;
} }

View File

@@ -0,0 +1,29 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Per-role control over the new-chat auto-start behavior. Previously picking a
// role card ALWAYS sent a hardcoded launch message and started the dialog.
// These two columns make that configurable per role.
await db.schema
.alterTable('ai_agent_roles')
// When true (default), picking the role auto-sends a launch message and
// starts the conversation; when false the client only binds the role and
// reveals the composer (nothing is sent). Default true => existing roles
// keep their previous behavior.
.addColumn('auto_start', 'boolean', (col) => col.notNull().defaultTo(true))
// Optional custom text sent on auto-start instead of the built-in default.
// NULL/empty => the client falls back to its default launch message.
.addColumn('launch_message', 'text', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('ai_agent_roles')
.dropColumn('launch_message')
.execute();
await db.schema
.alterTable('ai_agent_roles')
.dropColumn('auto_start')
.execute();
}

View File

@@ -49,3 +49,81 @@ describe('AiAgentRoleRepo.findLiveEnabled', () => {
expect(await repo.findLiveEnabled('r-1', 'ws-1')).toBeUndefined(); expect(await repo.findLiveEnabled('r-1', 'ws-1')).toBeUndefined();
}); });
}); });
/**
* Column-threading tests for the auto-start feature: insert defaults autoStart to
* true and stores an empty launchMessage as null; update only sets a column when
* the patch field is present, and clears launchMessage to null on empty string.
*/
describe('AiAgentRoleRepo insert/update auto-start columns', () => {
function makeInsertRepo() {
const values = jest.fn();
const builder = {
values: jest.fn((v: unknown) => {
values(v);
return builder;
}),
returningAll: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue({}),
};
const db = {
insertInto: jest.fn(() => builder),
} as unknown as KyselyDB;
return { repo: new AiAgentRoleRepo(db), values };
}
function makeUpdateRepo() {
const set = jest.fn();
const builder = {
set: jest.fn((s: unknown) => {
set(s);
return builder;
}),
where: jest.fn(() => builder),
execute: jest.fn().mockResolvedValue(undefined),
};
const db = {
updateTable: jest.fn(() => builder),
} as unknown as KyselyDB;
return { repo: new AiAgentRoleRepo(db), set };
}
it('insert defaults autoStart to true and stores empty launchMessage as null', async () => {
const { repo, values } = makeInsertRepo();
await repo.insert({
workspaceId: 'ws-1',
name: 'R',
instructions: 'do',
launchMessage: '',
});
const v = values.mock.calls[0][0];
expect(v.autoStart).toBe(true);
expect(v.launchMessage).toBeNull();
});
it('insert threads autoStart:false and a launchMessage', async () => {
const { repo, values } = makeInsertRepo();
await repo.insert({
workspaceId: 'ws-1',
name: 'R',
instructions: 'do',
autoStart: false,
launchMessage: 'Go',
});
const v = values.mock.calls[0][0];
expect(v.autoStart).toBe(false);
expect(v.launchMessage).toBe('Go');
});
it('update omits unchanged columns; clears launchMessage to null on empty', async () => {
const { repo, set } = makeUpdateRepo();
await repo.update('r-1', 'ws-1', { autoStart: false });
expect(set.mock.calls[0][0].autoStart).toBe(false);
expect('launchMessage' in set.mock.calls[0][0]).toBe(false);
const { repo: repo2, set: set2 } = makeUpdateRepo();
await repo2.update('r-1', 'ws-1', { launchMessage: '' });
expect(set2.mock.calls[0][0].launchMessage).toBeNull();
expect('autoStart' in set2.mock.calls[0][0]).toBe(false);
});
});

View File

@@ -76,6 +76,9 @@ export class AiAgentRoleRepo {
instructions: string; instructions: string;
modelConfig?: ModelConfigValue; modelConfig?: ModelConfigValue;
enabled?: boolean; enabled?: boolean;
autoStart?: boolean;
// null/'' => stored as null (client default launch message).
launchMessage?: string | null;
}, },
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<AiAgentRole> { ): Promise<AiAgentRole> {
@@ -91,6 +94,9 @@ export class AiAgentRoleRepo {
instructions: values.instructions, instructions: values.instructions,
modelConfig: jsonbObject(values.modelConfig), modelConfig: jsonbObject(values.modelConfig),
enabled: values.enabled ?? true, enabled: values.enabled ?? true,
autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null.
launchMessage: values.launchMessage ? values.launchMessage : null,
}) })
.returningAll() .returningAll()
.executeTakeFirst(); .executeTakeFirst();
@@ -108,6 +114,9 @@ export class AiAgentRoleRepo {
// undefined => unchanged; null => clear; object => set. // undefined => unchanged; null => clear; object => set.
modelConfig?: ModelConfigValue; modelConfig?: ModelConfigValue;
enabled?: boolean; enabled?: boolean;
autoStart?: boolean;
// undefined => unchanged; null/'' => clear to null; string => set.
launchMessage?: string | null;
}, },
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<void> { ): Promise<void> {
@@ -121,6 +130,11 @@ export class AiAgentRoleRepo {
set.modelConfig = jsonbObject(patch.modelConfig); set.modelConfig = jsonbObject(patch.modelConfig);
} }
if (patch.enabled !== undefined) set.enabled = patch.enabled; if (patch.enabled !== undefined) set.enabled = patch.enabled;
if (patch.autoStart !== undefined) set.autoStart = patch.autoStart;
if (patch.launchMessage !== undefined) {
// Empty string clears to null (client default launch message).
set.launchMessage = patch.launchMessage ? patch.launchMessage : null;
}
await db await db
.updateTable('aiAgentRoles') .updateTable('aiAgentRoles')
.set(set) .set(set)

View File

@@ -601,6 +601,11 @@ export interface AiAgentRoles {
// { chatModel } | { driver, chatModel } | null. null => workspace default. // { chatModel } | { driver, chatModel } | null. null => workspace default.
modelConfig: Json | null; modelConfig: Json | null;
enabled: Generated<boolean>; enabled: Generated<boolean>;
// When true (default), picking the role auto-sends a launch message and starts
// the new chat; when false the client only binds the role and shows the composer.
autoStart: Generated<boolean>;
// Optional custom auto-start text. null/empty => client default launch message.
launchMessage: string | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null; deletedAt: Timestamp | null;