fix(desktop): clicking a bot no longer burns a model turn on a fake user prompt
The intro kickoff ('Hey, tell me about yourself!') now fires ONLY from
genuine New Agent creation. The bot-click canonical resolution path mints
silently: the eager session.title write already persists the lazy row on
modern gateways, so the kickoff's session-persistence job is obsolete
there. A resolution miss (retitled row, hidden-listing gap, post-update
skew) previously re-fired the kickoff on EVERY click — a burned model
turn plus a user-attributed prompt the user never typed (ScottFive
report). Older gateways that reject the eager title keep a narrow compat
kickoff, else the pruner reaps the empty lazy session.
This commit is contained in:
@@ -5731,14 +5731,25 @@ async function findExistingCanonicalChat(owner) {
|
||||
return rows.find(row => isCanonicalBotChatHistory(row)) || null
|
||||
}
|
||||
|
||||
/** Create the bot's ONE forever chat: a real session titled "Bot Chat",
|
||||
* opened with a kickoff message (the gateway prunes zero-message sessions,
|
||||
* so the chat is born with the bot introducing itself). Adopts the existing
|
||||
* "Bot Chat" row instead of creating when the profile already has one —
|
||||
* minting while a "Bot Chat" row exists is always wrong twice over: it
|
||||
* forks the forever-chat AND the new row can never take the (already held)
|
||||
* canonical title. Creates on the bot's own source via requestForBot. */
|
||||
function createCanonicalChat(owner) {
|
||||
/** Create the bot's ONE forever chat: a real session titled "Bot Chat".
|
||||
* Adopts the existing "Bot Chat" row instead of creating when the profile
|
||||
* already has one — minting while a "Bot Chat" row exists is always wrong
|
||||
* twice over: it forks the forever-chat AND the new row can never take the
|
||||
* (already held) canonical title. Creates on the bot's own source via
|
||||
* requestForBot.
|
||||
*
|
||||
* `kickoff` (New Agent creation ONLY): submit the self-introduction prompt
|
||||
* so a brand-new bot greets its owner once. Every other caller — the bot
|
||||
* row's click-path canonical resolution above all — must NOT pass it: a
|
||||
* resolution miss (retitled row, hidden-listing gap, post-update skew)
|
||||
* re-mints the session, and re-firing the intro there burned a model turn
|
||||
* and stamped a user-attributed "Hey, tell me about yourself!" into the
|
||||
* chat on every click (ScottFive report). The kickoff's original session-
|
||||
* persistence job is done by the eager session.title write below on modern
|
||||
* gateways; older gateways that reject the eager write keep a narrow
|
||||
* compat kickoff, else the pruner reaps the empty lazy session and the
|
||||
* chat never survives its own creation. */
|
||||
function createCanonicalChat(owner, { kickoff = false } = {}) {
|
||||
const { bot, name, key, route } = botOwner(owner)
|
||||
const inflight = canonicalCreations.get(key)
|
||||
|
||||
@@ -5781,9 +5792,12 @@ function createCanonicalChat(owner) {
|
||||
// before either the open or kickoff, closing both the 404 race and the
|
||||
// untitled window. Older gateways may not support the eager write; retain
|
||||
// the kickoff-and-retry fallback below.
|
||||
let titled = false
|
||||
|
||||
if (runtime) {
|
||||
try {
|
||||
await requestForBot(bot, 'session.title', { session_id: runtime, title: CANONICAL_CHAT_TITLE })
|
||||
titled = true
|
||||
} catch {
|
||||
/* compatibility fallback: prompt.submit will persist the lazy row */
|
||||
}
|
||||
@@ -5809,22 +5823,44 @@ function createCanonicalChat(owner) {
|
||||
}
|
||||
|
||||
if (runtime) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 400))
|
||||
// Intro turn: only on genuine New Agent creation (`kickoff`), or as the
|
||||
// COMPAT persistence write when the eager title failed — an old gateway
|
||||
// prunes the zero-message lazy session, so without some first prompt
|
||||
// the chat never survives its own creation. A titled row needs neither:
|
||||
// the user speaks first.
|
||||
const submitIntro = kickoff || !titled
|
||||
|
||||
try {
|
||||
await requestForBot(bot, 'prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' })
|
||||
if (submitIntro) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 400))
|
||||
|
||||
if (!opened && sid && typeof host.openSession === 'function') {
|
||||
try {
|
||||
await requestForBot(bot, 'prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' })
|
||||
|
||||
if (!opened && sid && typeof host.openSession === 'function') {
|
||||
await host.openSession(sid, {
|
||||
...(route ? { route } : {}),
|
||||
profile: name,
|
||||
intent: 'main',
|
||||
keepAllProfilesScope: route ? true : false
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// The chat already exists under the canonical title — the next click
|
||||
// finds it by name instead of making a second Bot Chat.
|
||||
}
|
||||
} else if (!opened && sid && typeof host.openSession === 'function') {
|
||||
// No intro turn: still finish mounting the chat when the first open
|
||||
// raced the (now titled) row.
|
||||
try {
|
||||
await host.openSession(sid, {
|
||||
...(route ? { route } : {}),
|
||||
profile: name,
|
||||
intent: 'main',
|
||||
keepAllProfilesScope: route ? true : false
|
||||
})
|
||||
} catch {
|
||||
/* row is titled and persistent — the next click opens it by name */
|
||||
}
|
||||
} catch {
|
||||
// The chat already exists under the canonical title — the next click
|
||||
// finds it by name instead of making a second Bot Chat.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10083,8 +10119,11 @@ function CreateAgentDialog({ open, onClose, roster }) {
|
||||
// Birth the bot's forever chat right away: it introduces itself as
|
||||
// the first thing the user sees, and the pin exists from minute one.
|
||||
try {
|
||||
// Creates, pins, opens, and kicks off the intro in one flow.
|
||||
const sid = await createCanonicalChat(slug)
|
||||
// Creates, pins, opens, and kicks off the intro in one flow. This is
|
||||
// the ONE caller allowed to request the intro turn — genuine New
|
||||
// Agent creation. Click-path resolution (openBotCanonicalChat) mints
|
||||
// silently so a resolution miss never burns a turn (ScottFive).
|
||||
const sid = await createCanonicalChat(slug, { kickoff: true })
|
||||
|
||||
if (!sid && typeof host.newChat === 'function') {
|
||||
host.newChat(slug)
|
||||
|
||||
@@ -35,13 +35,42 @@ test('regression: creation materializes and titles the lazy row before opening i
|
||||
events.push(method)
|
||||
if (method === 'session.create') return { stored_session_id: 'stored-1', session_id: 'runtime-1' }
|
||||
if (method === 'session.title') {
|
||||
assert.deepEqual(params, { session_id: 'runtime-1', title: 'Bot Chat' })
|
||||
// vm-realm objects fail assert.deepEqual on prototype identity —
|
||||
// compare via JSON.stringify (harness contract). A throw here is
|
||||
// NOT inert: createCanonicalChat reads it as "eager title
|
||||
// unsupported" and falls back to the compat kickoff.
|
||||
assert.equal(JSON.stringify(params), JSON.stringify({ session_id: 'runtime-1', title: 'Bot Chat' }))
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
// No kickoff option: the click-path mint. The eager title write persists
|
||||
// the row, so NO intro turn fires — the user speaks first (ScottFive).
|
||||
assert.equal(await runtime.createCanonicalChat('ops'), 'stored-1')
|
||||
assert.deepEqual(events, [
|
||||
'session.list',
|
||||
'session.create',
|
||||
'session.title',
|
||||
'open:stored-1'
|
||||
])
|
||||
})
|
||||
|
||||
test('New Agent creation (kickoff: true) still sends the one intro turn', async () => {
|
||||
const events = []
|
||||
const runtime = loadCanonicalCreation({
|
||||
openSession: async id => events.push(`open:${id}`),
|
||||
request: async (method, params) => {
|
||||
events.push(method)
|
||||
if (method === 'session.create') return { stored_session_id: 'stored-1', session_id: 'runtime-1' }
|
||||
if (method === 'prompt.submit') {
|
||||
assert.equal(params.session_id, 'runtime-1')
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(await runtime.createCanonicalChat('ops', { kickoff: true }), 'stored-1')
|
||||
assert.deepEqual(events, [
|
||||
'session.list',
|
||||
'session.create',
|
||||
@@ -84,5 +113,5 @@ test('regression: a failed intro still returns the created registry row', async
|
||||
|
||||
// The chat exists under the canonical title — the next click finds it by
|
||||
// NAME (the registry), so a failed kickoff can never orphan or fork it.
|
||||
assert.equal(await runtime.createCanonicalChat('newbie'), 'new-bot-chat')
|
||||
assert.equal(await runtime.createCanonicalChat('newbie', { kickoff: true }), 'new-bot-chat')
|
||||
})
|
||||
|
||||
@@ -133,7 +133,11 @@ test('openBotCanonicalChat takes only the bot owner — identity needs nothing e
|
||||
|
||||
// ── 2. no registry row → create ─────────────────────────────────────────────
|
||||
|
||||
test('no registry row mints a hidden "Bot Chat" session with the intro kickoff', async () => {
|
||||
test('no registry row mints a hidden "Bot Chat" session WITHOUT an intro kickoff', async () => {
|
||||
// Click-path resolution: a miss mints the session silently. The intro turn
|
||||
// fires only from New Agent creation (kickoff: true) — re-firing it here
|
||||
// burned a model turn and stamped a user-attributed prompt into the chat
|
||||
// on every resolution miss (retitle/hidden-listing/update skew).
|
||||
const runtime = loadOpenPath({
|
||||
request: async method => {
|
||||
if (method === 'session.list') return { sessions: [] }
|
||||
@@ -148,8 +152,11 @@ test('no registry row mints a hidden "Bot Chat" session with the intro kickoff',
|
||||
const create = runtime.requests.find(r => r.method === 'session.create')
|
||||
assert.equal(create?.params?.title, 'Bot Chat')
|
||||
assert.equal(create?.params?.hidden, true)
|
||||
// The eager title write persisted the row; no user-attributed intro.
|
||||
const titled = runtime.requests.find(r => r.method === 'session.title')
|
||||
assert.equal(titled?.params?.session_id, 'rt-1')
|
||||
const kickoff = runtime.requests.find(r => r.method === 'prompt.submit')
|
||||
assert.equal(kickoff?.params?.session_id, 'rt-1')
|
||||
assert.equal(kickoff, undefined)
|
||||
})
|
||||
|
||||
test('a failed open of the registry row surfaces instead of forking a replacement', async () => {
|
||||
|
||||
Reference in New Issue
Block a user