fix(bot-mode): a bot row opens the conversation you were last having
Clicking a bot in the roster always reopened its pinned canonical Bot Chat. Start a new conversation with bot A, click bot B, click back to A — the new conversation was gone, replaced by the pinned transcript. A bot row is a workspace entry point, so it has to land on the live conversation. Two independent causes, both fixed here: 1. The pin overrode newer work. `openBotCanonicalChat` opened the pin unconditionally. It now prefers the bot's freshest VISIBLE session — but only AFTER `profiles.list` has verified through `preferred_session` that the pin is alive and is a real canonical Bot Chat. That ordering matters: with a dead or unverified pin, adopting the profile's latest row would claim an unrelated user conversation as the bot's chat, and the hide sweep would then hide it. The existing "no pin" / "dead pin" safety tests cover exactly that and still pass. The pin keeps owning plumbing (creation, hide sweep, DM delivery); it just stops shadowing newer conversations. Guards on the candidate (`newerVisibleBotChat`): the canonical chat can never shadow itself, an empty draft never displaces a real conversation, and a gateway that omits `message_count` is treated as real history rather than discarded. 2. The workspace did not follow the bot. The three `host.openSession` calls on the bot path relied on the SDK default `keepAllProfilesScope: true`, so `$activeGatewayProfile` stayed on whatever profile was active before the click. Sessions created afterwards were then filed under the previous bot's profile — measured: four new chats started from three different bots all persisted into one profile's state.db. Clicking a bot IS a profile switch, so these pass `false`. Note on the call shape: `previewSession` is `bot.preferred_session || last`, so on a pinned bot it resolves to the PIN (preview identity must match click identity). Feeding that as the "newer" candidate makes the whole preference dead code — it always sees the pin and short-circuits on "same id". The freshest visible session therefore arrives as its own argument. The first attempt at this fix had that bug and passed its tests, which is why `bot-row-opens-latest.test.mjs` mirrors the production call site argument for argument rather than constructing a convenient one. Tests: 362 pass (was 348). Each new guard was verified by sabotage — reverting any one of the three behaviours above makes the suite fail (1, 3, and 1 tests respectively), so none of them is a test that passes either way.
This commit is contained in:
@@ -4286,7 +4286,16 @@ async function openStoredBotChat(name, storedId, summary) {
|
||||
intent: 'main',
|
||||
awaitHydration: true,
|
||||
expectHistory,
|
||||
keepAllProfilesScope: true,
|
||||
// Move the WORKSPACE onto this bot, not just the transcript.
|
||||
//
|
||||
// With the default (true) the bot's chat opened against its own backend
|
||||
// while `$activeGatewayProfile` stayed on whatever profile was active
|
||||
// before — so "New session" from inside any bot was created on that other
|
||||
// backend. Measured: four consecutive new chats started from different
|
||||
// bots all landed in the `ops` profile's state.db. Clicking a bot is a
|
||||
// workspace switch in this product (one bot = one workspace), so the
|
||||
// chrome has to follow.
|
||||
keepAllProfilesScope: false,
|
||||
retryHydrationTimeoutOnce: true
|
||||
})
|
||||
|
||||
@@ -4374,7 +4383,7 @@ function createCanonicalChat(name) {
|
||||
|
||||
if (sid && typeof host.openSession === 'function') {
|
||||
try {
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true })
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false })
|
||||
opened = true
|
||||
} catch {
|
||||
// The stored row may not exist until the kickoff persists it. Retry
|
||||
@@ -4389,7 +4398,7 @@ function createCanonicalChat(name) {
|
||||
await host.request('prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' })
|
||||
|
||||
if (!opened && sid && typeof host.openSession === 'function') {
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true })
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false })
|
||||
}
|
||||
} catch {
|
||||
// The chat already exists. Keep the pin so the next click
|
||||
@@ -4425,7 +4434,37 @@ function isCanonicalBotChatHistory(history) {
|
||||
return rootTitle === 'Bot Chat' || (!rootTitle && title === 'Bot Chat')
|
||||
}
|
||||
|
||||
async function openBotCanonicalChat(name, pinned, history) {
|
||||
/** The bot's newest VISIBLE conversation when it should win over the pin, else
|
||||
* null.
|
||||
*
|
||||
* A bot row is a workspace entry point, so it must land on what the user was
|
||||
* last saying to that bot — not on a pin frozen weeks ago. Guards, all of
|
||||
* which matter:
|
||||
* - the canonical Bot Chat itself is never "newer" (it IS the pin), so
|
||||
* plumbing can't shadow itself;
|
||||
* - an empty draft is skipped: clicking a bot right after a stray ⌘N would
|
||||
* otherwise open a blank chat instead of the conversation;
|
||||
* - identical ids mean the pin already points there — nothing to switch to.
|
||||
* Returns the stored id so callers keep using the normal open path. */
|
||||
function newerVisibleBotChat(pinned, history) {
|
||||
const id = history?.id
|
||||
|
||||
if (!id || id === pinned || isCanonicalBotChatHistory(history)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// `message_count` is absent on older gateways — treat unknown as real
|
||||
// history rather than discarding a legitimate conversation.
|
||||
const count = history?.message_count
|
||||
|
||||
if (typeof count === 'number' && count <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
async function openBotCanonicalChat(name, pinned, history, latestVisible) {
|
||||
if (!pinned) {
|
||||
// Grandfather only an actual Bot Chat. `last_session` is merely the most
|
||||
// recent row for the profile; adopting it blindly can claim an unrelated
|
||||
@@ -4467,6 +4506,38 @@ async function openBotCanonicalChat(name, pinned, history) {
|
||||
}
|
||||
|
||||
if (preferred && isCanonicalBotChatHistory(preferred)) {
|
||||
// The pin is alive and healthy — but it is not necessarily where the user
|
||||
// left off. Prefer their MOST RECENT real conversation with this bot.
|
||||
//
|
||||
// "One bot = one forever chat" welded each row to a single session: start
|
||||
// a new chat with a bot, click another bot, click back, and the new chat
|
||||
// was stranded behind the pinned transcript ("세션을 다시 만들어도 다른 봇
|
||||
// 갔다가 다시 누르면 그 전 세션으로 돌아와"). A bot row is a workspace
|
||||
// entry point here, so it should land on the live conversation. The pin
|
||||
// keeps owning plumbing — creation, hide sweep, DM delivery — and stays
|
||||
// untouched; it just stops overriding newer work.
|
||||
//
|
||||
// Deliberately AFTER the verification above: with a dead or unverified
|
||||
// pin, adopting the profile's latest row would claim an unrelated user
|
||||
// conversation as the bot's chat (see the "dead pin" safety tests).
|
||||
//
|
||||
// Uses `latestVisible` (the roster's freshest visible session), NOT
|
||||
// `history` — the caller's `history` prefers the pin so preview identity
|
||||
// matches click identity, which means it can never BE the newer chat.
|
||||
// Falls back to `history` for callers that pass only three arguments.
|
||||
const newer = newerVisibleBotChat(pinned, latestVisible ?? history)
|
||||
|
||||
if (newer) {
|
||||
try {
|
||||
await openStoredBotChat(name, newer, history)
|
||||
|
||||
return newer
|
||||
} catch {
|
||||
// Deleted or unreachable — fall back to the verified pin below so the
|
||||
// row is never dead.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred)
|
||||
return pinned
|
||||
@@ -6359,7 +6430,13 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await openBotCanonicalChat(bot.name, pinnedChat, previewSession)
|
||||
// `previewSession` prefers the PIN (preview identity must match click
|
||||
// identity), so it can never carry the newer conversation. Pass the
|
||||
// roster's freshest VISIBLE session (`last`) separately — that is what
|
||||
// "open where I left off" needs. Without this the newer-chat preference
|
||||
// was dead code: it always received the pin and short-circuited on
|
||||
// "same id".
|
||||
const id = await openBotCanonicalChat(bot.name, pinnedChat, previewSession, last)
|
||||
|
||||
if (generation === botOpenGeneration && id) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
|
||||
const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
|
||||
|
||||
/**
|
||||
* A bot row must open the conversation the user was LAST having with that bot.
|
||||
*
|
||||
* Symptom (2026-08-21): every bot was welded to one session. Start a new chat
|
||||
* with 기획총괄, click 시스템총괄, click back — and the new chat was gone,
|
||||
* replaced by the pinned transcript. "세션을 다시 만들어도 다른 봇 갔다가 다시
|
||||
* 누르면 그 전 세션으로 다시 돌아와."
|
||||
*
|
||||
* The pin still owns plumbing (creation, hide sweep, DM delivery); it just
|
||||
* must not override a newer real conversation.
|
||||
*/
|
||||
function loadOpenPath({ openSession, request }) {
|
||||
const start = source.indexOf('const canonicalCreations = new Map()')
|
||||
const end = source.indexOf('function displayName(', start)
|
||||
|
||||
assert.notEqual(start, -1, 'canonical creation section is missing')
|
||||
assert.notEqual(end, -1, 'canonical creation section delimiter is missing')
|
||||
|
||||
const saved = []
|
||||
const opened = []
|
||||
const context = {
|
||||
host: {
|
||||
openSession: async (id, options) => {
|
||||
opened.push({ id, options })
|
||||
|
||||
return openSession(id, options)
|
||||
},
|
||||
request: async (method, params) => request(method, params)
|
||||
},
|
||||
saveBotMeta: (name, patch) => saved.push({ name, patch: JSON.parse(JSON.stringify(patch)) }),
|
||||
$hideBotChats: { get: () => false },
|
||||
window: { setTimeout: callback => callback() }
|
||||
}
|
||||
|
||||
const section = source
|
||||
.slice(start, end)
|
||||
.concat('\nglobalThis.__open = { openBotCanonicalChat, newerVisibleBotChat };\n')
|
||||
|
||||
vm.runInNewContext(section, context, { filename: 'canonical-open.js' })
|
||||
|
||||
return { ...context.__open, saved, opened }
|
||||
}
|
||||
|
||||
const noRequests = async () => ({})
|
||||
|
||||
/** A live, healthy pin: `profiles.list` resolves it to the canonical Bot Chat.
|
||||
* That verification is the gate the newer-conversation preference sits behind
|
||||
* — with a dead or unverified pin the bot must NOT adopt the profile's latest
|
||||
* row (that would claim an unrelated conversation). */
|
||||
const healthyPin =
|
||||
(pinned = 'pinned-bot-chat') =>
|
||||
async (method, params) => {
|
||||
if (method === 'profiles.list') {
|
||||
const name = Object.keys(params?.preferred_session_ids ?? { ops: 1 })[0]
|
||||
|
||||
return {
|
||||
profiles: [{ name, preferred_session: { id: pinned, resolved_id: pinned, title: 'Bot Chat' } }]
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
test('bot row opens the NEWER real conversation instead of the pinned chat', async () => {
|
||||
const runtime = loadOpenPath({ openSession: async () => undefined, request: healthyPin() })
|
||||
|
||||
// The roster's freshest visible session is a real conversation the user
|
||||
// started after the pin was made.
|
||||
const history = { id: 'new-chat', title: '릴시아 카피 회의', message_count: 12, last_active: 9000 }
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('plan', 'pinned-bot-chat', history, history)
|
||||
|
||||
assert.equal(result, 'new-chat', 'should return the newer conversation')
|
||||
assert.equal(runtime.opened.length, 1)
|
||||
assert.equal(runtime.opened[0].id, 'new-chat', 'must not reopen the pinned transcript')
|
||||
assert.equal(runtime.opened[0].options.profile, 'plan')
|
||||
assert.equal(
|
||||
runtime.opened[0].options.keepAllProfilesScope,
|
||||
false,
|
||||
'clicking a bot moves the workspace onto that bot'
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* The REAL call shape from the roster row — this is what the first fix got
|
||||
* wrong. `previewSession` is `bot.preferred_session || last`, so on a pinned
|
||||
* bot it resolves to the PIN (preview identity must match click identity).
|
||||
* Feeding that as the "newer" candidate made the whole preference dead code:
|
||||
* it always saw the pin and short-circuited on "same id", and the user still
|
||||
* got the old session back ("다른 봇 눌렀다가 다시 그 봇 누르면 그 전 세션 열림").
|
||||
* The freshest visible session has to arrive as its own argument.
|
||||
*/
|
||||
test('real roster call: previewSession is the pin, latest arrives separately', async () => {
|
||||
const runtime = loadOpenPath({ openSession: async () => undefined, request: healthyPin('pin-1') })
|
||||
|
||||
const pinnedPreview = { id: 'pin-1', title: 'Bot Chat', preview: 'plumbing' }
|
||||
const last = { id: 'user-newest', title: '오늘 기획 회의', message_count: 8, last_active: 9999 }
|
||||
|
||||
// Mirrors: openBotCanonicalChat(bot.name, pinnedChat, previewSession, last)
|
||||
const result = await runtime.openBotCanonicalChat('plan', 'pin-1', pinnedPreview, last)
|
||||
|
||||
assert.equal(result, 'user-newest', 'must open the newest real conversation, not the pin')
|
||||
assert.equal(runtime.opened[0].id, 'user-newest')
|
||||
})
|
||||
|
||||
test('the canonical Bot Chat itself never counts as "newer" (it IS the pin)', () => {
|
||||
const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests })
|
||||
|
||||
assert.equal(runtime.newerVisibleBotChat('pin-1', { id: 'hidden-plumbing', title: 'Bot Chat' }), null)
|
||||
assert.equal(
|
||||
runtime.newerVisibleBotChat('pin-1', { id: 'hidden-plumbing', root_title: 'Bot Chat', title: '자동 제목' }),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('an empty draft never displaces the pinned conversation', () => {
|
||||
const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests })
|
||||
|
||||
assert.equal(runtime.newerVisibleBotChat('pin-1', { id: 'blank', title: '', message_count: 0 }), null)
|
||||
})
|
||||
|
||||
test('a gateway that omits message_count still yields the newer session', () => {
|
||||
const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests })
|
||||
|
||||
assert.equal(runtime.newerVisibleBotChat('pin-1', { id: 'legacy', title: '대화' }), 'legacy')
|
||||
})
|
||||
|
||||
test('history that IS the pin changes nothing', () => {
|
||||
const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests })
|
||||
|
||||
assert.equal(runtime.newerVisibleBotChat('same-id', { id: 'same-id', title: '대화', message_count: 5 }), null)
|
||||
})
|
||||
|
||||
/**
|
||||
* Every path that mounts a bot's chat must move the workspace onto that bot.
|
||||
*
|
||||
* `keepAllProfilesScope` defaults to TRUE in the SDK, which keeps
|
||||
* `$activeGatewayProfile` pointing at whatever profile was active before the
|
||||
* click. Bot Mode wants the opposite: clicking a bot IS a profile switch, and
|
||||
* leaving the scope behind meant sessions created afterwards were filed under
|
||||
* the previous bot's profile (measured: four new chats started from three
|
||||
* different bots all landed in `ops`).
|
||||
*
|
||||
* The newly-minted-chat path is asserted separately from the stored-chat path
|
||||
* because they are different call sites; a guard on only one of them let the
|
||||
* other regress silently.
|
||||
*/
|
||||
function creationRuntime({ failFirstOpen = false } = {}) {
|
||||
let opens = 0
|
||||
|
||||
return loadOpenPath({
|
||||
openSession: async () => {
|
||||
opens += 1
|
||||
|
||||
if (failFirstOpen && opens === 1) {
|
||||
throw new Error('stored row not persisted yet')
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
request: async method => {
|
||||
if (method === 'session.create') {
|
||||
return { stored_session_id: 'fresh-stored', session_id: 'fresh-runtime' }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('a newly minted Bot Chat opens with the workspace following the bot', async () => {
|
||||
const runtime = creationRuntime()
|
||||
|
||||
// No pin and no adoptable history — the real "first click on a bot" path.
|
||||
const result = await runtime.openBotCanonicalChat('plan', null, null, null)
|
||||
|
||||
assert.equal(result, 'fresh-stored')
|
||||
assert.ok(runtime.opened.length >= 1, 'the new chat is mounted')
|
||||
|
||||
for (const entry of runtime.opened) {
|
||||
assert.equal(entry.options.keepAllProfilesScope, false, 'creating a bot chat must move the workspace onto that bot')
|
||||
assert.equal(entry.options.profile, 'plan')
|
||||
}
|
||||
})
|
||||
|
||||
test('the post-kickoff retry open also follows the bot', async () => {
|
||||
const runtime = creationRuntime({ failFirstOpen: true })
|
||||
|
||||
await runtime.openBotCanonicalChat('plan', null, null, null)
|
||||
|
||||
assert.equal(runtime.opened.length, 2, 'first open fails, retry runs after the kickoff')
|
||||
assert.equal(
|
||||
runtime.opened[1].options.keepAllProfilesScope,
|
||||
false,
|
||||
'the retry must not silently fall back to the SDK default'
|
||||
)
|
||||
})
|
||||
|
||||
test('a failed open of the newer session falls back to the pin (row never dies)', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async id => {
|
||||
if (id === 'deleted-chat') {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
request: healthyPin('pin-1')
|
||||
})
|
||||
|
||||
const history = { id: 'deleted-chat', title: '지워진 대화', message_count: 3 }
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'pin-1', history)
|
||||
|
||||
const ids = runtime.opened.map(entry => entry.id)
|
||||
|
||||
assert.ok(ids.includes('deleted-chat'), 'tries the newer session first')
|
||||
assert.ok(ids.includes('pin-1'), 'falls back to the verified pin')
|
||||
assert.equal(result, 'pin-1', 'row resolves to the pin rather than failing')
|
||||
})
|
||||
@@ -129,7 +129,12 @@ test('pin: preferred_session present opens the resolved session and keeps the pi
|
||||
intent: 'main',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
keepAllProfilesScope: true,
|
||||
// false: clicking a bot moves the WORKSPACE onto that bot, not just the
|
||||
// transcript. With true, `$activeGatewayProfile` stayed on the previously
|
||||
// active profile, so "New session" from inside any bot was created on
|
||||
// that other backend (measured: four new chats from different bots all
|
||||
// landed in `ops`).
|
||||
keepAllProfilesScope: false,
|
||||
retryHydrationTimeoutOnce: true
|
||||
}
|
||||
}])
|
||||
|
||||
Reference in New Issue
Block a user