Merge branch 'master' into fix/upload-config-capability

This commit is contained in:
Çağın Dönmez 2026-04-12 22:01:35 +03:00 committed by GitHub
commit 4ab2c3ec81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 227 additions and 1 deletions

View file

@ -394,6 +394,206 @@ describe('SessionContext', () => {
expect(rpcMock).not.toHaveBeenCalledWith('sessions.list', expect.objectContaining({ activeMinutes: expect.any(Number) }));
});
it('keeps a newly spawned active child session visible after the authoritative refresh omits it', async () => {
let subagentSpawnRequested = false;
rpcMock.mockImplementation(async (method: string, params?: Record<string, unknown>) => {
if (method === 'sessions.list') {
if (params?.spawnedBy === 'agent:main:main') {
return {
sessions: subagentSpawnRequested
? [
{ sessionKey: 'agent:main:main', label: 'Main' },
{ sessionKey: 'agent:main:subagent:new-child', label: 'New child', state: 'running' },
]
: [
{ sessionKey: 'agent:main:main', label: 'Main' },
],
};
}
if (params?.activeMinutes === 24 * 60) {
return {
sessions: subagentSpawnRequested
? [
{ sessionKey: 'agent:main:main', label: 'Main' },
{ sessionKey: 'agent:main:subagent:new-child', label: 'New child', state: 'running' },
]
: [
{ sessionKey: 'agent:main:main', label: 'Main' },
],
};
}
return {
sessions: [
{ sessionKey: 'agent:main:main', label: 'Main' },
],
};
}
return {};
});
globalThis.fetch = vi.fn((input: string | URL | Request) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes('/api/sessions/spawn-subagent')) {
subagentSpawnRequested = true;
return Promise.resolve({
ok: true,
json: async () => ({ ok: true, sessionKey: 'agent:main:subagent:new-child' }),
} as Response);
}
if (url.includes('/api/server-info')) return Promise.resolve(jsonResponse({ agentName: 'Jen' }));
if (url.includes('/api/agentlog')) return Promise.resolve(jsonResponse([]));
if (url.includes('/api/sessions/hidden')) return Promise.resolve(jsonResponse({ ok: true, sessions: [] }));
return Promise.resolve(jsonResponse({}));
}) as typeof fetch;
function SpawnSubagent() {
const { spawnSession } = useSessionContext();
return (
<button
data-testid="spawn-subagent"
onClick={() => spawnSession({
kind: 'subagent',
task: 'Investigate issue',
parentSessionKey: 'agent:main:main',
})}
/>
);
}
render(
<SessionProvider>
<SessionLabels />
<SpawnSubagent />
</SessionProvider>,
);
await waitFor(() => {
expect(screen.getByText('Main')).toBeInTheDocument();
});
screen.getByTestId('spawn-subagent').click();
await waitFor(() => {
expect(screen.getByTestId('current-session').textContent).toBe('agent:main:subagent:new-child');
expect(screen.getByText('New child')).toBeInTheDocument();
});
expect(rpcMock).toHaveBeenCalledWith('sessions.list', { limit: 1000 });
expect(rpcMock).toHaveBeenCalledWith('sessions.list', { spawnedBy: 'agent:main:main', limit: 500 });
});
it('includes spawned children from non-main roots when refreshing the sessions sidebar', async () => {
let subagentSpawnRequested = false;
rpcMock.mockImplementation(async (method: string, params?: Record<string, unknown>) => {
if (method === 'sessions.list') {
if (params?.spawnedBy === 'agent:reviewer:main') {
return {
sessions: subagentSpawnRequested
? [
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
{ sessionKey: 'agent:reviewer:subagent:new-child', label: 'Reviewer child', state: 'running' },
]
: [
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
],
};
}
if (params?.spawnedBy === 'agent:main:main') {
return {
sessions: [
{ sessionKey: 'agent:main:main', label: 'Main' },
],
};
}
if (params?.activeMinutes === 24 * 60) {
return {
sessions: subagentSpawnRequested
? [
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
{ sessionKey: 'agent:reviewer:subagent:new-child', label: 'Reviewer child', state: 'running' },
]
: [
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
],
};
}
return {
sessions: [
{ sessionKey: 'agent:main:main', label: 'Main' },
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
],
};
}
return {};
});
globalThis.fetch = vi.fn((input: string | URL | Request) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes('/api/sessions/spawn-subagent')) {
subagentSpawnRequested = true;
return Promise.resolve({
ok: true,
json: async () => ({ ok: true, sessionKey: 'agent:reviewer:subagent:new-child' }),
} as Response);
}
if (url.includes('/api/server-info')) return Promise.resolve(jsonResponse({ agentName: 'Jen' }));
if (url.includes('/api/agentlog')) return Promise.resolve(jsonResponse([]));
if (url.includes('/api/sessions/hidden')) return Promise.resolve(jsonResponse({ ok: true, sessions: [] }));
return Promise.resolve(jsonResponse({}));
}) as typeof fetch;
function SpawnReviewerSubagent() {
const { spawnSession } = useSessionContext();
return (
<button
data-testid="spawn-reviewer-subagent"
onClick={() => spawnSession({
kind: 'subagent',
task: 'Investigate reviewer issue',
parentSessionKey: 'agent:reviewer:main',
})}
/>
);
}
render(
<SessionProvider>
<SessionLabels />
<SpawnReviewerSubagent />
</SessionProvider>,
);
await waitFor(() => {
expect(screen.getByText('Reviewer')).toBeInTheDocument();
});
screen.getByTestId('spawn-reviewer-subagent').click();
await waitFor(() => {
expect(screen.getByTestId('current-session').textContent).toBe('agent:reviewer:subagent:new-child');
expect(screen.getByText('Reviewer child')).toBeInTheDocument();
});
expect(rpcMock).toHaveBeenCalledWith('sessions.list', { spawnedBy: 'agent:reviewer:main', limit: 500 });
});
it('marks background top-level roots unread on start and pings when chat reaches a terminal event', async () => {
rpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {

View file

@ -23,6 +23,8 @@ const IDLE_STATES = new Set(['idle', 'done', 'error', 'final', 'aborted', 'compl
// Use the full session list for the sidebar so older root chats stay visible.
const FULL_SESSIONS_LIMIT = 1000;
const MAIN_SESSION_KEY = 'agent:main:main';
const SESSIONS_SPAWNED_LIMIT = 500;
export type SubagentCleanupMode = 'keep' | 'delete';
@ -222,7 +224,31 @@ export function SessionProvider({ children }: { children: ReactNode }) {
rpc('sessions.list', { limit: FULL_SESSIONS_LIMIT }) as Promise<SessionsListResponse>,
fetchHiddenCronSessions(24 * 60, FULL_SESSIONS_LIMIT),
]);
return mergeSessionLists(res?.sessions ?? [], hiddenCronSessions);
const baseSessions = mergeSessionLists(res?.sessions ?? [], hiddenCronSessions);
const spawnedByRoots = new Set<string>([MAIN_SESSION_KEY]);
for (const rootSession of getTopLevelAgentSessions(baseSessions)) {
spawnedByRoots.add(getSessionKey(rootSession));
}
// Keep active child sessions visible even when the full sessions.list
// result lags behind the recent spawn/discovery flow.
const spawnedSessionLists = await Promise.all(
[...spawnedByRoots].map(async (rootSessionKey) => {
try {
const spawnedRes = await rpc('sessions.list', { spawnedBy: rootSessionKey, limit: SESSIONS_SPAWNED_LIMIT }) as SessionsListResponse;
return spawnedRes?.sessions ?? [];
} catch (err) {
console.debug('[SessionContext] Failed to fetch spawned sessions for root:', rootSessionKey, err);
return [];
}
}),
);
return spawnedSessionLists.reduce(
(acc, spawnedSessions) => mergeSessionLists(acc, spawnedSessions),
baseSessions,
);
} catch (err) {
console.debug('[SessionContext] Failed to fetch authoritative session list:', err);
return sessionsRef.current;