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:
Teknium
2026-08-25 23:37:12 -07:00
parent 3e5c49643c
commit 4faa721d7d
3 changed files with 96 additions and 21 deletions
+56 -17
View File
@@ -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 () => {