mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-02 17:22:06 +03:00
fix(webui): delete unpersisted pane sessions (#5624)
Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
This commit is contained in:
@@ -2333,6 +2333,48 @@ async def test_session_delete_removes_transcript_without_canonical_file(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_delete_removes_unpersisted_new_chat(
|
||||||
|
bus: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
sm = SessionManager(tmp_path / "sessions")
|
||||||
|
project = tmp_path / "project"
|
||||||
|
project.mkdir()
|
||||||
|
channel = _ch(bus, session_manager=sm, workspace_path=tmp_path, port=_free_port())
|
||||||
|
connection = AsyncMock()
|
||||||
|
connection.remote_address = ("127.0.0.1", 50123)
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
connection,
|
||||||
|
"webui-client",
|
||||||
|
{
|
||||||
|
"type": "new_chat",
|
||||||
|
"workspace_scope": {
|
||||||
|
"project_path": str(project),
|
||||||
|
"access_mode": "full",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
attached = next(
|
||||||
|
payload
|
||||||
|
for payload in (
|
||||||
|
json.loads(call.args[0]) for call in connection.send.await_args_list
|
||||||
|
)
|
||||||
|
if payload.get("event") == "attached"
|
||||||
|
)
|
||||||
|
key = f"websocket:{attached['chat_id']}"
|
||||||
|
|
||||||
|
assert sm.list_sessions() == []
|
||||||
|
assert channel.gateway.workspaces.scope_for_session_key(key).project_path == project.resolve()
|
||||||
|
|
||||||
|
response = await _webui_mutate(channel, "session.delete", {"key": key})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["deleted"] is True
|
||||||
|
assert channel.gateway.workspaces.scope_for_session_key(key).project_path == tmp_path.resolve()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
||||||
bus: MagicMock, tmp_path: Path
|
bus: MagicMock, tmp_path: Path
|
||||||
|
|||||||
@@ -357,3 +357,7 @@ class WebUIWorkspaceController:
|
|||||||
self._draft_scopes.move_to_end(session_key)
|
self._draft_scopes.move_to_end(session_key)
|
||||||
while len(self._draft_scopes) > _MAX_DRAFT_SCOPES:
|
while len(self._draft_scopes) > _MAX_DRAFT_SCOPES:
|
||||||
self._draft_scopes.popitem(last=False)
|
self._draft_scopes.popitem(last=False)
|
||||||
|
|
||||||
|
def discard_draft_scope(self, session_key: str) -> bool:
|
||||||
|
"""Discard the staged scope for a chat that has not persisted yet."""
|
||||||
|
return self._draft_scopes.pop(session_key, None) is not None
|
||||||
|
|||||||
@@ -952,9 +952,12 @@ class GatewayHTTPHandler:
|
|||||||
self.local_trigger_store.delete(job.id)
|
self.local_trigger_store.delete(job.id)
|
||||||
elif self.cron_service is not None:
|
elif self.cron_service is not None:
|
||||||
self.cron_service.remove_job(job.id)
|
self.cron_service.remove_job(job.id)
|
||||||
|
draft_deleted = self.workspaces.discard_draft_scope(decoded_key)
|
||||||
session_deleted = self.session_manager.delete_session(decoded_key)
|
session_deleted = self.session_manager.delete_session(decoded_key)
|
||||||
transcript_deleted = delete_webui_thread(decoded_key)
|
transcript_deleted = delete_webui_thread(decoded_key)
|
||||||
return _http_json_response({"deleted": bool(session_deleted or transcript_deleted)})
|
return _http_json_response(
|
||||||
|
{"deleted": bool(draft_deleted or session_deleted or transcript_deleted)}
|
||||||
|
)
|
||||||
|
|
||||||
# -- Automation routes --------------------------------------------------
|
# -- Automation routes --------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -257,11 +257,14 @@ export function useSessions(): {
|
|||||||
|
|
||||||
const deleteChat = useCallback(
|
const deleteChat = useCallback(
|
||||||
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||||
|
const optimistic = optimisticKeysRef.current.has(key);
|
||||||
const result = await apiDeleteSession(client, key, options);
|
const result = await apiDeleteSession(client, key, options);
|
||||||
if (!result.deleted) return result;
|
if (result.blocked_by_automations || (!result.deleted && !optimistic)) return result;
|
||||||
optimisticKeysRef.current.delete(key);
|
optimisticKeysRef.current.delete(key);
|
||||||
setSessions((prev) => prev.filter((s) => s.key !== key));
|
setSessions((prev) => prev.filter((s) => s.key !== key));
|
||||||
return result;
|
// The gateway may have restarted and forgotten an unpersisted chat's
|
||||||
|
// draft scope, but removing that optimistic session is still a deletion.
|
||||||
|
return result.deleted ? result : { ...result, deleted: true };
|
||||||
},
|
},
|
||||||
[client],
|
[client],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -124,6 +124,59 @@ describe("useSessions", () => {
|
|||||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes an optimistic chat when the gateway no longer has its draft", async () => {
|
||||||
|
vi.mocked(api.listSessions).mockResolvedValue([]);
|
||||||
|
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: false });
|
||||||
|
const client = fakeClient();
|
||||||
|
client.newChat.mockResolvedValue("chat-empty");
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSessions(), {
|
||||||
|
wrapper: wrap(client),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.createChat();
|
||||||
|
});
|
||||||
|
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-empty"]);
|
||||||
|
|
||||||
|
let deleteResult: Awaited<ReturnType<typeof result.current.deleteChat>> | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
deleteResult = await result.current.deleteChat("websocket:chat-empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteResult?.deleted).toBe(true);
|
||||||
|
expect(result.current.sessions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an optimistic chat when delete is blocked by bound automations", async () => {
|
||||||
|
vi.mocked(api.listSessions).mockResolvedValue([]);
|
||||||
|
vi.mocked(api.deleteSession).mockResolvedValue({
|
||||||
|
deleted: false,
|
||||||
|
blocked_by_automations: true,
|
||||||
|
automations: [],
|
||||||
|
});
|
||||||
|
const client = fakeClient();
|
||||||
|
client.newChat.mockResolvedValue("chat-empty");
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSessions(), {
|
||||||
|
wrapper: wrap(client),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.createChat();
|
||||||
|
});
|
||||||
|
|
||||||
|
let deleteResult: Awaited<ReturnType<typeof result.current.deleteChat>> | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
deleteResult = await result.current.deleteChat("websocket:chat-empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteResult?.blocked_by_automations).toBe(true);
|
||||||
|
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-empty"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a session when delete is blocked by bound automations", async () => {
|
it("keeps a session when delete is blocked by bound automations", async () => {
|
||||||
vi.mocked(api.listSessions).mockResolvedValue([
|
vi.mocked(api.listSessions).mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user