Skip to content

Commit cc4025a

Browse files
fix(web,hub): queued bar SSE + never-started inactive resume (#761)
* fix(web): apply messages-consumed on global SSE connection The global all-sessions SSE subscription returned early on message-stream events without updating the message-window store. When session-scoped SSE was reconnecting or the user had another session selected, messages-consumed never cleared the queued bar even though the hub had stamped invoked_at. Also harden mergeMessages so a stale invokedAt:null snapshot cannot clobber an existing ack timestamp. Fixes #758 Co-authored-by: Cursor <cursoragent@cursor.com> * 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 #759 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): scope sessionResume guard to current flavor only Hub `resolveAgentResumeId` only honors the metadata.flavor's id; the web guard was falling back across all flavors so a cursor session with a stale codexSessionId still tried to resume and 409'd. Mirror the hub switch and default to claude when flavor is unknown. Addresses HAPI Bot review on #761. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): allow claude session resume via hub message-id recovery Hub `resolveAgentResumeId` falls back to `recoverClaudeSessionIdFromMessages` on the claude branch when `metadata.claudeSessionId` is absent, so the web guard must not block inactive claude sessions that have stored messages but no metadata id. Other flavors have no such recovery path and stay rejected. Addresses second HAPI Bot review thread on #761 (`web/src/lib/sessionResume.ts:41`). Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent df35a8c commit cc4025a

11 files changed

Lines changed: 306 additions & 7 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/hooks/useSSE.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,16 @@ export function useSSE(options: {
458458
) {
459459
queueSessionListInvalidation()
460460
}
461+
// The global `all` subscription also receives message-stream events.
462+
// Session-scoped SSE normally drives the message window, but during
463+
// reconnect gaps or while another session is selected, only the global
464+
// connection may be alive — still clear the queued bar / optimistic rows.
465+
if (event.type === 'messages-consumed') {
466+
markMessagesConsumed(event.sessionId, event.localIds, event.invokedAt)
467+
}
468+
if (event.type === 'message-cancelled') {
469+
removeOptimisticMessage(event.sessionId, event.messageId)
470+
}
461471
onEventRef.current(event)
462472
return
463473
}

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/messages.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { DecryptedMessage } from '@/types/api'
3+
import { mergeMessages } from '@/lib/messages'
4+
5+
function userMessage(partial: Partial<DecryptedMessage> & { id: string }): DecryptedMessage {
6+
return {
7+
id: partial.id,
8+
localId: partial.localId ?? partial.id,
9+
seq: partial.seq ?? 1,
10+
createdAt: partial.createdAt ?? 1_000,
11+
invokedAt: partial.invokedAt ?? null,
12+
status: partial.status,
13+
content: { role: 'user', content: [{ type: 'text', text: 'hi' }] },
14+
}
15+
}
16+
17+
describe('mergeMessages', () => {
18+
it('preserves invokedAt when a stale snapshot omits the ack timestamp', () => {
19+
const invokedAt = 2_000
20+
const existing = [userMessage({ id: 'server-1', localId: 'local-1', invokedAt })]
21+
const incoming = [userMessage({ id: 'server-1', localId: 'local-1', invokedAt: null })]
22+
23+
const merged = mergeMessages(existing, incoming)
24+
expect(merged).toHaveLength(1)
25+
expect(merged[0]?.invokedAt).toBe(invokedAt)
26+
})
27+
})

web/src/lib/messages.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ export function mergeMessages(existing: DecryptedMessage[], incoming: DecryptedM
5656
byId.set(msg.id, msg)
5757
}
5858
for (const msg of incoming) {
59-
byId.set(msg.id, msg)
59+
const existing = byId.get(msg.id)
60+
if (existing && existing.invokedAt != null && msg.invokedAt == null) {
61+
byId.set(msg.id, { ...msg, invokedAt: existing.invokedAt })
62+
} else {
63+
byId.set(msg.id, msg)
64+
}
6065
}
6166

6267
let merged = Array.from(byId.values())

web/src/lib/sessionResume.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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 picks the id matching the session flavor', () => {
19+
expect(resolveAgentSessionIdFromMetadata({
20+
path: '/p',
21+
host: 'h',
22+
flavor: 'codex',
23+
codexSessionId: 'codex-1',
24+
cursorSessionId: 'cursor-1',
25+
})).toBe('codex-1')
26+
expect(resolveAgentSessionIdFromMetadata({
27+
path: '/p',
28+
host: 'h',
29+
flavor: 'cursor',
30+
cursorSessionId: 'cursor-1',
31+
})).toBe('cursor-1')
32+
})
33+
34+
it('resolveAgentSessionIdFromMetadata ignores stale cross-flavor ids', () => {
35+
expect(resolveAgentSessionIdFromMetadata({
36+
path: '/p',
37+
host: 'h',
38+
flavor: 'cursor',
39+
codexSessionId: 'codex-1',
40+
})).toBeUndefined()
41+
})
42+
43+
it('resolveAgentSessionIdFromMetadata defaults to claude when flavor is missing', () => {
44+
expect(resolveAgentSessionIdFromMetadata({
45+
path: '/p',
46+
host: 'h',
47+
claudeSessionId: 'claude-1',
48+
})).toBe('claude-1')
49+
})
50+
51+
it('inactiveSessionCanResume is true for active sessions', () => {
52+
expect(inactiveSessionCanResume(makeSession({ active: true }), 0)).toBe(true)
53+
})
54+
55+
it('inactiveSessionCanResume allows fresh spawn when no agent id and no messages', () => {
56+
expect(inactiveSessionCanResume(makeSession(), 0)).toBe(true)
57+
})
58+
59+
it('inactiveSessionCanResume allows resume when agent id exists', () => {
60+
expect(inactiveSessionCanResume(makeSession({
61+
metadata: {
62+
path: '/tmp/project',
63+
host: 'localhost',
64+
flavor: 'cursor',
65+
cursorSessionId: 'cursor-thread-1',
66+
},
67+
}), 5)).toBe(true)
68+
})
69+
70+
it('inactiveSessionCanResume rejects inactive sessions with messages but no agent id', () => {
71+
expect(inactiveSessionCanResume(makeSession(), 3)).toBe(false)
72+
})
73+
74+
it('inactiveSessionCanResume rejects when stale cross-flavor agent id is present but no messages', () => {
75+
expect(inactiveSessionCanResume(makeSession({
76+
metadata: {
77+
path: '/tmp/project',
78+
host: 'localhost',
79+
flavor: 'cursor',
80+
codexSessionId: 'stale-codex-1',
81+
},
82+
}), 0)).toBe(true)
83+
expect(inactiveSessionCanResume(makeSession({
84+
metadata: {
85+
path: '/tmp/project',
86+
host: 'localhost',
87+
flavor: 'cursor',
88+
codexSessionId: 'stale-codex-1',
89+
},
90+
}), 3)).toBe(false)
91+
})
92+
93+
it('inactiveSessionCanResume rejects when metadata path is missing', () => {
94+
expect(inactiveSessionCanResume(makeSession({ metadata: { path: '', host: 'localhost' } }), 0)).toBe(false)
95+
})
96+
97+
it('inactiveSessionCanResume allows claude resume by message recovery when no claudeSessionId is stored', () => {
98+
expect(inactiveSessionCanResume(makeSession({
99+
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'claude' },
100+
}), 3)).toBe(true)
101+
})
102+
103+
it('inactiveSessionCanResume allows claude recovery when flavor is missing (defaults to claude)', () => {
104+
expect(inactiveSessionCanResume(makeSession({
105+
metadata: { path: '/tmp/project', host: 'localhost' },
106+
}), 3)).toBe(true)
107+
})
108+
109+
it('inactiveSessionCanResume rejects non-claude flavors with messages but no flavor-specific id (no recovery path)', () => {
110+
expect(inactiveSessionCanResume(makeSession({
111+
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'codex' },
112+
}), 3)).toBe(false)
113+
})
114+
})

