mirror of
https://github.com/daggerhashimoto/openclaw-nerve
synced 2026-04-21 10:37:17 +00:00
Merge branch 'master' into fix/upload-config-capability
This commit is contained in:
commit
4ab2c3ec81
2 changed files with 227 additions and 1 deletions
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue