fix(webui): delete unpersisted pane sessions (#5624)

Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
This commit is contained in:
Kail Tian
2026-09-02 13:05:36 +08:00
committed by GitHub
co-authored by chengyongru
parent 9ecdc4533f
commit 042f96f6ba
5 changed files with 108 additions and 3 deletions
@@ -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
+4
View File
@@ -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
+4 -1
View File
@@ -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 --------------------------------------------------
+5 -2
View File
@@ -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],
);
+53
View File
@@ -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([
{