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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user