From 57d69d433d625b20024b01bbc0e82218d23b3101 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Thu, 22 Oct 2026 15:14:53 +0800 Subject: [PATCH] feat: nothing starts a turn when a team is created (#645) Creating a Bot or a group chat sends nothing on its own: no kickoff, no greeting round and no first-request cue. A line the user typed in the composer before creating a Bot is sent to it as one user turn, so the Bot answers it, and it never becomes the Bot's instructions: a brief is instructions only when another Bot creates the Bot. A picked model is applied without a turn, and a Bot's first turn is otherwise a line the user writes to it. Tests cover the three-Bot team, the group, the composer line and a kickoff line in an older transcript. Docs-not-needed: core/dev/smoke.mjs only follows the silent-creation behaviour; docs/subsystems/ds-bot-core.md describes what the script is for, not its bootstrap steps, and the behaviour itself is in core-host.md and core-client.md, both updated here. --- changelog.d/645-silent-creation.changed.md | 1 + core/client.js | 20 +- core/dev/smoke.mjs | 10 +- core/src/client/bot-draft.js | 7 +- core/src/client/cells.js | 2 +- core/src/client/create-bot.js | 6 +- core/src/client/group-settings.js | 2 +- core/src/client/model-menu.js | 2 +- core/src/client/new-chat.js | 12 +- core/src/client/text.js | 2 +- core/src/host/channel.js | 5 +- core/src/host/delivery.js | 1 - core/src/host/groups.js | 39 +-- core/src/host/hooks.js | 4 +- core/src/host/models.js | 4 +- core/src/host/parts.js | 6 +- core/src/host/remote-chat.js | 2 +- core/src/host/roster.js | 70 ++---- core/src/host/tools.js | 4 +- core/src/host/turns.js | 8 +- core/test/approval-notices.test.js | 1 - core/test/blocking-question.test.js | 25 +- core/test/bot-messages.test.js | 1 - core/test/create-bot.test.js | 9 +- core/test/diagnostics.test.js | 1 - core/test/flybot-feed.test.js | 1 - core/test/history.test.js | 1 + core/test/models.test.js | 2 +- core/test/new-chat.test.js | 13 + core/test/roster.test.js | 138 ++++------- core/test/rounds.test.js | 21 +- core/test/tool-node.test.js | 3 +- core/test/work-dir.test.js | 2 +- docs/subsystems/core-client.md | 4 + docs/subsystems/core-host.md | 5 +- docs/subsystems/plugin-runtime.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 125 +++++++++- patches/ds-bot/0002-flybot-api-host.patch | 198 ++++++++++++--- patches/ds-bot/0003-flybot-api-client.patch | 84 ++++++- patches/ds-bot/0005-social-seams.patch | 80 +++++-- patches/ds-bot/0007-version-and-tooling.patch | 31 +++ patches/ds-bot/0008-client-bundle.patch | 101 +++++--- patches/ds-bot/0009-identity-schema.patch | 226 +++++++++++++++++- patches/ds-bot/0012-room-membership.patch | 108 ++++++--- patches/ds-bot/0014-flybot-branding.patch | 13 + patches/ds-bot/files/src/host/hooks.js | 4 +- patches/ds-bot/files/src/host/remote-chat.js | 2 +- patches/ds-bot/files/test/flybot-feed.test.js | 1 - patches/ds-bot/files/test/new-chat.test.js | 13 + patches/ds-bot/topics.json | 8 +- 50 files changed, 1021 insertions(+), 409 deletions(-) create mode 100644 changelog.d/645-silent-creation.changed.md diff --git a/changelog.d/645-silent-creation.changed.md b/changelog.d/645-silent-creation.changed.md new file mode 100644 index 000000000..43f185f77 --- /dev/null +++ b/changelog.d/645-silent-creation.changed.md @@ -0,0 +1 @@ +Making a Bot or a group chat no longer starts a turn: a new Bot is sent no greeting and waits in the sidebar until you write to it or a parent Bot messages it, a new group posts no opening round, and picking a Bot's model applies it without speaking to the Bot. Text you type in the composer before creating a Bot is sent to it as your first message, so it answers you, and it is never saved as the Bot's instructions; making a team costs no model turn ([#645](https://github.com/ThinkFlowLab/FlyBot/issues/645)). diff --git a/core/client.js b/core/client.js index fc5e426a1..eb3832f72 100644 --- a/core/client.js +++ b/core/client.js @@ -5806,7 +5806,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us } if (source.kind === "bot") { if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react8.createElement)(EarlierParts, { sessionId, roster, actions }); - if (source.role === "kickoff" || source.role === "room") return null; + if (source.role === "room") return null; if (source.role === "mention") return (0, import_react8.createElement)("div", { className: "bt-event" }, (0, import_react8.createElement)("span", null, contentText(data.content))); if (source.role === "secret" || deferred) return null; if (source.role === "post") { @@ -11306,11 +11306,10 @@ function startModel(roster) { const main = roster.byId?.[roster.mainBotId] ?? (roster.bots ?? []).find((bot) => bot.id === roster.mainBotId); return (served(main?.model) ?? served(main?.appliedModel) ?? served(roster.defaultModel) ?? models[0])?.ref ?? ""; } -function createPayload({ name, look, image, model, effort, brief }) { +function createPayload({ name, look, image, model, effort }) { const payload = { name, color: look.color, avatar: image ? { shape: look.shape, image } : { shape: look.shape } }; if (model) payload.model = model; if (effort) payload.effort = effort; - if (brief) payload.brief = brief; return payload; } @@ -11322,7 +11321,7 @@ var randomLook = (avoid = {}) => { const colors = DEFAULT_COLORS.filter((color) => color !== avoid.color); return { shape: pick(shapes), color: pick(colors) }; }; -function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCreated, inline = false }) { +function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inline = false }) { const [look, setLook] = (0, import_react26.useState)(() => randomLook()); const [image, setImage] = (0, import_react26.useState)(null); const [name, setName] = (0, import_react26.useState)(start?.name ?? ""); @@ -11345,8 +11344,8 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr setError(""); setBusy(true); try { - const bot = await actions.createBot(createPayload({ name: finalName, look, image, model, effort, brief })); - onCreated(bot); + const bot = await actions.createBot(createPayload({ name: finalName, look, image, model, effort })); + await onCreated(bot); } catch (failure) { setError(failure?.message ?? String(failure)); setBusy(false); @@ -11561,7 +11560,9 @@ function NewChat({ mode, roster, actions }) { setError(""); setDraft({ name: query.trim() !== "" && !exact ? query.trim() : "", fromList: true }); }; - const created = (bot) => { + const created = async (bot) => { + const text = message.trim(); + if (text) await actions.send(bot.id, text); close(); actions.openSession(bot.id); actions.showDetails(bot.id, "settings"); @@ -11574,7 +11575,7 @@ function NewChat({ mode, roster, actions }) { ...members.map((id) => ({ kind: "bot", id, role: id === admin ? "admin" : "member" })), ...remotes ]; - const room2 = await actions.createRoom(members, { admin, ...participants ? { participants } : {}, ...text ? {} : { greet: true } }); + const room2 = await actions.createRoom(members, { admin, ...participants ? { participants } : {} }); if (text) await actions.send(room2.id, text); actions.openSession(room2.id); close(); @@ -11662,7 +11663,6 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); roster, actions, start: draft, - brief: message.trim(), canGoBack: draft.fromList, onBack: draft.fromList ? () => { setDraft(null); @@ -12466,7 +12466,7 @@ function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { if (busy || admin === null || members.length === 0) return; setBusy(true); let room2; - const done = await run(actions.createRoom([admin, ...members], { admin, greet: true, ...name.trim() ? { name: name.trim() } : {} }).then((value) => { + const done = await run(actions.createRoom([admin, ...members], { admin, ...name.trim() ? { name: name.trim() } : {} }).then((value) => { room2 = value; })); if (done && room2) onCreated(room2); diff --git a/core/dev/smoke.mjs b/core/dev/smoke.mjs index 140165e9d..313bafcb1 100644 --- a/core/dev/smoke.mjs +++ b/core/dev/smoke.mjs @@ -169,18 +169,16 @@ await step('bootstrap: Chief waits for a model', () => api('state'), (value, cal const bots = Object.values(current.bots) models = value.models ?? [] return expect(bots.length === 1 && bots[0].name === 'Chief' && current.mainBotIds[0] === bots[0].id, 'expected one Main Bot named Chief') - ?? expect(bots[0].model === undefined && bots[0].kickoff !== undefined, 'Chief should hold its greeting until a model is picked') - ?? expect(value.bots[0].waiting === true, 'the view should show Chief as waiting') + ?? expect(bots[0].model === undefined, 'Chief should have no model until one is picked') ?? expect(calls.length === 0, `Chief called the model ${calls.length} times before it had one`) ?? expect(models.length > 0, 'no models listed for the model card') }) -await step('user picks a model; Chief greets and asks in its reply', () => api('update-bot', { id: chief().id, model: models.at(-1).ref }), (_value, calls) => { +await step('user picks a model; nothing is sent', () => api('update-bot', { id: chief().id, model: models.at(-1).ref }), (_value, calls) => { const current = state() return expect(chief().model === models.at(-1).ref, `Chief runs on ${chief().model}`) - ?? expect(chief().kickoff === undefined, 'the greeting is still held') - ?? expect(!current.questions?.[chief().sessionId ?? chief().id], 'the greeting should not leave a question card') - ?? expect(saidText(calls, /先让团队帮你做哪块/), 'expected Chief to ask in its reply') + ?? expect(!current.questions?.[chief().sessionId ?? chief().id], 'picking a model should leave no question card') + ?? expect(calls.length === 0, `Chief called the model ${calls.length} times when its model was picked`) }) await step('Chief creates Writer, who replies', () => send(chief().id, '创建一个叫Writer的bot'), () => { diff --git a/core/src/client/bot-draft.js b/core/src/client/bot-draft.js index 53b95238b..d8d3d0c11 100644 --- a/core/src/client/bot-draft.js +++ b/core/src/client/bot-draft.js @@ -19,11 +19,12 @@ export function startModel(roster) { return (served(main?.model) ?? served(main?.appliedModel) ?? served(roster.defaultModel) ?? models[0])?.ref ?? '' } -// The model is always sent: the host fixes it on the Bot at creation. -export function createPayload({ name, look, image, model, effort, brief }) { +// The model is always sent: the host fixes it on the Bot at creation. The card never sends a +// brief: a line typed in the composer is the Bot's first request, and the host takes a brief +// as instructions only from a Bot that creates another (#645). +export function createPayload({ name, look, image, model, effort }) { const payload = { name, color: look.color, avatar: image ? { shape: look.shape, image } : { shape: look.shape } } if (model) payload.model = model if (effort) payload.effort = effort - if (brief) payload.brief = brief return payload } diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 6d62ad31b..4bbaa403a 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -568,7 +568,7 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl // A part opens with its handoff package, so the earlier parts go right above it. if (source.handoff === true) return roster.roomsById[sessionId] ? null : h(EarlierParts, { sessionId, roster, actions }) // Group turns belong to the group chat; the member's own chat stays one-to-one. - if (source.role === 'kickoff' || source.role === 'room') return null + if (source.role === 'room') return null // The host's receipt for the user's @ of a friend's Bot in this group (#80): // where the message went, or why it did not leave. if (source.role === 'mention') return h('div', { className: 'bt-event' }, h('span', null, contentText(data.content))) diff --git a/core/src/client/create-bot.js b/core/src/client/create-bot.js index 282781dc9..0e8015cb5 100644 --- a/core/src/client/create-bot.js +++ b/core/src/client/create-bot.js @@ -18,7 +18,7 @@ const randomLook = (avoid = {}) => { // The new-Bot form: look first, then name and model. Nothing exists until Create. // `inline` drops the pane's header for a form shown inside Settings. -export function CreateBot({ roster, actions, start, brief = '', onBack, canGoBack, onCreated, inline = false }) { +export function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inline = false }) { const [look, setLook] = useState(() => randomLook()) const [image, setImage] = useState(null) const [name, setName] = useState(start?.name ?? '') @@ -40,8 +40,8 @@ export function CreateBot({ roster, actions, start, brief = '', onBack, canGoBac setError('') setBusy(true) try { - const bot = await actions.createBot(createPayload({ name: finalName, look, image, model, effort, brief })) - onCreated(bot) + const bot = await actions.createBot(createPayload({ name: finalName, look, image, model, effort })) + await onCreated(bot) } catch (failure) { setError(failure?.message ?? String(failure)) setBusy(false) diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index 873f61fe3..f92d8d2e7 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -235,7 +235,7 @@ export function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { if (busy || admin === null || members.length === 0) return setBusy(true) let room - const done = await run(actions.createRoom([admin, ...members], { admin, greet: true, ...(name.trim() ? { name: name.trim() } : {}) }).then((value) => { room = value })) + const done = await run(actions.createRoom([admin, ...members], { admin, ...(name.trim() ? { name: name.trim() } : {}) }).then((value) => { room = value })) if (done && room) onCreated(room) else setBusy(false) } diff --git a/core/src/client/model-menu.js b/core/src/client/model-menu.js index 4898d400f..c4273cdce 100644 --- a/core/src/client/model-menu.js +++ b/core/src/client/model-menu.js @@ -184,7 +184,7 @@ export function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onP } // The card a Bot's chat opens on while the Bot has no model, or has one that is no -// longer served. The first Main Bot greets the user only after a pick. +// longer served. The first Main Bot starts no turn until a model is picked. export function ModelChoiceDock({ sessionId, useRoster, actions }) { const roster = useRoster(value => value) const bot = roster.byId[sessionId] diff --git a/core/src/client/new-chat.js b/core/src/client/new-chat.js index b4629abdb..6e2a6120b 100644 --- a/core/src/client/new-chat.js +++ b/core/src/client/new-chat.js @@ -110,9 +110,13 @@ export function NewChat({ mode, roster, actions }) { setError('') setDraft({ name: query.trim() !== '' && !exact ? query.trim() : '', fromList: true }) } - // Close first: closing the new-chat pane keeps the details panel, while closing + // A line typed in the composer was a request, so it is the new Bot's first turn, not its + // instructions (#645): the card is handed no brief, and the line is sent once the Bot + // exists. Close first: closing the new-chat pane keeps the details panel, while closing // nothing would clear it. - const created = (bot) => { + const created = async (bot) => { + const text = message.trim() + if (text) await actions.send(bot.id, text) close() actions.openSession(bot.id) actions.showDetails(bot.id, 'settings') @@ -127,7 +131,7 @@ export function NewChat({ mode, roster, actions }) { ...members.map(id => ({ kind: 'bot', id, role: id === admin ? 'admin' : 'member' })), ...remotes, ] - const room = await actions.createRoom(members, { admin, ...(participants ? { participants } : {}), ...(text ? {} : { greet: true }) }) + const room = await actions.createRoom(members, { admin, ...(participants ? { participants } : {}) }) if (text) await actions.send(room.id, text) actions.openSession(room.id) close() @@ -197,7 +201,7 @@ export function NewChat({ mode, roster, actions }) { if (draft) { return h('div', { className: 'bt-pane', style: { left: paneLeft } }, h(CreateBot, { - roster, actions, start: draft, brief: message.trim(), canGoBack: draft.fromList, + roster, actions, start: draft, canGoBack: draft.fromList, onBack: draft.fromList ? () => { setDraft(null); setTimeout(() => inputRef.current?.focus()) } : close, onCreated: created, })) diff --git a/core/src/client/text.js b/core/src/client/text.js index edfbf9253..cfb1670f9 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -178,7 +178,7 @@ export function toolArgs(root) { } // A relay context is a Bot-to-Bot request, reply or stop notice injected into the -// model's history. ds-bot's own package sources (kickoff, room, secret, handoff +// model's history. ds-bot's own package sources (room, secret, handoff // parts) are not events in the chat, nor is a request handed back after its line was // already shown: deferred, resumed after a restart, or retried after a tool call // written as text. diff --git a/core/src/host/channel.js b/core/src/host/channel.js index a59ee364b..4e53d6ab6 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -124,8 +124,7 @@ export function install(rt) { mainBotId: state.mainBotIds[0] ?? null, mainBotIds: state.mainBotIds, // `sessionId` is the Bot's current own-chat part; `parts` its earlier ones. - // `waiting`: the Bot holds its greeting until the user picks its model. - bots: Object.values(state.bots).map(({ segments, kickoff, ...bot }) => { + bots: Object.values(state.bots).map(({ segments, ...bot }) => { // A friend's chat, and a local Bot's own held sends (#371): the provider says which. const pending = rt.listChatApprovals?.(bot, actor) ?? [] // A local Bot's held replies to friends (#418), for its owner only: drawn on the turns they answer, @@ -141,7 +140,6 @@ export function install(rt) { parts: (segments ?? []).map(segment => segment.sessionId), pendingCount: pending.length + waitingReplies, ...(held.length ? { heldReplies: held } : {}), - ...(kickoff ? { waiting: true } : {}), } }), // `parts`: the members' hidden group Sessions and their earlier segments, for usage. @@ -348,7 +346,6 @@ export function install(rt) { ...(typeof body.admin === 'string' ? { admin: body.admin } : {}), ...(Array.isArray(body.participants) ? { participants: body.participants } : {}), }) - if (body.greet === true) await rt.greetRoom(room.id).catch(error => rt.warn('ds-bot: greeting in %s failed: %s', room.name, error)) return ok(room) } case 'update-room': { diff --git a/core/src/host/delivery.js b/core/src/host/delivery.js index d8ab76dfd..95a0aeedb 100644 --- a/core/src/host/delivery.js +++ b/core/src/host/delivery.js @@ -80,7 +80,6 @@ export function install(rt) { if (role === 'stopped') return `[Stopped by ${fromName}]` if (role === 'room') return `[Group chat "${roomName}" · ${note}]` if (role === 'report') return `[Group chat "${roomName}" · replies to your post]` - if (role === 'kickoff') return '[Bot team setup]' if (role === 'secret') return '[Secret card]' if (audience === 'user') return `[Message from ${fromName} · the user sees your reply here]` return `[Message from ${fromName}]` diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 4f9e7d46d..e528bfcd0 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -175,9 +175,6 @@ const adminNote = room => isFaceRoom(room) ? FACE_LEAD_NOTE : [ '"@all" calls every member. When the user asks, change the group with update_group.', ].filter(Boolean).join(' ') -// The opening turn of a group the user just made without a first message. -const roomKickoff = language => `The user just created this group and has not written yet. Greet the user in one short message and ask what they want this group to work on. Do not call members with @ yet, and do not reply [PASS]. No name prefix. Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.` - const ROOM_READ_MAX = 30 export function install(rt) { @@ -251,7 +248,6 @@ export function install(rt) { const after = round.slots.length - index - 1 const members = `${isFaceRoom(room) ? `${faceRoster(room, member, rt.botOf)}\n` : `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. `}Members speak one at a time; each message is posted before the next speaker starts. ${ROOM_CALLS}` const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : '' - if (round.kickoff) return [members, notice, leads ? adminNote(room) : '', roomKickoff(rt.uiLanguage?.())].filter(Boolean).join('\n\n') const working = workingIn(room).filter(item => item.bot.id !== member.id) return [ members, @@ -317,8 +313,6 @@ export function install(rt) { // Session's inbox, so it does not open another round when this one ends. Returns // the number of turns added, which the round's limit allows for. function drainRoomInbox(room, round, turns, index, agent) { - // The greeting's prompt tells the admin the user has not written yet. - if (round.kickoff) return 0 const lines = waitingLines(agent).filter(line => agent.inbox.remove(line.id)) if (lines.length === 0) return 0 const before = round.slots.length @@ -408,7 +402,7 @@ export function install(rt) { // judged (settled() reads the room at the moment of each call). const roundOrigins = new WeakMap() const postOrigin = (post) => { - if (post.kickoff || post.notice || post.failed || post.resumed || post.stopped || post.botId === 'face-notice') return undefined + if (post.notice || post.failed || post.resumed || post.stopped || post.botId === 'face-notice') return undefined if (post.botId === undefined) return undefined const bot = rt.botOf(post.botId) if (bot === undefined || bot.remote !== undefined) return OTHER @@ -537,19 +531,18 @@ export function install(rt) { rt.roomInbox.delete(room.id) const posts = queued.length > 0 ? queued : [{ text: '' }] const startSeq = room.seq + 1 - // The opening greeting is a cue for the admin, not a line of the group; a member's - // late failure, its work picked up after a restart, or its work the user stopped is - // an event of the round. + // A member's late failure, its work picked up after a restart, or its work the user + // stopped is an event of the round. const event = item => item.failed || item.resumed || item.stopped || item.notice || item.botId === 'face-notice' // A post a running round already showed and logged when it arrived (#455) is not logged again. - for (const post of posts.filter(item => !item.kickoff && !event(item) && !item.shown)) { + for (const post of posts.filter(item => !event(item) && !item.shown)) { // Image readings go into the log only; `@` names are picked from what the user typed. const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n') // A face-room line the send path already logged and sent is not logged or sent again (#287). if (!post.botId && takePosted(room.id, post.text)) continue logPost(room, post, text) } - if (posts.some(post => !post.botId && !post.kickoff)) room.lateRounds = 0 + if (posts.some(post => !post.botId)) room.lateRounds = 0 await rt.save() // An event post never picks the speakers over a message queued with it; a friend's line that asks // this home's Bots to answer is a person's line, not an event (#462). @@ -567,11 +560,9 @@ export function install(rt) { ...(route ? { route } : {}), startSeq, openedSeq: room.seq, - ...(opener.kickoff ? { kickoff: true } : {}), ...(references.length > 0 ? { references, shown: [] } : {}), slots: speakers.map(member => ({ id: member.id, name: member.name, status: 'waiting', - ...(opener.kickoff ? { note: 'the group was just created' } : {}), ...(named.includes(member) ? { by: opener.botId } : {}), })), // One pass: a plain line's speakers do not call each other on (#295), so Bots in two homes cannot loop. @@ -719,9 +710,9 @@ export function install(rt) { // Whether this opener is a person's plain line there: the one that starts a single pass over the // Bots with no Bot-triggered follow-ups inside it (#295). An @ that names a Bot, a Bot's post, a late - // reply and the greeting are not. + // reply is not. function isPlainFaceLine(room, opener) { - if (!isFaceRoom(room) || opener.kickoff || opener.late) return false + if (!isFaceRoom(room) || opener.late) return false // A friend's attested plain line (or @all) is a person's line too, routed and single like the owner's (#462). if (friendLine(opener)) return opener.plain === true if (opener.botId !== undefined) return false @@ -734,7 +725,6 @@ export function install(rt) { const everyone = room.members.map(rt.botOf).filter(Boolean) const members = everyone.filter(bot => bot.id !== opener.botId) const admin = members.find(bot => rt.isAdminOf(room, bot.id)) - if (opener.kickoff) return admin ? [admin] : [] // A friend's own typed line that @-calls Bots here (#287): only those Bots answer. The caller // resolved the names and only passes lines the server attests as typed by that person. if (opener.callIds?.length > 0) return members.filter(bot => opener.callIds.includes(bot.id)) @@ -904,19 +894,6 @@ export function install(rt) { } rt.lateRoomFailure = lateRoomFailure - // A group the user just made opens with its admin asking what the group is for. - async function greetRoom(roomId) { - const room = rt.roomOf(roomId) - if (!room?.admin) return - const agent = await rt.resolveAgent(room.id) - agent.followup(createUserMessage({ - content: [{ type: 'text', text: '[Group chat created]' }], - source: { kind: SOURCE_KIND, form: 'relay', role: 'kickoff', exchangeId: randomUUID(), senderName: 'You', hop: 0, roomId: room.id }, - })) - await ctx.sessions.flush(agent.session) - } - rt.greetRoom = greetRoom - async function reportRound(room, post, round) { const lines = round.replies.filter(reply => reply.kind === undefined).map(reply => `${reply.name}: ${reply.text}`) const waiting = round.replies.filter(reply => reply.kind === 'approval').map(reply => reply.name) @@ -952,7 +929,7 @@ export function install(rt) { // message from the user, a scheduled task, or a Bot's post after the last model or // tool step starts a relay. const opens = message => message.role === 'user' - && (message.source?.kind === 'user' || message.source?.kind === 'schedule' || (message.source?.kind === SOURCE_KIND && (message.source.role === 'post' || message.source.role === 'kickoff'))) + && (message.source?.kind === 'user' || message.source?.kind === 'schedule' || (message.source?.kind === SOURCE_KIND && message.source.role === 'post')) const latest = [...options.messages].reverse() .find(message => message.role === 'assistant' || message.role === 'tool' || opens(message)) if (latest?.role !== 'user') { diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 31afe91e0..94d6dd328 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -23,7 +23,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' export const FLYBOT_API_VERSION = 1 export const SHARED = 'shared' -// Roles that are one Bot talking to another; group rounds, kickoffs, and notices are not. +// Roles that are one Bot talking to another; group rounds and notices are not. const BOT_TO_BOT_ROLES = new Set(['request', 'reply', 'stopped', 'report']) const ACTIONS = new Set(['allow', 'deny', 'hold']) const MAX_ATTACHMENTS = 5 @@ -680,7 +680,7 @@ export function install(rt) { innerDeliver = rt.deliver rt.deliver = async (args) => { // Everything that is not a first, checked send still carries its sender: resumed, retried, - // deferred and re-sent deliveries (and kickoffs, notices) are judged by the owner policy + // deferred and re-sent deliveries (and notices) are judged by the owner policy // now, since the Bot they name may have had its grant revoked, replaced or removed since. if (!gated(args) && !args.flybotChecked && (args.remote?.handle || args.fromId !== undefined)) { const owner = ownerPolicy(args) diff --git a/core/src/host/models.js b/core/src/host/models.js index 221962dfb..d76205fb1 100644 --- a/core/src/host/models.js +++ b/core/src/host/models.js @@ -193,8 +193,8 @@ export function install(rt) { return ref } - // A provider may register after the plugin starts; a greeting waits for its Bot's - // model instead of failing with "no adapter registered". + // A provider may register after the plugin starts; the model is applied once it is + // served instead of failing with "no adapter registered". async function waitForModel(bot, ms = ROUTE_WAIT_MS) { const deadline = Date.now() + ms for (;;) { diff --git a/core/src/host/parts.js b/core/src/host/parts.js index 6be6851bc..96815345f 100644 --- a/core/src/host/parts.js +++ b/core/src/host/parts.js @@ -41,9 +41,9 @@ export function cardText(args) { // What one event adds to a Bot's chat history: an answer of the Bot's (its text, or a // question card), or a message from the user (`who: 'user'`) or another Bot // (`who: 'bot'`) that the answers after it belong to. Context that other plugins append -// (time, attachments) adds nothing. Setup kickoffs, handoff packages, and, in an own -// chat, group turns from before group Sessions existed show no text, and drop the -// answers to them. +// (time, attachments) adds nothing. Handoff packages, setup kickoffs from a team made +// before #645, and, in an own chat, group turns from before group Sessions existed show no +// text, and drop the answers to them. // The user's answer to an ask_user card. The message carries the question with the // answer, so the answer still makes sense after a checkpoint drops the card. export function questionReply(question, answer) { diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index d02213e55..2c17f5488 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -68,7 +68,7 @@ export function notSentLine(error) { * What a model call on a remote chat's route should do, from the request alone: * { kind: 'echo', text } writes a friend's message back as the reply; { kind: 'send', * messageId, text } is a message the user typed in the chat; { kind: 'none' } is anything - * else (a kickoff, a one-off request without a durable message), which writes nothing. + * else (a one-off request without a durable message), which writes nothing. */ // Messages the host appends as role:'user' that the user did not type: context snapshots (the // time context), the AGENTS.md baseline (form 'instructions') and notices (plan mode, tool diff --git a/core/src/host/roster.js b/core/src/host/roster.js index eaefbf547..ae3063180 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -56,12 +56,12 @@ export function install(rt) { return `${stem} ${index}` } - // `pickModel` leaves the model to the user: the chat opens on a model card, and the - // greeting waits until a model is chosen. So does a Bot made when no model is served. - async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, workDir, copyOf, pickModel = false, systemPrompt, fields, remote, ownerId: explicitOwnerId }) { + // `pickModel` leaves the model to the user: the chat opens on a model card. So does a + // Bot made when no model is served. + async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, workDir, pickModel = false, systemPrompt, fields, remote, ownerId: explicitOwnerId }) { const cleanName = String(botName ?? '').trim().slice(0, 40) // A friend's Bot (remote-chat.js) keeps its own name: it is not a team member, so - // team names never clash with it and it gets no greeting. + // team names never clash with it and creating it starts no turn. const isRemote = typeof remote?.handle === 'string' if (!isRemote) { const problem = nameProblem(cleanName) @@ -75,6 +75,10 @@ export function install(rt) { // model, as applyModel does for the maker), set before its first request, so it never // tries an unconfigured profile default first. A friend's Bot is never a maker here. const maker = !model && !pickModel && !isRemote && createdBy ? rt.botOf(createdBy) : undefined + // A brief is a parent's context (`create_bot`), so it becomes instructions only when + // another Bot made this one. A line the user typed in the new-Bot card is their first + // request: the client sends it as a turn, and it is never saved as a standing rule (#645). + const parentBrief = !isRemote && createdBy ? rt.botOf(createdBy) : undefined const inherited = maker && !maker.remote ? await rt.desiredModel(maker) : undefined const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : inherited ?? await rt.defaultModel() const inheritedEffort = effort === undefined && inherited !== undefined ? maker.effort : undefined @@ -97,7 +101,7 @@ export function install(rt) { ...(modelRef ? { model: modelRef } : {}), ...(effortId ? { effort: effortId } : {}), ...(dir ? { workDir: dir } : {}), - instructions: String(instructions).trim(), + instructions: [String(instructions).trim(), parentBrief && brief ? `Why you exist: ${String(brief).trim()}` : ''].filter(Boolean).join('\n\n'), createdAt: Date.now(), createdBy: createdBy ?? null, ownerId, @@ -116,38 +120,18 @@ export function install(rt) { owner.designatedChiefId = id } } - const creator = rt.botOf(createdBy) - const original = rt.botOf(copyOf) - const language = rt.uiLanguage?.() - // The kickoff arrives as a user message, so it outranks the Bot's instructions - // unless it gives way to them, and it names no tool those instructions may rule out. - const kickoff = main - ? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then ask in one short question what they want the team to help with first. Write in ${language ?? 'English'}.` - : original - ? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then ask in one short question what this copy should focus on.` - : creator - ? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then ask in one short question where to start. Keep it brief.` - : brief - ? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.` - : 'The user just created you. Greet the user in one short message, then ask in one short question what you should focus on.' - const opener = `${kickoff}\nWhere your instructions say otherwise, follow them.` - const text = !main && language ? `${opener}\nWrite in ${language}.` : opener const bot = state.bots[id] if (isRemote) { await rt.save() void rt.waitForModel(bot).then(() => rt.applyModel(bot)).catch(error => rt.warn('ds-bot: route for %s not applied: %s', cleanName, error)) return bot } - if (modelRef === undefined) { - bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) } - await rt.save() - return bot - } await rt.save() - // The greeting runs on the Bot's own model, once its provider is registered. - void rt.waitForModel(bot) - .then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error))) - .then(() => sendKickoff(bot, { text, from: createdBy })) + // Nothing is sent on creation (#645): the user's first message is what starts a turn. + if (modelRef !== undefined) { + void rt.waitForModel(bot) + .then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error))) + } return bot } catch (error) { // The Bot's Session was already made: archive it instead of leaving an orphan. @@ -165,19 +149,6 @@ export function install(rt) { } } - function sendKickoff(bot, { text, from }) { - void rt.deliver({ fromId: from ?? undefined, toId: bot.id, text, role: 'kickoff' }) - .catch(error => rt.warn('ds-bot: kickoff failed: %s', error)) - } - - // The user wrote first, so the held greeting would only interrupt. - function dropKickoff(botId) { - const bot = rt.botOf(botId) - if (bot?.kickoff === undefined) return - delete bot.kickoff - void rt.save().catch(() => {}) - } - async function duplicateBot(id) { const bot = rt.botOf(id) if (bot === undefined) throw new Error('Unknown Bot') @@ -192,7 +163,6 @@ export function install(rt) { effort: served ? bot.effort : undefined, // A folder that went away since leaves the copy in the team workspace. workDir: bot.workDir !== undefined && await rt.isFolder(bot.workDir) ? bot.workDir : undefined, - copyOf: bot.id, }) } @@ -251,12 +221,6 @@ export function install(rt) { await rt.save() if (modelChanged) await rt.applyModel(bot, { force: true }) if (moved) void rt.followFolder(bot.id) - if (bot.model && bot.kickoff !== undefined) { - const held = bot.kickoff - delete bot.kickoff - await rt.save() - sendKickoff(bot, held) - } return bot } @@ -512,10 +476,6 @@ export function install(rt) { } // The sidebar shows every Main Bot, so a hidden Bot would vanish again once demoted. if (main) delete bot.hidden - // A held setup greeting says "you are the Main Bot"; it must not go out after demotion. - for (const other of state.mainBotIds.filter(other => !next.includes(other))) { - if (rt.botOf(other)?.kickoff?.main) delete rt.botOf(other).kickoff - } state.mainBotIds = next rt.syncDesignatedChief() await rt.save() @@ -592,6 +552,6 @@ export function install(rt) { Object.assign(rt, { ensureWorkspace, createSession, createBot, duplicateBot, updateBot, setFlags, roomBusy, autoRoomName, retitleRoom, createRoom, updateRoom, deleteRoom, - setMain, deleteBot, bootstrap, dropKickoff, + setMain, deleteBot, bootstrap, }) } diff --git a/core/src/host/tools.js b/core/src/host/tools.js index 676cc4c45..be96f4bd1 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -251,7 +251,7 @@ export function install(rt) { ctx.tools.register(defineTool({ name: 'create_bot', - description: 'Main Bot only. Create a new Bot on the user\'s team. It appears in the sidebar and greets the user.', + description: 'Main Bot only. Create a new Bot on the user\'s team. It appears in the sidebar and starts no turn until the user writes to it.', parameters: { name: { type: 'string', required: true, description: 'Short display name, e.g. "Researcher" or "Editor"' }, role: { type: 'string', required: true, description: 'Two-to-four character role label shown under the name' }, @@ -269,7 +269,7 @@ export function install(rt) { const name = String(args.name ?? '').trim() await rt.approveFolder(exec, { path: workDir, who: { en: `the new Bot ${name}`, zh: `新 Bot ${name} ` } }) const bot = await rt.createBot({ ...args, workDir, createdBy: rt.selfOf(exec.agent.id) }) - return `Created ${bot.name}${workDir ? `, working in ${workDir}` : ''}. It is greeting the user now.` + return `Created ${bot.name}${workDir ? `, working in ${workDir}` : ''}. It starts no turn until the user writes to it.` } catch (error) { return `Could not create the Bot: ${error.message}` } diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 3bdd2f0b0..2bdb69a32 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -25,7 +25,7 @@ export const failureLine = failure => `${failure.code}${failure.status ? ` ${fai // A group's own Session never reaches a model, so the snapshot DSH prepared there // rides with the user's queued message to the members who answer it. export function attachReference(queued, source, text) { - const post = queued?.findLast(item => item.botId === undefined && item.kickoff !== true) + const post = queued?.findLast(item => item.botId === undefined) if (post === undefined || text === '') return const labels = (Array.isArray(source?.references) ? source.references : []) .map(reference => reference?.label).filter(label => typeof label === 'string' && label !== '') @@ -78,7 +78,7 @@ export function install(rt) { const roomWaiters = new Map() /** @type {Map} */ const roomProgress = new Map() - /** @type {Map} messages a room's next relay posts */ + /** @type {Map} messages a room's next relay posts */ const roomInbox = new Map() const queueRoomMessage = (roomId, item) => roomInbox.set(roomId, [...(roomInbox.get(roomId) ?? []), item]) // Member turns of a group round the user stopped: what they end with stays out of the group. @@ -444,8 +444,6 @@ export function install(rt) { // Whose request the post carries on (turn-origin.js): the round's member turns answer it. ...(typeof source.origin === 'string' ? { origin: source.origin } : {}), }) - } else if (source?.kind === SOURCE_KIND && source.role === 'kickoff' && rt.roomOf(id)) { - queueRoomMessage(id, { text: '', kickoff: true }) } else if (source?.kind === SOURCE_KIND) { if (entry.turn === undefined) entry.pending = source else if (!opened) claimTrigger(id, entry, source) @@ -476,8 +474,6 @@ export function install(rt) { ...(source.authorId ? { authorId: source.authorId, authorKind: source.authorKind || 'human', authorName: source.authorName } : {}), }) } - const own = rt.chatOwners.get(id) - if (own !== undefined) rt.dropKickoff(own) // Any user message answers or supersedes the open question card. A group's card waits for // its own answer: a line typed in the group goes to its round, not to the Bot that asked (#550). if (state.questions[id] !== undefined && !rt.roomOf(id)) { diff --git a/core/test/approval-notices.test.js b/core/test/approval-notices.test.js index ecd3a42e6..a02c726d6 100644 --- a/core/test/approval-notices.test.js +++ b/core/test/approval-notices.test.js @@ -219,7 +219,6 @@ function turnsHost({ bots, rooms = {}, groupOwners = new Map(), chatOwners = new noteFailure() {}, warn() {}, switchJobs: () => [], - dropKickoff() {}, flybotReplyRemote: async () => ({}), } installTurns(rt) diff --git a/core/test/blocking-question.test.js b/core/test/blocking-question.test.js index 4b9db5a6b..f4e11a1b0 100644 --- a/core/test/blocking-question.test.js +++ b/core/test/blocking-question.test.js @@ -81,14 +81,6 @@ async function setup({ served = ['read', 'ask_user', QUESTION] } = {}) { return { ctx, rt, agentFor, hidden, hiddenIn, guard, sent, stop } } -const until = async (check) => { - const end = Date.now() + 2000 - while (!check()) { - if (Date.now() > end) throw new Error('until: the condition did not hold within 2 s') - await new Promise(resolve => setTimeout(resolve, 20)) - } -} - test('a Bot\'s own chat and its group Session lose dsh\'s blocking question tool; other Sessions keep it', async (t) => { const { agentFor, hidden, hiddenIn, stop } = await setup() t.after(stop) @@ -129,18 +121,15 @@ test('the guard refuses the blocking question tool in Bot Sessions only', async assert.equal(guard('read', 'writer'), undefined) }) -// FeiZhuLulu/DeepSeek-Bot#77: a new Bot told never to use a question tool was opened -// with "use ask_user", and a model that picked dsh's ask_user_question for it held the -// Bot until the card was answered, with other Bots' messages queued behind it. -test('a new Bot that rules out question tools opens without one and cannot block on dsh\'s', async (t) => { +// FeiZhuLulu/DeepSeek-Bot#77: a new Bot told never to use a question tool must not be +// told to use one, and its Session must not carry dsh's either. Creating it speaks to +// nobody (#645); the mask lands on its first delivered turn, and the guard holds before that. +test('a new Bot that rules out question tools is sent nothing and cannot block on dsh\'s', async (t) => { const { rt, hiddenIn, guard, sent, stop } = await setup() t.after(stop) const receiver = await rt.createBot({ name: 'Receiver', instructions: 'Never use ask_user or any question tool.', createdBy: 'chief' }) - await until(() => sent.some(entry => entry.sessionId === receiver.id)) - const kickoff = sent.find(entry => entry.sessionId === receiver.id).message.content[0].text - assert.doesNotMatch(kickoff, /ask_user|allowCustom/) - assert.match(kickoff, /Where your instructions say otherwise, follow them\./) - // The Agent was made before Receiver was on the team; the kickoff trims its list. - assert.equal(hiddenIn(receiver.id).length, 1) + assert.deepEqual(sent, []) assert.match(guard(QUESTION, receiver.id), /Bots cannot use ask_user_question/) + await rt.deliver({ fromId: 'chief', toId: receiver.id, text: 'Start.', role: 'request' }) + assert.equal(hiddenIn(receiver.id).length, 1) }) diff --git a/core/test/bot-messages.test.js b/core/test/bot-messages.test.js index 961b718b6..546122826 100644 --- a/core/test/bot-messages.test.js +++ b/core/test/bot-messages.test.js @@ -269,7 +269,6 @@ function turnsHost() { noteFailure() {}, warn() {}, switchJobs: () => [], - dropKickoff() {}, } installTurns(rt) let seq = 0 diff --git a/core/test/create-bot.test.js b/core/test/create-bot.test.js index 413f6c218..7c4b740f9 100644 --- a/core/test/create-bot.test.js +++ b/core/test/create-bot.test.js @@ -29,11 +29,14 @@ test('without a Main Bot the form starts on the DSH default model, else the firs assert.equal(startModel({ models: [], defaultModel: 'cloud/qwen-max' }), '') }) -test('the payload carries the look, the chosen model, and the brief', () => { +test('the payload carries the look and the chosen model, and never a brief (#645)', () => { const look = { shape: 'star', color: 'blue' } assert.deepEqual(createPayload({ name: 'A', look, model: 'cloud/qwen-max' }), { name: 'A', color: 'blue', avatar: { shape: 'star' }, model: 'cloud/qwen-max' }) - assert.deepEqual(createPayload({ name: 'A', look, model: '', brief: 'hi', image: 'data:image/png;base64,AA' }), - { name: 'A', color: 'blue', avatar: { shape: 'star', image: 'data:image/png;base64,AA' }, brief: 'hi' }) + // A line typed in the composer is the new Bot's first request, sent as a turn, so the card + // sends no `brief` the host would fold into the Bot's instructions. + const payload = createPayload({ name: 'A', look, model: '', brief: '帮我写周报', image: 'data:image/png;base64,AA' }) + assert.deepEqual(payload, { name: 'A', color: 'blue', avatar: { shape: 'star', image: 'data:image/png;base64,AA' } }) + assert.equal(payload.brief, undefined) }) test('the suggested name skips taken names', () => { diff --git a/core/test/diagnostics.test.js b/core/test/diagnostics.test.js index 7c3c3a32c..ed54d6ebe 100644 --- a/core/test/diagnostics.test.js +++ b/core/test/diagnostics.test.js @@ -199,7 +199,6 @@ function turnsHost() { noteFailure: entry => noted.push(entry), warn() {}, switchJobs: () => [], - dropKickoff() {}, } installTurns(rt) let seq = 0 diff --git a/core/test/flybot-feed.test.js b/core/test/flybot-feed.test.js index 4964f5ebf..f040e0400 100644 --- a/core/test/flybot-feed.test.js +++ b/core/test/flybot-feed.test.js @@ -203,7 +203,6 @@ test('negative: host nudges, replays and strangers stay out of the feed', async emit('b', 'user/message', relay({ role: 'request', resumed: true })) emit('b', 'user/message', relay({ role: 'request', retried: true })) emit('b', 'user/message', relay({ role: 'retry' })) - emit('b', 'user/message', relay({ role: 'kickoff' })) emit('b', 'user/message', { source: { kind: 'schedule' }, content: [] }) emit('nobody', 'turn/start', { turn: 1 }) emit('nobody', 'user/message', { source: { kind: 'user' }, content: [] }) diff --git a/core/test/history.test.js b/core/test/history.test.js index 1aa08dbc0..2fef9946b 100644 --- a/core/test/history.test.js +++ b/core/test/history.test.js @@ -27,6 +27,7 @@ test('historyEntry keeps answers, user and Bot messages, and skips setup', () => assert.equal(historyEntry({ ...asked, type: 'tool/ptc-dispatch' }, false), undefined) assert.deepEqual(historyEntry(said('hello', { kind: 'user' }), false), { answer: false, who: 'user', text: 'hello', keepAnswers: true }) assert.deepEqual(historyEntry(said('[Reply from W]\nok', { kind: 'bot', role: 'reply' }), false), { answer: false, who: 'bot', text: '[Reply from W]\nok', keepAnswers: true }) + // A team made before #645 still has the greeting prompt in its transcript. assert.deepEqual(historyEntry(said('[Bot team setup]\nhi', { kind: 'bot', role: 'kickoff' }), false), { answer: false, who: 'bot', text: '', keepAnswers: false }) assert.deepEqual(historyEntry(said('[Handoff · part 2]', { kind: 'bot', handoff: true }), false), { answer: false, who: 'bot', text: '', keepAnswers: false }) assert.deepEqual(historyEntry(said('[Handoff · part 2]', { kind: 'bot', handoff: true, resume: true }), false), { answer: false, who: 'bot', text: '', keepAnswers: true }) diff --git a/core/test/models.test.js b/core/test/models.test.js index b072232e2..94ecb4659 100644 --- a/core/test/models.test.js +++ b/core/test/models.test.js @@ -188,7 +188,7 @@ test('the catalog attaches thinking levels to models a Bot uses', async () => { assert.equal(entries.find(entry => entry.ref === 'stepfun/step-5')?.reasoning, undefined) }) -test('a greeting waits for the provider of its Bot model to register', async () => { +test('a model is applied once the provider of its Bot registers', async () => { const { rt, providers, listeners } = bench({ providers: {} }) const waiting = rt.waitForModel({ model: 'stepfun/step-5' }, 5000) setTimeout(() => { diff --git a/core/test/new-chat.test.js b/core/test/new-chat.test.js index a5d2cc120..b6aa30908 100644 --- a/core/test/new-chat.test.js +++ b/core/test/new-chat.test.js @@ -183,3 +183,16 @@ test('#385: the + menu rows have their own marks, one verb style and short hints assert.doesNotMatch(menu, /t\('Rename Bot'\)/) assert.match(menu, /t\('Edit Bot'\)/) }) + +test('#645: a line typed in the composer is the new Bot\'s first turn, not its instructions', () => { + const src = readFileSync(new URL('../src/client/new-chat.js', import.meta.url), 'utf8') + const card = readFileSync(new URL('../src/client/create-bot.js', import.meta.url), 'utf8') + // The new-Bot card is handed no brief at all: a typed request never becomes a standing rule. + assert.match(src, /h\(CreateBot, \{\n\s*roster, actions, start: draft, canGoBack: draft\.fromList,/) + assert.doesNotMatch(src, /brief:/) + assert.doesNotMatch(card, /brief/) + // Creating from the composer sends that line to the Bot, the way opening one does. + assert.match(src, /const created = async \(bot\) => \{\n\s*const text = message\.trim\(\)\n\s*if \(text\) await actions\.send\(bot\.id, text\)/) + assert.match(src, /onCreated: created/) + assert.match(card, /await onCreated\(bot\)/) +}) diff --git a/core/test/roster.test.js b/core/test/roster.test.js index 1a1f4e6b2..400de654c 100644 --- a/core/test/roster.test.js +++ b/core/test/roster.test.js @@ -9,7 +9,7 @@ import { format } from 'node:util' import { apply } from '../index.js' import { fakeContext } from './fake-ctx.js' -// Enough of the dsh services for the roster to make Sessions; kickoff messages are dropped. +// Enough of the dsh services for the roster to make Sessions; every delivered message is kept. function start(team, { models = [], reasoning = {}, archiveError } = {}) { const home = mkdtempSync(join(tmpdir(), 'ds-bot-roster-')) if (team !== undefined) writeFileSync(join(home, 'state.json'), JSON.stringify(team)) @@ -67,7 +67,7 @@ function start(team, { models = [], reasoning = {}, archiveError } = {}) { } const settle = () => new Promise(resolve => setTimeout(resolve, 20)) -// A greeting goes out after the team is saved, which can take longer than one settle() on a +// A model is applied after the team is saved, which can take longer than one settle() on a // busy machine. const until = async (check) => { const end = Date.now() + 2000 @@ -77,61 +77,57 @@ const until = async (check) => { } } -test('the first Main Bot waits for the user to pick a model, then greets', async (t) => { +test('the first Main Bot waits for the user to pick a model, and nothing opens a turn', async (t) => { const { call, stop, sent, selected } = start(undefined, { models: ['deepseek-v4-flash', 'deepseek-v4-pro'] }) t.after(stop) let team = (await call('state', {})).value let main = team.bots.find(bot => bot.id === team.mainBotId) await settle() assert.equal(main.model, undefined) - assert.equal(main.waiting, true) - assert.equal(main.kickoff, undefined) assert.equal(sent.length, 0) assert.equal(selected.length, 0) assert.deepEqual(team.models.map(model => model.ref), ['deepseek/deepseek-v4-flash', 'deepseek/deepseek-v4-pro']) assert.equal(team.defaultModel, 'deepseek/deepseek-v4-flash') assert.equal((await call('update-bot', { id: main.id, model: 'deepseek/deepseek-v4-pro' })).ok, true) - await until(() => sent.length > 0) + await until(() => selected.length > 0) + await settle() team = (await call('state', {})).value main = team.bots.find(bot => bot.id === team.mainBotId) assert.equal(main.model, 'deepseek/deepseek-v4-pro') - assert.equal(main.waiting, undefined) assert.deepEqual(selected.map(({ sessionId, model }) => [sessionId, model]), [[main.id, 'deepseek-v4-pro']]) - assert.equal(sent.length, 1) - assert.equal(sent[0].sessionId, main.id) - assert.equal(sent[0].message.source.role, 'kickoff') + // The pick applies the model; the user's own message is what starts a turn (#645). + assert.equal(sent.length, 0) - // A later model change does not greet again. + // A later model change stays silent too. await call('update-bot', { id: main.id, model: 'deepseek/deepseek-v4-flash' }) await settle() - assert.equal(sent.length, 1) + assert.equal(sent.length, 0) }) -test('other Bots start on the default model and greet at once', async (t) => { +test('other Bots start on the default model and are sent nothing', async (t) => { const { call, stop, sent } = start(undefined, { models: ['deepseek-v4-flash'] }) t.after(stop) await call('state', {}) const writer = (await call('create-bot', { name: 'Writer' })).value - await until(() => sent.length > 0) await settle() assert.equal(writer.model, 'deepseek/deepseek-v4-flash') - assert.deepEqual(sent.map(entry => entry.sessionId), [writer.id]) + assert.deepEqual(sent, []) }) test('a Bot made by the Main Bot starts on the Main Bot\'s model and level before its first request (#410)', async (t) => { const reasoning = { 'deepseek/deepseek-v4-pro': { reasoning: { efforts: [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }], defaultEffort: 'high' } } } // The profile default is the first served model (flash); Chief runs on pro. - const { call, stop, sent, selected, ctx } = start(undefined, { models: ['deepseek-v4-flash', 'deepseek-v4-pro'], reasoning }) + const { call, stop, selected, ctx } = start(undefined, { models: ['deepseek-v4-flash', 'deepseek-v4-pro'], reasoning }) t.after(stop) const chief = (await call('state', {})).value.mainBotId await call('update-bot', { id: chief, model: 'deepseek/deepseek-v4-pro', effort: 'low' }) - await until(() => sent.some(entry => entry.sessionId === chief)) + await until(() => selected.some(entry => entry.sessionId === chief)) await ctx.registeredTools.get('create_bot').execute({ name: 'Writer', role: 'Writer', instructions: 'Write.' }, { agent: { id: chief } }) const writer = (await call('state', {})).value.bots.find(entry => entry.name === 'Writer') assert.equal(writer.model, 'deepseek/deepseek-v4-pro') assert.equal(writer.effort, 'low') - await until(() => sent.some(entry => entry.sessionId === writer.id)) + await until(() => selected.some(entry => entry.sessionId === writer.id)) // No model selection for the new Bot ever names the profile default. const own = selected.filter(request => request.sessionId === writer.id) assert.ok(own.length > 0) @@ -155,85 +151,36 @@ test('a new name must be one "@" can call; a name saved before the rule still sa assert.equal((await call('update-bot', { id: writer.id, name: '新 Writer' })).ok, true) }) -// The kickoff arrives as a user message, so it must not name a question tool the Bot's -// instructions can rule out, and it gives way to those instructions. -for (const kind of ['main', 'copy', 'made by a Main Bot', 'made by the user']) { - test(`the ${kind} kickoff asks in plain text and gives way to the Bot's instructions`, async (t) => { - const { call, stop, sent, ctx } = start(undefined, { models: ['deepseek-v4-flash'] }) - t.after(stop) - const chief = (await call('state', { locale: 'zh-CN' })).value.mainBotId - const instructions = 'Use only plain text. Never call ask_user or ask_user_question.' - let bot - if (kind === 'main') { - bot = (await call('update-bot', { id: chief, instructions, model: 'deepseek/deepseek-v4-flash' })).value - } else if (kind === 'copy') { - const original = (await call('create-bot', { name: 'Writer', instructions })).value - await until(() => sent.some(entry => entry.sessionId === original.id)) - bot = (await call('duplicate-bot', { id: original.id })).value - } else if (kind === 'made by a Main Bot') { - await ctx.registeredTools.get('create_bot').execute({ name: 'Writer', role: 'Writer', instructions, brief: 'Review the team notes.' }, { agent: { id: chief } }) - bot = (await call('state', {})).value.bots.find(entry => entry.name === 'Writer') - } else { - bot = (await call('create-bot', { name: 'Writer', instructions })).value - } - await until(() => sent.some(entry => entry.sessionId === bot.id)) - const greetings = sent.filter(entry => entry.sessionId === bot.id) - assert.equal(greetings.length, 1) - const { message } = greetings[0] - const text = message.content[0].text - assert.equal(message.source.role, 'kickoff') - assert.equal(bot.instructions, instructions) - assert.doesNotMatch(text, /\b(?:ask_user(?:_question)?|allowCustom)\b/) - assert.match(text, /ask in one short question/) - assert.match(text, /Where your instructions say otherwise, follow them\./) - assert.match(text, /Write in Chinese \(简体中文\)\./) - if (kind === 'copy') assert.match(text, /copy of Writer/) - if (kind === 'made by a Main Bot') assert.match(text, /Why you exist: Review the team notes\./) - }) -} - -test('a first message from the user is helped with, not replaced by an opening question', async (t) => { - const { call, stop, sent } = start(undefined, { models: ['deepseek-v4-flash'] }) +// Creation is silent (#645): no path that makes a Bot opens a turn, and only a brief from a +// Bot that creates one (`create_bot`) becomes instructions. A line typed in the new-Bot card +// is the user's first request, sent as a turn by the client, never a standing rule. +test('creating a Bot sends it nothing, and only a parent Bot\'s brief becomes its instructions', async (t) => { + const { call, stop, sent, ctx } = start(undefined, { models: ['deepseek-v4-flash'] }) t.after(stop) - await call('state', {}) - const brief = 'Review the team notes and list the next tasks.' - const bot = (await call('create-bot', { name: 'Writer', brief })).value - await until(() => sent.some(entry => entry.sessionId === bot.id)) - const text = sent.find(entry => entry.sessionId === bot.id).message.content[0].text - assert.ok(text.includes(brief)) - assert.match(text, /then help with it\./) - assert.doesNotMatch(text, /\b(?:ask_user(?:_question)?|allowCustom)\b/) + const chief = (await call('state', {})).value.mainBotId + const brief = '帮我写周报' + const writer = (await call('create-bot', { name: 'Writer', instructions: 'Write.', brief })).value + assert.equal(writer.instructions, 'Write.', 'a line typed in the new-Bot card is not saved as an instruction') + const copy = (await call('duplicate-bot', { id: writer.id })).value + assert.equal(copy.instructions, writer.instructions) + const answer = await ctx.registeredTools.get('create_bot').execute({ name: 'Editor', role: 'Edit', instructions: 'Edit.', brief: 'Keep the docs in step.' }, { agent: { id: chief } }) + assert.equal(answer, 'Created Editor. It starts no turn until the user writes to it.') + const editor = (await call('state', {})).value.bots.find(entry => entry.name === 'Editor') + assert.equal(editor.instructions, 'Edit.\n\nWhy you exist: Keep the docs in step.') + await settle() + assert.deepEqual(sent, []) }) -test('a group the user makes without a first message opens with its admin asking what it is for', async (t) => { - const { call, stop, sent, ctx, home } = start(undefined, { models: ['deepseek-v4-flash'] }) +test('a group the user makes starts no turn of its own', async (t) => { + const { call, stop, sent, home } = start(undefined, { models: ['deepseek-v4-flash'] }) t.after(stop) const chief = (await call('state', {})).value.mainBotId const writer = (await call('create-bot', { name: 'Writer' })).value - await until(() => sent.some(entry => entry.sessionId === writer.id)) - sent.length = 0 - await call('create-room', { members: [chief, writer.id], admin: writer.id }) - assert.equal(sent.length, 0) - const room = (await call('create-room', { members: [chief, writer.id], admin: writer.id, greet: true })).value - assert.deepEqual(sent.map(entry => [entry.sessionId, entry.message.source.role]), [[room.id, 'kickoff']]) - - // The relay gives the admin alone a turn, and the cue adds no line to the group. - for (const listener of ctx.listeners.get('session/event')) listener({ id: room.id }, { type: 'user/message', data: sent[0].message }) - const controller = new AbortController() - const relay = ctx.registeredTools.get('group_relay').execute({}, { agent: { id: room.id }, callId: 'relay', signal: controller.signal }) - await until(() => sent.length > 1) - controller.abort() - const result = JSON.parse(await relay) - const turns = sent.slice(1) - assert.equal(turns.length, 1) - const text = turns[0].message.content[0].text - assert.match(text, /^\[Group chat "[^"]+" · the group was just created\]\n/) - assert.match(text, /The user just created this group/) - assert.doesNotMatch(text, /This round so far|Nothing new in this round/) - assert.deepEqual(result.passed, ['Writer']) + const room = (await call('create-room', { members: [chief, writer.id], admin: writer.id })).value + await settle() + assert.deepEqual(sent, []) const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).rooms[room.id] - assert.equal(saved.sessions[writer.id], turns[0].sessionId) - assert.deepEqual(saved.log, []) + assert.equal(saved.log?.length ?? 0, 0) }) test('a Bot takes a thinking level its model serves, applied with the model', async (t) => { @@ -435,29 +382,26 @@ test('an archive failure on delete is warned instead of passing silently', async assert.equal(warnings.filter(text => text.includes('not archived')).length, 1) }) -test('a hidden Bot made Main Bot shows again, and a replaced Chief drops its held greeting', async (t) => { +test('a hidden Bot made Main Bot shows again, and a replaced Chief is sent nothing', async (t) => { const { call, stop, sent } = start(undefined, { models: ['deepseek-v4-flash'] }) t.after(stop) const chief = (await call('state', {})).value.mainBotId const writer = (await call('create-bot', { name: 'Writer' })).value await call('set-flags', { id: writer.id, hidden: true }) await settle() - // Only Chief's messages count: Writer's own greeting can go out late on a busy machine. - const toChief = () => sent.filter(item => item.sessionId === chief).length - const greeted = toChief() + const before = sent.length const team = (await call('set-main', { id: writer.id, main: true, replace: chief })).value assert.equal(team.bots.find(bot => bot.id === writer.id).hidden, undefined) - assert.equal(team.bots.find(bot => bot.id === chief).waiting, undefined) + // A replaced Chief keeps no held greeting to drop; a model change speaks to no one (#645). await call('update-bot', { id: chief, model: 'deepseek/deepseek-v4-flash' }) await settle() - assert.equal(toChief(), greeted) + assert.equal(sent.length, before) }) const takeover = (home, team) => { writeFileSync(join(home, 'state.lock'), JSON.stringify({ pid: process.ppid, host: hostname(), token: 'new-owner' })) if (team !== undefined) writeFileSync(join(home, 'state.json'), JSON.stringify(team)) } - test('a host that loses its lock to another dsh refuses changes instead of making a phantom Bot', async (t) => { const { call, stop, home, ctx } = start({ version: 1, mainBotIds: ['chief'], bots: { chief: { id: 'chief', name: 'Chief' } } }) t.after(stop) diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index fd9df7d7f..d6de24bb0 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -286,19 +286,26 @@ test('a message with an image or a chat reference waits for its own round, and s assert.equal(inboxOf(room.id).nextStep.length, 1) }) -test('the greeting round leaves a waiting message for its own round', async (t) => { - const { call, emit, compose, relay, inboxOf, answer, stop, sent } = start() +test('creating a team starts no turn, and the user\'s first message opens one round (#645)', async (t) => { + const { call, emit, userMessage, relay, answer, stop, sent } = start() t.after(stop) - const room = await team(call, sent) + const chief = (await call('state', {})).value.mainBotId + const writer = (await call('create-bot', { name: 'Writer' })).value + const editor = (await call('create-bot', { name: 'Editor' })).value + const room = (await call('create-room', { name: 'R1', members: [chief, writer.id, editor.id] })).value + await settle() + // Three Bots and a group cost no model turn. + assert.deepEqual(sent, []) - await emit(room.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'kickoff' }, content: [{ type: 'text', text: '[Group chat created]' }] } }) + // The user's first message opens the round, and the admin speaks first, once. + await emit(room.id, userMessage('Hello team')) const running = relay(room.id, 'relay') const first = await turnFor(sent) - await compose(room.id, 'hello there') - await answer(first.sessionId, first.message.source.exchangeId, 'Welcome!', 1) + assert.ok(first, 'the admin gets the first turn') + assert.equal(sent.length, 1) + await answer(first.sessionId, first.message.source.exchangeId, 'Ready.', 1) const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief']) - assert.equal(inboxOf(room.id).nextTurn.length, 1) }) test('a stopped round leaves a waiting message for the next round', async (t) => { diff --git a/core/test/tool-node.test.js b/core/test/tool-node.test.js index 64836e5b7..4ebb076ab 100644 --- a/core/test/tool-node.test.js +++ b/core/test/tool-node.test.js @@ -97,7 +97,6 @@ test('non-relay context and ds-bot package sources are ignored', () => { const steps = [stepAt(1, 10), stepAt(2, 30)] const contexts = [ relay(12, 'request', { handoff: true }), - relay(14, 'kickoff'), relay(16, 'room'), relay(18, 'secret'), relay(22, 'request', { senderSessionId: undefined }), @@ -108,5 +107,5 @@ test('non-relay context and ds-bot package sources are ignored', () => { relay(27, 'stopped', { retried: true }), relay(28, 'request'), ] - assert.deepEqual(relaysBefore(contexts, steps, steps[1]), [contexts[10]]) + assert.deepEqual(relaysBefore(contexts, steps, steps[1]), [contexts[9]]) }) diff --git a/core/test/work-dir.test.js b/core/test/work-dir.test.js index 89ba2a8b8..5a9346225 100644 --- a/core/test/work-dir.test.js +++ b/core/test/work-dir.test.js @@ -300,7 +300,7 @@ test('a folder outside the team workspace needs the user\'s approval', async (t) const allowed = approvalServices('allowed-once') const third = await start({ services: allowed.services }) t.after(third.stop) - assert.equal(await third.tool('create_bot', { name: 'Builder', role: 'Build', instructions: 'Build', work_dir: third.project }, 'a'), `Created Builder, working in ${third.project}. It is greeting the user now.`) + assert.equal(await third.tool('create_bot', { name: 'Builder', role: 'Build', instructions: 'Build', work_dir: third.project }, 'a'), `Created Builder, working in ${third.project}. It starts no turn until the user writes to it.`) assert.deepEqual(third.created, [{ cwd: third.project }]) assert.match(await third.tool('list_bots', {}, 'a'), new RegExp(`- Builder \\(Build, works in ${escapeRegExp(third.project)}\\)`)) }) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 8b720500c..be0305715 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -163,6 +163,10 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`Chief (SelfA)`, `Chief (SelfB)`), plus **Everyone** with the room's Bot count (`2 Bots`, #457). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305). - A face room's `@` menu shows no DSH Sessions rows (#305, owner decision 2026-10-19: filter in FlyBot for now; #422 asks DSH for an API). DSH's built-in `reference` source cannot be dropped for one Session, so `HeaderPill` (`header.js`) starts `actions.filterFaceAtMenu` for a face room, which watches that Session's trigger controller (`inputTriggers.sessionOf`, the same one the composer uses) with `watchFaceMenu` (`face-at-filter.js`): a `