diff --git a/changelog.d/1127-group-session-model.fixed.md b/changelog.d/1127-group-session-model.fixed.md new file mode 100644 index 000000000..06b60104a --- /dev/null +++ b/changelog.d/1127-group-session-model.fixed.md @@ -0,0 +1 @@ +A Bot's existing group sessions follow a model change, including when the record already says that model is applied, so a turn no longer asks a provider the Bot was moved off (#1127). diff --git a/core/src/host/models.js b/core/src/host/models.js index d76205fb1..8a8fdac62 100644 --- a/core/src/host/models.js +++ b/core/src/host/models.js @@ -168,13 +168,67 @@ export function install(rt) { const sameSelection = (a, b) => a.provider === b.provider && a.model === b.model && a.reasoningEffort === b.reasoningEffort + // The provider/model the Session last requested, or null when it has not requested. + // undefined means the route cannot be read, so the record is left alone (#289). + async function liveRoute(sessionId) { + if (typeof rt.resolveAgent !== 'function') return undefined + let agent + try { + agent = await rt.resolveAgent(sessionId) + } catch { + // A Session that cannot be opened has no route to compare. + return undefined + } + const read = agent?.session?.requestHeader + if (typeof read !== 'function') return undefined + let header + try { + header = read() + } catch { + // A header that cannot be read is not evidence of a sticky route. + return undefined + } + const config = header?.config + if (!config?.provider || !config?.model) return null + return { provider: config.provider, model: config.model } + } + + // The record can say the model is applied while a group Session still requests the + // previous provider (#1127). Re-select that Session. A Session we cannot read is + // skipped, so a settled Bot is not selected again on every refresh (#289). + async function rebindSessionModel(bot, sessionId) { + const ref = await desiredModel(bot) + if (ref === undefined || sessionId === undefined) return + const live = await liveRoute(sessionId) + if (live === undefined) return + const route = splitRef(ref) + if (live !== null && live.provider === route.provider && live.model === route.model) return + const effort = await desiredEffort(bot, ref) + try { + await selectSessionModel(sessionId, { ...route, ...(effort === undefined ? {} : { reasoningEffort: effort }) }) + } catch (error) { + bot.modelStale = true + rt.warn('ds-bot: model for %s in a group not applied: %s', bot.name, error) + await rt.save?.() + } + } + + async function rebindGroupSessions(bot) { + for (const sessionId of rt.groupSessionsOf(bot.id)) await rebindSessionModel(bot, sessionId) + } + // `force` re-selects even when the record says it is applied: the composer's own // model picker can move the Session without telling this plugin. async function applyModel(bot, { force = false } = {}) { const ref = await desiredModel(bot) if (ref === undefined) return ref const effort = await desiredEffort(bot, ref) - if (!force && !bot.modelStale && bot.appliedModel === ref && (bot.appliedEffort ?? undefined) === effort) return ref + // The own chat stays as recorded. Group Sessions still on another route are + // re-selected here, and again before their next turn (#1127). + if (!force && !bot.modelStale && bot.appliedModel === ref && (bot.appliedEffort ?? undefined) === effort) { + await rebindGroupSessions(bot) + return ref + } const route = { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) } await selectSessionModel(rt.chatOf(bot.id), route) // A group Session that did not take the model keeps the Bot "stale", so the next apply (a model @@ -274,6 +328,6 @@ export function install(rt) { testModel, catalog, cachedCatalog: () => catalogCache.value, cachedDefault: () => pickDefault(catalogCache.value), defaultRef, defaultModel, resolveModel, desiredModel, reasoningOf, resolveEffort, desiredEffort, - selectSessionModel, applyModel, waitForModel, syncModels, + selectSessionModel, applyModel, rebindSessionModel, waitForModel, syncModels, }) } diff --git a/core/src/host/parts.js b/core/src/host/parts.js index 9f019b61b..570741c12 100644 --- a/core/src/host/parts.js +++ b/core/src/host/parts.js @@ -122,6 +122,20 @@ export function canonicalHeadings(note, names) { return lines.join('\n') } +// The model a fresh part starts on. The Bot's own model wins. The previous request +// is only a fallback when the Bot has none, so a sticky group route is not copied +// onto the next part (#1127). +export function freshPartModel(header, desiredRef) { + if (desiredRef !== undefined) return { ref: desiredRef } + if (header?.provider && header?.model) { + return { + ref: `${header.provider}/${header.model}`, + ...(header.reasoningEffort === undefined ? {} : { effort: header.reasoningEffort }), + } + } + return undefined +} + export function install(rt) { const { ctx, config, state } = rt @@ -813,11 +827,12 @@ export function install(rt) { const dir = rt.workDirOf(job) const intended = dir !== undefined && await rt.isFolder(dir) ? dir : rt.teamFolder() const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name, { dir }) - const route = session.requestHeader()?.config - const ref = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot) - if (ref !== undefined) { - const effort = route?.reasoningEffort ?? await rt.desiredEffort(bot, ref) - await rt.selectSessionModel(freshId, { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) }) + // The Bot's model wins over the previous request. Copying that header is what + // kept a group Session on a provider the Bot had already left (#1127). + const picked = freshPartModel(session.requestHeader()?.config, await rt.desiredModel(bot)) + if (picked !== undefined) { + const effort = picked.effort ?? await rt.desiredEffort(bot, picked.ref) + await rt.selectSessionModel(freshId, { ...splitRef(picked.ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) }) .catch(error => rt.warn('ds-bot: model for the fresh part of %s not applied: %s', bot.name, error)) } await rt.applyPermission(bot, freshId) diff --git a/core/src/host/room-participant.js b/core/src/host/room-participant.js index ffd26bfcd..c536f7226 100644 --- a/core/src/host/room-participant.js +++ b/core/src/host/room-participant.js @@ -10,7 +10,9 @@ export function createDshRoomParticipant(rt) { const known = room.sessions[member.id] if (known !== undefined) { // A failed model selection left this Bot stale (#289): try again before it speaks. + // A record that already matches can still hide a sticky route (#1127). if (member.modelStale) await rt.applyModel(member).catch(error => rt.warn('ds-bot: model for %s in %s not repaired: %s', member.name, room.name, error)) + else await rt.rebindSessionModel?.(member, known) await rt.ensureCapabilities(known) return { id: known, created: false } } diff --git a/core/test/fresh-part-model.test.js b/core/test/fresh-part-model.test.js new file mode 100644 index 000000000..4ce07e3bc --- /dev/null +++ b/core/test/fresh-part-model.test.js @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { freshPartModel } from '../src/host/parts.js' + +test('a fresh part takes the Bot\'s model, not the previous request\'s provider (#1127)', () => { + const sticky = { provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'high' } + assert.deepEqual(freshPartModel(sticky, 'opencsitool/DeepSeek-V4.1-Flash-1017'), { ref: 'opencsitool/DeepSeek-V4.1-Flash-1017' }) + assert.deepEqual(freshPartModel(sticky, undefined), { ref: 'deepseek-official/deepseek-flash', effort: 'high' }) + assert.equal(freshPartModel(undefined, undefined), undefined) + assert.deepEqual(freshPartModel({ provider: 'deepseek-official', model: 'deepseek-flash' }, undefined), { ref: 'deepseek-official/deepseek-flash' }) +}) diff --git a/core/test/models.test.js b/core/test/models.test.js index 94ecb4659..acebe3df0 100644 --- a/core/test/models.test.js +++ b/core/test/models.test.js @@ -144,6 +144,74 @@ test('a Bot model applies its thinking level, and only re-selects on a change', assert.equal(bot.appliedEffort, undefined) }) +// The next request uses whatever selectModel last installed. A Session we cannot +// read stays out of this, which is what the #289 settled early-return relies on. +function routesOf(rt, headers) { + rt.resolveAgent = async (id) => ({ + session: { requestHeader: () => (headers.has(id) ? { config: { ...headers.get(id) } } : undefined) }, + }) + const select = rt.ctx.sessionController.selectModel + rt.ctx.sessionController.selectModel = async (request) => { + const result = await select(request) + headers.set(request.sessionId, { provider: request.provider, model: request.model }) + return result + } +} + +test('a settled record still re-selects a group Session left on another provider (#1127)', async () => { + const next = 'opencsitool/DeepSeek-V4.1-Flash-1017' + const { rt, selected } = bench({ providers: { 'deepseek-official': ['deepseek-flash'], opencsitool: ['DeepSeek-V4.1-Flash-1017'] } }) + const headers = new Map([ + ['g1', { provider: 'deepseek-official', model: 'deepseek-flash' }], + ['g2', { provider: 'opencsitool', model: 'DeepSeek-V4.1-Flash-1017' }], + ['b1', { provider: 'deepseek-official', model: 'deepseek-flash' }], + ['stranger', { provider: 'deepseek-official', model: 'deepseek-flash' }], + ]) + routesOf(rt, headers) + rt.groupSessionsOf = () => ['g1', 'g2'] + const bot = { id: 'b1', name: 'Data', model: next, appliedModel: next } + await rt.applyModel(bot) + assert.deepEqual(headers.get('g1'), { provider: 'opencsitool', model: 'DeepSeek-V4.1-Flash-1017' }) + assert.deepEqual(selected.filter(item => item.startsWith('g1:')), ['g1:opencsitool/DeepSeek-V4.1-Flash-1017']) + // Already on the Bot's model: not selected again. The own chat and a stranger Session stay put. + assert.equal(selected.some(item => item.startsWith('g2:')), false) + assert.equal(selected.some(item => item.startsWith('b1:')), false) + assert.equal(selected.some(item => item.startsWith('stranger:')), false) + assert.deepEqual(headers.get('b1'), { provider: 'deepseek-official', model: 'deepseek-flash' }) + assert.deepEqual(headers.get('stranger'), { provider: 'deepseek-official', model: 'deepseek-flash' }) + assert.equal('modelStale' in bot, false) + const before = selected.length + await rt.applyModel(bot) + assert.equal(selected.length, before) +}) + +test('force-apply re-selects every group Session even when the record already matches (#1127)', async () => { + const next = 'opencsitool/DeepSeek-V4.1-Flash-1017' + const { rt, selected } = bench({ providers: { 'deepseek-official': ['deepseek-flash'], opencsitool: ['DeepSeek-V4.1-Flash-1017'] } }) + rt.groupSessionsOf = () => ['g1'] + const bot = { id: 'b1', name: 'Data', model: next, appliedModel: next } + await rt.applyModel(bot, { force: true }) + assert.deepEqual(selected.filter(item => item.startsWith('g1:')), ['g1:opencsitool/DeepSeek-V4.1-Flash-1017']) + assert.ok(selected.some(item => item.startsWith('b1:'))) +}) + +test('a drifted group Session that refuses the model marks the Bot stale (#1127)', async () => { + const next = 'opencsitool/DeepSeek-V4.1-Flash-1017' + const { rt } = bench({ providers: { 'deepseek-official': ['deepseek-flash'], opencsitool: ['DeepSeek-V4.1-Flash-1017'] } }) + const headers = new Map([['g1', { provider: 'deepseek-official', model: 'deepseek-flash' }]]) + routesOf(rt, headers) + const select = rt.ctx.sessionController.selectModel + rt.ctx.sessionController.selectModel = async (request) => { + if (request.sessionId === 'g1') throw new Error('no adapter') + return select(request) + } + rt.groupSessionsOf = () => ['g1'] + const bot = { id: 'b1', name: 'Data', model: next, appliedModel: next } + await rt.applyModel(bot) + assert.equal(bot.modelStale, true) + assert.equal(bot.appliedModel, next) +}) + test('a group Session that did not take the model keeps the Bot stale and is retried (#289)', async () => { const { rt, selected } = bench() let failing = true diff --git a/core/test/room-participant.test.js b/core/test/room-participant.test.js index 008786f96..a29c5f4cd 100644 --- a/core/test/room-participant.test.js +++ b/core/test/room-participant.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { getEventListeners } from 'node:events' import { createDshRoomParticipant } from '../src/host/room-participant.js' +import { install as installModels } from '../src/host/models.js' import { ROOM_WORK_GRACE_MS } from '../src/host/constants.js' function fixture(t) { @@ -31,6 +32,66 @@ function fixture(t) { return { rt, driver, room, member, calls, controller, started, run, waiter } } +test('a stale Bot still repairs through applyModel before it speaks (#289)', async (t) => { + const { rt, driver, room, member, calls } = fixture(t) + room.sessions = { writer: 's1' } + member.modelStale = true + rt.applyModel = async (bot) => { calls.push(['apply', bot.id]); delete bot.modelStale } + rt.rebindSessionModel = async () => { calls.push(['rebind']) } + assert.deepEqual(await driver.open({ room, member }), { id: 's1', created: false }) + assert.deepEqual(calls.filter(call => call[0] === 'apply' || call[0] === 'rebind'), [['apply', 'writer']]) +}) + +test('opening an existing group session re-selects a route the record already calls applied (#1127)', async (t) => { + const next = 'opencsitool/DeepSeek-V4.1-Flash-1017' + const headers = new Map([ + ['g1', { provider: 'deepseek-official', model: 'deepseek-flash' }], + ['own', { provider: 'deepseek-official', model: 'deepseek-flash' }], + ['stranger', { provider: 'deepseek-official', model: 'deepseek-flash' }], + ]) + const selected = [] + let current = { provider: 'deepseek-official', model: 'deepseek-flash' } + const ctx = { + llm: { + listProviders: () => [{ id: 'deepseek-official' }, { id: 'opencsitool' }], + listModels: async (id) => [{ id: id === 'opencsitool' ? 'DeepSeek-V4.1-Flash-1017' : 'deepseek-flash' }], + resolveModelInfo: async () => ({}), + }, + get: () => ({ currentSelection: () => ({ ...current }), saveSelection: async (nextSelection) => { current = { ...nextSelection } } }), + sessionController: { + selectModel: async (request) => { + selected.push(request) + headers.set(request.sessionId, { provider: request.provider, model: request.model }) + }, + }, + } + const member = { id: 'data', name: 'Data', model: next, appliedModel: next } + const room = { id: 'ops', name: '研发组', seen: {}, sessions: { data: 'g1' } } + const rt = { + ctx, state: { bots: { data: member } }, warn() {}, save: async () => {}, + chatOf: () => 'own', groupSessionsOf: () => ['g1'], + resolveAgent: async (id) => ({ session: { requestHeader: () => (headers.has(id) ? { config: { ...headers.get(id) } } : undefined) } }), + live: new Map(), groupOwners: new Map(), roomWaiters: new Map(), + createSession: async () => { throw new Error('the session already exists') }, + ensureCapabilities: async () => {}, applyPermission: async () => {}, + workDirOf: () => '/fixture', + } + installModels(rt) + const driver = createDshRoomParticipant(rt) + t.after(() => {}) + assert.deepEqual(await driver.open({ room, member }), { id: 'g1', created: false }) + assert.equal(selected.length, 1) + assert.equal(selected[0].sessionId, 'g1') + assert.equal(selected[0].provider, 'opencsitool') + assert.equal(selected[0].model, 'DeepSeek-V4.1-Flash-1017') + assert.deepEqual(headers.get('g1'), { provider: 'opencsitool', model: 'DeepSeek-V4.1-Flash-1017' }) + assert.equal(headers.get('own').provider, 'deepseek-official', 'the own chat is not re-selected from a group open') + assert.equal(headers.get('stranger').provider, 'deepseek-official', 'a session that is not this Bot\'s group session stays') + assert.equal('modelStale' in member, false) + await driver.open({ room, member }) + assert.equal(selected.length, 1, 'a session already on the Bot\'s model is not selected again') +}) + test('DSH participant opens isolated room sessions and reuses persisted links', async t => { const { rt, driver, room, member, calls } = fixture(t) assert.deepEqual(await driver.open({ room, member }), { id: 's1', created: true }) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index fa3cad25c..4447dbc65 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -230,7 +230,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - New Bot model (#410): `roster.js` `createBot` fixes a Bot's model at creation. A Bot made by another Bot (`create_bot`, a tree child via `flybot.bot.create` with `createdBy`) and given no model starts on its maker's effective model (`desiredModel`) and its thinking level resolved against that model (dropped when that model does not serve it), so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by `core/test/roster.test.js`. - Silent creation (#645): `createBot` and `create-room` send nothing by themselves. A Bot's model is applied without a turn, and a new group posts no opening round, so making a team costs no model turn. A line the user typed in the new-Bot card is their first request: `new-chat.js` sends it to the new Bot as one user turn and the card carries no `brief`; `roster.js` turns a `brief` into `Why you exist: …` instructions only when a Bot created the Bot (`create_bot`, a tree child), where the brief is a parent's context. The `kickoff` role, the `[Bot team setup]` text and the group-created cue are gone (an older transcript's kickoff line is still skipped by `historyEntry`), and `create_bot` answers that the new Bot starts no turn. Covered by `core/test/roster.test.js`, `core/test/rounds.test.js` and `core/test/new-chat.test.js`. - Every new Bot is announced (`bot.created`) through the `rt.createBot` wrapper, including the first Main Bot `bootstrap` creates on the first `state` request and a `duplicate-bot` copy. Creating those through the module-local `createBot` used to skip the event, so a plugin that adopts new Bots (the tree root) could miss the first Chief or leave a copy outside the tree until a restart. Covered by `core/test/flybot.test.js`. -- Group Session models (#289): `applyModel` does not trust `appliedModel` while a Bot is `modelStale`. A failed model selection in a group Session (from `applyModel` or from `room-participant.js` `open()`) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. Covered by `core/test/models.test.js`. +- Group Session models (#289, #1127): `applyModel` does not trust `appliedModel` while a Bot is `modelStale`. A failed model selection in a group Session (from `applyModel` or from `room-participant.js` `open()`) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. A settled record (`appliedModel` matches and `modelStale` is unset) still re-selects a group Session whose last request used another provider or model, on that apply and before the Session's next turn; the Bot's own chat is left as recorded. A Session whose route cannot be read is left alone, so a settled Bot is not selected again on every refresh. A fresh part takes the Bot's model, not the previous request's route. Covered by `core/test/models.test.js`, `core/test/room-participant.test.js` and `core/test/fresh-part-model.test.js`. ### Face-to-face room lead (#476) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 9c2754ef2..aa8a6f5e0 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -3472,12 +3472,72 @@ diff --git a/src/host/models.js b/src/host/models.js if (!bot.model) return undefined const entries = await catalog() const served = new Set(entries.map(entry => entry.ref)) -@@ -149,13 +149,18 @@ export function install(rt) { +@@ -143,19 +143,78 @@ export function install(rt) { + + const sameSelection = (a, b) => a.provider === b.provider && a.model === b.model && a.reasoningEffort === b.reasoningEffort + ++ // The provider/model the Session last requested, or null when it has not requested. ++ // undefined means the route cannot be read, so the record is left alone (#289). ++ async function liveRoute(sessionId) { ++ if (typeof rt.resolveAgent !== 'function') return undefined ++ let agent ++ try { ++ agent = await rt.resolveAgent(sessionId) ++ } catch { ++ // A Session that cannot be opened has no route to compare. ++ return undefined ++ } ++ const read = agent?.session?.requestHeader ++ if (typeof read !== 'function') return undefined ++ let header ++ try { ++ header = read() ++ } catch { ++ // A header that cannot be read is not evidence of a sticky route. ++ return undefined ++ } ++ const config = header?.config ++ if (!config?.provider || !config?.model) return null ++ return { provider: config.provider, model: config.model } ++ } ++ ++ // The record can say the model is applied while a group Session still requests the ++ // previous provider (#1127). Re-select that Session. A Session we cannot read is ++ // skipped, so a settled Bot is not selected again on every refresh (#289). ++ async function rebindSessionModel(bot, sessionId) { ++ const ref = await desiredModel(bot) ++ if (ref === undefined || sessionId === undefined) return ++ const live = await liveRoute(sessionId) ++ if (live === undefined) return ++ const route = splitRef(ref) ++ if (live !== null && live.provider === route.provider && live.model === route.model) return ++ const effort = await desiredEffort(bot, ref) ++ try { ++ await selectSessionModel(sessionId, { ...route, ...(effort === undefined ? {} : { reasoningEffort: effort }) }) ++ } catch (error) { ++ bot.modelStale = true ++ rt.warn('ds-bot: model for %s in a group not applied: %s', bot.name, error) ++ await rt.save?.() ++ } ++ } ++ ++ async function rebindGroupSessions(bot) { ++ for (const sessionId of rt.groupSessionsOf(bot.id)) await rebindSessionModel(bot, sessionId) ++ } ++ + // `force` re-selects even when the record says it is applied: the composer's own + // model picker can move the Session without telling this plugin. + async function applyModel(bot, { force = false } = {}) { const ref = await desiredModel(bot) if (ref === undefined) return ref const effort = await desiredEffort(bot, ref) - if (!force && bot.appliedModel === ref && (bot.appliedEffort ?? undefined) === effort) return ref -+ if (!force && !bot.modelStale && bot.appliedModel === ref && (bot.appliedEffort ?? undefined) === effort) return ref ++ // The own chat stays as recorded. Group Sessions still on another route are ++ // re-selected here, and again before their next turn (#1127). ++ if (!force && !bot.modelStale && bot.appliedModel === ref && (bot.appliedEffort ?? undefined) === effort) { ++ await rebindGroupSessions(bot) ++ return ref ++ } const route = { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) } await selectSessionModel(rt.chatOf(bot.id), route) + // A group Session that did not take the model keeps the Bot "stale", so the next apply (a model @@ -3504,7 +3564,7 @@ diff --git a/src/host/models.js b/src/host/models.js async function waitForModel(bot, ms = ROUTE_WAIT_MS) { const deadline = Date.now() + ms for (;;) { -@@ -194,7 +194,54 @@ export function install(rt) { +@@ -194,9 +194,56 @@ export function install(rt) { } } @@ -3558,7 +3618,10 @@ diff --git a/src/host/models.js b/src/host/models.js + testModel, catalog, cachedCatalog: () => catalogCache.value, cachedDefault: () => pickDefault(catalogCache.value), defaultRef, defaultModel, resolveModel, desiredModel, reasoningOf, resolveEffort, desiredEffort, - selectSessionModel, applyModel, waitForModel, syncModels, +- selectSessionModel, applyModel, waitForModel, syncModels, ++ selectSessionModel, applyModel, rebindSessionModel, waitForModel, syncModels, + }) + } diff --git a/src/host/prompt.js b/src/host/prompt.js --- a/src/host/prompt.js +++ b/src/host/prompt.js @@ -7851,10 +7914,78 @@ diff --git a/test/model-menu.test.js b/test/model-menu.test.js diff --git a/test/models.test.js b/test/models.test.js --- a/test/models.test.js +++ b/test/models.test.js -@@ -144,6 +144,30 @@ test('a Bot model applies its thinking level, and only re-selects on a change', +@@ -144,6 +144,98 @@ test('a Bot model applies its thinking level, and only re-selects on a change', assert.equal(bot.appliedEffort, undefined) }) ++// The next request uses whatever selectModel last installed. A Session we cannot ++// read stays out of this, which is what the #289 settled early-return relies on. ++function routesOf(rt, headers) { ++ rt.resolveAgent = async (id) => ({ ++ session: { requestHeader: () => (headers.has(id) ? { config: { ...headers.get(id) } } : undefined) }, ++ }) ++ const select = rt.ctx.sessionController.selectModel ++ rt.ctx.sessionController.selectModel = async (request) => { ++ const result = await select(request) ++ headers.set(request.sessionId, { provider: request.provider, model: request.model }) ++ return result ++ } ++} ++ ++test('a settled record still re-selects a group Session left on another provider (#1127)', async () => { ++ const next = 'opencsitool/DeepSeek-V4.1-Flash-1017' ++ const { rt, selected } = bench({ providers: { 'deepseek-official': ['deepseek-flash'], opencsitool: ['DeepSeek-V4.1-Flash-1017'] } }) ++ const headers = new Map([ ++ ['g1', { provider: 'deepseek-official', model: 'deepseek-flash' }], ++ ['g2', { provider: 'opencsitool', model: 'DeepSeek-V4.1-Flash-1017' }], ++ ['b1', { provider: 'deepseek-official', model: 'deepseek-flash' }], ++ ['stranger', { provider: 'deepseek-official', model: 'deepseek-flash' }], ++ ]) ++ routesOf(rt, headers) ++ rt.groupSessionsOf = () => ['g1', 'g2'] ++ const bot = { id: 'b1', name: 'Data', model: next, appliedModel: next } ++ await rt.applyModel(bot) ++ assert.deepEqual(headers.get('g1'), { provider: 'opencsitool', model: 'DeepSeek-V4.1-Flash-1017' }) ++ assert.deepEqual(selected.filter(item => item.startsWith('g1:')), ['g1:opencsitool/DeepSeek-V4.1-Flash-1017']) ++ // Already on the Bot's model: not selected again. The own chat and a stranger Session stay put. ++ assert.equal(selected.some(item => item.startsWith('g2:')), false) ++ assert.equal(selected.some(item => item.startsWith('b1:')), false) ++ assert.equal(selected.some(item => item.startsWith('stranger:')), false) ++ assert.deepEqual(headers.get('b1'), { provider: 'deepseek-official', model: 'deepseek-flash' }) ++ assert.deepEqual(headers.get('stranger'), { provider: 'deepseek-official', model: 'deepseek-flash' }) ++ assert.equal('modelStale' in bot, false) ++ const before = selected.length ++ await rt.applyModel(bot) ++ assert.equal(selected.length, before) ++}) ++ ++test('force-apply re-selects every group Session even when the record already matches (#1127)', async () => { ++ const next = 'opencsitool/DeepSeek-V4.1-Flash-1017' ++ const { rt, selected } = bench({ providers: { 'deepseek-official': ['deepseek-flash'], opencsitool: ['DeepSeek-V4.1-Flash-1017'] } }) ++ rt.groupSessionsOf = () => ['g1'] ++ const bot = { id: 'b1', name: 'Data', model: next, appliedModel: next } ++ await rt.applyModel(bot, { force: true }) ++ assert.deepEqual(selected.filter(item => item.startsWith('g1:')), ['g1:opencsitool/DeepSeek-V4.1-Flash-1017']) ++ assert.ok(selected.some(item => item.startsWith('b1:'))) ++}) ++ ++test('a drifted group Session that refuses the model marks the Bot stale (#1127)', async () => { ++ const next = 'opencsitool/DeepSeek-V4.1-Flash-1017' ++ const { rt } = bench({ providers: { 'deepseek-official': ['deepseek-flash'], opencsitool: ['DeepSeek-V4.1-Flash-1017'] } }) ++ const headers = new Map([['g1', { provider: 'deepseek-official', model: 'deepseek-flash' }]]) ++ routesOf(rt, headers) ++ const select = rt.ctx.sessionController.selectModel ++ rt.ctx.sessionController.selectModel = async (request) => { ++ if (request.sessionId === 'g1') throw new Error('no adapter') ++ return select(request) ++ } ++ rt.groupSessionsOf = () => ['g1'] ++ const bot = { id: 'b1', name: 'Data', model: next, appliedModel: next } ++ await rt.applyModel(bot) ++ assert.equal(bot.modelStale, true) ++ assert.equal(bot.appliedModel, next) ++}) ++ +test('a group Session that did not take the model keeps the Bot stale and is retried (#289)', async () => { + const { rt, selected } = bench() + let failing = true diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index a5a61622c..37588dbbc 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -453,6 +453,27 @@ diff --git a/src/host/parts.js b/src/host/parts.js // The user's answer to an ask_user card. The message carries the question with the // answer, so the answer still makes sense after a checkpoint drops the card. export function questionReply(question, answer) { +@@ -121,6 +121,20 @@ export function canonicalHeadings(note, names) { + return lines.join('\n') + } + ++// The model a fresh part starts on. The Bot's own model wins. The previous request ++// is only a fallback when the Bot has none, so a sticky group route is not copied ++// onto the next part (#1127). ++export function freshPartModel(header, desiredRef) { ++ if (desiredRef !== undefined) return { ref: desiredRef } ++ if (header?.provider && header?.model) { ++ return { ++ ref: `${header.provider}/${header.model}`, ++ ...(header.reasoningEffort === undefined ? {} : { effort: header.reasoningEffort }), ++ } ++ } ++ return undefined ++} ++ + export function install(rt) { + const { ctx, config, state } = rt + @@ -149,21 +149,44 @@ export function install(rt) { // A broken provider adapter must not crash every turn; estimate from the // derived messages instead, with one warning. @@ -607,7 +628,23 @@ diff --git a/src/host/parts.js b/src/host/parts.js } } -@@ -775,6 +775,32 @@ export function install(rt) { +@@ -765,16 +765,43 @@ export function install(rt) { + const dir = rt.workDirOf(job) + const intended = dir !== undefined && await rt.isFolder(dir) ? dir : rt.teamFolder() + const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name, { dir }) +- const route = session.requestHeader()?.config +- const ref = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot) +- if (ref !== undefined) { +- const effort = route?.reasoningEffort ?? await rt.desiredEffort(bot, ref) +- await rt.selectSessionModel(freshId, { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) }) ++ // The Bot's model wins over the previous request. Copying that header is what ++ // kept a group Session on a provider the Bot had already left (#1127). ++ const picked = freshPartModel(session.requestHeader()?.config, await rt.desiredModel(bot)) ++ if (picked !== undefined) { ++ const effort = picked.effort ?? await rt.desiredEffort(bot, picked.ref) ++ await rt.selectSessionModel(freshId, { ...splitRef(picked.ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) }) + .catch(error => rt.warn('ds-bot: model for the fresh part of %s not applied: %s', bot.name, error)) + } await rt.applyPermission(bot, freshId) .catch(error => rt.warn('ds-bot: permission for the fresh part of %s not applied: %s', bot.name, error)) const segment = { sessionId: agent.id, endedAt: Date.now(), ...(tokens !== undefined ? { tokens } : {}), ...noted } @@ -665,12 +702,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js + restore() + await ctx.workspaceRegistry.archiveSession(freshId).catch(() => { /* best effort */ }) + throw error - } -- const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare, folder }) -- freshParts.set(freshId, estimateTokens(text)) -- fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) })) -- await ctx.sessions.flush(fresh.session) -- // Then what the old part held: the stopped turn's input, then whatever waited. ++ } + let folder + try { + await rt.ensureCapabilities(freshId) @@ -692,7 +724,12 @@ diff --git a/src/host/parts.js b/src/host/parts.js + await rt.save().catch(() => { /* the in-memory state is the old part; a failed rollback save is the worst case */ }) + await ctx.workspaceRegistry.archiveSession(freshId).catch(() => { /* best effort */ }) + throw error -+ } + } +- const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare, folder }) +- freshParts.set(freshId, estimateTokens(text)) +- fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) })) +- await ctx.sessions.flush(fresh.session) +- // Then what the old part held: the stopped turn's input, then whatever waited. + // The earlier part may not be archived while this move still holds its tasks (#896), so + // the switch keeps the promise and waits for it before archiving; the handoff goes on now. + job.scheduleMove = rt.moveSchedules(agent.id, freshId) diff --git a/patches/ds-bot/files/src/host/room-participant.js b/patches/ds-bot/files/src/host/room-participant.js index ffd26bfcd..c536f7226 100644 --- a/patches/ds-bot/files/src/host/room-participant.js +++ b/patches/ds-bot/files/src/host/room-participant.js @@ -10,7 +10,9 @@ export function createDshRoomParticipant(rt) { const known = room.sessions[member.id] if (known !== undefined) { // A failed model selection left this Bot stale (#289): try again before it speaks. + // A record that already matches can still hide a sticky route (#1127). if (member.modelStale) await rt.applyModel(member).catch(error => rt.warn('ds-bot: model for %s in %s not repaired: %s', member.name, room.name, error)) + else await rt.rebindSessionModel?.(member, known) await rt.ensureCapabilities(known) return { id: known, created: false } } diff --git a/patches/ds-bot/files/test/fresh-part-model.test.js b/patches/ds-bot/files/test/fresh-part-model.test.js new file mode 100644 index 000000000..4ce07e3bc --- /dev/null +++ b/patches/ds-bot/files/test/fresh-part-model.test.js @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { freshPartModel } from '../src/host/parts.js' + +test('a fresh part takes the Bot\'s model, not the previous request\'s provider (#1127)', () => { + const sticky = { provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'high' } + assert.deepEqual(freshPartModel(sticky, 'opencsitool/DeepSeek-V4.1-Flash-1017'), { ref: 'opencsitool/DeepSeek-V4.1-Flash-1017' }) + assert.deepEqual(freshPartModel(sticky, undefined), { ref: 'deepseek-official/deepseek-flash', effort: 'high' }) + assert.equal(freshPartModel(undefined, undefined), undefined) + assert.deepEqual(freshPartModel({ provider: 'deepseek-official', model: 'deepseek-flash' }, undefined), { ref: 'deepseek-official/deepseek-flash' }) +}) diff --git a/patches/ds-bot/files/test/room-participant.test.js b/patches/ds-bot/files/test/room-participant.test.js index 008786f96..a29c5f4cd 100644 --- a/patches/ds-bot/files/test/room-participant.test.js +++ b/patches/ds-bot/files/test/room-participant.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { getEventListeners } from 'node:events' import { createDshRoomParticipant } from '../src/host/room-participant.js' +import { install as installModels } from '../src/host/models.js' import { ROOM_WORK_GRACE_MS } from '../src/host/constants.js' function fixture(t) { @@ -31,6 +32,66 @@ function fixture(t) { return { rt, driver, room, member, calls, controller, started, run, waiter } } +test('a stale Bot still repairs through applyModel before it speaks (#289)', async (t) => { + const { rt, driver, room, member, calls } = fixture(t) + room.sessions = { writer: 's1' } + member.modelStale = true + rt.applyModel = async (bot) => { calls.push(['apply', bot.id]); delete bot.modelStale } + rt.rebindSessionModel = async () => { calls.push(['rebind']) } + assert.deepEqual(await driver.open({ room, member }), { id: 's1', created: false }) + assert.deepEqual(calls.filter(call => call[0] === 'apply' || call[0] === 'rebind'), [['apply', 'writer']]) +}) + +test('opening an existing group session re-selects a route the record already calls applied (#1127)', async (t) => { + const next = 'opencsitool/DeepSeek-V4.1-Flash-1017' + const headers = new Map([ + ['g1', { provider: 'deepseek-official', model: 'deepseek-flash' }], + ['own', { provider: 'deepseek-official', model: 'deepseek-flash' }], + ['stranger', { provider: 'deepseek-official', model: 'deepseek-flash' }], + ]) + const selected = [] + let current = { provider: 'deepseek-official', model: 'deepseek-flash' } + const ctx = { + llm: { + listProviders: () => [{ id: 'deepseek-official' }, { id: 'opencsitool' }], + listModels: async (id) => [{ id: id === 'opencsitool' ? 'DeepSeek-V4.1-Flash-1017' : 'deepseek-flash' }], + resolveModelInfo: async () => ({}), + }, + get: () => ({ currentSelection: () => ({ ...current }), saveSelection: async (nextSelection) => { current = { ...nextSelection } } }), + sessionController: { + selectModel: async (request) => { + selected.push(request) + headers.set(request.sessionId, { provider: request.provider, model: request.model }) + }, + }, + } + const member = { id: 'data', name: 'Data', model: next, appliedModel: next } + const room = { id: 'ops', name: '研发组', seen: {}, sessions: { data: 'g1' } } + const rt = { + ctx, state: { bots: { data: member } }, warn() {}, save: async () => {}, + chatOf: () => 'own', groupSessionsOf: () => ['g1'], + resolveAgent: async (id) => ({ session: { requestHeader: () => (headers.has(id) ? { config: { ...headers.get(id) } } : undefined) } }), + live: new Map(), groupOwners: new Map(), roomWaiters: new Map(), + createSession: async () => { throw new Error('the session already exists') }, + ensureCapabilities: async () => {}, applyPermission: async () => {}, + workDirOf: () => '/fixture', + } + installModels(rt) + const driver = createDshRoomParticipant(rt) + t.after(() => {}) + assert.deepEqual(await driver.open({ room, member }), { id: 'g1', created: false }) + assert.equal(selected.length, 1) + assert.equal(selected[0].sessionId, 'g1') + assert.equal(selected[0].provider, 'opencsitool') + assert.equal(selected[0].model, 'DeepSeek-V4.1-Flash-1017') + assert.deepEqual(headers.get('g1'), { provider: 'opencsitool', model: 'DeepSeek-V4.1-Flash-1017' }) + assert.equal(headers.get('own').provider, 'deepseek-official', 'the own chat is not re-selected from a group open') + assert.equal(headers.get('stranger').provider, 'deepseek-official', 'a session that is not this Bot\'s group session stays') + assert.equal('modelStale' in member, false) + await driver.open({ room, member }) + assert.equal(selected.length, 1, 'a session already on the Bot\'s model is not selected again') +}) + test('DSH participant opens isolated room sessions and reuses persisted links', async t => { const { rt, driver, room, member, calls } = fixture(t) assert.deepEqual(await driver.open({ room, member }), { id: 's1', created: true })