Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions crates/agent-gateway/test/websocket/v2_managed_process_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package websocket_test

// ManagedProcess 快照链路集成测试:agent 发布 ManagedProcessSnapshot 后,已连接
// 浏览器应收到 process_state 广播帧,新连接则应收到缓存回放帧。

import (
"net/http"
"testing"
"time"

"github.com/gorilla/websocket"

gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2"
"github.com/liveagent/agent-gateway/internal/protocol/pbws"
"github.com/liveagent/agent-gateway/internal/session"
)

func receiveProcessStateFrame(t *testing.T, conn *websocket.Conn) *gatewayv2.WebServerFrame {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
frame := receiveWebFrameRaw(t, conn)
if frame.GetProcessState() != nil {
return frame
}
}
t.Fatalf("timed out waiting for process_state frame")
return nil
}

func expectProcessState(t *testing.T, conn *websocket.Conn, context string) {
t.Helper()
frame := receiveProcessStateFrame(t, conn)
if frame.GetAgentId() != "desktop-agent" || frame.GetProcessState().GetRevision() != 7 {
t.Fatalf("%s frame = agent %q rev %d, want desktop-agent rev 7",
context, frame.GetAgentId(), frame.GetProcessState().GetRevision())
}
}

func TestV2ManagedProcessBroadcastAndReplay(t *testing.T) {
t.Parallel()

sm := session.NewManager()
store := newAgentTokenStore(t)
agentToken, err := store.Issue("desktop-agent", "")
if err != nil {
t.Fatalf("issue desktop agent token: %v", err)
}
srv := pbws.NewServer(newV2TestConfig(), sm, store)

mux := http.NewServeMux()
mux.Handle("/ws/v2", srv.BrowserHandler())
mux.Handle("/ws/v2/agent", srv.AgentHandler())

agentConn, agentCleanup := dialV2Path(t, mux, "/ws/v2/agent")
defer agentCleanup()
sendProtoFrame(t, agentConn, &gatewayv2.AgentClientFrame{
Payload: &gatewayv2.AgentClientFrame_Hello{
Hello: &gatewayv2.ClientHello{
ProtocolVersion: pbws.ProtocolVersion,
Role: gatewayv2.ClientRole_CLIENT_ROLE_AGENT,
Token: agentToken,
AgentId: "desktop-agent",
},
},
})
if hello := receiveAgentServerFrame(t, agentConn).GetHello(); hello == nil || !hello.GetOk() {
t.Fatalf("agent hello reply = %#v, want ok", hello)
}

browserConn, browserCleanup := dialV2Path(t, mux, "/ws/v2")
defer browserCleanup()
helloV2(t, browserConn, "ws-token")

sendProtoFrame(t, agentConn, &gatewayv2.AgentClientFrame{
Payload: &gatewayv2.AgentClientFrame_Envelope{
Envelope: &gatewayv2.AgentEnvelope{
RequestId: "managed-process-1",
Payload: &gatewayv2.AgentEnvelope_ManagedProcessSnapshot{
ManagedProcessSnapshot: &gatewayv2.ManagedProcessSnapshot{
Revision: 7,
Processes: []*gatewayv2.ManagedProcessRecord{
{Id: "p-1", Command: "sleep 1000", Pid: 4242, Running: true},
},
},
},
},
},
})

// 已连接浏览器收到广播;新浏览器连接收到缓存回放。
expectProcessState(t, browserConn, "broadcast")
browserConn2, browserCleanup2 := dialV2Path(t, mux, "/ws/v2")
defer browserCleanup2()
helloV2(t, browserConn2, "ws-token")
expectProcessState(t, browserConn2, "replay")
}
1 change: 1 addition & 0 deletions crates/agent-gateway/test/webui/web-settings.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ test("web settings normalization canonicalizes project keyed maps with Windows p
openedAt: 2,
},
},
backgroundTasks: { opened: false, dismissedIds: [] },
openVersion: 0,
stateVersion: 0,
writerId: "",
Expand Down
9 changes: 8 additions & 1 deletion crates/agent-gateway/web/src/lib/gatewaySocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2568,6 +2568,7 @@ export class GatewayWebSocketClient {
this.sftpTransferListeners.size > 0 ||
this.chatActivityListeners.size > 0 ||
this.tunnelStateListeners.size > 0 ||
this.processStateListeners.size > 0 ||
this.workspaceActivityListeners.size > 0 ||
this.conversationStreams.size > 0)
);
Expand Down Expand Up @@ -3569,6 +3570,11 @@ export type GatewayWebSocketClientLike = {

let activeClient: GatewayWebSocketClient | null = null;
let activeToken = "";
// True once any client has existed this page lifetime. reset* nulls
// activeClient without notifying, so "activeClient !== null" alone would
// miss the reset→create sequence and leave module-scoped stores subscribed
// to the disposed instance forever.
let everHadClient = false;
const clientReplacedListeners = new Set<() => void>();

/**
Expand All @@ -3588,10 +3594,11 @@ export function getGatewayWebSocketClient(token: string): GatewayWebSocketClient
if (activeClient && activeToken === normalizedToken) {
return activeClient;
}
const replaced = activeClient !== null;
const replaced = everHadClient;
activeClient?.dispose();
activeToken = normalizedToken;
activeClient = new GatewayWebSocketClient(normalizedToken);
everHadClient = true;
if (replaced) {
// The new instance is already installed, so re-entrant
// getGatewayWebSocketClient calls from listeners hit the fast path.
Expand Down
37 changes: 37 additions & 0 deletions crates/agent-gateway/web/test/gateway-socket-singleton.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fileURLToPath } from "node:url";

import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs";

const storage = new Map();
globalThis.localStorage = {
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
setItem: (key, value) => storage.set(key, String(value)),
removeItem: (key) => storage.delete(key),
};
globalThis.location = { origin: "http://127.0.0.1:9", href: "http://127.0.0.1:9/" };

const loader = createWebModuleLoader({
rootDir: fileURLToPath(new URL("../", import.meta.url)),
});
const { getGatewayWebSocketClient, onGatewayWebSocketClientReplaced, resetGatewayWebSocketClient } =
loader.loadModule("src/lib/gatewaySocket.ts");

// 凡是新实例顶替过既有实例——包括 reset 置空后再创建(登出→登录)——都必须
// 触发 replaced,否则模块级 store 会永远挂在已 dispose 的旧实例上收不到事件。
test("单例 reset→create 也触发 replaced", () => {
let fired = 0;
const detach = onGatewayWebSocketClientReplaced(() => {
fired += 1;
});
const first = getGatewayWebSocketClient("token-a");
assert.equal(fired, 0); // 首个创建不算替换
resetGatewayWebSocketClient();
assert.equal(fired, 0); // reset 本身不通知(此刻无新实例可接)
const second = getGatewayWebSocketClient("token-a");
assert.notEqual(first, second);
assert.equal(fired, 1);
detach();
resetGatewayWebSocketClient();
});
81 changes: 81 additions & 0 deletions crates/agent-gateway/web/test/managed-process-store.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fileURLToPath } from "node:url";

import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs";

function snapshot(revision, overrides = {}) {
return {
ready: true,
agentOnline: true,
revision,
processes: [{ id: `p-${revision}`, running: true }],
...overrides,
};
}

const listeners = new Set();
const backend = {
failNextFetch: false,
nextState: snapshot(1),
async fetchState() {
if (backend.failNextFetch) {
backend.failNextFetch = false;
throw new Error("fetch failed");
}
return backend.nextState;
},
async stop() {
return null;
},
async clear() {
return null;
},
async readLog() {
return { content: "", logPath: "", truncated: false };
},
subscribe(onState) {
listeners.add(onState);
return () => listeners.delete(onState);
},
push(state) {
for (const listener of listeners) listener(state);
},
};

const loader = createWebModuleLoader({
rootDir: fileURLToPath(new URL("../", import.meta.url)),
mocks: { "@liveagent/app/lib/managed-process/backend": { backend } },
});
const store = loader.loadModule("@liveagent/ui/lib/managed-process/store.ts");

test("managed-process store 镜像语义与 refresh 自愈", async () => {
// 失败的初始化不留僵尸订阅,refresh 兼作重试补齐。
backend.failNextFetch = true;
await assert.rejects(store.ensureManagedProcessInit(), /fetch failed/);
assert.equal(store.getManagedProcessState().ready, false);
assert.equal(listeners.size, 0);

backend.nextState = snapshot(5);
await store.refreshManagedProcessState();
assert.equal(store.getManagedProcessState().revision, 5);
assert.equal(listeners.size, 1);

// 后端推送直接喂入镜像。
backend.push(snapshot(6));
assert.equal(store.getManagedProcessState().revision, 6);

// 陈旧修订丢弃列表但采纳 agentOnline;等修订放行(在线位翻转不递增修订)。
backend.push(snapshot(3, { agentOnline: false, processes: [] }));
const stale = store.getManagedProcessState();
assert.equal(stale.revision, 6);
assert.equal(stale.processes.length, 1);
assert.equal(stale.agentOnline, false);
backend.push(snapshot(6, { agentOnline: true }));
assert.equal(store.getManagedProcessState().agentOnline, true);

// refresh 拉取新快照对账。
backend.nextState = snapshot(9);
await store.refreshManagedProcessState();
assert.equal(store.getManagedProcessState().revision, 9);
});
4 changes: 4 additions & 0 deletions crates/agent-gui/test/settings/normalization.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ test("settings normalization canonicalizes project keyed maps with Windows path
openedAt: 2,
},
},
backgroundTasks: { opened: false, dismissedIds: [] },
openVersion: 0,
stateVersion: 0,
writerId: "",
Expand Down Expand Up @@ -890,6 +891,7 @@ test("gateway settings sync payload redacts provider api keys", () => {
openedAt: 2,
},
},
backgroundTasks: { opened: false, dismissedIds: [] },
openVersion: 3,
stateVersion: 4,
writerId: "",
Expand All @@ -903,6 +905,7 @@ test("gateway settings sync payload redacts provider api keys", () => {
openedAt: 3,
},
},
backgroundTasks: { opened: false, dismissedIds: [] },
openVersion: 2,
stateVersion: 2,
writerId: "",
Expand Down Expand Up @@ -1241,6 +1244,7 @@ test("normalizes right dock from current settings", () => {
},
},
},
backgroundTasks: { opened: false, dismissedIds: [] },
openVersion: 6,
stateVersion: 7,
writerId: "",
Expand Down
74 changes: 74 additions & 0 deletions crates/agent-gui/test/settings/right-dock-model.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -704,3 +704,77 @@ describe("file tree model", () => {
);
});
});

test("background-tasks 开合手势:归一化上限、固定可见与 dismissal 快照", () => {
assert.deepEqual(
settings.normalizeRightDockBackgroundTasksState({
opened: true,
dismissedIds: ["p-1", "", "p-1", 42, " p-2 ", "x".repeat(200)],
}),
{ opened: true, dismissedIds: ["p-1", "p-2"] },
);

const base = settings.normalizeRightDockProjectState({});
const opened = settings.openRightDockBackgroundTasksTabState(base);
assert.equal(opened.activeTabId, settings.RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID);
assert.ok(opened.tabOrder.includes(settings.RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID));
assert.deepEqual(opened.backgroundTasks, { opened: true, dismissedIds: [] });

// 关是 hide-only:仅写 dismissal 快照,activeTabId/tabOrder 不动;重开清空快照。
const closed = settings.closeRightDockBackgroundTasksTabState(opened, ["p-1"]);
assert.deepEqual(closed, {
...opened,
backgroundTasks: { opened: false, dismissedIds: ["p-1"] },
});
assert.deepEqual(settings.openRightDockBackgroundTasksTabState(closed).backgroundTasks, {
opened: true,
dismissedIds: [],
});
});

test("background-tasks 意图落盘递增版本并随 (stateVersion, writerId) 归并", () => {
// 仅 backgroundTasks 变更也是内容:落盘并递增 stateVersion。
const opened = settings.updateRightDockProjectState(
settings.normalizeSettings({}),
"/workspace/app",
(current) => settings.openRightDockBackgroundTasksTabState(current),
);
const state = settings.getRightDockProjectState(opened.customSettings, "/workspace/app");
assert.deepEqual(state.backgroundTasks, { opened: true, dismissedIds: [] });
assert.equal(state.stateVersion, 1);

// 仅后台意图的桶是活状态:lastUsedAt 久远时若被误判为空桶会走墓碑 TTL 被丢弃。
assert.ok(
settings.normalizeRightDockSettings({
projects: {
"/workspace/app": {
tools: {},
backgroundTasks: { opened: true, dismissedIds: [] },
stateVersion: 2,
lastUsedAt: 1,
},
},
}).projects["/workspace/app"],
);

// 更高 stateVersion 的对端意图在归并中胜出。
const merged = sync.applyGatewaySettingsSyncPayload(
opened,
rightDockSyncPayload({
"/workspace/app": {
activeTabId: settings.RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID,
tabOrder: [settings.RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID],
tools: {},
backgroundTasks: { opened: false, dismissedIds: ["p-1"] },
openVersion: 0,
stateVersion: 2,
writerId: "writer-remote",
lastUsedAt: Date.now(),
},
}),
);
assert.deepEqual(merged.customSettings.rightDock.projects["/workspace/app"].backgroundTasks, {
opened: false,
dismissedIds: ["p-1"],
});
});
Loading
Loading