Skip to content

Commit 0139612

Browse files
committed
Expose local cross-session discovery and delivery
Add session-bound Node and Rust SDK operations for listing active local peers and sending an exact-ID message through the runtime contract. Preserve typed refused, not-delivered, and ambiguous outcomes without retrying an uncertain delivery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea23a388-a87d-436d-8110-4f7f543141d6
1 parent dc77d92 commit 0139612

14 files changed

Lines changed: 1437 additions & 194 deletions

File tree

‎nodejs/src/generated/rpc.ts‎

Lines changed: 295 additions & 178 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎nodejs/src/generated/session-events.ts‎

Lines changed: 23 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎nodejs/src/index.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
export { CopilotClient } from "./client.js";
1212
export { DisableBypassPermissionsModes, RuntimeConnection } from "./types.js";
1313
export { BuiltInTools, ToolSet } from "./toolSet.js";
14-
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
14+
export {
15+
CopilotSession,
16+
SendSessionMessageError,
17+
type AssistantMessageEvent,
18+
type SendSessionMessageErrorCode,
19+
} from "./session.js";
1520
export { defineFactory, FactoryResumeError, isFactoryRunTerminal } from "./factory.js";
1621
export {
1722
Canvas,
@@ -52,6 +57,15 @@ export {
5257
// shadow the names arriving via `export type *`, so the hand-authored public API
5358
// surface for those six identifiers is preserved unchanged.
5459
export type * from "./generated/session-events.js";
60+
export type {
61+
ListMessageableSessionsRequest,
62+
ListMessageableSessionsResult,
63+
MessageableSession,
64+
SendMode,
65+
SendSessionMessageRequest,
66+
SendSessionMessageResult,
67+
SessionMessageDelivery,
68+
} from "./generated/rpc.js";
5569
export type {
5670
AskUserVariant,
5771
CommandContext,

‎nodejs/src/session.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import type {
1818
McpOauthPendingRequestResponse,
1919
FactoryLogLine,
2020
FactoryRunResult as WireFactoryRunResult,
21+
ListMessageableSessionsRequest,
22+
ListMessageableSessionsResult,
23+
SendSessionMessageRequest,
24+
SendSessionMessageResult,
2125
} from "./generated/rpc.js";
2226
import { type Canvas, CanvasError } from "./canvas.js";
2327
import type { OpenCanvasInstance } from "./generated/rpc.js";
@@ -382,6 +386,78 @@ function isFactoryFatalError(error: unknown): boolean {
382386
/** Assistant message event - the final response from the assistant. */
383387
export type AssistantMessageEvent = Extract<SessionEvent, { type: "assistant.message" }>;
384388

389+
/** Stable public outcomes for a failed cross-session message send. */
390+
export type SendSessionMessageErrorCode = "refused" | "not-delivered" | "ambiguous";
391+
392+
/**
393+
* Error returned when the runtime reaches a recognized terminal cross-session
394+
* message outcome.
395+
*
396+
* @experimental
397+
*/
398+
export class SendSessionMessageError extends Error {
399+
constructor(
400+
public readonly code: SendSessionMessageErrorCode,
401+
message: string,
402+
public readonly messageId?: string
403+
) {
404+
super(message);
405+
this.name = "SendSessionMessageError";
406+
}
407+
}
408+
409+
function parseSendSessionMessageErrorData(
410+
data: unknown
411+
): { code: SendSessionMessageErrorCode; messageId?: string } | undefined {
412+
if (typeof data !== "object" || data === null) {
413+
return undefined;
414+
}
415+
416+
const envelope = data as { kind?: unknown; code?: unknown; messageId?: unknown };
417+
if (
418+
typeof envelope.code !== "string" ||
419+
(envelope.messageId !== undefined && typeof envelope.messageId !== "string")
420+
) {
421+
return undefined;
422+
}
423+
424+
let code: SendSessionMessageErrorCode;
425+
switch (envelope.kind) {
426+
case "session_message_refused":
427+
if (
428+
![
429+
"target-not-active",
430+
"target-generation-changed",
431+
"source-not-active",
432+
"self-send",
433+
"request-invalid",
434+
"recipient-refused",
435+
"transport-unavailable",
436+
].includes(envelope.code)
437+
) {
438+
return undefined;
439+
}
440+
code = "refused";
441+
break;
442+
case "session_message_not_delivered":
443+
if (envelope.code !== "not-delivered") {
444+
return undefined;
445+
}
446+
code = "not-delivered";
447+
break;
448+
case "session_message_ambiguous":
449+
if (envelope.code !== "ambiguous") {
450+
return undefined;
451+
}
452+
code = "ambiguous";
453+
break;
454+
default:
455+
return undefined;
456+
}
457+
458+
return envelope.messageId === undefined ? { code } : { code, messageId: envelope.messageId };
459+
}
460+
385461
const TOOL_SEARCH_TOOL_NAME = "tool_search_tool";
386462

387463
/**
@@ -725,6 +801,46 @@ export class CopilotSession {
725801
return (response as { messageId: string }).messageId;
726802
}
727803

804+
/**
805+
* Lists active local sessions that this bound session can select by exact
806+
* ID for cross-session messaging. The result grants no delivery authority;
807+
* call {@link sendSessionMessage} with a selected `sessionId`.
808+
*
809+
* @experimental
810+
*/
811+
async listMessageableSessions(
812+
params: ListMessageableSessionsRequest = {}
813+
): Promise<ListMessageableSessionsResult> {
814+
return this.rpc.listMessageableSessions(params);
815+
}
816+
817+
/**
818+
* Sends one authenticated non-user message from this bound session to an
819+
* exact active local session.
820+
*
821+
* Success reports recipient admission, not completion of delegated work.
822+
* An ambiguous error means delivery may have started and is never retried.
823+
*
824+
* @experimental
825+
*/
826+
async sendSessionMessage(params: SendSessionMessageRequest): Promise<SendSessionMessageResult> {
827+
try {
828+
return await this.rpc.sendSessionMessage(params);
829+
} catch (error) {
830+
if (error instanceof ResponseError) {
831+
const translated = parseSendSessionMessageErrorData(error.data);
832+
if (translated) {
833+
throw new SendSessionMessageError(
834+
translated.code,
835+
error.message,
836+
translated.messageId
837+
);
838+
}
839+
}
840+
throw error;
841+
}
842+
}
843+
728844
/**
729845
* Sends a message to this session and waits until the session becomes idle.
730846
*
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import { readFileSync } from "node:fs";
6+
import { describe, expect, it, vi } from "vitest";
7+
import {
8+
CopilotSession,
9+
type ListMessageableSessionsRequest,
10+
type ListMessageableSessionsResult,
11+
type MessageableSession,
12+
} from "../src/index.js";
13+
14+
type AssertEqual<A, B> =
15+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
16+
17+
type RequestMatchesPublicContract = AssertEqual<
18+
ListMessageableSessionsRequest,
19+
{
20+
name?: string;
21+
}
22+
>;
23+
const requestMatchesPublicContract: RequestMatchesPublicContract = true;
24+
25+
type CandidateMatchesPublicContract = AssertEqual<
26+
MessageableSession,
27+
{
28+
sessionId: string;
29+
name?: string;
30+
summary?: string;
31+
}
32+
>;
33+
const candidateMatchesPublicContract: CandidateMatchesPublicContract = true;
34+
35+
type ResultMatchesPublicContract = AssertEqual<
36+
ListMessageableSessionsResult,
37+
{
38+
sessions: MessageableSession[];
39+
}
40+
>;
41+
const resultMatchesPublicContract: ResultMatchesPublicContract = true;
42+
43+
if (false) {
44+
const session = null as unknown as CopilotSession;
45+
46+
// @ts-expect-error Source identity is derived from the bound session.
47+
void session.listMessageableSessions({ sourceSessionId: "forged" });
48+
// @ts-expect-error Discovery never accepts a delivery target.
49+
void session.listMessageableSessions({ targetSessionId: "target-session" });
50+
}
51+
52+
describe("CopilotSession.listMessageableSessions", () => {
53+
it("lists all candidates when no name is supplied", async () => {
54+
const result = {
55+
sessions: [
56+
{ sessionId: "session-a", name: "Research" },
57+
{ sessionId: "session-b", summary: "Research" },
58+
],
59+
};
60+
const sendRequest = vi.fn(async () => result);
61+
const session = new CopilotSession("source-session", { sendRequest } as never);
62+
63+
await expect(session.listMessageableSessions()).resolves.toEqual(result);
64+
expect(sendRequest).toHaveBeenCalledExactlyOnceWith("session.listMessageableSessions", {
65+
sessionId: "source-session",
66+
});
67+
});
68+
69+
it("forwards the exact-name query without rewriting it", async () => {
70+
const result = { sessions: [{ sessionId: "session-a", name: "Research" }] };
71+
const sendRequest = vi.fn(async () => result);
72+
const session = new CopilotSession("source-session", { sendRequest } as never);
73+
74+
await expect(session.listMessageableSessions({ name: " ReSeArCh " })).resolves.toEqual(
75+
result
76+
);
77+
expect(sendRequest).toHaveBeenCalledExactlyOnceWith("session.listMessageableSessions", {
78+
sessionId: "source-session",
79+
name: " ReSeArCh ",
80+
});
81+
});
82+
83+
it("does not allow untyped input to override the bound source session", async () => {
84+
const result = { sessions: [] };
85+
const sendRequest = vi.fn(async () => result);
86+
const session = new CopilotSession("source-session", { sendRequest } as never);
87+
const params = JSON.parse(
88+
'{"sessionId":"forged-session","name":"Research"}'
89+
) as ListMessageableSessionsRequest;
90+
91+
await expect(session.listMessageableSessions(params)).resolves.toEqual(result);
92+
expect(sendRequest).toHaveBeenCalledExactlyOnceWith("session.listMessageableSessions", {
93+
sessionId: "source-session",
94+
name: "Research",
95+
});
96+
});
97+
98+
it("keeps the generated wrapper source-bound", () => {
99+
const generatedRpc = readFileSync(
100+
new URL("../src/generated/rpc.ts", import.meta.url),
101+
"utf8"
102+
);
103+
104+
expect(generatedRpc).toContain(
105+
"listMessageableSessions: async (params: ListMessageableSessionsRequest): Promise<ListMessageableSessionsResult> =>"
106+
);
107+
expect(generatedRpc).toContain(
108+
'connection.sendRequest("session.listMessageableSessions", { ...params, sessionId })'
109+
);
110+
});
111+
});
112+
113+
void requestMatchesPublicContract;
114+
void candidateMatchesPublicContract;
115+
void resultMatchesPublicContract;

0 commit comments

Comments
 (0)