fix(desktop): don't claim a stranger's cwd as the selected session's workspace
An unnamed `session.info` was treated as describing whatever the pane had selected. The gateway stamps `stored_session_id: session_key or ""`, so every not-yet-persisted session emits one, and `broadcast_session_info` / the approvals loop re-emit for every live session at once. An unscoped event applies exactly when no session is active, so with nothing selected each of those repointed `$currentCwd` and claimed it for the null selection — the file tree, coding rail and statusbar painted a folder no selected conversation owned, until the next `releaseWorkspaceCwdOwner` dropped the claim and they un-painted it. Require the event to be bound to the pane's own runtime before an absent id reads as the selection. The case the allowance exists for — a lazy session that is the pane's runtime but is not persisted yet — still adopts and owns its cwd.
This commit is contained in:
committed by
brooklyn!
parent
3340bbbdad
commit
0401e08884
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
$currentCwd,
|
||||
$selectedStoredSessionId,
|
||||
$workspaceCwdOwner,
|
||||
releaseWorkspaceCwdOwner,
|
||||
setCurrentCwd
|
||||
} from '@/store/session'
|
||||
|
||||
import { handleSessionInfoEvent } from './session-info'
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
// `_session_info` stamps `stored_session_id: session_key or ""`, so every
|
||||
// not-yet-persisted session on the gateway emits an UNNAMED session.info that
|
||||
// still carries a real cwd.
|
||||
function sessionInfoEvent({
|
||||
activeSessionId,
|
||||
cwd,
|
||||
explicitSid = '',
|
||||
storedSessionId = ''
|
||||
}: {
|
||||
activeSessionId: null | string
|
||||
cwd: string
|
||||
explicitSid?: string
|
||||
storedSessionId?: string
|
||||
}): GatewayEventContext {
|
||||
const sessionId = explicitSid || activeSessionId
|
||||
|
||||
return {
|
||||
deps: {
|
||||
activeGatewayProfile: 'default',
|
||||
activeSessionIdRef: { current: activeSessionId },
|
||||
hydrateFromStoredSession: vi.fn(),
|
||||
lastCwdInfoSessionRef: { current: null },
|
||||
queryClient: { invalidateQueries: vi.fn() },
|
||||
refreshHermesConfig: vi.fn(),
|
||||
scheduleSessionsRefresh: vi.fn(),
|
||||
sessionInterrupted: () => false,
|
||||
sessionStateByRuntimeIdRef: { current: new Map() },
|
||||
updateSessionState: vi.fn(state => state),
|
||||
upsertToolCall: vi.fn()
|
||||
},
|
||||
event: { profile: 'default', session_id: explicitSid, type: 'session.info' },
|
||||
explicitSid,
|
||||
fromActiveSource: () => true,
|
||||
isActiveEvent: !!sessionId && sessionId === activeSessionId,
|
||||
occurredAt: Date.now() / 1000,
|
||||
payload: { cwd, stored_session_id: storedSessionId },
|
||||
scheduleConfigRefresh: vi.fn(),
|
||||
sessionId
|
||||
} as unknown as GatewayEventContext
|
||||
}
|
||||
|
||||
describe('handleSessionInfoEvent workspace ownership', () => {
|
||||
beforeEach(() => {
|
||||
$selectedStoredSessionId.set(null)
|
||||
$workspaceCwdOwner.set(null)
|
||||
setCurrentCwd('')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$selectedStoredSessionId.set(null)
|
||||
$workspaceCwdOwner.set(null)
|
||||
setCurrentCwd('')
|
||||
})
|
||||
|
||||
// #55831 / the "workspace pane visible with no agent selected" report: with
|
||||
// nothing selected an unscoped event is exactly the one that applies, and
|
||||
// `broadcast_session_info` re-emits for EVERY live session at once. Adopting
|
||||
// those repointed the pane at a stranger's folder and claimed it for the null
|
||||
// selection, so the tree/coding rail painted it until the next release
|
||||
// un-painted it — a flicker per fan-out, with no agent selected at all.
|
||||
it('ignores an unnamed broadcast from a session the pane is not bound to', () => {
|
||||
releaseWorkspaceCwdOwner()
|
||||
const unowned = $workspaceCwdOwner.get()
|
||||
|
||||
handleSessionInfoEvent(sessionInfoEvent({ activeSessionId: null, cwd: '/repo/someone-elses-worktree' }))
|
||||
|
||||
expect($currentCwd.get()).toBe('')
|
||||
expect($workspaceCwdOwner.get()).toBe(unowned)
|
||||
})
|
||||
|
||||
it('does not let a fan-out of unnamed broadcasts walk the workspace path', () => {
|
||||
const cwds = ['/repo/one', '/repo/two', '/repo/three']
|
||||
|
||||
for (const cwd of cwds) {
|
||||
handleSessionInfoEvent(sessionInfoEvent({ activeSessionId: null, cwd }))
|
||||
}
|
||||
|
||||
expect($currentCwd.get()).toBe('')
|
||||
})
|
||||
|
||||
// The case the absent-id allowance exists for: a lazy session that has not
|
||||
// been persisted yet is still the runtime this pane is bound to, so its cwd
|
||||
// must be adopted and owned — otherwise the workspace reads as un-owned for
|
||||
// the rest of the conversation.
|
||||
it('adopts an unnamed session.info from the pane its own runtime', () => {
|
||||
$selectedStoredSessionId.set('selected-session')
|
||||
|
||||
handleSessionInfoEvent(
|
||||
sessionInfoEvent({ activeSessionId: 'runtime-1', cwd: '/repo/mine', explicitSid: 'runtime-1' })
|
||||
)
|
||||
|
||||
expect($currentCwd.get()).toBe('/repo/mine')
|
||||
expect($workspaceCwdOwner.get()).toBe('selected-session')
|
||||
})
|
||||
})
|
||||
@@ -38,16 +38,26 @@ import type { GatewayEventContext } from './types'
|
||||
*
|
||||
* Absent is not the same as different: the backend omits the id on a
|
||||
* not-yet-built (`lazy`) session, and refusing there would leave the workspace
|
||||
* marked un-owned for the rest of the conversation. Matching goes through the
|
||||
* lineage (`sessionMatchesStoredId`) so a compression-rotated tip and the root
|
||||
* a pinned-row selection may hold still read as one conversation.
|
||||
* marked un-owned for the rest of the conversation. That only reads as the
|
||||
* selection when the event is the pane's OWN runtime, though: an unscoped
|
||||
* event applies precisely when no session is active, and the fan-outs
|
||||
* (`broadcast_session_info`, the approvals loop) re-emit for every live
|
||||
* session at once, each with its own cwd. As an unconditional wildcard those
|
||||
* repoint `$currentCwd` and claim it for whatever is selected — nothing, in
|
||||
* the report this comes from — until the next release drops the claim again.
|
||||
* Hence `boundToPane`: with no binding there is no evidence, and no evidence
|
||||
* must not become an ownership claim.
|
||||
*
|
||||
* Matching goes through the lineage (`sessionMatchesStoredId`) so a
|
||||
* compression-rotated tip and the root a pinned-row selection may hold still
|
||||
* read as one conversation.
|
||||
*/
|
||||
function sessionInfoDescribesSelectedSession(storedSessionId: string | undefined): boolean {
|
||||
function sessionInfoDescribesSelectedSession(storedSessionId: string | undefined, boundToPane: boolean): boolean {
|
||||
const infoStoredSessionId = storedSessionId?.trim() || null
|
||||
const selected = $selectedStoredSessionId.get() ?? null
|
||||
|
||||
if (!infoStoredSessionId) {
|
||||
return true
|
||||
return boundToPane
|
||||
}
|
||||
|
||||
// A named session cannot describe a fresh draft. Treating a null selection as
|
||||
@@ -91,7 +101,10 @@ function maybeRebindPaneToRebuiltRuntime(ctx: GatewayEventContext): boolean {
|
||||
|
||||
const selected = $selectedStoredSessionId.get()
|
||||
|
||||
if (!selected || !sessionInfoDescribesSelectedSession(payload.stored_session_id)) {
|
||||
// A rebuilt runtime announces itself for a conversation that is already
|
||||
// persisted, so it always names one; an unnamed payload has no lineage to
|
||||
// match and must not capture the pane's active runtime id.
|
||||
if (!selected || !sessionInfoDescribesSelectedSession(payload.stored_session_id, false)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -180,7 +193,10 @@ export function handleSessionInfoEvent(ctx: GatewayEventContext): boolean {
|
||||
// Active-session model/provider still flows through the session state
|
||||
// cache via updateSessionState → syncRuntimeMetadataToView below.
|
||||
|
||||
if (typeof payload?.cwd === 'string' && sessionInfoDescribesSelectedSession(payload.stored_session_id)) {
|
||||
if (
|
||||
typeof payload?.cwd === 'string' &&
|
||||
sessionInfoDescribesSelectedSession(payload.stored_session_id, isActiveEvent || rebound)
|
||||
) {
|
||||
// The active session's agent can relocate itself (new repo/worktree
|
||||
// via the terminal). When the SAME active session's cwd actually
|
||||
// moves, follow it — refresh the project tree + scope so the sidebar
|
||||
|
||||
Reference in New Issue
Block a user