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.",
|
"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",
|
||||||
|
|||||||
@@ -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…": "Думаю…",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
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;
|
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)
|
||||||
|
|||||||
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.
|
// { 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user