From df23c86642ba07c5714d07d2725d904ea489a00c Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 1 Apr 2026 13:16:46 +0200 Subject: [PATCH 1/2] fix(cloud-agent): keep transport alive after interrupt instead of disconnecting Eagerly disable canSend/canInterrupt atoms before the async interrupt call to prevent sending during in-flight requests, and remove the disconnect() call so the session remains usable for follow-up messages. --- .../cloud-agent-sdk/session-manager.test.ts | 62 ++++++++++++++++++- src/lib/cloud-agent-sdk/session-manager.ts | 7 ++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/lib/cloud-agent-sdk/session-manager.test.ts b/src/lib/cloud-agent-sdk/session-manager.test.ts index 3019bcb34f..89dc7ec754 100644 --- a/src/lib/cloud-agent-sdk/session-manager.test.ts +++ b/src/lib/cloud-agent-sdk/session-manager.test.ts @@ -24,11 +24,16 @@ const mockSession = { canSend: true, canInterrupt: true, state: { - subscribe: jest.fn(() => () => {}), + subscribe: jest.fn(callback => { + callback(); + return () => {}; + }), getActivity: jest.fn(() => ({ type: 'idle' as const })), getStatus: jest.fn(() => ({ type: 'idle' as const })), + getCloudStatus: jest.fn(() => null), getQuestion: jest.fn(() => null), getSessionInfo: jest.fn(() => null), + getPermission: jest.fn(() => null), }, storage: {}, }; @@ -129,7 +134,10 @@ describe('createSessionManager', () => { mockSession.respondToPermission.mockClear(); mockSession.canSend = true; mockSession.canInterrupt = true; - mockSession.state.subscribe.mockImplementation(() => () => {}); + mockSession.state.subscribe.mockImplementation(callback => { + callback(); + return () => {}; + }); mockSessionCallbacks.onQuestionAsked = undefined; mockSessionCallbacks.onQuestionResolved = undefined; mockSessionCallbacks.onPermissionAsked = undefined; @@ -609,6 +617,56 @@ describe('createSessionManager', () => { expect(mockSession.interrupt).not.toHaveBeenCalled(); }); + + it('does NOT call session.disconnect after interrupt', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + await mgr.interrupt(); + + expect(mockSession.disconnect).not.toHaveBeenCalled(); + }); + + it('disables canSendAtom immediately on interrupt', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + // Verify canSend is true before interrupt + expect(atomValue(config.store, mgr.atoms.canSend)).toBe(true); + + // Call interrupt without awaiting — check synchronously after call + void mgr.interrupt(); + // After calling interrupt (even before it resolves), canSend should be false + expect(atomValue(config.store, mgr.atoms.canSend)).toBe(false); + }); + + it('disables canInterruptAtom immediately on interrupt', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + expect(atomValue(config.store, mgr.atoms.canInterrupt)).toBe(true); + + void mgr.interrupt(); + expect(atomValue(config.store, mgr.atoms.canInterrupt)).toBe(false); + }); + + it('session remains usable after interrupt — send does not throw', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + await mgr.interrupt(); + + // After interrupt, send should NOT throw — transport should still be alive + mockSession.send.mockResolvedValue({}); + await expect( + mgr.send({ prompt: 'follow-up message', mode: 'code', model: 'claude-3-5-sonnet' }) + ).resolves.not.toThrow(); + expect(mockSession.send).toHaveBeenCalledTimes(1); + }); }); // ------------------------------------------------------------------------- diff --git a/src/lib/cloud-agent-sdk/session-manager.ts b/src/lib/cloud-agent-sdk/session-manager.ts index 307da0b400..7d7ea6ef9f 100644 --- a/src/lib/cloud-agent-sdk/session-manager.ts +++ b/src/lib/cloud-agent-sdk/session-manager.ts @@ -601,11 +601,16 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { async function interrupt(): Promise { if (!currentSession) return; + // Eagerly disable send/interrupt to prevent the user from sending a + // message while the async interrupt HTTP call is in flight. We do NOT + // call disconnect() — interrupt stops the agent but keeps the transport + // alive so the user can continue the session. + store.set(canSendAtom, false); + store.set(canInterruptAtom, false); try { if (currentSession.canInterrupt) { await currentSession.interrupt(); } - currentSession.disconnect(); setIndicator({ type: 'info', message: 'Session stopped', timestamp: Date.now() }); } catch { store.set(errorAtom, 'Failed to stop execution'); From 34da8aa6d4a7b1de2dbceae265527127e6cd9249 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 1 Apr 2026 14:40:04 +0200 Subject: [PATCH 2/2] fix(dev): suppress false env-sync warnings for keys already in .dev.vars Keys with empty defaults in .dev.vars.example that aren't in .env.local were always reported as missing, even when .dev.vars already had values. Now unresolved keys are skipped from both warnings and change diffs when the existing .dev.vars has a non-empty value. --- dev/local/env-sync/plan.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/dev/local/env-sync/plan.ts b/dev/local/env-sync/plan.ts index d9ec80f9f8..bb84d83b68 100644 --- a/dev/local/env-sync/plan.ts +++ b/dev/local/env-sync/plan.ts @@ -219,7 +219,7 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan const serviceUsesLanIp = dirUsesLanIp.get(workerDir) ?? false; const resolvedVars = new Map(); - const missingValues: string[] = []; + const unresolvedKeys: string[] = []; for (const entry of entries) { const { value, resolved } = resolveAnnotatedValue( @@ -230,7 +230,7 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan serviceUsesLanIp ); resolvedVars.set(entry.key, value); - if (!resolved) missingValues.push(entry.key); + if (!resolved) unresolvedKeys.push(entry.key); } allResolvedEntries.set(workerDir, { vars: resolvedVars, entries }); @@ -246,17 +246,24 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan const isNew = existingContent === null; const keyChanges: KeyChange[] = []; + let missingValues: string[]; if (existingContent !== null) { const oldVars = parseEnvFile(existingContent); - const missingSet = new Set(missingValues); + // Only report keys as missing if the existing .dev.vars also lacks a value. + // Keys that couldn't be resolved but already have a value in .dev.vars are + // kept as-is — skip them from both missing warnings and key change diffs. + const unresolvedSet = new Set(unresolvedKeys); + missingValues = unresolvedKeys.filter(key => !oldVars.get(key)); for (const [key, newVal] of resolvedVars) { - if (missingSet.has(key)) continue; + if (unresolvedSet.has(key)) continue; const oldVal = oldVars.get(key); if (oldVal !== newVal) { keyChanges.push({ key, oldValue: oldVal, newValue: newVal }); } } + } else { + missingValues = unresolvedKeys; } if (isNew || keyChanges.length > 0 || missingValues.length > 0) {