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
|
||||
|
||||
|
||||
@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
|
||||
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
|
||||
@@ -357,3 +357,7 @@ class WebUIWorkspaceController:
|
||||
self._draft_scopes.move_to_end(session_key)
|
||||
while len(self._draft_scopes) > _MAX_DRAFT_SCOPES:
|
||||
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)
|
||||
elif self.cron_service is not None:
|
||||
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)
|
||||
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 --------------------------------------------------
|
||||
|
||||
|
||||
@@ -257,11 +257,14 @@ export function useSessions(): {
|
||||
|
||||
const deleteChat = useCallback(
|
||||
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||
const optimistic = optimisticKeysRef.current.has(key);
|
||||
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);
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -124,6 +124,59 @@ describe("useSessions", () => {
|
||||
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 () => {
|
||||
vi.mocked(api.listSessions).mockResolvedValue([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user