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
55 changes: 55 additions & 0 deletions web/src/lib/gatewayClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { JsonRpcGatewayClient } from "@hermes/shared";

import { GatewayClient } from "./gatewayClient";

// The dashboard, the Electron desktop app, and the Ink TUI all drive the same
// tui_gateway JSON-RPC server, which historically stamped every turn
// platform="tui". GatewayClient now tags SESSION-ORIGINATING calls with
// source:"dashboard" so the backend attributes browser-dashboard turns to the
// dashboard client (→ blackbox turns.platform → tokens.ace source chart). These
// tests pin that contract by spying on the inherited request().

function captureForwarded() {
// Spy on the base-class request so we see exactly what GatewayClient forwards,
// without needing a live WebSocket.
const spy = vi
.spyOn(JsonRpcGatewayClient.prototype, "request")
.mockResolvedValue({} as never);
return spy;
}

afterEach(() => {
vi.restoreAllMocks();
});

describe("GatewayClient source tagging", () => {
it('stamps source:"dashboard" on session.create', async () => {
const spy = captureForwarded();
await new GatewayClient().request("session.create", { cols: 96 });
expect(spy).toHaveBeenCalledWith(
"session.create",
{ cols: 96, source: "dashboard" },
undefined,
undefined,
);
});

it('stamps source:"dashboard" on session.resume', async () => {
const spy = captureForwarded();
await new GatewayClient().request("session.resume", { session_id: "s1" });
expect(spy.mock.calls[0][1]).toMatchObject({ session_id: "s1", source: "dashboard" });
});

it("does NOT add source to non-session-originating methods", async () => {
const spy = captureForwarded();
await new GatewayClient().request("prompt.submit", { session_id: "s1", text: "hi" });
expect(spy.mock.calls[0][1]).not.toHaveProperty("source");
});

it("never overrides an explicit caller-provided source (e.g. sidebar sidecar tool)", async () => {
const spy = captureForwarded();
await new GatewayClient().request("session.create", { close_on_disconnect: true, source: "tool" });
expect(spy.mock.calls[0][1]).toMatchObject({ source: "tool" });
});
});
30 changes: 30 additions & 0 deletions web/src/lib/gatewayClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";

export type { ConnectionState, GatewayEvent, GatewayEventName };

// This client's source label, threaded to the backend on session-originating
// RPCs so turns from the in-browser dashboard chat are attributed to
// "dashboard" rather than the shared "tui" default (the dashboard, the Electron
// desktop app, and the Ink TUI all drive the same JSON-RPC server). The backend
// sanitizes it (see tui_gateway.server._sanitize_client_source).
const CLIENT_SOURCE = "dashboard";

// RPC methods that MINT or (RE)ATTACH a session and therefore build an agent
// whose platform should carry the client label. Other methods (steer, title,
// prompt.submit, …) act on an existing session and ignore a `source` field.
const SESSION_ORIGIN_METHODS = new Set(["session.create", "session.resume"]);

export class GatewayClient extends JsonRpcGatewayClient {
constructor() {
super({
Expand All @@ -35,6 +47,24 @@ export class GatewayClient extends JsonRpcGatewayClient {
});
}

// Tag session-originating calls with this client so the backend records the
// turn's platform/source as "dashboard" instead of the shared "tui" default.
// Only stamps when the caller hasn't set an explicit source (e.g. the sidebar
// sidecar session deliberately sends source:"tool"), and only on the methods
// that mint/attach a session — every other RPC ignores the field.
request<T>(
method: string,
params: Record<string, unknown> = {},
timeoutMs?: number,
signal?: AbortSignal,
): Promise<T> {
const outbound =
SESSION_ORIGIN_METHODS.has(method) && params.source === undefined
? { ...params, source: CLIENT_SOURCE }
: params;
return super.request<T>(method, outbound, timeoutMs, signal);
}

async connect(token?: string): Promise<void> {
if (this.connectionState === "open" || this.connectionState === "connecting") {
return;
Expand Down
Loading