fix(desktop): preserve branch compatibility with older gateways
This commit is contained in:
committed by
brooklyn!
parent
44812b5e90
commit
1c7ec93aea
@@ -219,7 +219,7 @@ describe('desktop branch creation idempotency', () => {
|
||||
const createReady = deferred<{ session_id: string; stored_session_id: string }>()
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.branch_stored') {
|
||||
return createReady.promise as never
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ describe('desktop branch creation idempotency', () => {
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(requestGateway.mock.calls.filter(([method]) => method === 'session.create')).toHaveLength(1)
|
||||
expect(requestGateway.mock.calls.filter(([method]) => method === 'session.branch_stored')).toHaveLength(1)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
@@ -257,9 +257,9 @@ describe('desktop branch creation idempotency', () => {
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true])
|
||||
})
|
||||
|
||||
expect(requestGateway.mock.calls.filter(([method]) => method === 'session.create')).toHaveLength(1)
|
||||
expect(requestGateway.mock.calls.filter(([method]) => method === 'session.branch_stored')).toHaveLength(1)
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'session.create',
|
||||
'session.branch_stored',
|
||||
expect.objectContaining({
|
||||
copy_parent_history: true,
|
||||
omit_messages: true,
|
||||
@@ -283,7 +283,7 @@ describe('desktop branch creation idempotency', () => {
|
||||
const otherCreate = deferred<{ session_id: string; stored_session_id: string }>()
|
||||
|
||||
routedCreate.mockImplementation((async (connectionId: string, _profile: string, method: string) => {
|
||||
if (method !== 'session.create') {
|
||||
if (method !== 'session.branch_stored') {
|
||||
return {} as never
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ describe('desktop branch creation idempotency', () => {
|
||||
await act(async () => {
|
||||
second = actions!.branchStoredSession('parent')
|
||||
await waitFor(() =>
|
||||
expect(routedCreate.mock.calls.filter(([, , method]) => method === 'session.create')).toHaveLength(2)
|
||||
expect(routedCreate.mock.calls.filter(([, , method]) => method === 'session.branch_stored')).toHaveLength(2)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -327,7 +327,7 @@ describe('desktop branch creation idempotency', () => {
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true])
|
||||
})
|
||||
|
||||
const creates = routedCreate.mock.calls.filter(([, , method]) => method === 'session.create')
|
||||
const creates = routedCreate.mock.calls.filter(([, , method]) => method === 'session.branch_stored')
|
||||
|
||||
expect(creates.map(([connectionId]) => connectionId)).toEqual(['pandora', 'other-box'])
|
||||
// Two distinct children, not one child claimed twice.
|
||||
@@ -672,7 +672,7 @@ async function createWith(
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.create' || method === 'session.branch_stored') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never
|
||||
@@ -1952,7 +1952,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
|
||||
it('opens the branch as the primary session in the main workspace (#93444)', async () => {
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.create' || method === 'session.branch_stored') {
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
@@ -2005,7 +2005,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
|
||||
it('keeps the current view when branching a different session from the sidebar (does not reintroduce #69750)', async () => {
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.create' || method === 'session.branch_stored') {
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
@@ -2068,7 +2068,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
_profile: string,
|
||||
method: string
|
||||
) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.create' || method === 'session.branch_stored') {
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
@@ -2090,10 +2090,10 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
// The create must ride the parent's own (connection, profile) socket...
|
||||
expect(requestGatewayForAgent).toHaveBeenCalledWith(
|
||||
expect(requestGatewayForAgent).toHaveBeenLastCalledWith(
|
||||
'pandora',
|
||||
'default',
|
||||
'session.create',
|
||||
'session.branch_stored',
|
||||
expect.objectContaining({ parent_session_id: 'stored-parent', source: 'desktop' })
|
||||
)
|
||||
// ...and never the ambient socket, which may serve a different machine.
|
||||
@@ -2108,7 +2108,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
_profile: string,
|
||||
method: string
|
||||
) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.create' || method === 'session.branch_stored') {
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
@@ -2124,10 +2124,10 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
expect(requestGatewayForAgent).toHaveBeenCalledWith(
|
||||
expect(requestGatewayForAgent).toHaveBeenLastCalledWith(
|
||||
'pandora',
|
||||
'default',
|
||||
'session.create',
|
||||
'session.branch_stored',
|
||||
expect.objectContaining({
|
||||
copy_parent_history: true,
|
||||
omit_messages: true,
|
||||
@@ -2149,7 +2149,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
_profile: string,
|
||||
method: string
|
||||
) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.create' || method === 'session.branch_stored') {
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
@@ -2182,7 +2182,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const ambientRequest = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.create' || method === 'session.branch_stored') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
@@ -2263,7 +2263,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
let branchParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.branch') {
|
||||
if (method === 'session.branch_whole') {
|
||||
branchParams = params
|
||||
|
||||
return {
|
||||
@@ -2354,7 +2354,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.create' || method === 'session.branch_stored') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
@@ -2391,7 +2391,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.create' || method === 'session.branch_stored') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
@@ -2420,7 +2420,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
if (method === 'session.create' || method === 'session.branch_stored') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
@@ -2438,6 +2438,39 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
expect(createParams).toBeDefined()
|
||||
expect(createParams).not.toHaveProperty('profile')
|
||||
})
|
||||
|
||||
it('falls back without creating an empty branch against an older backend', async () => {
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.branch_stored') {
|
||||
throw new Error('unknown method: session.branch_stored')
|
||||
}
|
||||
|
||||
if (method === 'session.create') {
|
||||
expect(params?.messages).toEqual([{ content: 'persisted context', role: 'user' }])
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
vi.mocked(getAllSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'persisted context', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} 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(requestGateway).toHaveBeenCalledWith(
|
||||
'session.create',
|
||||
expect.objectContaining({
|
||||
parent_session_id: 'stored-parent'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Main/tile dedup (the "same session open in main AND its own tab" bug) ─────
|
||||
|
||||
@@ -2360,23 +2360,57 @@ export function useSessionActions({
|
||||
|
||||
// No title: the backend auto-names the branch from its parent's lineage.
|
||||
if (!createFlight) {
|
||||
const branchParams = {
|
||||
session_id: sourceSessionId,
|
||||
omit_messages: true,
|
||||
...(branchCount !== undefined ? { count: branchCount } : {})
|
||||
}
|
||||
|
||||
const createParams = {
|
||||
cols: 96,
|
||||
copy_parent_history: true,
|
||||
omit_messages: true,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(profile ? { profile } : {}),
|
||||
...(parentStoredId && { parent_session_id: parentStoredId })
|
||||
}
|
||||
|
||||
createFlight = (
|
||||
sourceSessionId
|
||||
? requestBranchGateway<SessionCreateResponse>('session.branch', {
|
||||
session_id: sourceSessionId,
|
||||
omit_messages: true,
|
||||
...(branchCount !== undefined ? { count: branchCount } : {})
|
||||
})
|
||||
: requestBranchGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(profile ? { profile } : {}),
|
||||
...(branchMessages.length
|
||||
? { messages: branchMessages.map(({ content, role }) => ({ content, role })) }
|
||||
: { copy_parent_history: true, omit_messages: true }),
|
||||
...(parentStoredId && { parent_session_id: parentStoredId })
|
||||
? requestBranchGateway<SessionCreateResponse>(
|
||||
branchCount === undefined ? 'session.branch_whole' : 'session.branch',
|
||||
branchParams
|
||||
).catch(err => {
|
||||
if (!isMissingRpcMethod(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
return requestBranchGateway<SessionCreateResponse>('session.branch', branchParams)
|
||||
})
|
||||
: branchMessages.length
|
||||
? requestBranchGateway<SessionCreateResponse>('session.create', {
|
||||
...createParams,
|
||||
messages: branchMessages.map(({ content, role }) => ({ content, role }))
|
||||
})
|
||||
: requestBranchGateway<SessionCreateResponse>('session.branch_stored', createParams).catch(
|
||||
async err => {
|
||||
if (!isMissingRpcMethod(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
const { messages } = await getAllSessionMessages(parentStoredId ?? '', ownerRoute ?? profile)
|
||||
|
||||
if (!messages.length) {
|
||||
throw new Error('nothing to branch — send a message first')
|
||||
}
|
||||
|
||||
return requestBranchGateway<SessionCreateResponse>('session.create', {
|
||||
...createParams,
|
||||
messages: messages.map(({ content, role }) => ({ content, role }))
|
||||
})
|
||||
}
|
||||
)
|
||||
).catch(err => {
|
||||
// Drop the flight so a genuine retry re-issues the create; a
|
||||
// resolved flight is cleared once the child is fully published.
|
||||
|
||||
@@ -331,8 +331,7 @@ def _create_overrides(params: dict) -> tuple:
|
||||
return model_override, reasoning_override, service_tier_override
|
||||
|
||||
|
||||
@method("session.create")
|
||||
def _(rid, params: dict) -> dict:
|
||||
def _create_session(rid, params: dict) -> dict:
|
||||
# ``profile`` (app-global remote mode): stored so the build and every turn re-bind HERMES_HOME.
|
||||
profile_home = _profile_home(profile := (params.get("profile") or "").strip() or None)
|
||||
# Reject an incoherent model×provider pair BEFORE any state exists: minting it only defers the
|
||||
@@ -429,6 +428,25 @@ def _(rid, params: dict) -> dict:
|
||||
"profile_name": _response_profile_name(profile)}})
|
||||
|
||||
|
||||
@method("session.create")
|
||||
def _(rid, params: dict) -> dict:
|
||||
return _create_session(rid, params)
|
||||
|
||||
|
||||
@method("session.branch_stored")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Create a whole-session branch from a stored parent without routing its transcript through the client.
|
||||
|
||||
This is a distinct method, rather than an overloaded ``session.create`` flag, so older Desktop clients can
|
||||
safely detect the capability and older gateways cannot silently create an empty branch when they ignore an
|
||||
unknown parameter.
|
||||
"""
|
||||
branch_params = dict(params)
|
||||
branch_params["copy_parent_history"] = True
|
||||
branch_params["omit_messages"] = True
|
||||
return _create_session(rid, branch_params)
|
||||
|
||||
|
||||
def _unarchive_recoverable(db, session_id: str) -> bool:
|
||||
"""``unarchive_recoverable_session`` that works on a read-only listing handle (foreign profile):
|
||||
the rare write escalates to a short-lived registry writer instead of writing on the reader."""
|
||||
@@ -2063,8 +2081,7 @@ def _branch_source_history(db, session: dict, old_key: str) -> list:
|
||||
return history or _visible_branch_history(in_memory_history)
|
||||
|
||||
|
||||
@_session_method("session.branch", live=True)
|
||||
def _(rid, params: dict, session: dict) -> dict:
|
||||
def _branch_live(rid, params: dict, session: dict) -> dict:
|
||||
# Write into the parent's profile-scoped state.db; the launch handle would orphan rows.
|
||||
with _session_db(session) as db:
|
||||
if db is None:
|
||||
@@ -2099,6 +2116,20 @@ def _(rid, params: dict, session: dict) -> dict:
|
||||
return _ok(rid, response)
|
||||
|
||||
|
||||
@_session_method("session.branch", live=True)
|
||||
def _(rid, params: dict, session: dict) -> dict:
|
||||
return _branch_live(rid, params, session)
|
||||
|
||||
|
||||
@_session_method("session.branch_whole", live=True)
|
||||
def _(rid, params: dict, session: dict) -> dict:
|
||||
"""Whole-session branch response seam for Desktop clients that do not need the copied transcript echoed back."""
|
||||
branch_params = dict(params)
|
||||
branch_params.pop("count", None)
|
||||
branch_params["omit_messages"] = True
|
||||
return _branch_live(rid, branch_params, session)
|
||||
|
||||
|
||||
# ── interrupt / steer / redirect ─────────────────────────────────────
|
||||
@method("session.interrupt")
|
||||
def _(rid, params: dict) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user