mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
fix(desktop): branch a chat on its parent's owning profile
forkBranch called session.create without a profile, so in app-global remote mode (one backend serving every profile) a branch of a non-default-profile chat silently landed on the launch profile — the "session jumps between profiles after branching" bug. New chats already carry their profile (desktopSessionCreateParams, #39993); branching was the remaining create callsite without it. Thread the parent's owning profile through both branch entry points: branchStoredSession already resolved it (#67603) but dropped it before the create; branchCurrentSession now resolves the open chat's profile via the same cache → active → cross-profile ladder as resume. forkBranch swaps the live gateway onto that profile and passes it on session.create, which also makes the optimistic sidebar row's profile stamp correct. Supersedes #40788 — same invariant, rebuilt for the current forkBranch/ branchStoredSession split. Co-authored-by: Dusk1e <yusufalweshdemir@gmail.com>
This commit is contained in:
parent
b9d2eb7f43
commit
40a80487c4
2 changed files with 91 additions and 4 deletions
|
|
@ -1102,8 +1102,12 @@ describe('branchStoredSession desktop source tagging', () => {
|
|||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
|
|
@ -1120,9 +1124,71 @@ describe('branchStoredSession desktop source tagging', () => {
|
|||
|
||||
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
|
||||
expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work')
|
||||
// The create itself must carry the owning profile: in app-global remote
|
||||
// mode the soft gateway swap alone is not enough — an omitted profile
|
||||
// lands the branch on the launch (default) profile's state.db.
|
||||
expect(createParams).toMatchObject({ parent_session_id: 'stored-parent', profile: 'work' })
|
||||
|
||||
vi.mocked(getSession).mockReset()
|
||||
})
|
||||
|
||||
it('creates the branch on the cached parent session profile', async () => {
|
||||
setSessions([storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' })])
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
|
||||
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(branchStoredSession).not.toBeNull())
|
||||
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
|
||||
expect(createParams).toMatchObject({ profile: 'work' })
|
||||
})
|
||||
|
||||
it('omits profile for a profile-less parent so single-profile users are unchanged', async () => {
|
||||
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
|
||||
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(branchStoredSession).not.toBeNull())
|
||||
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
expect(createParams).toBeDefined()
|
||||
expect(createParams).not.toHaveProperty('profile')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ import {
|
|||
patchSessionWorkspace,
|
||||
preserveLocalPendingTurnMessages,
|
||||
reconcileResumeMessages,
|
||||
resolveSessionProfile,
|
||||
resolveStoredSession,
|
||||
sessionMatchesStoredId,
|
||||
sessionShouldHaveTranscript,
|
||||
|
|
@ -1056,15 +1057,30 @@ export function useSessionActions({
|
|||
// `parentStoredId` so it nests under its parent, then open it as its own tab
|
||||
// and switch to it — the parent chat stays put (mirrors openNewSessionTile).
|
||||
const forkBranch = useCallback(
|
||||
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
|
||||
async (
|
||||
branchMessages: BranchMessage[],
|
||||
parentStoredId: null | string,
|
||||
cwd?: string,
|
||||
profile?: null | string
|
||||
): Promise<boolean> => {
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
// A branch belongs to its parent's OWNING profile. Swapping the live
|
||||
// gateway first AND passing `profile` on the create mirrors
|
||||
// desktopSessionCreateParams/resumeSession: in app-global remote mode
|
||||
// one backend serves every profile, so an omitted profile silently
|
||||
// lands the branch on the launch (default) profile — the "session
|
||||
// jumps between profiles after branching" bug. The swap also makes
|
||||
// upsertOptimisticSession's $activeGatewayProfile stamp correct.
|
||||
await ensureGatewayProfile(profile)
|
||||
|
||||
// No title: the backend auto-names the branch from its parent's lineage.
|
||||
const branched = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(profile ? { profile } : {}),
|
||||
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
||||
...(parentStoredId && { parent_session_id: parentStoredId })
|
||||
})
|
||||
|
|
@ -1165,7 +1181,12 @@ export function useSessionActions({
|
|||
|
||||
clearNotifications()
|
||||
|
||||
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim())
|
||||
// The open chat's owning profile, NOT the picker's / launch profile —
|
||||
// /profile only retargets new chats, so a branch of an existing thread
|
||||
// must stay on that thread's backend (cache hit for an open session).
|
||||
const profile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
|
||||
|
||||
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim(), profile)
|
||||
},
|
||||
[activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
|
@ -1197,7 +1218,7 @@ export function useSessionActions({
|
|||
return false
|
||||
}
|
||||
|
||||
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim())
|
||||
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile)
|
||||
} catch (err) {
|
||||
notifyError(err, copy.branchFailed)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue