@copilotkit/channels-core 0.5.0 → 0.5.1-canary.1785633429

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +35 -14
  2. package/dist/action-registry.d.ts +15 -3
  3. package/dist/action-registry.d.ts.map +1 -1
  4. package/dist/action-registry.js +61 -9
  5. package/dist/action-registry.test.js +107 -2
  6. package/dist/action-store.d.ts +23 -0
  7. package/dist/action-store.d.ts.map +1 -1
  8. package/dist/action-store.js +11 -0
  9. package/dist/canonical-run-loop.test.js +6 -1
  10. package/dist/channel-identity.test.d.ts +2 -0
  11. package/dist/channel-identity.test.d.ts.map +1 -0
  12. package/dist/channel-identity.test.js +297 -0
  13. package/dist/channel-memory.test.d.ts +2 -0
  14. package/dist/channel-memory.test.d.ts.map +1 -0
  15. package/dist/channel-memory.test.js +199 -0
  16. package/dist/commands.d.ts +5 -3
  17. package/dist/commands.d.ts.map +1 -1
  18. package/dist/create-channel.d.ts +30 -56
  19. package/dist/create-channel.d.ts.map +1 -1
  20. package/dist/create-channel.foundations.test.js +30 -7
  21. package/dist/create-channel.js +209 -72
  22. package/dist/create-channel.test.js +293 -66
  23. package/dist/hitl-continuation.test.d.ts +2 -0
  24. package/dist/hitl-continuation.test.d.ts.map +1 -0
  25. package/dist/hitl-continuation.test.js +393 -0
  26. package/dist/identity.d.ts +51 -0
  27. package/dist/identity.d.ts.map +1 -0
  28. package/dist/identity.js +56 -0
  29. package/dist/identity.test.d.ts +2 -0
  30. package/dist/identity.test.d.ts.map +1 -0
  31. package/dist/identity.test.js +109 -0
  32. package/dist/index.d.ts +10 -5
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +5 -1
  35. package/dist/managed-v1-await-choice-guard.test.js +4 -1
  36. package/dist/memory.d.ts +23 -0
  37. package/dist/memory.d.ts.map +1 -0
  38. package/dist/memory.js +25 -0
  39. package/dist/modal-submit.test.js +21 -6
  40. package/dist/open-modal.test.js +16 -4
  41. package/dist/platform-adapter.d.ts +29 -6
  42. package/dist/platform-adapter.d.ts.map +1 -1
  43. package/dist/platform-adapter.test.js +2 -0
  44. package/dist/reactions.test.js +49 -11
  45. package/dist/run-loop.test.js +30 -5
  46. package/dist/sanitize-agent-events.test.js +2 -0
  47. package/dist/source-platform.test.js +24 -9
  48. package/dist/state/kv-action-store.d.ts.map +1 -1
  49. package/dist/state/kv-action-store.js +1 -0
  50. package/dist/state/kv-action-store.test.js +11 -0
  51. package/dist/state/memory-store.d.ts +1 -0
  52. package/dist/state/memory-store.d.ts.map +1 -1
  53. package/dist/state/memory-store.js +9 -0
  54. package/dist/state/state-store.d.ts +2 -0
  55. package/dist/state/state-store.d.ts.map +1 -1
  56. package/dist/telemetry/create-channel-telemetry.test.js +4 -2
  57. package/dist/telemetry/e2e-telemetry.test.js +1 -0
  58. package/dist/telemetry/install-id.test.js +5 -0
  59. package/dist/testing/fake-adapter.d.ts +26 -9
  60. package/dist/testing/fake-adapter.d.ts.map +1 -1
  61. package/dist/testing/fake-adapter.js +65 -21
  62. package/dist/testing/state-store-conformance.d.ts.map +1 -1
  63. package/dist/testing/state-store-conformance.js +16 -0
  64. package/dist/thread-capabilities.test.js +29 -9
  65. package/dist/thread-ephemeral.test.js +8 -5
  66. package/dist/thread-promise-contract.test.js +4 -0
  67. package/dist/thread-reactions.test.js +8 -2
  68. package/dist/thread.d.ts +50 -7
  69. package/dist/thread.d.ts.map +1 -1
  70. package/dist/thread.js +163 -17
  71. package/dist/thread.test.js +4 -0
  72. package/dist/tools.d.ts +5 -3
  73. package/dist/tools.d.ts.map +1 -1
  74. package/dist/tools.js +2 -1
  75. package/dist/transcripts.d.ts +6 -13
  76. package/dist/transcripts.d.ts.map +1 -1
  77. package/dist/transcripts.js +12 -12
  78. package/dist/transcripts.test.js +28 -29
  79. package/dist/welcome.test.d.ts +2 -0
  80. package/dist/welcome.test.d.ts.map +1 -0
  81. package/dist/welcome.test.js +53 -0
  82. package/package.json +5 -5
