Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions dev/local/env-sync/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ function computePlan(repoRoot: string, serviceFilter?: Set<string>): EnvSyncPlan
const serviceUsesLanIp = dirUsesLanIp.get(workerDir) ?? false;

const resolvedVars = new Map<string, string>();
const missingValues: string[] = [];
const unresolvedKeys: string[] = [];

for (const entry of entries) {
const { value, resolved } = resolveAnnotatedValue(
Expand All @@ -230,7 +230,7 @@ function computePlan(repoRoot: string, serviceFilter?: Set<string>): 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 });
Expand All @@ -246,17 +246,24 @@ function computePlan(repoRoot: string, serviceFilter?: Set<string>): 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) {
Expand Down
62 changes: 60 additions & 2 deletions src/lib/cloud-agent-sdk/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean>(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<boolean>(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<boolean>(config.store, mgr.atoms.canInterrupt)).toBe(true);

void mgr.interrupt();
expect(atomValue<boolean>(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);
});
});

// -------------------------------------------------------------------------
Expand Down
7 changes: 6 additions & 1 deletion src/lib/cloud-agent-sdk/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,11 +601,16 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {

async function interrupt(): Promise<void> {
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');
Comment thread
eshurakov marked this conversation as resolved.
Expand Down
Loading