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
103 changes: 101 additions & 2 deletions assistant/src/__tests__/assistant-event-hub.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, test } from "bun:test";
import { beforeEach, describe, expect, test } from "bun:test";

import type { AssistantEvent } from "../runtime/assistant-event.js";
import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
import {
AssistantEventHub,
broadcastMessage,
capabilityForMessageType,
} from "../runtime/assistant-event-hub.js";
import * as pendingInteractions from "../runtime/pending-interactions.js";

function makeEvent(overrides: Partial<AssistantEvent> = {}): AssistantEvent {
return {
Expand Down Expand Up @@ -350,3 +355,97 @@ describe("AssistantEventHub — re-entrancy / snapshot isolation", () => {
expect(received).toHaveLength(1);
});
});

// ── capabilityForMessageType — host-prefix routing ───────────────────────────

describe("capabilityForMessageType — host-prefix routing", () => {
test("two-segment domains map to their capability", () => {
expect(capabilityForMessageType("host_bash_request")).toBe("host_bash");
expect(capabilityForMessageType("host_bash_cancel")).toBe("host_bash");
expect(capabilityForMessageType("host_file_request")).toBe("host_file");
expect(capabilityForMessageType("host_cu_request")).toBe("host_cu");
expect(capabilityForMessageType("host_cu_cancel")).toBe("host_cu");
expect(capabilityForMessageType("host_browser_request")).toBe(
"host_browser",
);
});

test("host_transfer_* piggybacks on host_file capability", () => {
expect(capabilityForMessageType("host_transfer_request")).toBe("host_file");
expect(capabilityForMessageType("host_transfer_cancel")).toBe("host_file");
});

test("three-segment host_app_control routes to its own capability (longest-prefix wins)", () => {
expect(capabilityForMessageType("host_app_control_request")).toBe(
"host_app_control",
);
expect(capabilityForMessageType("host_app_control_cancel")).toBe(
"host_app_control",
);
});

test("non-host messages return undefined (broadcast)", () => {
expect(capabilityForMessageType("assistant_text_delta")).toBeUndefined();
expect(capabilityForMessageType("confirmation_request")).toBeUndefined();
expect(
capabilityForMessageType("conversation_list_invalidated"),
).toBeUndefined();
});

test("unknown host_<domain>_* prefixes return undefined", () => {
expect(capabilityForMessageType("host_unknown_request")).toBeUndefined();
});
});

// ── broadcastMessage — pending interaction registration ─────────────────────

describe("broadcastMessage — pending interaction registration", () => {
beforeEach(() => {
pendingInteractions.clear();
});

test("registers host_bash_request as kind: host_bash", () => {
broadcastMessage({
type: "host_bash_request",
requestId: "req-bash-1",
conversationId: "conv-1",
command: "echo hi",
timeout_ms: 1000,
} as never);

const entry = pendingInteractions.get("req-bash-1");
expect(entry).toBeDefined();
expect(entry?.kind).toBe("host_bash");
expect(entry?.conversationId).toBe("conv-1");
});

test("registers host_cu_request as kind: host_cu", () => {
broadcastMessage({
type: "host_cu_request",
requestId: "req-cu-1",
conversationId: "conv-1",
toolName: "computer",
input: { action: "screenshot" },
} as never);

const entry = pendingInteractions.get("req-cu-1");
expect(entry).toBeDefined();
expect(entry?.kind).toBe("host_cu");
expect(entry?.conversationId).toBe("conv-1");
});

test("registers host_app_control_request as kind: host_app_control", () => {
broadcastMessage({
type: "host_app_control_request",
requestId: "req-app-1",
conversationId: "conv-1",
toolName: "app_control_observe",
input: { tool: "observe", app: "com.example.editor" },
} as never);

const entry = pendingInteractions.get("req-app-1");
expect(entry).toBeDefined();
expect(entry?.kind).toBe("host_app_control");
expect(entry?.conversationId).toBe("conv-1");
});
});
43 changes: 31 additions & 12 deletions assistant/src/runtime/assistant-event-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,40 @@ const HOST_PREFIX_TO_CAPABILITY: Record<string, HostProxyCapability> = {
host_transfer: "host_file", // transfers piggyback on host_file capability
host_cu: "host_cu",
host_browser: "host_browser",
host_app_control: "host_app_control",
};

/**
* Prefix keys sorted by length descending, so longest-prefix matches win.
* This matters for three-segment domains like `host_app_control` whose prefix
* (`host_app_control`) shares a leading segment with shorter ones (`host_app`
* would otherwise be undefined and silently fall through to a broadcast).
*/
const HOST_PREFIX_KEYS_BY_LENGTH = Object.keys(HOST_PREFIX_TO_CAPABILITY).sort(
(a, b) => b.length - a.length,
);

/**
* Infer the {@link HostProxyCapability} a message should be targeted at based
* on its `type` field. Returns `undefined` for message types that are not
* host-proxy messages (i.e. they should broadcast to all subscribers).
*
* Host-proxy message types are shaped `host_<domain>[_<sub>]_<verb>` where
* `<verb>` is `request` or `cancel`. We strip the trailing verb and then
* pick the longest registered prefix that the remainder starts with — this
* way both two-segment domains (`host_bash`) and three-segment domains
* (`host_app_control`) route correctly.
*/
function capabilityForMessageType(
export function capabilityForMessageType(
type: string,
): HostProxyCapability | undefined {
// All host-proxy message types are prefixed with `host_<domain>_<verb>`.
// We match on the first two underscore-delimited segments.
const first = type.indexOf("_");
if (first === -1) return undefined;
const second = type.indexOf("_", first + 1);
const prefix = second === -1 ? type : type.slice(0, second);
return HOST_PREFIX_TO_CAPABILITY[prefix];
const stem = type.replace(/_(request|cancel)$/, "");
for (const key of HOST_PREFIX_KEYS_BY_LENGTH) {
if (stem === key || stem.startsWith(`${key}_`)) {
return HOST_PREFIX_TO_CAPABILITY[key];
}
}
return undefined;
}
import { emitFeedEvent } from "../home/emit-feed-event.js";
import { rewriteCommandPreview } from "../home/rewrite-command-preview.js";
Expand Down Expand Up @@ -401,10 +418,7 @@ export class AssistantEventHub {
disposeClient(clientId: string): number {
const targets: SubscriberEntry[] = [];
for (const entry of this.subscribers) {
if (
entry.type === "client" &&
entry.clientId === clientId
) {
if (entry.type === "client" && entry.clientId === clientId) {
targets.push(entry);
}
}
Expand Down Expand Up @@ -601,6 +615,11 @@ function registerPendingInteraction(
conversationId,
kind: "host_cu",
});
} else if (msg.type === "host_app_control_request") {
pendingInteractions.register(msg.requestId, {
conversationId,
kind: "host_app_control",
});
} else if (msg.type === "host_transfer_request") {
pendingInteractions.register(msg.requestId, {
conversationId,
Expand Down
Loading