-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expose local cross-session discovery and delivery #2616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
dc77d92
0139612
ee12cf3
403b5c7
d8aaeed
36944a8
306a40b
6cc1c12
8f6a864
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
@@ -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"; | ||
|
|
||
| /** | ||
|
|
@@ -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( | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New |
||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Sends a message to this session and waits until the session becomes idle. | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New |
||
| enableCrossSessionMessaging?: boolean; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| /** | ||
| * When true, enables skill loading (including builtin skills and discovered | ||
| * skill directories). When false, no skills are loaded regardless of | ||
|
|
||
| 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; |
There was a problem hiding this comment.
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 (asenableSessionStoreand other session-scoped methods do), please addlist_messageable_sessions/send_session_message-style methods to those SDKs, or note that this is an intentional Node/Rust-first rollout.