Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope'
import { getSession } from '@/hermes'
import { textPart } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
Expand Down Expand Up @@ -465,6 +466,41 @@ describe('usePromptActions HUD surface', () => {
})
})

describe('usePromptActions Bot Mode lifecycle', () => {
afterEach(() => {
cleanup()
setWorkspaceScope('sessions')
vi.restoreAllMocks()
})

it('marks prompt.submit so Bot work survives a viewer detach', async () => {
setWorkspaceScope('bots', 'remote::worker')

const submitted: (Record<string, unknown> | undefined)[] = []

const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'prompt.submit') {
submitted.push(params)
}

return {} as never
})

let handle: HarnessHandle | null = null

await actRender(
<Harness
onReady={value => (handle = value)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('keep working')

expect(submitted[0]).toMatchObject({ preserve_running_on_disconnect: true })
})
})

describe('usePromptActions slash session targeting', () => {
const STORED_SESSION_ID = 'stored-db-xyz789'
const RECOVERED_SESSION_ID = 'rt-recovered-456'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type MutableRefObject, useCallback } from 'react'

import { $workspaceMode } from '@/components/pane-shell/workspace-scope'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import type { Translations } from '@/i18n'
import { type ChatMessage, textPart } from '@/lib/chat-messages'
Expand Down Expand Up @@ -756,6 +757,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const submitParams = (targetId: string) => ({
session_id: targetId,
text,
...($workspaceMode.get() === 'bots' && { preserve_running_on_disconnect: true }),
...(interrupted && { interrupted }),
// Off-screen widget intent: the gateway types the persisted user
// row display_kind=hidden so no client renders it as a bubble.
Expand Down
82 changes: 82 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { NO_PROJECT_ID } from '@/app/chat/sidebar/projects/workspace-groups'
import { resolveSessionRpcOwner } from '@/app/contrib/wiring-routing'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { noteActiveTreeGroup, revealTreePane } from '@/components/pane-shell/tree/store'
import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope'
import {
deleteSession,
getAllSessionMessages,
Expand Down Expand Up @@ -2347,6 +2348,37 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(sessionStateByRuntimeIdRef.current.has('rt-recycled')).toBe(false)
})

it('marks Bot Mode resumes to keep running after the viewer detaches', async () => {
setWorkspaceScope('bots', 'source-a::bot-a')
vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: [] } as never)

const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'rt-bot', resumed: params?.session_id, messages: [], info: {} } as never
}

return {} as never
})

let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null

try {
render(<ResumeHarness onReady={value => (resume = value)} requestGateway={requestGateway} />)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-bot', true)

expect(requestGateway).toHaveBeenCalledWith(
'session.resume',
expect.objectContaining({
preserve_running_on_disconnect: true,
session_id: 'stored-bot'
})
)
} finally {
setWorkspaceScope('sessions')
}
})

