Skip to content

Commit 786fe47

Browse files
heavygeecursoragent
andcommitted
fix(web,hub): resume never-started inactive sessions on first send
Hub fresh-spawns when inactive session has path but no agent thread id and zero messages. Web guards resume, updates inactive banner copy, and surfaces resume_unavailable before POST /resume when resume is impossible. Fixes tiann#759 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 760d7a8 commit 786fe47

8 files changed

Lines changed: 195 additions & 6 deletions

File tree

hub/src/sync/sessionModel.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,61 @@ describe('session model', () => {
11481148
}
11491149
})
11501150

1151+
it('resumeSession fresh-spawns when inactive cursor session has no agent id and no user messages', async () => {
1152+
const store = new Store(':memory:')
1153+
const engine = new SyncEngine(
1154+
store,
1155+
{} as never,
1156+
new RpcRegistry(),
1157+
{ broadcast() {} } as never
1158+
)
1159+
1160+
try {
1161+
const session = engine.getOrCreateSession(
1162+
'never-started-cursor',
1163+
{
1164+
path: '/tmp/project',
1165+
host: 'localhost',
1166+
machineId: 'machine-1',
1167+
flavor: 'cursor'
1168+
},
1169+
null,
1170+
'default'
1171+
)
1172+
engine.getOrCreateMachine(
1173+
'machine-1',
1174+
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
1175+
null,
1176+
'default'
1177+
)
1178+
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
1179+
1180+
let capturedResumeSessionId: string | undefined = 'unset'
1181+
;(engine as any).rpcGateway.spawnSession = async (
1182+
_machineId: string,
1183+
_directory: string,
1184+
_agent: string,
1185+
_model?: string,
1186+
_modelReasoningEffort?: string,
1187+
_yolo?: boolean,
1188+
_sessionType?: string,
1189+
_worktreeName?: string,
1190+
resumeSessionId?: string
1191+
) => {
1192+
capturedResumeSessionId = resumeSessionId
1193+
return { type: 'success', sessionId: session.id }
1194+
}
1195+
;(engine as any).waitForSessionActive = async () => true
1196+
1197+
const result = await engine.resumeSession(session.id, 'default')
1198+
1199+
expect(result).toEqual({ type: 'success', sessionId: session.id })
1200+
expect(capturedResumeSessionId).toBeUndefined()
1201+
} finally {
1202+
engine.stop()
1203+
}
1204+
})
1205+
11511206
it('includes first user message in local resumable sessions', () => {
11521207
const store = new Store(':memory:')
11531208
const engine = new SyncEngine(

hub/src/sync/syncEngine.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,18 @@ export class SyncEngine {
617617
return undefined
618618
}
619619

620+
/** Inactive session with directory path but no agent thread and no prior user turn. */
621+
private canFreshSpawnNeverStartedSession(session: Session, sessionId: string, namespace: string): boolean {
622+
const metadata = session.metadata
623+
if (!metadata || typeof metadata.path !== 'string' || metadata.path.length === 0) {
624+
return false
625+
}
626+
if (this.resolveAgentResumeId(session, namespace)) {
627+
return false
628+
}
629+
return this.store.messages.getFirstMessages(sessionId, 1).length === 0
630+
}
631+
620632
async resumeSession(sessionId: string, namespace: string, opts?: { permissionMode?: PermissionMode }): Promise<ResumeSessionResult> {
621633
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
622634
if (!access.ok) {
@@ -633,14 +645,27 @@ export class SyncEngine {
633645
}
634646

635647
const targetResult = this.resolveLocalResumeTarget(access.sessionId, namespace)
636-
if (targetResult.type === 'error') {
648+
let flavor: AgentFlavor
649+
let resumeToken: string | undefined
650+
let directory: string
651+
652+
if (targetResult.type === 'success') {
653+
flavor = targetResult.target.flavor
654+
resumeToken = targetResult.target.agentSessionId
655+
directory = targetResult.target.directory
656+
} else if (
657+
targetResult.code === 'resume_unavailable'
658+
&& this.canFreshSpawnNeverStartedSession(session, access.sessionId, namespace)
659+
) {
660+
const metadata = session.metadata!
661+
flavor = this.resolveFlavor(session)
662+
resumeToken = undefined
663+
directory = metadata.path
664+
} else {
637665
return targetResult
638666
}
639667

640-
const target = targetResult.target
641668
const metadata = session.metadata!
642-
const flavor = target.flavor
643-
const resumeToken = target.agentSessionId
644669

645670
const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace)
646671
if (onlineMachines.length === 0) {
@@ -668,7 +693,7 @@ export class SyncEngine {
668693
?? session.metadata?.preferredPermissionMode
669694
const spawnResult = await this.rpcGateway.spawnSession(
670695
targetMachine.id,
671-
target.directory,
696+
directory,
672697
flavor,
673698
session.model ?? undefined,
674699
session.modelReasoningEffort ?? undefined,

web/src/components/SessionChat.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { reconcileChatBlocks } from '@/chat/reconcile'
1818
import { buildConversationOutline } from '@/chat/outline'
1919
import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups'
2020
import { isQueuedForInvocation, mergeMessages } from '@/lib/messages'
21+
import { inactiveSessionCanResume } from '@/lib/sessionResume'
2122
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
2223
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
2324
import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
@@ -125,6 +126,7 @@ export function SessionChat(props: {
125126
const { t } = useTranslation()
126127
const navigate = useNavigate()
127128
const sessionInactive = !props.session.active
129+
const inactiveCanResume = inactiveSessionCanResume(props.session, props.messages.length)
128130
const terminalSupported = isRemoteTerminalSupported(props.session.metadata)
129131
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
130132
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
@@ -560,7 +562,9 @@ export function SessionChat(props: {
560562
{sessionInactive ? (
561563
<div className="px-3 pt-3">
562564
<div className="mx-auto w-full max-w-content rounded-md bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-hint)]">
563-
Session is inactive. Sending will resume it automatically.
565+
{inactiveCanResume
566+
? t('session.inactive.autoResume')
567+
: t('session.inactive.cannotResume')}
564568
</div>
565569
</div>
566570
) : null}

web/src/lib/locales/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,9 @@ export default {
362362
'send.blocked.title': 'Cannot send message',
363363
'send.blocked.noConnection': 'Not connected to server',
364364
'resume.failed.title': 'Resume failed',
365+
'resume.unavailable.noTarget': 'This session cannot be resumed. Start a new session in this directory.',
366+
'session.inactive.autoResume': 'Session is inactive. Sending will resume it automatically.',
367+
'session.inactive.cannotResume': 'Session is inactive and cannot be resumed from here. Start a new session in this directory.',
365368
'toast.ready.title': 'Ready for input',
366369
'toast.ready.body': '{agent} is waiting in {session}',
367370
'toast.permission.title': 'Permission Request',

web/src/lib/locales/zh-CN.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,9 @@ export default {
364364
'send.blocked.title': '无法发送消息',
365365
'send.blocked.noConnection': '未连接到服务器',
366366
'resume.failed.title': '恢复会话失败',
367+
'resume.unavailable.noTarget': '无法恢复此会话。请在该目录下新建会话。',
368+
'session.inactive.autoResume': '会话已停用。发送消息将自动恢复会话。',
369+
'session.inactive.cannotResume': '会话已停用且无法在此恢复。请在该目录下新建会话。',
367370
'toast.ready.title': '等待输入',
368371
'toast.ready.body': '{agent} 正在 {session} 等待你的输入',
369372
'toast.permission.title': '权限请求',

web/src/lib/sessionResume.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { Session } from '@/types/api'
3+
import { inactiveSessionCanResume, resolveAgentSessionIdFromMetadata } from './sessionResume'
4+
5+
function makeSession(overrides: Partial<Session> = {}): Session {
6+
return {
7+
id: 'session-1',
8+
active: false,
9+
thinking: false,
10+
activeAt: 0,
11+
updatedAt: 0,
12+
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'cursor' },
13+
...overrides,
14+
} as Session
15+
}
16+
17+
describe('sessionResume', () => {
18+
it('resolveAgentSessionIdFromMetadata matches hub flavor precedence (codex before cursor)', () => {
19+
expect(resolveAgentSessionIdFromMetadata({
20+
path: '/p',
21+
host: 'h',
22+
codexSessionId: 'codex-1',
23+
cursorSessionId: 'cursor-1',
24+
})).toBe('codex-1')
25+
expect(resolveAgentSessionIdFromMetadata({
26+
path: '/p',
27+
host: 'h',
28+
cursorSessionId: 'cursor-1',
29+
})).toBe('cursor-1')
30+
})
31+
32+
it('inactiveSessionCanResume is true for active sessions', () => {
33+
expect(inactiveSessionCanResume(makeSession({ active: true }), 0)).toBe(true)
34+
})
35+
36+
it('inactiveSessionCanResume allows fresh spawn when no agent id and no messages', () => {
37+
expect(inactiveSessionCanResume(makeSession(), 0)).toBe(true)
38+
})
39+
40+
it('inactiveSessionCanResume allows resume when agent id exists', () => {
41+
expect(inactiveSessionCanResume(makeSession({
42+
metadata: {
43+
path: '/tmp/project',
44+
host: 'localhost',
45+
flavor: 'cursor',
46+
cursorSessionId: 'cursor-thread-1',
47+
},
48+
}), 5)).toBe(true)
49+
})
50+
51+
it('inactiveSessionCanResume rejects inactive sessions with messages but no agent id', () => {
52+
expect(inactiveSessionCanResume(makeSession(), 3)).toBe(false)
53+
})
54+
55+
it('inactiveSessionCanResume rejects when metadata path is missing', () => {
56+
expect(inactiveSessionCanResume(makeSession({ metadata: { path: '', host: 'localhost' } }), 0)).toBe(false)
57+
})
58+
})

web/src/lib/sessionResume.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Session } from '@/types/api'
2+
3+
/** Agent thread id used by hub `resolveAgentResumeId` (any flavor). */
4+
export function resolveAgentSessionIdFromMetadata(
5+
metadata: Session['metadata'] | null | undefined,
6+
): string | undefined {
7+
if (!metadata) {
8+
return undefined
9+
}
10+
return metadata.codexSessionId
11+
?? metadata.claudeSessionId
12+
?? metadata.geminiSessionId
13+
?? metadata.opencodeSessionId
14+
?? metadata.cursorSessionId
15+
?? metadata.kimiSessionId
16+
?? undefined
17+
}
18+
19+
/**
20+
* Whether an inactive session can be activated via resume (or fresh spawn on first send).
21+
* Matches hub: resume with agent id, or fresh spawn when path exists, no agent id, no user messages.
22+
*/
23+
export function inactiveSessionCanResume(
24+
session: Session,
25+
userMessageCount: number,
26+
): boolean {
27+
if (session.active) {
28+
return true
29+
}
30+
if (!session.metadata?.path) {
31+
return false
32+
}
33+
if (resolveAgentSessionIdFromMetadata(session.metadata)) {
34+
return true
35+
}
36+
return userMessageCount === 0
37+
}

web/src/router.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { useToast } from '@/lib/toast-context'
3434
import { useTranslation } from '@/lib/use-translation'
3535
import { fetchLatestMessages, seedMessageWindowFromSession } from '@/lib/message-window-store'
3636
import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend'
37+
import { inactiveSessionCanResume } from '@/lib/sessionResume'
3738
import { markSessionSeen } from '@/lib/sessionLastSeen'
3839
import type { Machine } from '@/types/api'
3940
import FilesPage from '@/routes/sessions/files'
@@ -296,6 +297,9 @@ function SessionPage() {
296297
if (!api || !session || session.active) {
297298
return currentSessionId
298299
}
300+
if (!inactiveSessionCanResume(session, messages.length)) {
301+
throw new Error(t('resume.unavailable.noTarget'))
302+
}
299303
try {
300304
return await api.resumeSession(currentSessionId, { permissionMode: session.permissionMode ?? undefined })
301305
} catch (error) {

0 commit comments

Comments
 (0)