web/src/lib/sessionResume.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { isKnownFlavor } from '@hapi/protocol'
2+
import type { Session } from '@/types/api'
3+
4+
/** Agent thread id used by hub `resolveAgentResumeId`, flavor-specific.
5+
* Mirrors hub: cross-flavor ids are ignored to avoid the web layer claiming a
6+
* session is resumable when the hub will only honor the current flavor's id. */
7+
export function resolveAgentSessionIdFromMetadata(
8+
metadata: Session['metadata'] | null | undefined,
9+
): string | undefined {
10+
if (!metadata) {
11+
return undefined
12+
}
13+
const flavor = isKnownFlavor(metadata.flavor) ? metadata.flavor : 'claude'
14+
switch (flavor) {
15+
case 'codex': return metadata.codexSessionId ?? undefined
16+
case 'gemini': return metadata.geminiSessionId ?? undefined
17+
case 'opencode': return metadata.opencodeSessionId ?? undefined
18+
case 'cursor': return metadata.cursorSessionId ?? undefined
19+
case 'kimi': return metadata.kimiSessionId ?? undefined
20+
default: return metadata.claudeSessionId ?? undefined
21+
}
22+
}
23+
24+
/**
25+
* Whether an inactive session can be activated via resume (or fresh spawn on first send).
26+
* Matches hub: resume with agent id, or fresh spawn when path exists, no agent id, no user messages.
27+
* Claude with messages but no `claudeSessionId` is allowed because hub
28+
* `recoverClaudeSessionIdFromMessages` reconstructs the resume id from the
29+
* stored message log (only the claude path has this recovery fallback).
30+
*/
31+
export function inactiveSessionCanResume(
32+
session: Session,
33+
userMessageCount: number,
34+
): boolean {
35+
if (session.active) {
36+
return true
37+
}
38+
if (!session.metadata?.path) {
39+
return false
40+
}
41+
if (resolveAgentSessionIdFromMetadata(session.metadata)) {
42+
return true
43+
}
44+
const flavor = isKnownFlavor(session.metadata.flavor) ? session.metadata.flavor : 'claude'
45+
if (flavor === 'claude' && userMessageCount > 0) {
46+
return true
47+
}
48+
return userMessageCount === 0
49+
}

0 commit comments

Comments
 (0)