diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index a4dd886b..1b3f54ab 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -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",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index ca14b406..875e5493 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -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…": "Думаю…",
diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx
index b5dc6d48..8cd69484 100644
--- a/apps/client/src/features/ai-chat/components/chat-thread.tsx
+++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx
@@ -21,6 +21,10 @@ import {
IAiChatMessageRow,
IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
+import {
+ roleLaunchMessage,
+ shouldResetRolePicked,
+} from "@/features/ai-chat/utils/role-launch.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import {
@@ -315,16 +319,45 @@ 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") });
+ const launch = roleLaunchMessage(
+ role,
+ t("Take a look at the current document"),
+ );
+ if (launch !== null) {
+ sendMessage({ text: launch });
+ } else {
+ // autoStart=false -> bind only: hide the cards, show the composer.
+ 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 (shouldResetRolePicked(chatId, roleId, rolePickedNoSend)) {
+ setRolePickedNoSend(false);
+ }
+ const showRoleCards =
+ chatId === null && (roles?.length ?? 0) > 0 && !rolePickedNoSend;
const roleCardsEmptyState = showRoleCards ? (
) : undefined;
diff --git a/apps/client/src/features/ai-chat/components/role-cards.test.tsx b/apps/client/src/features/ai-chat/components/role-cards.test.tsx
index d8213d8d..af3f4dd2 100644
--- a/apps/client/src/features/ai-chat/components/role-cards.test.tsx
+++ b/apps/client/src/features/ai-chat/components/role-cards.test.tsx
@@ -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,
},
];
diff --git a/apps/client/src/features/ai-chat/types/ai-chat.types.ts b/apps/client/src/features/ai-chat/types/ai-chat.types.ts
index f4b0ccb6..db43f674 100644
--- a/apps/client/src/features/ai-chat/types/ai-chat.types.ts
+++ b/apps/client/src/features/ai-chat/types/ai-chat.types.ts
@@ -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;
}
/**
diff --git a/apps/client/src/features/ai-chat/utils/role-launch.test.ts b/apps/client/src/features/ai-chat/utils/role-launch.test.ts
new file mode 100644
index 00000000..22d36906
--- /dev/null
+++ b/apps/client/src/features/ai-chat/utils/role-launch.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from "vitest";
+import { roleLaunchMessage, shouldResetRolePicked } from "./role-launch.ts";
+
+const DEFAULT = "Take a look at the current document";
+
+// Covers the three-way handleRolePick behavior (issue #149) without mounting the
+// chat-thread component — the logic lives in these pure helpers.
+describe("roleLaunchMessage", () => {
+ it("autoStart=true + custom launchMessage -> the trimmed custom text", () => {
+ expect(
+ roleLaunchMessage(
+ { autoStart: true, launchMessage: " Draft a plan " },
+ DEFAULT,
+ ),
+ ).toBe("Draft a plan");
+ });
+
+ it("autoStart=true + empty launchMessage -> the default fallback", () => {
+ expect(
+ roleLaunchMessage({ autoStart: true, launchMessage: "" }, DEFAULT),
+ ).toBe(DEFAULT);
+ });
+
+ it("autoStart=true + whitespace-only launchMessage -> the default fallback", () => {
+ expect(
+ roleLaunchMessage({ autoStart: true, launchMessage: " " }, DEFAULT),
+ ).toBe(DEFAULT);
+ });
+
+ it("autoStart=true + null launchMessage -> the default fallback", () => {
+ expect(
+ roleLaunchMessage({ autoStart: true, launchMessage: null }, DEFAULT),
+ ).toBe(DEFAULT);
+ });
+
+ it("autoStart=false -> null (bind only, send nothing) regardless of message", () => {
+ expect(
+ roleLaunchMessage(
+ { autoStart: false, launchMessage: "ignored" },
+ DEFAULT,
+ ),
+ ).toBeNull();
+ expect(
+ roleLaunchMessage({ autoStart: false, launchMessage: null }, DEFAULT),
+ ).toBeNull();
+ });
+});
+
+// Regression guard for #149: the "picked, not sent" flag must reset when the
+// user starts a fresh chat after an autoStart=false pick. On pre-fix code there
+// was no reset, so the flag stayed stuck and the role cards never returned —
+// this is exactly the `true` case below (which the old code never acted on).
+describe("shouldResetRolePicked", () => {
+ it("resets when the thread is empty and the bound role was cleared (New chat)", () => {
+ // chatId still null, roleId cleared by the parent, flag stuck -> reset.
+ expect(shouldResetRolePicked(null, null, true)).toBe(true);
+ expect(shouldResetRolePicked(null, undefined, true)).toBe(true);
+ });
+
+ it("does NOT reset while a role is still bound (cards stay hidden, composer shown)", () => {
+ // Right after the autoStart=false pick, roleId is the picked role -> keep hidden.
+ expect(shouldResetRolePicked(null, "role-1", true)).toBe(false);
+ });
+
+ it("does NOT reset once the chat exists (a message was sent / chat created)", () => {
+ expect(shouldResetRolePicked("chat-1", null, true)).toBe(false);
+ });
+
+ it("is a no-op when the flag is already false", () => {
+ expect(shouldResetRolePicked(null, null, false)).toBe(false);
+ });
+});
diff --git a/apps/client/src/features/ai-chat/utils/role-launch.ts b/apps/client/src/features/ai-chat/utils/role-launch.ts
new file mode 100644
index 00000000..48ffdfa3
--- /dev/null
+++ b/apps/client/src/features/ai-chat/utils/role-launch.ts
@@ -0,0 +1,34 @@
+import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
+
+/**
+ * Decide what (if anything) to auto-send when an agent role card is picked
+ * (issue #149). Extracted as a pure function so the three-way behavior is
+ * unit-testable without mounting the chat-thread component:
+ * - autoStart=false -> null (bind the role only, send nothing)
+ * - autoStart=true + message -> the trimmed custom launchMessage
+ * - autoStart=true + empty/null -> the default fallback text
+ */
+export function roleLaunchMessage(
+ role: Pick,
+ defaultText: string,
+): string | null {
+ if (!role.autoStart) return null;
+ return role.launchMessage?.trim() || defaultText;
+}
+
+/**
+ * Whether the "role picked but nothing sent yet" flag (`rolePickedNoSend`)
+ * should reset to false. After an autoStart=false pick the thread shows the
+ * composer with chatId still null; when the user then starts a fresh chat the
+ * parent clears the bound role (roleId -> null) but chatId stays null, so the
+ * thread never remounts and the flag would otherwise stay set — hiding the role
+ * cards forever. Reset exactly in that state; a still-bound role (roleId set)
+ * keeps the cards hidden. (Regression guard for #149.)
+ */
+export function shouldResetRolePicked(
+ chatId: string | null,
+ roleId: string | null | undefined,
+ rolePickedNoSend: boolean,
+): boolean {
+ return chatId === null && roleId == null && rolePickedNoSend;
+}
diff --git a/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx b/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx
index afbb4f59..86b29aec 100644
--- a/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx
+++ b/apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx
@@ -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;
@@ -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({
)}
+
+ form.setFieldValue("autoStart", event.currentTarget.checked)
+ }
+ />
+
+
+
{
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;
- // 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 () => {
diff --git a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts
index ed402a9f..f49e33f8 100644
--- a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts
+++ b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts
@@ -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 | 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
| 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,
};
}
}
diff --git a/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.spec.ts b/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.spec.ts
index bb063dc1..51c56769 100644
--- a/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.spec.ts
+++ b/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.spec.ts
@@ -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');
+ });
});
diff --git a/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.ts b/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.ts
index 9aff4c36..620558fd 100644
--- a/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.ts
+++ b/apps/server/src/core/ai-chat/roles/dto/agent-role.dto.ts
@@ -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;
}
diff --git a/apps/server/src/database/migrations/20260624T120000-ai-agent-roles-autostart.ts b/apps/server/src/database/migrations/20260624T120000-ai-agent-roles-autostart.ts
new file mode 100644
index 00000000..464dd8c1
--- /dev/null
+++ b/apps/server/src/database/migrations/20260624T120000-ai-agent-roles-autostart.ts
@@ -0,0 +1,29 @@
+import { type Kysely } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ // 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): Promise {
+ await db.schema
+ .alterTable('ai_agent_roles')
+ .dropColumn('launch_message')
+ .execute();
+ await db.schema
+ .alterTable('ai_agent_roles')
+ .dropColumn('auto_start')
+ .execute();
+}
diff --git a/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.spec.ts b/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.spec.ts
index 91a3ffad..723c7627 100644
--- a/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.spec.ts
+++ b/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.spec.ts
@@ -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);
+ });
+});
diff --git a/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts b/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts
index 1d44c776..fb950585 100644
--- a/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts
+++ b/apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts
@@ -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 {
@@ -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 || 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 {
@@ -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 || null;
+ }
await db
.updateTable('aiAgentRoles')
.set(set)
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 8dd97b54..8574d613 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -601,6 +601,11 @@ export interface AiAgentRoles {
// { chatModel } | { driver, chatModel } | null. null => workspace default.
modelConfig: Json | null;
enabled: Generated;
+ // 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;
+ // Optional custom auto-start text. null/empty => client default launch message.
+ launchMessage: string | null;
createdAt: Generated;
updatedAt: Generated;
deletedAt: Timestamp | null;