package/README.md CHANGED
@@ -32,23 +32,28 @@ import { slack } from "@copilotkit/channels-slack";
32
32
  `createChannel(opts)` returns a `Channel`:
33
33
 
34
34
  - `onMention(handler)` / `onMessage(handler)` — turn handlers receiving
35
- `{ thread, message }`. (Routing is mention-preferred: if any mention
36
- handler is registered, all turns route to it; otherwise message handlers
37
- fire.)
35
+ `{ thread, message }`. Bot mentions select `onMention` when registered and
36
+ otherwise fall back to `onMessage`; other content selects `onMessage`.
38
37
  - `onThreadStarted(handler)` — a conversation surface opened (e.g. the Slack
39
- assistant pane); receives `{ thread, user? }`. Greet, set suggested prompts
38
+ assistant pane); receives `{ thread, user, actor }`. Greet, set suggested prompts
40
39
  or a title, or run the agent. Adapters without the concept never fire it.
40
+ - `onWelcome(handler)` — a provider installation or conversation activation;
41
+ receives `{ thread, user?, platform }`. Adapters without a reliable
42
+ activation event never fire it.
41
43
  - `onInteraction<TValue>(id, handler)` — explicit escape-hatch handler for a
42
44
  known action id, bypassing the registry; `ctx.action.value` is typed `TValue`.
43
45
  - `onInterrupt<TPayload>(eventName, handler)` — handle a captured agent
44
- interrupt (LangGraph-style `on_interrupt`); receives `{ payload, thread }`
46
+ interrupt (LangGraph-style `on_interrupt`); receives `{ payload, thread, user, actor }`
45
47
  with `payload` typed `TPayload`.
46
48
  - `onCommand(command)` / `onCommand(name, handler)` — register a slash command.
47
- The handler gets `{ thread, command, text, options, user }`. `text` is the
49
+ The handler gets `{ thread, command, text, options, user, actor }`. `text` is the
48
50
  raw args (Slack); `options` is the typed, parsed form (`defineChannelCommand`
49
51
  with an `options` Standard Schema) for surfaces with native structured args
50
- (e.g. Discord). Forwarded to adapters that support commands and ignored
51
- elsewhere also pass them up front via `commands` in `CreateChannelOptions`.
52
+ (e.g. Discord). This is a direct-adapter capability: managed Slack and Teams
53
+ treat leading-slash text as ordinary message content and do not register
54
+ provider slash commands. Forwarded to direct adapters that support commands
55
+ and ignored elsewhere — also pass them up front via `commands` in
56
+ `CreateChannelOptions`.
52
57
  - `tool(t)` — register a `ChannelTool` (alternative to `opts.tools`); must be
53
58
  added before the runtime activates the channel.
54
59
 
@@ -73,6 +78,7 @@ import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
73
78
 
74
79
  const channel = createChannel({
75
80
  name: "support-bot", // project-unique Intelligence Channel name
81
+ identifyUser: "platform",
76
82
  adapters: [slack({ botToken, appToken })],
77
83
  });
78
84
 
@@ -82,7 +88,6 @@ const runtime = new CopilotRuntime({
82
88
  // both together only for a self-hosted deployment.
83
89
  apiKey: process.env.COPILOTKIT_INTELLIGENCE_API_KEY!, // free tier available
84
90
  }),
85
- identifyUser: async () => ({ id: "support-bot", name: "Support Bot" }),
86
91
  channels: [channel],
87
92
  });
88
93
 
@@ -93,6 +98,12 @@ await listener.channels.ready();
93
98
  // await listener.channels.stop(); // tears them down
