Skip to content
2 changes: 2 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,7 @@ export class CopilotClient {
enableFileHooks: config.enableFileHooks,
enableHostGitOperations: config.enableHostGitOperations,
enableSessionStore: config.enableSessionStore,
enableCrossSessionMessaging: config.enableCrossSessionMessaging,
enableSkills: config.enableSkills,
skillDirectories: config.skillDirectories,
pluginDirectories: config.pluginDirectories,
Expand Down Expand Up @@ -1996,6 +1997,7 @@ export class CopilotClient {
enableFileHooks: config.enableFileHooks,
enableHostGitOperations: config.enableHostGitOperations,
enableSessionStore: config.enableSessionStore,
enableCrossSessionMessaging: config.enableCrossSessionMessaging,
enableSkills: config.enableSkills,
streaming: config.streaming,
includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true,
Expand Down
450 changes: 225 additions & 225 deletions nodejs/src/generated/rpc.ts

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,19 @@
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 MessageableSessionClientKind,
type SendSessionMessageRequest,
type SendSessionMessageResult,
type SendSessionMessageErrorCode,
type SessionMessageDelivery,
} from "./session.js";
export { defineFactory, FactoryResumeError, isFactoryRunTerminal } from "./factory.js";
export { defineWorkflow, WorkflowResumeError, isWorkflowRunTerminal } from "./workflow.js";
export {
Expand Down Expand Up @@ -53,6 +65,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
169 changes: 169 additions & 0 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
WorkflowLogLine,
WorkflowRunResult as WireWorkflowRunResult,
ModelSwitchAutoTierResult,
SendMode,
} from "./generated/rpc.js";
import { type Canvas, CanvasError } from "./canvas.js";
import type { OpenCanvasInstance } from "./generated/rpc.js";
Expand Down Expand Up @@ -619,6 +620,128 @@ 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 type MessageableSessionClientKind = "cli" | "acp" | "sdk";

/** 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;
/** Client family that owns this session when the registering runtime can identify it. */
clientKind?: MessageableSessionClientKind;
}

/** 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 @@ -1148,6 +1271,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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New cross-session messaging API (listMessageableSessions / sendSessionMessage) is added here for Node.js and mirrored in Rust (rust/src/session.rs), but I could not find equivalent methods in Python, Go, .NET, or Java. If this feature should have parity across all six SDKs (as enableSessionStore and other session-scoped methods do), please add list_messageable_sessions/send_session_message-style methods to those SDKs, or note that this is an intentional Node/Rust-first rollout.

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
);
}
}
Comment on lines 1271 to +1315

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New listMessageableSessions() / sendSessionMessage() methods (mirrored in Rust's Session::list_messageable_sessions / Session::send_session_message) introduce a new cross-session-messaging API surface. This is not yet present in Python, Go, .NET, or Java. Consider tracking follow-up work to add equivalent methods (list_messageable_sessions/send_session_message for Python/Rust conventions, ListMessageableSessions/SendSessionMessage for Go/.NET, listMessageableSessions/sendSessionMessage for Java) with the same request/result shapes and SendSessionMessageError (refused/not-delivered/ambiguous) error taxonomy for full feature parity.

throw error;
}
}

/**
* Sends a message to this session and waits until the session becomes idle.
*
Expand Down
6 changes: 6 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2949,6 +2949,12 @@ export interface SessionConfigBase {
*/
enableSessionStore?: boolean;

/**
* Whether this session participates in local cross-session messaging
* discovery and delivery. Defaults to true when omitted.
*/
Comment on lines 2949 to +2955

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New enableCrossSessionMessaging config option is added here (and in Rust's SessionConfig/ResumeSessionConfig), but not in Python, Go, .NET, or Java. If this feature is intended for all SDKs, consider adding the equivalent option (enable_cross_session_messaging in Python, EnableCrossSessionMessaging in Go/.NET, enableCrossSessionMessaging in Java) to keep config surfaces in parity, similar to the existing enableSessionStore option present in all six SDKs.

enableCrossSessionMessaging?: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enableCrossSessionMessaging is added to the Node.js session config and mirrored in Rust (SessionConfig::enable_cross_session_messaging / ResumeSessionConfig::enable_cross_session_messaging), but no equivalent flag exists yet in Python, Go, .NET, or Java session configs. Consider adding it to the remaining SDKs for parity, similar to how enableSessionStore is implemented everywhere.


/**
* When true, enables skill loading (including builtin skills and discovered
* skill directories). When false, no skills are loaded regardless of
Expand Down
100 changes: 100 additions & 0 deletions nodejs/test/session-list-messageable-sessions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*---------------------------------------------------------------------------------------------
* 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;
clientKind?: "cli" | "acp" | "sdk";
}
>;
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", clientKind: "cli" as const },
{ 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