it('paints the bounded latest transcript after the deferred resume acknowledgement', async () => {
const latestPage = Array.from({ length: 500 }, (_, index) => ({
content: `message-${index}`,
Expand Down Expand Up @@ -2459,6 +2491,56 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
})

it('marks a warm Bot Mode activation to keep its running turn after detach', async () => {
setWorkspaceScope('bots', 'source-a::bot-a')

const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-bot', 'rt-bot']])
}

const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-bot', clientState('stored-bot')]])
}

const requestGateway = vi.fn(async (method: string) =>
method === 'session.activate'
? ({
session_id: 'rt-bot',
session_key: 'stored-bot',
messages: [],
running: true,
info: {}
} as never)
: ({} as never)
)

vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: [], session_id: 'stored-bot' } as never)
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null

try {
render(
<ResumeHarness
onReady={value => (resume = value)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-bot', true)

expect(requestGateway).toHaveBeenCalledWith(
'session.activate',
expect.objectContaining({
preserve_running_on_disconnect: true,
session_id: 'rt-bot'
})
)
} finally {
setWorkspaceScope('sessions')
}
})

it('re-arms a pending clarify in place on the warm session.activate path', async () => {
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { NavigateFunction } from 'react-router'
import { NO_PROJECT_ID } from '@/app/chat/sidebar/projects/workspace-groups'
import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill'
import { revealTreePane } from '@/components/pane-shell/tree/store'
import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope'
import { $workspaceMode, setWorkspaceScope } from '@/components/pane-shell/workspace-scope'
import {
deleteSession,
fetchStoredTranscriptAcrossBackends,
Expand Down Expand Up @@ -714,7 +714,7 @@ export function useSessionActions({

const params = {
...(await desktopSessionCreateParams(cwd, capturedRoute)),
...(workspaceScope.workspaceMode === 'bots' ? { hidden: true } : {})
...(workspaceScope.workspaceMode === 'bots' ? { hidden: true, preserve_running_on_disconnect: true } : {})
}

// Same lease chain as createBackendSessionForSend: owner socket held
Expand Down Expand Up @@ -823,6 +823,10 @@ export function useSessionActions({
const resumedSameSelectedSession = selectedStoredSessionIdRef.current === storedSessionId
const resumeStartMessages = resumedSameSelectedSession ? $messages.get() : []

const preserveRunningOnDisconnect =
$workspaceMode.get() === 'bots' ||
$sessionTiles.get().some(tile => tile.storedSessionId === storedSessionId && tile.workspaceMode === 'bots')

const isCurrentResume = () =>
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId

Expand Down Expand Up @@ -1096,6 +1100,7 @@ export function useSessionActions({
activated = await requestForSession<SessionResumeResponse>('session.activate', {
session_id: cachedRuntimeId,
cols: 96,
...(preserveRunningOnDisconnect ? { preserve_running_on_disconnect: true } : {}),
omit_messages: true
})
} catch (error) {
Expand Down Expand Up @@ -1485,6 +1490,7 @@ export function useSessionActions({
session_id: storedSessionId,
cols: 96,
source: 'desktop',
...(preserveRunningOnDisconnect ? { preserve_running_on_disconnect: true } : {}),
defer_history: !watchWindow,
// REST is the transcript authority for Desktop. Avoid duplicating a
// potentially huge compression lineage in the WebSocket response.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ describe('the lazy row is materialized before anything else touches it', () => {
expect(created).toMatchObject({
hidden: true,
title: 'Bot Chat',
preserve_running_on_disconnect: true,
// The PR #97008 contract: the canonical Bot Chat's runtime always
// follows the profile's CURRENT config on resume — never the stored
// model/provider pin. Dropping this param silently regresses bots to
Expand All @@ -173,6 +174,7 @@ describe('the lazy row is materialized before anything else touches it', () => {

if (method === 'prompt.submit') {
expect(params.session_id).toBe('runtime-1')
expect(params.preserve_running_on_disconnect).toBe(true)
}

return {}
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/plugins/hermes-bots/canonical-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ export function createCanonicalChat(
// (deferred as pending_hidden until the row exists); older gateways
// ignore the unknown param and it stays visible.
hidden: true,
preserve_running_on_disconnect: true,
// Explicit contract (PR #97008): this session's runtime always follows
// the member profile's CURRENT config. Resume must NOT restore the
// stored model/provider pin from an old row — that left bot DMs stuck
Expand Down Expand Up @@ -423,7 +424,8 @@ export function createCanonicalChat(
try {
await requestForBot(bot, 'prompt.submit', {
session_id: runtime,
text: kickoffText()
text: kickoffText(),
preserve_running_on_disconnect: true
})

if (!opened && sid && typeof host.openSession === 'function' && canNavigate()) {
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/plugins/hermes-bots/group-turns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,25 @@ describe('session resolution', () => {
})
})

it('preserves member turns across every create, resume and submit boundary', async () => {
const room = await loadRoom({ turn: () => 'done' })

room.chat.updateGroupChat('Detach', current => {
current.roomId = 'r-detach'

return current
})

await room.turns.runGroupChatMemberTurn('Detach', ROUTED_MEMBER, 'keep working', 't1', [])

const lifecycleCalls = room.gateway.rpc.filter(call =>
['prompt.submit', 'session.create', 'session.resume'].includes(call.method)
)

expect(lifecycleCalls.length).toBeGreaterThan(0)
expect(lifecycleCalls.every(call => call.params.preserve_running_on_disconnect === true)).toBe(true)
})

it('mints fresh member sessions when a same-name group is recreated after disband', async () => {
const room = await loadRoom()
const member: GroupMember = { name: 'research', title: '' }
Expand Down
22 changes: 15 additions & 7 deletions apps/desktop/src/plugins/hermes-bots/group-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ export async function ensureGroupChatSession(group: string, member: GroupMember)
const res = (await requestForBot(member, 'session.resume', {
session_id: target,
profile: member.name,
omit_messages: true
omit_messages: true,
preserve_running_on_disconnect: true
})) as GroupSessionSnapshot

if (res?.session_id) {
Expand Down Expand Up @@ -199,6 +200,7 @@ export async function ensureGroupChatSession(group: string, member: GroupMember)
title,
// Room member sessions are plumbing — always hidden from the sidebar.
hidden: true,
preserve_running_on_disconnect: true,
// Explicit contracts (PR #97008): room plumbing sessions always rebuild
// from the member profile's CURRENT config on resume, never a stale
// stored model/provider pin. Older gateways ignore the unknown params;
Expand Down Expand Up @@ -309,7 +311,8 @@ async function submitGroupTurnPrompt(
try {
await requestForBot(member, 'prompt.submit', {
session_id: runtime,
text
text,
preserve_running_on_disconnect: true
})

return runtime
Expand All @@ -321,7 +324,8 @@ async function submitGroupTurnPrompt(
const res = (await requestForBot(member, 'session.resume', {
session_id: stored,
profile: member.name,
omit_messages: true
omit_messages: true,
preserve_running_on_disconnect: true
})) as GroupSessionSnapshot

const fresh = res?.session_id
Expand All @@ -332,7 +336,8 @@ async function submitGroupTurnPrompt(

await requestForBot(member, 'prompt.submit', {
session_id: fresh,
text
text,
preserve_running_on_disconnect: true
})

return fresh
Expand Down Expand Up @@ -564,7 +569,8 @@ async function runGroupChatMemberTurnLeased(
try {
const pre = (await requestForBot(member, 'session.resume', {
session_id: stored || runtime,
profile: member.name
profile: member.name,
preserve_running_on_disconnect: true
})) as GroupSessionSnapshot

before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
Expand Down Expand Up @@ -648,7 +654,8 @@ async function runGroupChatMemberTurnLeased(
try {
state = (await requestForBot(member, 'session.resume', {
session_id: stored || liveRuntime,
profile: member.name
profile: member.name,
preserve_running_on_disconnect: true
})) as GroupSessionSnapshot
} catch {
continue
Expand Down Expand Up @@ -738,7 +745,8 @@ export async function harvestStrandedGroupReply(group: string, member: GroupMemb
const stored = room.sessions?.[memberKey]
state = (await requestForBot(member, 'session.resume', {
session_id: stored || `Group: ${room.roomId || group}`,
profile: member.name
profile: member.name,
preserve_running_on_disconnect: true
})) as GroupSessionSnapshot
} catch {
return // source unreachable — leave the marker for the next boundary
Expand Down
1 change: 1 addition & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5022,6 +5022,7 @@ def test_ws_disconnect_running_sidecar_still_closes_without_orphan_timer(monkeyp
transport=transport,
running=True,
close_on_disconnect=True,
preserve_running_on_disconnect=True,
)
monkeypatch.setattr(
server,
Expand Down
Loading
Loading