94
99
  ```
95
100
 
101
+ The runtime also declares managed Slack and Teams for the same Channel name.
102
+ Developer-owned adapters stay in the adapter array and may run alongside that
103
+ managed adapter. If managed setup is incomplete, configured direct adapters
104
+ still start while `listener.channels.status()` reports `setup_required` for the
105
+ managed path.
106
+
96
107
  ## `Thread`
97
108
 
98
109
  A `Thread` is the per-conversation handle handed to your handlers and tool
@@ -108,8 +119,15 @@ interface Thread {
108
119
  runAgent(input?: {
109
120
  context?: ContextEntry[];
110
121
  tools?: ChannelTool[];
122
+ memory?: MemoryGrant;
111
123
  }): Promise<MessageRef | undefined>;
112
- resume(value: unknown): Promise<MessageRef | undefined>;
124
+ resume(
125
+ value: unknown,
126
+ options?: {
127
+ memory?: MemoryGrant;
128
+ subject?: "initiator" | "actor";
129
+ },
130
+ ): Promise<MessageRef | undefined>;
113
131
  awaitChoice<T = unknown>(ui: Renderable): Promise<T>;
114
132
  // Capability-gated (return { ok: false } on surfaces without support):
115
133
  setSuggestedPrompts(
@@ -208,8 +226,9 @@ reads after each `runAgent`), `decodeInteraction(raw)` (native event → opaque
208
226
  `postFile(target, args)` back the matching `thread` methods when the surface
209
227
  supports them — likewise `setSuggestedPrompts(target, prompts, opts?)` and
210
228
  `setThreadTitle(target, title)` back `thread.setSuggestedPrompts` /
211
- `thread.setTitle`, and `sink.onThreadStarted(...)` emits the "conversation
212
- opened" lifecycle event. Slash commands are also capability-gated: an adapter forwards
229
+ `thread.setTitle`, `sink.onWelcome(...)` emits a reliable installation or
230
+ activation event, and `sink.onThreadStarted(...)` emits the "conversation
231
+ opened" lifecycle event. Direct-adapter slash commands are also capability-gated: an adapter forwards
213
232
  invocations via `sink.onCommand(IncomingCommand)`, and may implement
214
233
  `registerCommands(specs)` to publish the channel's declared commands up front
215
234
  (e.g. Discord's application-command API); adapters that omit it are skipped.
@@ -217,9 +236,11 @@ See `@copilotkit/channels-slack` for a complete implementation.
217
236
 
218
237
  ## Exports
219
238
 
220
- `createChannel`, `Channel`, `CreateChannelOptions`, `ChannelHandler`, `ThreadStartHandler`;
239
+ `createChannel`, `Channel`, `CreateChannelOptions`, `ChannelHandler`,
240
+ `WelcomeHandler`, `ThreadStartHandler`;
221
241
  `Thread`; the `PlatformAdapter` boundary types (`RunRenderer`, `IngressSink`,
222
- `IncomingTurn`, `InteractionEvent`, `IncomingCommand`, `IncomingThreadStart`,
242
+ `IncomingTurn`, `InteractionEvent`, `IncomingCommand`, `IncomingWelcome`,
243
+ `IncomingThreadStart`,
223
244
  `SurfaceCapabilities`,
224
245
  `ReplyTarget`, `ConversationStore`, `AgentSession`, `CapturedToolCall`,
225
246
  `CapturedInterrupt`, `UserQuery`); `ActionStore` / `InMemoryActionStore` /
@@ -1,15 +1,23 @@
1
1
  import type { ChannelNode, InteractionContext, ComponentFn, Renderable, MessageReactionHandler } from "@copilotkit/channels-ui";
2
- import type { ActionStore } from "./action-store.js";
2
+ import type { ActionContinuationContext, ActionContinuationBinding, ActionContinuationSnapshot, ActionStore } from "./action-store.js";
3
3
  export declare class ActionExpiredError extends Error {
4
+ readonly code = "channel_action_expired";
4
5
  constructor(id: string);
5
6
  }
7
+ /** A continuation capability was presented outside its trusted Channel binding. */
8
+ export declare class ActionContinuationMismatchError extends Error {
9
+ readonly code = "channel_continuation_mismatch";
10
+ constructor();
11
+ }
6
12
  export declare class ActionRegistry {
7
13
  private store;
8
14
  private components;
9
15
  private hot;
10
16
  private messageReactions;
17
+ private readonly retentionMs?;
11
18
  constructor(opts: {
12
19
  store: ActionStore;
20
+ retentionMs?: number;
13
21
  });
14
22
  /** Cache a `<Message onReaction>` handler for the posted message (same-process). */
15
23
  registerMessageReaction(messageId: string, handler: MessageReactionHandler): void;
@@ -33,8 +41,8 @@ export declare class ActionRegistry {
33
41
  resolveMessageReaction(messageId: string): Promise<MessageReactionHandler | undefined>;
34
42
  registerComponent(name: string, fn: ComponentFn): void;
35
43
  clearHotCache(): void;
36
- bindTree(componentName: string, props: Record<string, unknown>, conversationKey: string): Promise<ChannelNode[]>;
37
- bindRenderable(ui: Renderable, conversationKey: string): Promise<{
44
+ bindTree(componentName: string, props: Record<string, unknown>, conversationKey: string, continuation?: ActionContinuationContext): Promise<ChannelNode[]>;
45
+ bindRenderable(ui: Renderable, conversationKey: string, continuation?: ActionContinuationContext): Promise<{
38
46
  root: ChannelNode[];
39
47
  onReaction?: MessageReactionHandler;
40
48
  /**
@@ -55,5 +63,9 @@ export declare class ActionRegistry {
55
63
  * the element has no `value`.
56
64
  */
57
65
  dispatch(id: string, ctx: InteractionContext): Promise<unknown>;
66
+ /** Read and validate one continuation capability without consuming it. */
67
+ getContinuation(id: string, expected: ActionContinuationBinding): Promise<ActionContinuationSnapshot>;
68
+ /** Validate and atomically consume one continuation capability. */
69
+ claimContinuation(id: string, expected: ActionContinuationBinding): Promise<ActionContinuationSnapshot>;
58
70
  }
59
71
  //# sourceMappingURL=action-registry.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"action-registry.d.ts","sourceRoot":"","sources":["../src/action-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EAEX,kBAAkB,EAClB,WAAW,EACX,UAAU,EACV,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AAGjC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,EAAE,EAAE,MAAM;CAIvB;AAcD,qBAAa,cAAc;IACzB,OAAO,CAAC,KAAK,CAAc;IAC3B,OAAO,CAAC,UAAU,CAAkC;IAKpD,OAAO,CAAC,GAAG,CAAgE;IAI3E,OAAO,CAAC,gBAAgB,CAA6C;gBAEzD,IAAI,EAAE;QAAE,KAAK,EAAE,WAAW,CAAA;KAAE;IAIxC,oFAAoF;IACpF,uBAAuB,CACrB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,sBAAsB,GAC9B,IAAI;IAIP;;;;;OAKG;IACG,sBAAsB,CAC1B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE;QACJ,SAAS,EAAE,MAAM,CAAC;QAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC/B,eAAe,EAAE,MAAM,CAAC;KACzB,GACA,OAAO,CAAC,IAAI,CAAC;IAShB;;;;;OAKG;IACG,sBAAsB,CAC1B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,sBAAsB,GAAG,SAAS,CAAC;IAa9C,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,WAAW,GAAG,IAAI;IAItD,aAAa,IAAI,IAAI;IAMf,QAAQ,CACZ,aAAa,EAAE,MAAM,EACrB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,WAAW,EAAE,CAAC;IAcnB,cAAc,CAClB,EAAE,EAAE,UAAU,EACd,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC;QACT,IAAI,EAAE,WAAW,EAAE,CAAC;QACpB,UAAU,CAAC,EAAE,sBAAsB,CAAC;QACpC;;;;WAIG;QACH,iBAAiB,CAAC,EAAE;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;SAAE,CAAC;KAC3E,CAAC;YAuBY,IAAI;IA0ClB;;;;;OAKG;IACG,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,kBAAkB,GAAG,OAAO,CAAC,OAAO,CAAC;CAsBtE"}
1
+ {"version":3,"file":"action-registry.d.ts","sourceRoot":"","sources":["../src/action-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EAEX,kBAAkB,EAClB,WAAW,EACX,UAAU,EACV,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AAGjC,OAAO,KAAK,EACV,yBAAyB,EACzB,yBAAyB,EACzB,0BAA0B,EAC1B,WAAW,EACZ,MAAM,mBAAmB,CAAC;AAE3B,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,IAAI,4BAA4B;gBAE7B,EAAE,EAAE,MAAM;CAIvB;AAED,mFAAmF;AACnF,qBAAa,+BAAgC,SAAQ,KAAK;IACxD,QAAQ,CAAC,IAAI,mCAAmC;;CAMjD;AAcD,qBAAa,cAAc;IACzB,OAAO,CAAC,KAAK,CAAc;IAC3B,OAAO,CAAC,UAAU,CAAkC;IAKpD,OAAO,CAAC,GAAG,CAGP;IAIJ,OAAO,CAAC,gBAAgB,CAA6C;IAErE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAS;gBAE1B,IAAI,EAAE;QAAE,KAAK,EAAE,WAAW,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE;IAK9D,oFAAoF;IACpF,uBAAuB,CACrB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,sBAAsB,GAC9B,IAAI;IAIP;;;;;OAKG;IACG,sBAAsB,CAC1B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE;QACJ,SAAS,EAAE,MAAM,CAAC;QAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC/B,eAAe,EAAE,MAAM,CAAC;KACzB,GACA,OAAO,CAAC,IAAI,CAAC;IAShB;;;;;OAKG;IACG,sBAAsB,CAC1B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,sBAAsB,GAAG,SAAS,CAAC;IAa9C,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,WAAW,GAAG,IAAI;IAItD,aAAa,IAAI,IAAI;IAMf,QAAQ,CACZ,aAAa,EAAE,MAAM,EACrB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,eAAe,EAAE,MAAM,EACvB,YAAY,CAAC,EAAE,yBAAyB,GACvC,OAAO,CAAC,WAAW,EAAE,CAAC;IAqBnB,cAAc,CAClB,EAAE,EAAE,UAAU,EACd,eAAe,EAAE,MAAM,EACvB,YAAY,CAAC,EAAE,yBAAyB,GACvC,OAAO,CAAC;QACT,IAAI,EAAE,WAAW,EAAE,CAAC;QACpB,UAAU,CAAC,EAAE,sBAAsB,CAAC;QACpC;;;;WAIG;QACH,iBAAiB,CAAC,EAAE;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;SAAE,CAAC;KAC3E,CAAC;YA4BY,IAAI;IAsDlB;;;;;OAKG;IACG,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,kBAAkB,GAAG,OAAO,CAAC,OAAO,CAAC;IA2BrE,0EAA0E;IACpE,eAAe,CACnB,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,yBAAyB,GAClC,OAAO,CAAC,0BAA0B,CAAC;IAOtC,mEAAmE;IAC7D,iBAAiB,CACrB,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,yBAAyB,GAClC,OAAO,CAAC,0BAA0B,CAAC;CASvC"}
@@ -1,11 +1,20 @@
1
1
  import { isBound, getBoundArgs, renderToIR } from "@copilotkit/channels-ui";
2
2
  import { mintId } from "./mint-id.js";
3
3
  export class ActionExpiredError extends Error {
4
+ code = "channel_action_expired";
4
5
  constructor(id) {
5
6
  super(`Action "${id}" has expired or is no longer available.`);
6
7
  this.name = "ActionExpiredError";
7
8
  }
8
9
  }
10
+ /** A continuation capability was presented outside its trusted Channel binding. */
11
+ export class ActionContinuationMismatchError extends Error {
12
+ code = "channel_continuation_mismatch";
13
+ constructor() {
14
+ super("The Channel continuation does not match this interaction");
15
+ this.name = "ActionContinuationMismatchError";
16
+ }
17
+ }
9
18
  const EVENT_PROPS = ["onClick", "onSelect", "onSubmit"];
10
19
  function isComponentElement(ui) {
11
20
  return (typeof ui === "object" &&
@@ -24,8 +33,10 @@ export class ActionRegistry {
24
33
  // posted message's id. Mirrors the `hot` action cache; the durable snapshot
25
34
  // (below) is the cross-restart counterpart, exactly like onClick.
26
35
  messageReactions = new Map();
36
+ retentionMs;
27
37
  constructor(opts) {
28
38
  this.store = opts.store;
39
+ this.retentionMs = opts.retentionMs;
29
40
  }
30
41
  /** Cache a `<Message onReaction>` handler for the posted message (same-process). */
31
42
  registerMessageReaction(messageId, handler) {
@@ -72,10 +83,10 @@ export class ActionRegistry {
72
83
  }
73
84
  // Renders the named component, binds all event-prop handlers in the tree
74
85
  // (mint id, hot-cache + ActionStore snapshot, rewrite prop to { id }), returns the bound IR.
75
- async bindTree(componentName, props, conversationKey) {
86
+ async bindTree(componentName, props, conversationKey, continuation) {
76
87
  const fn = this.components.get(componentName);
77
88
  const root = renderToIR((fn ? fn(props) : props));
78
- await this.walk(root, [], componentName, props, conversationKey);
89
+ await this.walk(root, [], componentName, props, conversationKey, continuation);
79
90
  return root;
80
91
  }
81
92
  // Binds an arbitrary Renderable for posting. If `ui` is a component element
@@ -85,7 +96,7 @@ export class ActionRegistry {
85
96
  // degradation for inline handlers that can't be re-derived). A top-level
86
97
  // `<Message onReaction>` handler is pulled off the IR (so it never reaches the
87
98
  // adapter) and returned for the caller to associate with the posted message.
88
- async bindRenderable(ui, conversationKey) {
99
+ async bindRenderable(ui, conversationKey, continuation) {
89
100
  let root;
90
101
  let component;
91
102
  let props;
@@ -94,11 +105,11 @@ export class ActionRegistry {
94
105
  component = fn.name || "anonymous";
95
106
  props = (ui.props ?? {});
96
107
  this.registerComponent(component, fn);
97
- root = await this.bindTree(component, props, conversationKey);
108
+ root = await this.bindTree(component, props, conversationKey, continuation);
98
109
  }
99
110
  else {
100
111
  root = renderToIR(ui);
101
- await this.walk(root, [], "", undefined, conversationKey);
112
+ await this.walk(root, [], "", undefined, conversationKey, continuation);
102
113
  }
103
114
  const onReaction = takeMessageReaction(root);
104
115
  return {
@@ -107,7 +118,7 @@ export class ActionRegistry {
107
118
  reactionComponent: onReaction && component && props ? { component, props } : undefined,
108
119
  };
109
120
  }
110
- async walk(nodes, base, comp, props, conv) {
121
+ async walk(nodes, base, comp, props, conv, continuation) {
111
122
  for (let i = 0; i < nodes.length; i++) {
112
123
  const node = nodes[i];
113
124
  const path = [...base, i];
@@ -115,10 +126,13 @@ export class ActionRegistry {
115
126
  const handler = node.props[ep];
116
127
  if (typeof handler === "function") {
117
128
  const fullPath = [...path, ep];
118
- const id = mintId(comp, fullPath, props);
129
+ const id = continuation
130
+ ? `ck:${globalThis.crypto.randomUUID()}`
131
+ : mintId(comp, fullPath, props);
119
132
  this.hot.set(id, {
120
133
  handler: handler,
121
134
  value: node.props.value,
135
+ ...(continuation ? { continuation: true } : {}),
122
136
  });
123
137
  await this.store.put(id, {
124
138
  component: comp,
@@ -126,13 +140,16 @@ export class ActionRegistry {
126
140
  path: fullPath,
127
141
  conversationKey: conv,
128
142
  boundArgs: isBound(handler) ? getBoundArgs(handler) : undefined,
129
- });
143
+ ...(continuation
144
+ ? { continuation: { ...continuation, actionId: id } }
145
+ : {}),
146
+ }, continuation ? this.retentionMs : undefined);
130
147
  node.props[ep] = { id };
131
148
  }
132
149
  }
133
150
  const children = node.props.children;
134
151
  if (Array.isArray(children)) {
135
- await this.walk(children, [...path, "children"], comp, props, conv);
152
+ await this.walk(children, [...path, "children"], comp, props, conv, continuation);
136
153
  }
137
154
  }
138
155
  }
@@ -147,6 +164,10 @@ export class ActionRegistry {
147
164
  let value;
148
165
  const hot = this.hot.get(id);
149
166
  if (hot) {
167
+ if (hot.continuation && !(await this.store.get(id))) {
168
+ this.hot.delete(id);
169
+ throw new ActionExpiredError(id);
170
+ }
150
171
  handler = hot.handler;
151
172
  value = hot.value;
152
173
  }
@@ -166,6 +187,37 @@ export class ActionRegistry {
166
187
  await handler({ ...ctx, action: { ...ctx.action, id } });
167
188
  return value;
168
189
  }
190
+ /** Read and validate one continuation capability without consuming it. */
191
+ async getContinuation(id, expected) {
192
+ const available = await this.store.get(id);
193
+ if (!available?.continuation)
194
+ throw new ActionExpiredError(id);
195
+ assertContinuationBinding(available.continuation, id, expected);
196
+ return available.continuation;
197
+ }
198
+ /** Validate and atomically consume one continuation capability. */
199
+ async claimContinuation(id, expected) {
200
+ await this.getContinuation(id, expected);
201
+ const claimed = await this.store.consume(id);
202
+ if (!claimed?.continuation)
203
+ throw new ActionExpiredError(id);
204
+ assertContinuationBinding(claimed.continuation, id, expected);
205
+ this.hot.delete(id);
206
+ return claimed.continuation;
207
+ }
208
+ }
209
+ function assertContinuationBinding(actual, actionId, expected) {
210
+ if (actual.actionId !== actionId ||
211
+ actual.channelName !== expected.channelName ||
212
+ actual.conversationKey !== expected.conversationKey ||
213
+ actual.threadId !== expected.threadId ||
214
+ typeof actual.runChainId !== "string" ||
215
+ actual.runChainId.length === 0 ||
216
+ typeof actual.initiator?.actor?.id !== "string" ||
217
+ (actual.initiator.user !== null &&
218
+ typeof actual.initiator.user?.id !== "string")) {
219
+ throw new ActionContinuationMismatchError();
220
+ }
169
221
  }
170
222
  /** Store key for a message's durable reaction snapshot (distinct from minted action ids). */
171
223
  function reactionKey(messageId) {
@@ -1,5 +1,5 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { ActionRegistry, ActionExpiredError } from "./action-registry.js";
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { ActionRegistry, ActionContinuationMismatchError, ActionExpiredError, } from "./action-registry.js";
3
3
  import { InMemoryActionStore } from "./action-store.js";
4
4
  import { MemoryStore } from "./state/memory-store.js";
5
5
  import { kvActionStore } from "./state/kv-action-store.js";
@@ -112,4 +112,109 @@ describe("ActionRegistry", () => {
112
112
  await expect(regBPrime.dispatch(id, ctx)).rejects.toBeInstanceOf(ActionExpiredError);
113
113
  });
114
114
  });
115
+ describe("one-use HITL continuations", () => {
116
+ const continuation = {
117
+ channelName: "approvals",
118
+ conversationKey: "conv1",
119
+ threadId: "thread1",
120
+ runChainId: "run-chain-1",
121
+ initiator: {
122
+ user: { id: "user-1", name: "Alice" },
123
+ actor: { id: "actor-1", kind: "human" },
124
+ },
125
+ };
126
+ it("mints random capabilities and stores the trusted binding in the action snapshot", async () => {
127
+ const store = new InMemoryActionStore();
128
+ const reg = new ActionRegistry({ store, retentionMs: 60_000 });
129
+ reg.registerComponent("Confirm", Confirm);
130
+ const first = await reg.bindTree("Confirm", { action: "approve" }, "conv1", continuation);
131
+ const second = await reg.bindTree("Confirm", { action: "approve" }, "conv1", continuation);
132
+ const firstId = first[0].props.children[0].props.onClick.id;
133
+ const secondId = second[0].props.children[0].props.onClick.id;
134
+ expect(firstId).not.toBe(secondId);
135
+ expect(await store.get(firstId)).toMatchObject({
136
+ continuation: { ...continuation, actionId: firstId },
137
+ });
138
+ });
139
+ it("allows exactly one claimant across concurrent resume attempts", async () => {
140
+ const reg = new ActionRegistry({
141
+ store: new InMemoryActionStore(),
142
+ retentionMs: 60_000,
143
+ });
144
+ reg.registerComponent("Confirm", Confirm);
145
+ const tree = await reg.bindTree("Confirm", { action: "approve" }, "conv1", continuation);
146
+ const id = tree[0].props.children[0].props.onClick.id;
147
+ const outcomes = await Promise.allSettled([
148
+ reg.claimContinuation(id, continuation),
149
+ reg.claimContinuation(id, continuation),
150
+ ]);
151
+ expect(outcomes.filter((outcome) => outcome.status === "fulfilled")).toHaveLength(1);
152
+ expect(outcomes.filter((outcome) => outcome.status === "rejected")).toHaveLength(1);
153
+ expect(outcomes.find((outcome) => outcome.status === "rejected").reason).toBeInstanceOf(ActionExpiredError);
154
+ });
155
+ it.each([
156
+ ["Channel", { channelName: "wrong" }],
157
+ ["conversation", { conversationKey: "wrong" }],
158
+ ["Thread", { threadId: "wrong" }],
159
+ ])("rejects a wrong %s binding without consuming the valid continuation", async (_label, tampered) => {
160
+ const reg = new ActionRegistry({
161
+ store: new InMemoryActionStore(),
162
+ retentionMs: 60_000,
163
+ });
164
+ reg.registerComponent("Confirm", Confirm);
165
+ const tree = await reg.bindTree("Confirm", { action: "approve" }, "conv1", continuation);
166
+ const id = tree[0].props.children[0].props.onClick.id;
167
+ await expect(reg.claimContinuation(id, { ...continuation, ...tampered })).rejects.toBeInstanceOf(ActionContinuationMismatchError);
168
+ await expect(reg.claimContinuation(id, continuation)).resolves.toMatchObject({
169
+ actionId: id,
170
+ runChainId: "run-chain-1",
171
+ });
172
+ });
173
+ it.each([
174
+ ["run chain", { runChainId: "" }],
175
+ ["action", { actionId: "ck:other" }],
176
+ [
177
+ "initiator actor",
178
+ { initiator: { ...continuation.initiator, actor: {} } },
179
+ ],
180
+ ])("rejects a snapshot with a tampered %s", async (_label, tampered) => {
181
+ const id = `ck:${globalThis.crypto.randomUUID()}`;
182
+ const store = new InMemoryActionStore();
183
+ const reg = new ActionRegistry({ store, retentionMs: 60_000 });
184
+ await store.put(id, {
185
+ path: [0, "onClick"],
186
+ conversationKey: "conv1",
187
+ continuation: {
188
+ ...continuation,
189
+ actionId: id,
190
+ ...tampered,
191
+ },
192
+ });
193
+ await expect(reg.claimContinuation(id, continuation)).rejects.toBeInstanceOf(ActionContinuationMismatchError);
194
+ });
195
+ it("rejects a wrong random capability", async () => {
196
+ const reg = new ActionRegistry({
197
+ store: new InMemoryActionStore(),
198
+ retentionMs: 60_000,
199
+ });
200
+ await expect(reg.claimContinuation("ck:wrong-capability", continuation)).rejects.toBeInstanceOf(ActionExpiredError);
201
+ });
202
+ it("expires with the action retention window", async () => {
203
+ vi.useFakeTimers();
204
+ try {
205
+ const reg = new ActionRegistry({
206
+ store: new InMemoryActionStore(),
207
+ retentionMs: 100,
208
+ });
209
+ reg.registerComponent("Confirm", Confirm);
210
+ const tree = await reg.bindTree("Confirm", { action: "approve" }, "conv1", continuation);
211
+ const id = tree[0].props.children[0].props.onClick.id;
212
+ vi.advanceTimersByTime(101);
213
+ await expect(reg.claimContinuation(id, continuation)).rejects.toBeInstanceOf(ActionExpiredError);
214
+ }
215
+ finally {
216
+ vi.useRealTimers();
217
+ }
218
+ });
219
+ });
115
220
  });
@@ -1,14 +1,36 @@
1
+ import type { ApplicationUser, ProviderActor } from "@copilotkit/channels-ui";
2
+ /** Trusted principal captured when one HITL run chain starts. */
3
+ export interface ActionContinuationInitiator {
4
+ user: ApplicationUser | null;
5
+ actor: ProviderActor;
6
+ }
7
+ /** Trusted facts bound to every one-use action rendered by a HITL run chain. */
8
+ export interface ActionContinuationContext {
9
+ channelName: string;
10
+ conversationKey: string;
11
+ threadId: string;
12
+ runChainId: string;
13
+ initiator: ActionContinuationInitiator;
14
+ }
15
+ /** Server-derived delivery facts used to validate a presented continuation. */
16
+ export type ActionContinuationBinding = Pick<ActionContinuationContext, "channelName" | "conversationKey" | "threadId">;
17
+ /** Persisted continuation binding, including its random action capability. */
18
+ export interface ActionContinuationSnapshot extends ActionContinuationContext {
19
+ actionId: string;
20
+ }
1
21
  export interface ActionSnapshot {
2
22
  component?: string;
3
23
  props?: unknown;
4
24
  path: (string | number)[];
5
25
  boundArgs?: unknown;
6
26
  conversationKey: string;
27
+ continuation?: ActionContinuationSnapshot;
7
28
  }
8
29
  /** @deprecated Configure `createChannel({ state })` instead. Action snapshots are stored via `StateStore.kv`. */
9
30
  export interface ActionStore {
10
31
  put(id: string, snap: ActionSnapshot, ttlMs?: number): Promise<void>;
11
32
  get(id: string): Promise<ActionSnapshot | undefined>;
33
+ consume(id: string): Promise<ActionSnapshot | undefined>;
12
34
  delete(id: string): Promise<void>;
13
35
  }
14
36
  /** @deprecated Configure `createChannel({ state })` instead. Action snapshots are stored via `StateStore.kv`. */
@@ -17,5 +39,6 @@ export declare class InMemoryActionStore implements ActionStore {
17
39
  put(id: string, snap: ActionSnapshot, ttlMs?: number): Promise<void>;
18
40
  get(id: string): Promise<ActionSnapshot | undefined>;
19
41
  delete(id: string): Promise<void>;
42
+ consume(id: string): Promise<ActionSnapshot | undefined>;
20
43
  }
21
44
  //# sourceMappingURL=action-store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"action-store.d.ts","sourceRoot":"","sources":["../src/action-store.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IAC1B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;CACzB;AACD,iHAAiH;AACjH,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACrD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnC;AACD,iHAAiH;AACjH,qBAAa,mBAAoB,YAAW,WAAW;IACrD,OAAO,CAAC,GAAG,CAAmE;IACxE,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMpE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC;IASpD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGxC"}
1
+ {"version":3,"file":"action-store.d.ts","sourceRoot":"","sources":["../src/action-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAE9E,iEAAiE;AACjE,MAAM,WAAW,2BAA2B;IAC1C,IAAI,EAAE,eAAe,GAAG,IAAI,CAAC;IAC7B,KAAK,EAAE,aAAa,CAAC;CACtB;AAED,gFAAgF;AAChF,MAAM,WAAW,yBAAyB;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,2BAA2B,CAAC;CACxC;AAED,+EAA+E;AAC/E,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAC1C,yBAAyB,EACzB,aAAa,GAAG,iBAAiB,GAAG,UAAU,CAC/C,CAAC;AAEF,8EAA8E;AAC9E,MAAM,WAAW,0BAA2B,SAAQ,yBAAyB;IAC3E,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IAC1B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,0BAA0B,CAAC;CAC3C;AACD,iHAAiH;AACjH,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACrD,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnC;AACD,iHAAiH;AACjH,qBAAa,mBAAoB,YAAW,WAAW;IACrD,OAAO,CAAC,GAAG,CAAmE;IACxE,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMpE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC;IASpD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAGjC,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC;CAU/D"}
@@ -20,4 +20,15 @@ export class InMemoryActionStore {
20
20
  async delete(id) {
21
21
  this.map.delete(id);
22
22
  }
23
+ async consume(id) {
24
+ const entry = this.map.get(id);
25
+ if (!entry)
26
+ return undefined;
27
+ if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
28
+ this.map.delete(id);
29
+ return undefined;
30
+ }
31
+ this.map.delete(id);
32
+ return entry.snap;
33
+ }
23
34
  }
@@ -334,7 +334,12 @@ test("terminal delivery tool failure stops the loop and closes renderer fanout",
334
334
  tools: new Map([["echo", postFile]]),
335
335
  toolDescriptors: [],
336
336
  context: [],
337
- makeToolCtx: () => ({ thread: {}, platform: "fake" }),
337
+ makeToolCtx: () => ({
338
+ thread: {},
339
+ user: null,
340
+ actor: { id: "actor", kind: "unknown" },
341
+ platform: "fake",
342
+ }),
338
343
  subscriber: {
339
344
  onEvent: ({ event }) => {
340
345
  ingestedEvents.push(event);
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=channel-identity.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-identity.test.d.ts","sourceRoot":"","sources":["../src/channel-identity.test.ts"],"names":[],"mappings":""}