Skip to content
Open
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
372 changes: 186 additions & 186 deletions nodejs/src/generated/rpc.ts

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,18 @@
export { CopilotClient } from "./client.js";
export { DisableBypassPermissionsModes, RuntimeConnection } from "./types.js";
export { BuiltInTools, ToolSet } from "./toolSet.js";
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
export {
CopilotSession,
SendSessionMessageError,
type AssistantMessageEvent,
type ListMessageableSessionsRequest,
type ListMessageableSessionsResult,
type MessageableSession,
type SendSessionMessageRequest,
type SendSessionMessageResult,
type SendSessionMessageErrorCode,
type SessionMessageDelivery,
} from "./session.js";
export { defineFactory, FactoryResumeError, isFactoryRunTerminal } from "./factory.js";
export {
Canvas,
Expand Down Expand Up @@ -52,6 +63,7 @@ export {
// shadow the names arriving via `export type *`, so the hand-authored public API
// surface for those six identifiers is preserved unchanged.
export type * from "./generated/session-events.js";
export type { SendMode } from "./generated/rpc.js";
export type {
AskUserVariant,
CommandContext,
Expand Down
164 changes: 164 additions & 0 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
FactoryLogLine,
FactoryRunResult as WireFactoryRunResult,
ModelSwitchAutoTierResult,
SendMode,
} from "./generated/rpc.js";
import { type Canvas, CanvasError } from "./canvas.js";
import type { OpenCanvasInstance } from "./generated/rpc.js";
Expand Down Expand Up @@ -384,6 +385,123 @@ function isFactoryFatalError(error: unknown): boolean {
/** Assistant message event - the final response from the assistant. */
export type AssistantMessageEvent = Extract<SessionEvent, { type: "assistant.message" }>;

/** Optional exact-name query for active local messageable sessions. */
export interface ListMessageableSessionsRequest {
/** Optional exact session name query. Matching semantics are owned by the local host. */
name?: string;
}

/** Sanitized active local session available for exact-ID messaging selection. */
export interface MessageableSession {
/** Stable session ID to provide to {@link CopilotSession.sendSessionMessage}. */
sessionId: string;
/** Current session name when available. */
name?: string;
/** Current session summary when available. */
summary?: string;
}

/** Sanitized active local sessions available for exact-ID messaging selection. */
export interface ListMessageableSessionsResult {
/** Messageable sessions in deterministic session-ID order. */
sessions: MessageableSession[];
}

/** Actual recipient delivery class for an admitted cross-session message. */
export type SessionMessageDelivery = "idle" | "steering" | "queued";

/** Parameters for one authenticated exact-target cross-session message. */
export interface SendSessionMessageRequest {
/** Exact active local recipient session ID. */
targetSessionId: string;
/** Natural-language message content. */
content: string;
/** Requested delivery mode. The host applies its existing default when omitted. */
delivery?: SendMode;
}

/** Recipient admission result for an authenticated cross-session message. */
export interface SendSessionMessageResult {
/** Unique identifier assigned to the admitted message. */
messageId: string;
/** Actual recipient delivery class at admission. */
delivery: SessionMessageDelivery;
/** Sanitized recipient display name for presentation only. */
targetDisplayName?: string;
}

/** Stable public outcomes for a failed cross-session message send. */
export type SendSessionMessageErrorCode = "refused" | "not-delivered" | "ambiguous";

/**
* Error returned when the runtime reaches a recognized terminal cross-session
* message outcome.
*
* @experimental
*/
export class SendSessionMessageError extends Error {
constructor(
public readonly code: SendSessionMessageErrorCode,
message: string,
public readonly messageId?: string
) {
super(message);
this.name = "SendSessionMessageError";
}
}

function parseSendSessionMessageErrorData(
data: unknown
): { code: SendSessionMessageErrorCode; messageId?: string } | undefined {
if (typeof data !== "object" || data === null) {
return undefined;
}

const envelope = data as { kind?: unknown; code?: unknown; messageId?: unknown };
if (
typeof envelope.code !== "string" ||
(envelope.messageId !== undefined && typeof envelope.messageId !== "string")
) {
return undefined;
}

let code: SendSessionMessageErrorCode;
switch (envelope.kind) {
case "session_message_refused":
if (
![
"target-not-active",
"target-generation-changed",
"source-not-active",
"self-send",
"request-invalid",
"recipient-refused",
"transport-unavailable",
].includes(envelope.code)
) {
return undefined;
}
code = "refused";
break;
case "session_message_not_delivered":
if (envelope.code !== "not-delivered") {
return undefined;
}
code = "not-delivered";
break;
case "session_message_ambiguous":
if (envelope.code !== "ambiguous") {
return undefined;
}
code = "ambiguous";
break;
default:
return undefined;
}

return envelope.messageId === undefined ? { code } : { code, messageId: envelope.messageId };
}

const TOOL_SEARCH_TOOL_NAME = "tool_search_tool";

/**
Expand Down Expand Up @@ -730,6 +848,52 @@ export class CopilotSession {
return (response as { messageId: string }).messageId;
}

/**
* Lists active local sessions that this bound session can select by exact
* ID for cross-session messaging. The result grants no delivery authority;
* call {@link sendSessionMessage} with a selected `sessionId`.
*
* @experimental
*/
async listMessageableSessions(
params: ListMessageableSessionsRequest = {}
): Promise<ListMessageableSessionsResult> {
return this.connection.sendRequest("session.listMessageableSessions", {
...params,
sessionId: this.sessionId,
});
}

/**
* Sends one authenticated non-user message from this bound session to an
* exact active local session.
*
* Success reports recipient admission, not completion of delegated work.
* An ambiguous error means delivery may have started and is never retried.
*
* @experimental
*/
async sendSessionMessage(params: SendSessionMessageRequest): Promise<SendSessionMessageResult> {
try {
return await this.connection.sendRequest("session.sendSessionMessage", {
...params,
sessionId: this.sessionId,
});
} catch (error) {
if (error instanceof ResponseError) {
const translated = parseSendSessionMessageErrorData(error.data);
if (translated) {
throw new SendSessionMessageError(
translated.code,
error.message,
translated.messageId
);
}
}
throw error;
}
}

/**
* Sends a message to this session and waits until the session becomes idle.
*
Expand Down
99 changes: 99 additions & 0 deletions nodejs/test/session-list-messageable-sessions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import { describe, expect, it, vi } from "vitest";
import {
CopilotSession,
type ListMessageableSessionsRequest,
type ListMessageableSessionsResult,
type MessageableSession,
} from "../src/index.js";

type AssertEqual<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;

type RequestMatchesPublicContract = AssertEqual<
ListMessageableSessionsRequest,
{
name?: string;
}
>;
const requestMatchesPublicContract: RequestMatchesPublicContract = true;

type CandidateMatchesPublicContract = AssertEqual<
MessageableSession,
{
sessionId: string;
name?: string;
summary?: string;
}
>;
const candidateMatchesPublicContract: CandidateMatchesPublicContract = true;

type ResultMatchesPublicContract = AssertEqual<
ListMessageableSessionsResult,
{
sessions: MessageableSession[];
}
>;
const resultMatchesPublicContract: ResultMatchesPublicContract = true;

const assertRejectedListInputs = (session: CopilotSession): void => {
// @ts-expect-error Source identity is derived from the bound session.
void session.listMessageableSessions({ sourceSessionId: "forged" });
// @ts-expect-error Discovery never accepts a delivery target.
void session.listMessageableSessions({ targetSessionId: "target-session" });
};
void assertRejectedListInputs;

describe("CopilotSession.listMessageableSessions", () => {
it("lists all candidates when no name is supplied", async () => {
const result = {
sessions: [
{ sessionId: "session-a", name: "Research" },
{ sessionId: "session-b", summary: "Research" },
],
};
const sendRequest = vi.fn(async () => result);
const session = new CopilotSession("source-session", { sendRequest } as never);

await expect(session.listMessageableSessions()).resolves.toEqual(result);
expect(sendRequest).toHaveBeenCalledExactlyOnceWith("session.listMessageableSessions", {
sessionId: "source-session",
});
});

it("forwards the exact-name query without rewriting it", async () => {
const result = { sessions: [{ sessionId: "session-a", name: "Research" }] };
const sendRequest = vi.fn(async () => result);
const session = new CopilotSession("source-session", { sendRequest } as never);

await expect(session.listMessageableSessions({ name: " ReSeArCh " })).resolves.toEqual(
result
);
expect(sendRequest).toHaveBeenCalledExactlyOnceWith("session.listMessageableSessions", {
sessionId: "source-session",
name: " ReSeArCh ",
});
});

it("does not allow untyped input to override the bound source session", async () => {
const result = { sessions: [] };
const sendRequest = vi.fn(async () => result);
const session = new CopilotSession("source-session", { sendRequest } as never);
const params = JSON.parse(
'{"sessionId":"forged-session","name":"Research"}'
) as ListMessageableSessionsRequest;

await expect(session.listMessageableSessions(params)).resolves.toEqual(result);
expect(sendRequest).toHaveBeenCalledExactlyOnceWith("session.listMessageableSessions", {
sessionId: "source-session",
name: "Research",
});
});
});

void requestMatchesPublicContract;
void candidateMatchesPublicContract;
void resultMatchesPublicContract;
Loading
Loading