mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15: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();
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
||||
import {
|
||||
automationStatesEqual,
|
||||
getAutomationStateEventPayload,
|
||||
} from "../../services/automationState";
|
||||
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -33,6 +39,9 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const prevAutomationState = getAutomationStateEventPayload(
|
||||
storeGetSessionWorker(sessionId)?.externalMetadata,
|
||||
);
|
||||
if (body.worker_status) {
|
||||
updateSessionStatus(sessionId, body.worker_status);
|
||||
} else {
|
||||
@@ -44,6 +53,17 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
externalMetadata: body.external_metadata,
|
||||
requiresActionDetails: body.requires_action_details,
|
||||
});
|
||||
const nextAutomationState = getAutomationStateEventPayload(worker.externalMetadata);
|
||||
|
||||
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
|
||||
getEventBus(sessionId).publish({
|
||||
id: uuid(),
|
||||
sessionId,
|
||||
type: "automation_state",
|
||||
payload: nextAutomationState,
|
||||
direction: "inbound",
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
status: "ok",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import { getAutomationStateSnapshot } from "../../services/automationState";
|
||||
import {
|
||||
createSession,
|
||||
getSession,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
resolveOwnedWebSessionId,
|
||||
toWebSessionResponse,
|
||||
} from "../../services/session";
|
||||
import { storeBindSession } from "../../store";
|
||||
import { storeBindSession, storeGetSessionWorker } from "../../store";
|
||||
import { createWorkItem } from "../../services/work-dispatch";
|
||||
import { createSSEStream } from "../../transport/sse-writer";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
@@ -68,7 +69,13 @@ app.get("/sessions/:id", uuidAuth, async (c) => {
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
return c.json(toWebSessionResponse(session), 200);
|
||||
const worker = storeGetSessionWorker(sessionId);
|
||||
const automationState = getAutomationStateSnapshot(worker?.externalMetadata);
|
||||
const response = toWebSessionResponse(session);
|
||||
return c.json(
|
||||
automationState === undefined ? response : { ...response, automation_state: automationState },
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
/** GET /web/sessions/:id/history — Historical events for session */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { AutomationStateResponse } from "../types/api";
|
||||
|
||||
const DISABLED_AUTOMATION_STATE: AutomationStateResponse = Object.freeze({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
|
||||
function cloneAutomationState(state: AutomationStateResponse): AutomationStateResponse {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function normalizeAutomationState(raw: unknown): AutomationStateResponse {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
||||
}
|
||||
|
||||
const state = raw as Record<string, unknown>;
|
||||
return {
|
||||
enabled: state.enabled === true,
|
||||
phase: state.phase === "standby" || state.phase === "sleeping" ? state.phase : null,
|
||||
next_tick_at: typeof state.next_tick_at === "number" ? state.next_tick_at : null,
|
||||
sleep_until: typeof state.sleep_until === "number" ? state.sleep_until : null,
|
||||
};
|
||||
}
|
||||
|
||||
function readAutomationStateValue(metadata: Record<string, unknown> | null | undefined): unknown {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, "automation_state")) {
|
||||
return undefined;
|
||||
}
|
||||
return metadata.automation_state;
|
||||
}
|
||||
|
||||
export function getAutomationStateSnapshot(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): AutomationStateResponse | undefined {
|
||||
const raw = readAutomationStateValue(metadata);
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeAutomationState(raw);
|
||||
}
|
||||
|
||||
export function getAutomationStateEventPayload(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): AutomationStateResponse {
|
||||
return getAutomationStateSnapshot(metadata) ?? cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
||||
}
|
||||
|
||||
export function automationStatesEqual(
|
||||
a: AutomationStateResponse,
|
||||
b: AutomationStateResponse,
|
||||
): boolean {
|
||||
return (
|
||||
a.enabled === b.enabled &&
|
||||
a.phase === b.phase &&
|
||||
a.next_tick_at === b.next_tick_at &&
|
||||
a.sleep_until === b.sleep_until
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,9 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
|
||||
};
|
||||
|
||||
if (typeof p.uuid === "string" && p.uuid) normalized.uuid = p.uuid;
|
||||
if (typeof p.isSynthetic === "boolean") normalized.isSynthetic = p.isSynthetic;
|
||||
if (typeof p.status === "string") normalized.status = p.status;
|
||||
if (typeof p.subtype === "string") normalized.subtype = p.subtype;
|
||||
|
||||
// Preserve tool fields
|
||||
if (p.tool_name) normalized.tool_name = p.tool_name;
|
||||
@@ -68,6 +71,12 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
|
||||
// Preserve message field for backward compat
|
||||
if (p.message) normalized.message = p.message;
|
||||
|
||||
if (type === "task_state") {
|
||||
if (typeof p.task_list_id === "string") normalized.task_list_id = p.task_list_id;
|
||||
if (typeof p.taskListId === "string") normalized.taskListId = p.taskListId;
|
||||
if (Array.isArray(p.tasks)) normalized.tasks = p.tasks;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
|
||||
/**
|
||||
* Convert an internal session event into the SDK/control message shape that
|
||||
* bridge workers consume on both the legacy WS path and the v2 worker SSE path.
|
||||
*/
|
||||
export function toClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
const payload = event.payload as Record<string, unknown> | null;
|
||||
const messageUuid =
|
||||
typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
|
||||
|
||||
if (event.type === "user" || event.type === "user_message") {
|
||||
return {
|
||||
type: "user",
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
...(payload?.isSynthetic === true ? { isSynthetic: true } : {}),
|
||||
message: {
|
||||
role: "user",
|
||||
content: payload?.content ?? payload?.message ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "permission_response" || event.type === "control_response") {
|
||||
const approved = !!payload?.approved;
|
||||
const existingResponse = payload?.response as Record<string, unknown> | undefined;
|
||||
if (existingResponse) {
|
||||
return { type: "control_response", response: existingResponse };
|
||||
}
|
||||
|
||||
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
|
||||
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
|
||||
const feedbackMessage = payload?.message as string | undefined;
|
||||
|
||||
return {
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: approved ? "success" : "error",
|
||||
request_id: payload?.request_id ?? "",
|
||||
...(approved
|
||||
? {
|
||||
response: {
|
||||
behavior: "allow" as const,
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
error: "Permission denied by user",
|
||||
response: { behavior: "deny" as const },
|
||||
...(feedbackMessage ? { message: feedbackMessage } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "interrupt") {
|
||||
return {
|
||||
type: "control_request",
|
||||
request_id: event.id,
|
||||
request: { subtype: "interrupt" },
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "control_request") {
|
||||
return {
|
||||
type: "control_request",
|
||||
request_id: payload?.request_id ?? event.id,
|
||||
request: payload?.request ?? payload,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: event.type,
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: payload,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { log, error as logError } from "../logger";
|
||||
import type { Context } from "hono";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { getEventBus } from "./event-bus";
|
||||
import { toClientPayload } from "./client-payload";
|
||||
|
||||
export interface SSEWriter {
|
||||
send(event: SessionEvent): void;
|
||||
@@ -118,6 +119,15 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
|
||||
}
|
||||
|
||||
function toWorkerClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
if (
|
||||
event.type === "permission_response" ||
|
||||
event.type === "control_response" ||
|
||||
event.type === "control_request" ||
|
||||
event.type === "interrupt"
|
||||
) {
|
||||
return toClientPayload(event);
|
||||
}
|
||||
|
||||
const normalized =
|
||||
event.payload && typeof event.payload === "object"
|
||||
? (event.payload as Record<string, unknown>)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { publishSessionEvent } from "../services/transport";
|
||||
import { log, error as logError } from "../logger";
|
||||
import { toClientPayload } from "./client-payload";
|
||||
|
||||
// Per-connection cleanup, keyed by sessionId (only one WS per session)
|
||||
interface CleanupEntry {
|
||||
@@ -24,75 +25,9 @@ const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
|
||||
* Convert internal EventBus event -> SDK message for bridge client.
|
||||
*/
|
||||
function toSDKMessage(event: SessionEvent): string {
|
||||
const payload = event.payload as Record<string, unknown> | null;
|
||||
const messageUuid = typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
|
||||
|
||||
let msg: Record<string, unknown>;
|
||||
|
||||
if (event.type === "user" || event.type === "user_message") {
|
||||
msg = {
|
||||
type: "user",
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: {
|
||||
role: "user",
|
||||
content: payload?.content ?? payload?.message ?? "",
|
||||
},
|
||||
};
|
||||
} else if (event.type === "permission_response" || event.type === "control_response") {
|
||||
const approved = !!payload?.approved;
|
||||
const existingResponse = payload?.response as Record<string, unknown> | undefined;
|
||||
if (existingResponse) {
|
||||
msg = { type: "control_response", response: existingResponse };
|
||||
} else {
|
||||
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
|
||||
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
|
||||
const feedbackMessage = payload?.message as string | undefined;
|
||||
msg = {
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: approved ? "success" : "error",
|
||||
request_id: payload?.request_id ?? "",
|
||||
...(approved
|
||||
? {
|
||||
response: {
|
||||
behavior: "allow" as const,
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
error: "Permission denied by user",
|
||||
response: { behavior: "deny" as const },
|
||||
...(feedbackMessage ? { message: feedbackMessage } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (event.type === "interrupt") {
|
||||
msg = {
|
||||
type: "control_request",
|
||||
request_id: event.id,
|
||||
request: { subtype: "interrupt" },
|
||||
};
|
||||
} else if (event.type === "control_request") {
|
||||
msg = {
|
||||
type: "control_request",
|
||||
request_id: payload?.request_id ?? event.id,
|
||||
request: payload?.request ?? payload,
|
||||
};
|
||||
} else {
|
||||
msg = {
|
||||
type: event.type,
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: payload,
|
||||
};
|
||||
}
|
||||
|
||||
// NDJSON format: each message MUST end with \n so the child process's
|
||||
// line-based parser can split messages correctly.
|
||||
return JSON.stringify(msg) + "\n";
|
||||
return JSON.stringify(toClientPayload(event)) + "\n";
|
||||
}
|
||||
|
||||
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
|
||||
@@ -236,7 +171,11 @@ export function ingestBridgeMessage(sessionId: string, msg: Record<string, unkno
|
||||
}
|
||||
payload = { message: msg.message, uuid: msg.uuid, content: text };
|
||||
} else if (eventType === "user" || eventType === "system") {
|
||||
payload = { message: msg.message, uuid: msg.uuid };
|
||||
payload = {
|
||||
message: msg.message,
|
||||
uuid: msg.uuid,
|
||||
...(typeof msg.isSynthetic === "boolean" ? { isSynthetic: msg.isSynthetic } : {}),
|
||||
};
|
||||
} else if (eventType === "control_request") {
|
||||
payload = { request_id: msg.request_id, request: msg.request };
|
||||
} else if (eventType === "control_response") {
|
||||
|
||||
@@ -70,6 +70,14 @@ export interface SessionResponse {
|
||||
username: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
automation_state?: AutomationStateResponse;
|
||||
}
|
||||
|
||||
export interface AutomationStateResponse {
|
||||
enabled: boolean;
|
||||
phase: "standby" | "sleeping" | null;
|
||||
next_tick_at: number | null;
|
||||
sleep_until: number | null;
|
||||
}
|
||||
|
||||
// --- v2 Code Sessions ---
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ControlRequest extends SDKMessage {
|
||||
export type SessionEventType =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "automation_state"
|
||||
| "permission_request"
|
||||
| "permission_response"
|
||||
| "control_request"
|
||||
@@ -49,6 +50,7 @@ export type SessionEventType =
|
||||
export interface NormalizedEventPayload {
|
||||
content: string;
|
||||
raw?: unknown;
|
||||
isSynthetic?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user