fix(bot-mode): first click on a bot opens its chat — the home no longer bounces over it

The Bots home landing appeared on EVERY first click of a bot whose
canonical Bot Chat had been compressed; only a second click got through.

openRosterBot claimed the center with the durable registry id, but the
session-focus edge fired by the open itself reports the compression-
lineage TIP. releaseStaleOpenBotChat compared tip !== registry id,
declared the claim stale, released it, and the home reasserted over the
freshly opened chat. The second click worked only because the tip was
already focused — no new focus edge fired to sabotage it.

openBotCanonicalChat now returns both identities (registryId + openedId);
the claim carries both; a focus edge matching EITHER keeps it. Foreign
sessions still release, and the legacy no-id draft claim is unchanged.
This commit is contained in:
Teknium
2026-08-23 04:33:49 -07:00
parent 42e39d0646
commit a4c6c6bddb
4 changed files with 110 additions and 15 deletions
+28 -8
View File
@@ -5278,11 +5278,17 @@ async function openBotCanonicalChat(owner) {
const existing = await findExistingCanonicalChat(owner)
if (existing?.id && typeof host.openSession === 'function') {
await openStoredBotChat(owner, existing.resolved_id || existing.id, existing)
return existing.id
const openedId = existing.resolved_id || existing.id
await openStoredBotChat(owner, openedId, existing)
// Both identities matter downstream: the durable registry row names the
// chat; the resolved lineage tip is what actually takes session focus.
// Callers matching focus against only the registry id mistook every
// compressed Bot Chat for a stale open (first click bounced to the home).
return { registryId: String(existing.id), openedId: String(openedId) }
}
return createCanonicalChat(owner)
const created = await createCanonicalChat(owner)
return created ? { registryId: String(created), openedId: String(created) } : null
}
async function prepareBotSource(bot) {
@@ -5375,17 +5381,26 @@ async function openRosterBot(bot) {
}
try {
const registryId = await openBotCanonicalChat(bot)
const opened = await openBotCanonicalChat(bot)
if (generation !== botOpenGeneration) {
return false
}
if (registryId) {
if (opened) {
// This is not an identity preference: opening already completed through
// the name registry. Keep only enough ephemeral state to release the
// home if another tab later claims the center.
$openBotChat.set({ key, openedRegistryId: String(registryId) })
// home if another tab later claims the center. Track BOTH identities —
// session focus reports the compression-lineage tip (openedId), not the
// durable registry row, and matching focus against the registry id
// alone released this claim on the first click of every compressed
// Bot Chat (home bounced over the chat; a second click stuck only
// because no new focus edge fired).
$openBotChat.set({
key,
openedRegistryId: opened.registryId,
openedSessionId: opened.openedId
})
closeBotsHomeWorkspace()
return true
}
@@ -12644,7 +12659,12 @@ function releaseStaleOpenBotChat(focusedStoredId) {
}
const focused = focusedStoredId === null || focusedStoredId === undefined ? '' : String(focusedStoredId)
const stale = open.openedRegistryId ? focused !== open.openedRegistryId : Boolean(focused)
// The focused stored id is the compression-lineage TIP; the claim carries
// both the durable registry id and the tip it actually opened. Either
// match keeps the claim — comparing only the registry id released it on
// the very focus edge the open itself caused (first-click home bounce).
const owned = [open.openedSessionId, open.openedRegistryId].filter(Boolean)
const stale = owned.length ? !owned.includes(focused) : Boolean(focused)
if (stale) {
$openBotChat.set(null)
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
// First-click home bounce (community report, Aug 2026): clicking a bot whose
// canonical Bot Chat had been COMPRESSED landed on the Bots home instead of
// the chat; only a second click got through. Root cause: openRosterBot
// claimed the center with the durable registry id, but the session-focus
// edge that the open itself fired reports the compression-lineage TIP —
// releaseStaleOpenBotChat compared tip !== registry id, called the claim
// stale, released it, and the home reasserted over the freshly opened chat.
// The second click "worked" only because the tip was already focused, so no
// new focus edge fired to sabotage it.
//
// Contract pinned here (source-shape):
// - openBotCanonicalChat returns BOTH identities (registryId + openedId);
// - the $openBotChat claim stores openedSessionId alongside openedRegistryId;
// - releaseStaleOpenBotChat keeps the claim when the focused id matches
// EITHER identity, and still releases on a genuinely foreign session.
const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
test('canonical open returns both the registry row and the opened tip', () => {
const fn = pluginSource.slice(
pluginSource.indexOf('async function openBotCanonicalChat'),
pluginSource.indexOf('async function prepareBotSource')
)
assert.match(fn, /registryId: String\(existing\.id\)/)
assert.match(fn, /openedId: String\(openedId\)/)
})
test('the open claim carries the opened session id', () => {
const fn = pluginSource.slice(
pluginSource.indexOf('async function openRosterBot'),
pluginSource.indexOf('function displayName')
)
assert.match(fn, /openedSessionId: opened\.openedId/)
})
test('focus on either owned identity keeps the claim; foreign focus releases', () => {
const start = pluginSource.indexOf('function releaseStaleOpenBotChat')
const body = pluginSource.slice(start, pluginSource.indexOf('\n}', start) + 2)
// executable check: evaluate the function against a stub store
let stored = null
const $openBotChat = {
get: () => stored,
set: value => {
stored = value
}
}
const release = new Function('$openBotChat', `${body}; return releaseStaleOpenBotChat`)($openBotChat)
// compressed chat: claim carries registry 'reg-1' and tip 'tip-9'
stored = { key: 'k', openedRegistryId: 'reg-1', openedSessionId: 'tip-9' }
release('tip-9') // the focus edge the open itself fires
assert.ok(stored, 'tip focus must NOT release the claim (first-click bounce)')
release('reg-1')
assert.ok(stored, 'registry-id focus must not release either')
release('other-session')
assert.equal(stored, null, 'a genuinely foreign session releases the claim')
// legacy draft claim (no ids): any focused session releases
stored = { key: 'k', openedRegistryId: '' }
release('any')
assert.equal(stored, null)
})
@@ -77,7 +77,9 @@ test('open resolves the profile\u2019s "Bot Chat" row by exact title and opens i
}
})
assert.equal(await runtime.openBotCanonicalChat('ops'), 'forever-chat')
const opened = await runtime.openBotCanonicalChat('ops')
assert.equal(opened.registryId, 'forever-chat')
assert.equal(opened.openedId, 'forever-chat')
assert.equal(runtime.opened.length, 1)
assert.equal(runtime.opened[0].id, 'forever-chat')
assert.equal(runtime.opened[0].options.profile, 'ops')
@@ -107,8 +109,9 @@ test('a compression-rotated registry row opens the lineage tip', async () => {
}
})
assert.equal(await runtime.openBotCanonicalChat('ops'), 'root-1',
'the durable registry id is returned')
const opened = await runtime.openBotCanonicalChat('ops')
assert.equal(opened.registryId, 'root-1', 'the durable registry id is returned')
assert.equal(opened.openedId, 'tip-9', 'the lineage tip rides alongside for focus matching')
assert.equal(runtime.opened[0].id, 'tip-9', 'the live tip is what opens')
})
@@ -139,7 +142,9 @@ test('no registry row mints a hidden "Bot Chat" session with the intro kickoff',
}
})
assert.equal(await runtime.openBotCanonicalChat('newbie'), 'fresh-1')
const opened = await runtime.openBotCanonicalChat('newbie')
assert.equal(opened.registryId, 'fresh-1')
assert.equal(opened.openedId, 'fresh-1')
const create = runtime.requests.find(r => r.method === 'session.create')
assert.equal(create?.params?.title, 'Bot Chat')
assert.equal(create?.params?.hidden, true)
@@ -186,8 +191,9 @@ test('an ordinary titled session never satisfies the registry lookup', async ()
}
})
assert.equal(await runtime.openBotCanonicalChat('ops'), 'fresh-2',
'no row titled "Bot Chat" → create; never adopt an ordinary conversation')
const opened = await runtime.openBotCanonicalChat('ops')
assert.equal(opened.registryId, 'fresh-2', 'no row titled "Bot Chat" → create; never adopt an ordinary conversation')
assert.equal(opened.openedId, 'fresh-2')
assert.ok(!runtime.opened.some(o => o.id === 'scratch'))
})
@@ -272,7 +272,8 @@ test('non-identity alias resolves the canonical chat by NAME on the backend prof
const result = await runtime.context.__race.openBotCanonicalChat(workerBot)
assert.equal(result, 'worker-chat')
assert.equal(result.registryId, 'worker-chat')
assert.equal(result.openedId, 'worker-chat-tip')
const lookup = requests.find(([method]) => method === 'session.list')
assert.equal(lookup[1].profile, 'backend-worker', 'lookup uses the backend alias, not the logical name')
assert.equal(lookup[1].title, 'Bot Chat')