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:
@@ -1266,6 +1266,10 @@
|
||||
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
|
||||
"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.",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -677,6 +677,10 @@
|
||||
"Ask AI": "Спросить ИИ",
|
||||
"AI agent": "AI-агент",
|
||||
"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-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Thinking…": "Думаю…",
|
||||
|
||||
@@ -315,16 +315,44 @@ export default function ChatThread({
|
||||
// of a generic "Something went wrong".
|
||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||
|
||||
// Clicking a role card both binds the role to THIS new chat and immediately
|
||||
// starts the conversation. 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.
|
||||
// A role was picked with autoStart=false: the role is bound but NOTHING was
|
||||
// sent, so chatId stays null and the empty state would keep showing the cards.
|
||||
// This flag hides the cards and reveals the composer (with the role indicated)
|
||||
// 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 => {
|
||||
roleIdRef.current = role.id;
|
||||
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 ? (
|
||||
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
|
||||
) : undefined;
|
||||
|
||||
@@ -13,6 +13,8 @@ const roles: IAiRole[] = [
|
||||
emoji: "🏴☠️",
|
||||
description: "Talks like a pirate",
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
@@ -20,6 +22,8 @@ const roles: IAiRole[] = [
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@ export interface IAiRole {
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
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;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -65,6 +69,8 @@ export interface IAiRoleCreate {
|
||||
instructions: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
launchMessage?: string;
|
||||
}
|
||||
|
||||
/** Admin update payload for a role (partial). */
|
||||
@@ -76,6 +82,8 @@ export interface IAiRoleUpdate {
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
launchMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,6 +53,8 @@ const formSchema = z.object({
|
||||
driver: z.enum(["", ...AI_DRIVER_VALUES]),
|
||||
chatModel: z.string(),
|
||||
enabled: z.boolean(),
|
||||
autoStart: z.boolean(),
|
||||
launchMessage: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -83,6 +85,8 @@ export default function AiAgentRoleForm({
|
||||
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
|
||||
chatModel: role?.modelConfig?.chatModel ?? "",
|
||||
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"],
|
||||
chatModel: role?.modelConfig?.chatModel ?? "",
|
||||
enabled: role?.enabled ?? true,
|
||||
autoStart: role?.autoStart ?? true,
|
||||
launchMessage: role?.launchMessage ?? "",
|
||||
});
|
||||
form.resetDirty();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -122,6 +128,8 @@ export default function AiAgentRoleForm({
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
enabled: values.enabled,
|
||||
autoStart: values.autoStart,
|
||||
launchMessage: values.launchMessage,
|
||||
};
|
||||
await updateMutation.mutateAsync(payload);
|
||||
} else {
|
||||
@@ -132,6 +140,10 @@ export default function AiAgentRoleForm({
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
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);
|
||||
}
|
||||
@@ -195,6 +207,28 @@ export default function AiAgentRoleForm({
|
||||
)}
|
||||
</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
|
||||
label={t("Enabled")}
|
||||
checked={form.values.enabled}
|
||||
|
||||
@@ -25,6 +25,8 @@ describe('AiAgentRolesService guards', () => {
|
||||
instructions: 'be a researcher',
|
||||
modelConfig: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...over,
|
||||
@@ -159,6 +161,8 @@ describe('AiAgentRolesService guards', () => {
|
||||
instructions: 'updated instructions',
|
||||
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
|
||||
enabled: false,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
@@ -186,6 +190,35 @@ describe('AiAgentRolesService guards', () => {
|
||||
expect(patch2.emoji).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', () => {
|
||||
@@ -319,6 +352,40 @@ describe('AiAgentRolesService guards', () => {
|
||||
} as CreateAgentRoleDto),
|
||||
).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)', () => {
|
||||
@@ -349,19 +416,25 @@ describe('AiAgentRolesService guards', () => {
|
||||
const list = await service.list('ws-1', false);
|
||||
expect(list).toHaveLength(1);
|
||||
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({
|
||||
id: 'r1',
|
||||
name: 'Researcher',
|
||||
emoji: '🔬',
|
||||
description: 'finds things',
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
});
|
||||
// ...and the admin-only fields are absent (not just undefined).
|
||||
expect('instructions' in item).toBe(false);
|
||||
expect('modelConfig' in item).toBe(false);
|
||||
expect('createdAt' 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 () => {
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface AgentRoleView {
|
||||
instructions: string;
|
||||
modelConfig: RoleModelConfig | null;
|
||||
enabled: boolean;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -31,6 +33,11 @@ export interface AgentRoleView {
|
||||
* role picker needs — deliberately WITHOUT `instructions`, `modelConfig`,
|
||||
* creator or timestamps, so non-admins never receive the admin-authored prompt
|
||||
* 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 {
|
||||
id: string;
|
||||
@@ -38,6 +45,8 @@ export interface AgentRolePickerView {
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +96,9 @@ export class AiAgentRolesService {
|
||||
instructions,
|
||||
modelConfig: modelConfig as Record<string, unknown> | null,
|
||||
enabled: dto.enabled ?? true,
|
||||
autoStart: dto.autoStart ?? true,
|
||||
// Empty/whitespace-only => null (client default launch message).
|
||||
launchMessage: emptyToNull(dto.launchMessage),
|
||||
});
|
||||
return this.toView(row);
|
||||
} catch (err) {
|
||||
@@ -128,6 +140,12 @@ export class AiAgentRolesService {
|
||||
| Record<string, unknown>
|
||||
| null),
|
||||
enabled: dto.enabled,
|
||||
autoStart: dto.autoStart,
|
||||
// undefined => unchanged; '' => clear to null.
|
||||
launchMessage:
|
||||
dto.launchMessage === undefined
|
||||
? undefined
|
||||
: emptyToNull(dto.launchMessage),
|
||||
});
|
||||
} catch (err) {
|
||||
throw rethrowDuplicateName(err, dto.name?.trim() || existing.name);
|
||||
@@ -156,12 +174,18 @@ export class AiAgentRolesService {
|
||||
instructions: row.instructions,
|
||||
modelConfig: (row.modelConfig ?? null) as RoleModelConfig | null,
|
||||
enabled: row.enabled,
|
||||
autoStart: row.autoStart,
|
||||
launchMessage: row.launchMessage ?? null,
|
||||
createdAt: row.createdAt,
|
||||
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 {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -169,6 +193,8 @@ export class AiAgentRolesService {
|
||||
emoji: row.emoji ?? null,
|
||||
description: row.description ?? null,
|
||||
enabled: row.enabled,
|
||||
autoStart: row.autoStart,
|
||||
launchMessage: row.launchMessage ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,4 +78,32 @@ describe('CreateAgentRoleDto with nested modelConfig', () => {
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,22 @@ export class CreateAgentRoleDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
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). */
|
||||
@@ -98,4 +114,19 @@ export class UpdateAgentRoleDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -49,3 +49,81 @@ describe('AiAgentRoleRepo.findLiveEnabled', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,9 @@ export class AiAgentRoleRepo {
|
||||
instructions: string;
|
||||
modelConfig?: ModelConfigValue;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
// null/'' => stored as null (client default launch message).
|
||||
launchMessage?: string | null;
|
||||
},
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<AiAgentRole> {
|
||||
@@ -91,6 +94,9 @@ export class AiAgentRoleRepo {
|
||||
instructions: values.instructions,
|
||||
modelConfig: jsonbObject(values.modelConfig),
|
||||
enabled: values.enabled ?? true,
|
||||
autoStart: values.autoStart ?? true,
|
||||
// Empty string is treated as "no custom text" => null.
|
||||
launchMessage: values.launchMessage ? values.launchMessage : null,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
@@ -108,6 +114,9 @@ export class AiAgentRoleRepo {
|
||||
// undefined => unchanged; null => clear; object => set.
|
||||
modelConfig?: ModelConfigValue;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
// undefined => unchanged; null/'' => clear to null; string => set.
|
||||
launchMessage?: string | null;
|
||||
},
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
@@ -121,6 +130,11 @@ export class AiAgentRoleRepo {
|
||||
set.modelConfig = jsonbObject(patch.modelConfig);
|
||||
}
|
||||
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
|
||||
.updateTable('aiAgentRoles')
|
||||
.set(set)
|
||||
|
||||
5
apps/server/src/database/types/db.d.ts
vendored
5
apps/server/src/database/types/db.d.ts
vendored
@@ -601,6 +601,11 @@ export interface AiAgentRoles {
|
||||
// { chatModel } | { driver, chatModel } | null. null => workspace default.
|
||||
modelConfig: Json | null;
|
||||
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>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
|
||||
Reference in New Issue
Block a user