diff --git a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts index 6482a9a2bf..cf049ea082 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts @@ -38,11 +38,17 @@ type BotClient = RuntimeHostBotSessionAdapterDeps['client']; test('creates an explore Session through the Host-owned default model route', async () => { const creates: unknown[] = []; + const updates: unknown[] = []; const changes: unknown[] = []; const client = botClient({ createSession: async (input) => { creates.push(input); - return session(input.sessionId, { permissionMode: 'explore' }); + // Host default is ask; Bot adapter must pin explore after create. + return session(input.sessionId, { permissionMode: 'ask' }); + }, + updateSessionConfiguration: async (sessionId, patch) => { + updates.push({ sessionId, patch }); + return session(sessionId, { permissionMode: 'explore' }); }, }); const adapter = createRuntimeHostBotSessionAdapter({ @@ -68,9 +74,11 @@ test('creates an explore Session through the Host-owned default model route', as name: 'Telegram conversation', labels: ['bot', 'telegram'], modelTarget: { kind: 'default' }, - permissionMode: 'explore', }, ]); + assert.deepEqual(updates, [ + { sessionId: 'bot-session-1', patch: { permissionMode: 'explore' } }, + ]); assert.deepEqual(changes, [ { reason: 'created', sessionId: 'bot-session-1', extra: undefined }, ]); @@ -163,6 +171,105 @@ test('reconciles an uncertain Host Session create with its stable Session identi ); }); +test('verifies an uncertain explore pin against the Host before binding the stable Session id', async () => { + const lifecycle: unknown[] = []; + const changes: unknown[] = []; + const adapter = createRuntimeHostBotSessionAdapter({ + client: botClient({ + createSession: async (input) => session(input.sessionId, { permissionMode: 'ask' }), + updateSessionConfiguration: async () => { + throw new RuntimeHostOperationError( + 'session.configuration.update', + 'commit_outcome_unknown', + 'response lost', + ); + }, + // The Host committed explore even though the update response was lost. + getSession: async (sessionId) => session(sessionId, { permissionMode: 'explore' }), + setSessionLifecycle: async (sessionId, state) => { + lifecycle.push([sessionId, state]); + return session(sessionId); + }, + }), + resolveCreateTarget: hostPathCreateTarget, + emitSessionsChanged: (reason, sessionId, extra) => + changes.push({ reason, sessionId, extra }), + newId: () => 'stable-session-id', + }); + + assert.equal( + await adapter.createSession({ name: 'Bot conversation', labels: ['bot'] }), + 'stable-session-id', + ); + assert.deepEqual(lifecycle, [], 'a verified explore Session must not be archived'); + assert.deepEqual(changes, [ + { reason: 'created', sessionId: 'stable-session-id', extra: undefined }, + ]); +}); + +test('archives the orphaned Session when the explore pin definitively fails', async () => { + const lifecycle: unknown[] = []; + const changes: unknown[] = []; + const adapter = createRuntimeHostBotSessionAdapter({ + client: botClient({ + createSession: async (input) => session(input.sessionId, { permissionMode: 'ask' }), + updateSessionConfiguration: async () => { + throw new RuntimeHostOperationError( + 'session.configuration.update', + 'invalid_request', + 'explore refused', + ); + }, + setSessionLifecycle: async (sessionId, state) => { + lifecycle.push([sessionId, state]); + return session(sessionId); + }, + }), + resolveCreateTarget: hostPathCreateTarget, + emitSessionsChanged: (reason, sessionId, extra) => + changes.push({ reason, sessionId, extra }), + newId: () => 'stable-session-id', + }); + + await assert.rejects( + adapter.createSession({ name: 'Bot conversation', labels: ['bot'] }), + RuntimeHostOperationError, + ); + assert.deepEqual(lifecycle, [['stable-session-id', 'archived']]); + assert.deepEqual(changes, []); +}); + +test('archives the orphaned Session when an uncertain explore pin cannot be verified as committed', async () => { + const lifecycle: unknown[] = []; + const adapter = createRuntimeHostBotSessionAdapter({ + client: botClient({ + createSession: async (input) => session(input.sessionId, { permissionMode: 'ask' }), + updateSessionConfiguration: async () => { + throw new RuntimeHostOperationError( + 'session.configuration.update', + 'commit_outcome_unknown', + 'response lost', + ); + }, + // The update never committed; the Session is still in ask mode. + getSession: async (sessionId) => session(sessionId, { permissionMode: 'ask' }), + setSessionLifecycle: async (sessionId, state) => { + lifecycle.push([sessionId, state]); + return session(sessionId); + }, + }), + resolveCreateTarget: hostPathCreateTarget, + emitSessionsChanged() {}, + newId: () => 'stable-session-id', + }); + + await assert.rejects( + adapter.createSession({ name: 'Bot conversation', labels: ['bot'] }), + RuntimeHostOperationError, + ); + assert.deepEqual(lifecycle, [['stable-session-id', 'archived']]); +}); + test('subscribes before Turn start and settles a fast Host reply without losing text', async () => { const events = new AsyncFrameQueue(); const changes: unknown[] = []; @@ -399,6 +506,7 @@ function botClient(overrides: Partial): BotClient { createSession: unexpected, getSession: unexpected, openSession: unexpected, + setSessionLifecycle: unexpected, startTurn: unexpected, updateSessionConfiguration: unexpected, ...overrides, diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index ea2d4b24b7..c579b94b3c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -937,6 +937,18 @@ function connectionHarness( if (operation === 'session.create') { return session((input as { sessionId: string }).sessionId); } + if (operation === 'session.configuration.update') { + // The Bot adapter pins explore after create; commit it like the + // real Host would instead of falling through to the unexpected- + // operation guard below. + return { + kind: 'committed', + session: { + ...session((input as { sessionId: string }).sessionId), + permissionMode: 'explore', + }, + }; + } if (operation === 'external-session.source.query') { return { adapterIds: ['codex'] }; } diff --git a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts index e0d0f31a90..58b1bcd148 100644 --- a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts +++ b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts @@ -40,6 +40,7 @@ type RuntimeHostBotSessionClient = Pick< | 'createSession' | 'getSession' | 'openSession' + | 'setSessionLifecycle' | 'startTurn' | 'updateSessionConfiguration' >; @@ -70,13 +71,17 @@ export function createRuntimeHostBotSessionAdapter( const sessionId = newId(); let session: SessionCatalogProjection; try { + // Do not pass permissionMode: 'explore' on create. Runtime Host + // requires a declared SessionStartMode (e.g. deep_research) when + // explore is requested at create time; bot conversations are not + // that product mode. Create with the Host default, then pin explore + // via the same configuration update prepareSession uses. session = await deps.client.createSession({ sessionId, workspace: target.workspace, name: input.name, labels: [...input.labels], modelTarget: { kind: 'default' }, - permissionMode: 'explore', }); } catch (error) { if ( @@ -89,6 +94,35 @@ export function createRuntimeHostBotSessionAdapter( if (!reconciled) throw error; session = reconciled; } + if (session.permissionMode !== 'explore') { + try { + session = await deps.client.updateSessionConfiguration(session.id, { + permissionMode: 'explore', + }); + } catch (error) { + throwUnavailable(error, session.id); + // session.configuration.update carries its own post-commit + // uncertainty: a lost response may still have pinned explore. The + // caller binds nothing unless createSession resolves, so verify the + // stable id before deciding the Session is unusable — otherwise the + // next inbound message forks another conversation. + const reconciled = isCommitOutcomeUnknown(error) + ? await deps.client.getSession(session.id).catch(() => null) + : null; + if (isVerifiedExploreSession(reconciled)) { + session = reconciled; + } else { + await abandonUnusableBotSession(deps.client, session.id); + throw error; + } + } + if (session.isArchived || session.permissionMode !== 'explore') { + await abandonUnusableBotSession(deps.client, session.id); + throw new Error( + `Bot Session could not enter explore mode: ${session.id}`, + ); + } + } deps.emitSessionsChanged('created', session.id); return session.id; }, @@ -226,6 +260,32 @@ async function collectRuntimeHostBotTurn( throw new Error('Runtime Host Bot Session subscription ended before the Turn settled'); } +function isCommitOutcomeUnknown(error: unknown): boolean { + return ( + error instanceof RuntimeHostOperationError && + error.code === 'commit_outcome_unknown' + ); +} + +function isVerifiedExploreSession( + session: SessionCatalogProjection | null, +): session is SessionCatalogProjection { + return session !== null && !session.isArchived && session.permissionMode === 'explore'; +} + +// A Session that reached the Host but never got bound to a conversation is a +// leak: the desktop session list shows it and the next inbound message +// creates a sibling instead of reusing it. Archiving (rather than removing) +// keeps any committed state inspectable while taking the Session out of the +// active conversation flow. Best-effort by design — a cleanup failure must +// not mask the original create/pin error. +async function abandonUnusableBotSession( + client: RuntimeHostBotSessionClient, + sessionId: string, +): Promise { + await client.setSessionLifecycle(sessionId, 'archived').catch(() => undefined); +} + function throwUnavailable(error: unknown, sessionId: string): void { if ( (error instanceof RuntimeHostOperationError && diff --git a/docs/images/pr/bot-feishu-explore-session-create/feishu-bot-dialogue-failed.png b/docs/images/pr/bot-feishu-explore-session-create/feishu-bot-dialogue-failed.png new file mode 100644 index 0000000000..5d2550a22e Binary files /dev/null and b/docs/images/pr/bot-feishu-explore-session-create/feishu-bot-dialogue-failed.png differ