mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
This commit is contained in:
@@ -678,6 +678,44 @@ describe("Web Session Routes", () => {
|
||||
expect(getRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — includes automation_state snapshot when worker metadata has it", async () => {
|
||||
const createRes = await app.request("/v1/code/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const {
|
||||
session: { id },
|
||||
} = await createRes.json();
|
||||
storeBindSession(id, "user-1");
|
||||
|
||||
await app.request(`/v1/code/sessions/${id}/worker`, {
|
||||
method: "PUT",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
worker_epoch: 1,
|
||||
external_metadata: {
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const getRes = await app.request(`/web/sessions/${toWebSessionId(id)}?uuid=user-1`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const body = await getRes.json();
|
||||
expect(body.automation_state).toEqual({
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — 403 for non-owner", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
@@ -704,6 +742,35 @@ describe("Web Session Routes", () => {
|
||||
expect(body.events).toEqual([]);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id/history — returns task_state snapshots", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
publishSessionEvent(
|
||||
id,
|
||||
"task_state",
|
||||
{
|
||||
task_list_id: "team-alpha",
|
||||
tasks: [{ id: "1", subject: "Investigate", status: "pending" }],
|
||||
},
|
||||
"inbound",
|
||||
);
|
||||
|
||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
|
||||
expect(histRes.status).toBe(200);
|
||||
const body = await histRes.json();
|
||||
expect(body.events).toHaveLength(1);
|
||||
expect(body.events[0]?.type).toBe("task_state");
|
||||
expect(body.events[0]?.payload.task_list_id).toBe("team-alpha");
|
||||
expect(body.events[0]?.payload.tasks).toEqual([
|
||||
{ id: "1", subject: "Investigate", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id and history — supports compat code session IDs", async () => {
|
||||
const codeSession = storeCreateSession({ idPrefix: "cse_" });
|
||||
storeBindSession(codeSession.id, "user-1");
|
||||
@@ -1218,7 +1285,15 @@ describe("V2 Worker Events Routes", () => {
|
||||
body: JSON.stringify({
|
||||
worker_epoch: 1,
|
||||
worker_status: "running",
|
||||
external_metadata: { permission_mode: "default" },
|
||||
external_metadata: {
|
||||
permission_mode: "default",
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
@@ -1230,6 +1305,21 @@ describe("V2 Worker Events Routes", () => {
|
||||
const body = await getRes.json();
|
||||
expect(body.worker.worker_status).toBe("running");
|
||||
expect(body.worker.external_metadata.permission_mode).toBe("default");
|
||||
expect(body.worker.external_metadata.automation_state).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
});
|
||||
|
||||
const events = getEventBus(id).getEventsSince(0);
|
||||
expect(events.some((event) => event.type === "automation_state")).toBe(true);
|
||||
expect(events.at(-1)?.payload).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat", async () => {
|
||||
@@ -1284,6 +1374,123 @@ describe("V2 Worker Events Routes", () => {
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web permission approvals to control_response", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(streamRes.status).toBe(200);
|
||||
|
||||
const reader = streamRes.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
if (!reader) return;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "permission_response",
|
||||
approved: true,
|
||||
request_id: "req-1",
|
||||
}),
|
||||
});
|
||||
expect(controlRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"permission_response\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
|
||||
expect(frame).toContain("\"request_id\":\"req-1\"");
|
||||
expect(frame).toContain("\"behavior\":\"allow\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web plan rejection feedback to deny control_response", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(streamRes.status).toBe(200);
|
||||
|
||||
const reader = streamRes.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
if (!reader) return;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "permission_response",
|
||||
approved: false,
|
||||
request_id: "req-2",
|
||||
message: "Need more detail",
|
||||
}),
|
||||
});
|
||||
expect(controlRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"permission_response\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
|
||||
expect(frame).toContain("\"request_id\":\"req-2\"");
|
||||
expect(frame).toContain("\"subtype\":\"error\"");
|
||||
expect(frame).toContain("\"behavior\":\"deny\"");
|
||||
expect(frame).toContain("\"message\":\"Need more detail\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web interrupts to control_request", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(streamRes.status).toBe(200);
|
||||
|
||||
const reader = streamRes.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
if (!reader) return;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const interruptRes = await app.request(`/web/sessions/${id}/interrupt?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
expect(interruptRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"interrupt\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_request\"");
|
||||
expect(frame).toContain("\"subtype\":\"interrupt\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
|
||||
@@ -353,6 +353,14 @@ describe("Transport Service", () => {
|
||||
expect(result.uuid).toBe("msg_123");
|
||||
});
|
||||
|
||||
test("preserves isSynthetic field", () => {
|
||||
const result = normalizePayload("user", {
|
||||
content: "scheduled job: refresh analytics cache",
|
||||
isSynthetic: true,
|
||||
});
|
||||
expect(result.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("uses name as tool_name fallback", () => {
|
||||
const result = normalizePayload("tool", { name: "Read" });
|
||||
expect(result.tool_name).toBe("Read");
|
||||
@@ -370,6 +378,28 @@ describe("Transport Service", () => {
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("preserves task_state fields", () => {
|
||||
const result = normalizePayload("task_state", {
|
||||
task_list_id: "team-alpha",
|
||||
tasks: [{ id: "1", subject: "Task 1", status: "pending" }],
|
||||
});
|
||||
expect(result.task_list_id).toBe("team-alpha");
|
||||
expect(result.tasks).toEqual([
|
||||
{ id: "1", subject: "Task 1", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves status metadata for conversation reset events", () => {
|
||||
const result = normalizePayload("status", {
|
||||
status: "conversation_cleared",
|
||||
subtype: "status",
|
||||
message: "conversation_cleared",
|
||||
});
|
||||
expect(result.status).toBe("conversation_cleared");
|
||||
expect(result.subtype).toBe("status");
|
||||
expect(result.message).toBe("conversation_cleared");
|
||||
});
|
||||
|
||||
test("handles undefined payload", () => {
|
||||
const result = normalizePayload("user", undefined);
|
||||
expect(result.content).toBe("");
|
||||
|
||||
@@ -69,6 +69,19 @@ describe("ws-handler", () => {
|
||||
expect((events[0] as any).direction).toBe("inbound");
|
||||
});
|
||||
|
||||
test("preserves synthetic flag on inbound user messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", {
|
||||
message: { role: "user", content: "scheduled job: refresh analytics cache" },
|
||||
uuid: "u_synth",
|
||||
isSynthetic: true,
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).payload.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("derives type from message.role for assistant messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
@@ -163,6 +176,24 @@ describe("ws-handler", () => {
|
||||
expect(msg.type).toBe("user");
|
||||
});
|
||||
|
||||
test("replays synthetic user metadata back to the bridge", () => {
|
||||
const bus = getEventBus("s3");
|
||||
bus.publish({
|
||||
id: "e1",
|
||||
sessionId: "s3",
|
||||
type: "user",
|
||||
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "s3");
|
||||
|
||||
const msg = JSON.parse(ws.getSentData()[0]);
|
||||
expect(msg.type).toBe("user");
|
||||
expect(msg.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("replaces existing connection for same session", () => {
|
||||
const ws1 = createMockWs();
|
||||
const ws2 = createMockWs();
|
||||
|
||||
Reference in New Issue
Block a user