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) { 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');