macp-sdk-typescript 0.9.0 → 0.10.0

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to `macp-sdk-typescript` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
5
5
  project uses [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.10.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.9.0...v0.10.0) (2026-09-01)
8
+
9
+
10
+ ### ⚠ BREAKING CHANGES
11
+
12
+ * `client.sendProgress()` now throws `MacpSessionError` when exactly one of `sessionId`/`mode` is supplied. Such calls used to succeed against a runtime that had the symmetric gap; as of macp-runtime PR #137 they are rejected with `INVALID_ENVELOPE`. Pass both fields for a session-scoped Progress, or neither for an ambient one.
13
+ * **projections:** `TaskProjection` assignee semantics changed twice. (1) The first-accept-wins guard is now per-session, not per-`task_id`: on a transcript with two `TaskRequest`s, only the first `TaskAccept` assigns — the second task keeps `assignee === undefined` and `status === 'requested'`, where it previously got its own assignee. A `TaskAccept` the guard discards also no longer sets `phase = 'InProgress'`. (2) `TaskAccept` is no longer sticky forever: a `TaskReject` from the active assignee clears `assignee` back to `undefined`, so code reading `getTask(id).assignee` after a reject now sees `undefined` (or the reassigned participant) rather than the participant who rejected.
14
+
15
+ ### Bug Fixes
16
+
17
+ * **projections:** scope the Task assignee slot to the session and free it on reject ([#75](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/75)) ([f7841a9](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/f7841a96d120b3fc55effdfabf63a2d72f431081)), closes [#59](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/59) [#60](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/60) [#70](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/70) [#71](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/71)
18
+ * reject mixed ambient/session-scoped Progress envelopes ([#76](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/76)) ([a9e00e3](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/a9e00e370b7a4bf08d09190e8b75a12d221319f8))
19
+
7
20
  ## [0.9.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.8.0...v0.9.0) (2026-09-01)
8
21
 
9
22
 
package/dist/client.d.ts CHANGED
@@ -262,6 +262,15 @@ export declare class MacpClient {
262
262
  auth?: AuthConfig;
263
263
  deadlineMs?: number;
264
264
  }): Promise<Ack>;
265
+ /**
266
+ * Send a `Progress` message.
267
+ *
268
+ * Per RFC-MACP-0001 §6, `Progress` is legal in exactly two shapes: *ambient*
269
+ * (`sessionId` and `mode` both omitted/empty) or *session-scoped* (both
270
+ * non-empty). Supplying exactly one of the two throws `MacpSessionError` —
271
+ * the runtime rejects that mixed envelope with `INVALID_ENVELOPE`, so this
272
+ * fails fast with a message naming the mismatched field.
273
+ */
265
274
  sendProgress(options: {
266
275
  sessionId?: string;
267
276
  mode?: string;
package/dist/client.js CHANGED
@@ -545,7 +545,17 @@ class MacpClient {
545
545
  });
546
546
  return this.send(envelope, { auth, deadlineMs: options.deadlineMs });
547
547
  }
548
+ /**
549
+ * Send a `Progress` message.
550
+ *
551
+ * Per RFC-MACP-0001 §6, `Progress` is legal in exactly two shapes: *ambient*
552
+ * (`sessionId` and `mode` both omitted/empty) or *session-scoped* (both
553
+ * non-empty). Supplying exactly one of the two throws `MacpSessionError` —
554
+ * the runtime rejects that mixed envelope with `INVALID_ENVELOPE`, so this
555
+ * fails fast with a message naming the mismatched field.
556
+ */
548
557
  async sendProgress(options) {
558
+ (0, validation_1.validateProgressScope)(options.sessionId ?? '', options.mode ?? '');
549
559
  const auth = this.requireAuth(options.auth);
550
560
  (0, auth_1.assertSenderMatchesIdentity)(auth, options.sender);
551
561
  const progressPayload = (0, envelope_1.buildProgressPayload)({
@@ -61,6 +61,26 @@ export declare class TaskProjection {
61
61
  * only as a one-line pointer.
62
62
  */
63
63
  private readonly seenMessageIds;
64
+ /**
65
+ * The **session-level** active assignment: who currently holds the session's
66
+ * single assignee slot, and which `task_id` they took it on.
67
+ *
68
+ * RFC-MACP-0009 §5 rule 3 (`:69`): "Only one assignee may become active for
69
+ * the Session in base v1." The constraint is scoped to the *Session*, not to
70
+ * a `task_id` — rule 1 (`:67`) separately caps a v1 session at one
71
+ * `TaskRequest`, so in a conforming transcript the two scopes coincide, but
72
+ * an unfiltered transcript carrying two `TaskRequest`s must not be able to
73
+ * hand out two active assignees. That is why this is one field on the
74
+ * projection rather than a per-`TaskRecord` flag (issue #71).
75
+ *
76
+ * `sender` is the envelope sender, not `TaskAcceptPayload.assignee`, to
77
+ * mirror the reference runtime: `macp-runtime`
78
+ * `crates/macp-modes/src/mode/task.rs:211` sets `state.active_assignee =
79
+ * Some(env.sender)` after rejecting any payload whose `assignee` disagrees
80
+ * with the sender (`:205-207`). `taskId` is retained so a reject can clear
81
+ * the right `TaskRecord.assignee` even on a multi-task transcript.
82
+ */
83
+ private activeAssignment?;
64
84
  /**
65
85
  * Apply one envelope to this projection's in-process state.
66
86
  *
@@ -31,6 +31,26 @@ class TaskProjection {
31
31
  * only as a one-line pointer.
32
32
  */
33
33
  seenMessageIds = new Set();
34
+ /**
35
+ * The **session-level** active assignment: who currently holds the session's
36
+ * single assignee slot, and which `task_id` they took it on.
37
+ *
38
+ * RFC-MACP-0009 §5 rule 3 (`:69`): "Only one assignee may become active for
39
+ * the Session in base v1." The constraint is scoped to the *Session*, not to
40
+ * a `task_id` — rule 1 (`:67`) separately caps a v1 session at one
41
+ * `TaskRequest`, so in a conforming transcript the two scopes coincide, but
42
+ * an unfiltered transcript carrying two `TaskRequest`s must not be able to
43
+ * hand out two active assignees. That is why this is one field on the
44
+ * projection rather than a per-`TaskRecord` flag (issue #71).
45
+ *
46
+ * `sender` is the envelope sender, not `TaskAcceptPayload.assignee`, to
47
+ * mirror the reference runtime: `macp-runtime`
48
+ * `crates/macp-modes/src/mode/task.rs:211` sets `state.active_assignee =
49
+ * Some(env.sender)` after rejecting any payload whose `assignee` disagrees
50
+ * with the sender (`:205-207`). `taskId` is retained so a reject can clear
51
+ * the right `TaskRecord.assignee` even on a multi-task transcript.
52
+ */
53
+ activeAssignment;
34
54
  /**
35
55
  * Apply one envelope to this projection's in-process state.
36
56
  *
@@ -81,46 +101,72 @@ class TaskProjection {
81
101
  case 'TaskAccept': {
82
102
  const record = payload;
83
103
  const task = this.tasks.get(record.taskId);
84
- // RFC-MACP-0009 §5 rules 3/3a (`:69-71`): "Only one assignee may
104
+ // RFC-MACP-0009 §5 rules 3/3a (`:69-70`): "Only one assignee may
85
105
  // become active for the Session in base v1. The first accepted
86
106
  // `TaskAccept` from any eligible participant designates that
87
107
  // participant as the active assignee. Subsequent `TaskAccept`
88
108
  // messages for the same session MUST be rejected if an active
89
109
  // assignee is already designated." A conforming runtime rejects the
90
- // second `TaskAccept` before it reaches accepted history; this guard
91
- // makes the projection first-accept-wins too, so a rogue second one
92
- // (e.g. an unfiltered transcript) cannot silently reassign the task.
110
+ // second `TaskAccept` before it reaches accepted history
111
+ // (`macp-runtime` `crates/macp-modes/src/mode/task.rs:184-186`); this
112
+ // guard makes the projection first-accept-wins too, so a rogue second
113
+ // one (e.g. an unfiltered transcript) cannot silently reassign.
93
114
  //
94
- // Rule 3c's policy-gated reassignment path (`allow_reassignment_on_reject`,
95
- // RFC-MACP-0012 `:135`) is deliberately NOT modelled here:
96
- // `TaskProjection` has no session-policy input today, and rule 3b
97
- // makes an unconditional reassignment wrong by default ("`TaskAccept`
98
- // is irrevocable unless policy explicitly permits reassignment").
99
- // Plumbing policy into this projection needs its own decision (see
100
- // plans/cross-repo/macp-sdk-typescript-conformance-and-transport.md,
101
- // Phase 3 open question 4) before that path can be implemented.
115
+ // The guard is keyed on the SESSION slot (`activeAssignment`), not on
116
+ // `task.assignee`, because rule 3's scope is the Session (issue #71).
117
+ // Two `TaskRequest`s in one transcript therefore share one assignee
118
+ // slot, matching the runtime, whose `TaskState` holds a single
119
+ // `active_assignee` (`task.rs:70`) alongside a single `task`.
120
+ //
121
+ // Rule 3c's policy-gated reassignment (`allow_reassignment_on_reject`,
122
+ // RFC-MACP-0012 `:135`) is reachable here via the `TaskReject` case
123
+ // below, which frees the slot — see its comment for why that needs no
124
+ // policy input.
102
125
  //
103
126
  // An anomaly would be recorded when this guard discards a second
104
127
  // `TaskAccept`, but `ProjectionAnomalyKind` (`base.ts:8-9`) is
105
128
  // deliberately frozen pending cross-SDK agreement with
106
129
  // macp-sdk-python.
107
- if (task && task.assignee === undefined) {
130
+ if (task && this.activeAssignment === undefined) {
108
131
  task.assignee = record.assignee;
109
132
  task.status = 'accepted';
133
+ this.activeAssignment = { sender: envelope.sender, taskId: record.taskId };
134
+ this.phase = 'InProgress';
110
135
  }
111
- this.phase = 'InProgress';
112
136
  break;
113
137
  }
114
138
  case 'TaskReject': {
115
139
  const record = payload;
116
140
  const task = this.tasks.get(record.taskId);
117
- // Deliberately does not clear `task.assignee`: rule 3c's
118
- // policy-gated reassignment path is not modelled here (see the
119
- // `TaskAccept` case above), so `assignee` is treated as sticky once
120
- // set, per rule 3b's default ("irrevocable unless policy explicitly
121
- // permits reassignment").
122
141
  if (task)
123
142
  task.status = 'rejected';
143
+ // RFC-MACP-0009 §5 rule 3c (`:72`): "When policy sets
144
+ // `allow_reassignment_on_reject: true` and the active assignee sends
145
+ // `TaskReject`, the session returns to the pre-assignment state. Other
146
+ // eligible participants MAY then send `TaskAccept` for the same
147
+ // `task_id`." Mirrors `macp-runtime`
148
+ // `crates/macp-modes/src/mode/task.rs:257-260`, which clears
149
+ // `active_assignee` only when the ACTIVE assignee is the rejecter —
150
+ // a reject from anyone else never frees the slot (`task.rs:241-244`
151
+ // rejects it as `InvalidPayload`).
152
+ //
153
+ // Deliberately NOT gated on the session policy, and it does not need
154
+ // to be. The runtime gates the reassignment twice — it denies the
155
+ // active assignee's `TaskReject` (`task.rs:223-239`) and the follow-up
156
+ // `TaskAccept` (`task.rs:187-204`) with `PolicyDenied` when
157
+ // `allow_reassignment_on_reject` is false — and a projection is a view
158
+ // of history the runtime ALREADY ACCEPTED (see this method's
159
+ // accepted-only input contract). A reassignment the runtime denied
160
+ // never reaches the transcript, so tracking the reject unconditionally
161
+ // cannot diverge from the runtime on any real transcript, and it needs
162
+ // no new API surface (threading a `PolicyDefinition` into the
163
+ // constructor would). Issue #70, option 2.
164
+ if (this.activeAssignment?.sender === envelope.sender) {
165
+ const held = this.tasks.get(this.activeAssignment.taskId);
166
+ if (held)
167
+ held.assignee = undefined;
168
+ this.activeAssignment = undefined;
169
+ }
124
170
  break;
125
171
  }
126
172
  case 'TaskUpdate': {
@@ -5,6 +5,26 @@ export declare function validateConfidence(value: number): void;
5
5
  export declare function validateSeverity(value: string): string;
6
6
  export declare function validateParticipantCount(count: number): void;
7
7
  export declare function validateSignalType(signalType: string, data?: Buffer | Uint8Array): void;
8
+ /**
9
+ * Validate the `Progress` scope pairing (RFC-MACP-0001 §6).
10
+ *
11
+ * `Progress` is legal in exactly two shapes — ambient (`sessionId` and `mode`
12
+ * both empty) or session-scoped (both non-empty). An envelope with exactly one
13
+ * of the two empty is a mixed shape that the runtime rejects with
14
+ * `INVALID_ENVELOPE`; raising here names the mismatched field instead.
15
+ *
16
+ * Unlike Signals, `Progress` is *not* required to be ambient — this is a
17
+ * tri-state rule, so neither field may be inferred from the other.
18
+ *
19
+ * The emptiness test deliberately mirrors `macp-runtime`'s
20
+ * `validate_envelope_shape` **exactly**, including its asymmetry: `sessionId`
21
+ * is compared raw while `mode` is trimmed first. Mirroring rather than
22
+ * normalising is the only choice under which the SDK neither accepts a shape
23
+ * the runtime rejects nor rejects one it accepts. Concretely, a
24
+ * whitespace-only `mode` alongside an empty `sessionId` is ambient to the
25
+ * runtime, so it stays ambient here.
26
+ */
27
+ export declare function validateProgressScope(sessionId: string, mode: string): void;
8
28
  export declare function validateTtlMs(ttlMs: number): void;
9
29
  export declare function validateMaxSuspendMs(maxSuspendMs: number): void;
10
30
  export declare function validateParticipants(participants: string[]): void;
@@ -7,6 +7,7 @@ exports.validateConfidence = validateConfidence;
7
7
  exports.validateSeverity = validateSeverity;
8
8
  exports.validateParticipantCount = validateParticipantCount;
9
9
  exports.validateSignalType = validateSignalType;
10
+ exports.validateProgressScope = validateProgressScope;
10
11
  exports.validateTtlMs = validateTtlMs;
11
12
  exports.validateMaxSuspendMs = validateMaxSuspendMs;
12
13
  exports.validateParticipants = validateParticipants;
@@ -61,6 +62,37 @@ function validateSignalType(signalType, data) {
61
62
  throw new errors_1.MacpSessionError('signalType must be non-empty when data is present');
62
63
  }
63
64
  }
65
+ /**
66
+ * Validate the `Progress` scope pairing (RFC-MACP-0001 §6).
67
+ *
68
+ * `Progress` is legal in exactly two shapes — ambient (`sessionId` and `mode`
69
+ * both empty) or session-scoped (both non-empty). An envelope with exactly one
70
+ * of the two empty is a mixed shape that the runtime rejects with
71
+ * `INVALID_ENVELOPE`; raising here names the mismatched field instead.
72
+ *
73
+ * Unlike Signals, `Progress` is *not* required to be ambient — this is a
74
+ * tri-state rule, so neither field may be inferred from the other.
75
+ *
76
+ * The emptiness test deliberately mirrors `macp-runtime`'s
77
+ * `validate_envelope_shape` **exactly**, including its asymmetry: `sessionId`
78
+ * is compared raw while `mode` is trimmed first. Mirroring rather than
79
+ * normalising is the only choice under which the SDK neither accepts a shape
80
+ * the runtime rejects nor rejects one it accepts. Concretely, a
81
+ * whitespace-only `mode` alongside an empty `sessionId` is ambient to the
82
+ * runtime, so it stays ambient here.
83
+ */
84
+ function validateProgressScope(sessionId, mode) {
85
+ const sessionIdEmpty = sessionId === '';
86
+ const modeEmpty = mode.trim() === '';
87
+ if (sessionIdEmpty === modeEmpty)
88
+ return;
89
+ const detail = sessionIdEmpty
90
+ ? `mode is ${JSON.stringify(mode)} but sessionId is empty`
91
+ : `sessionId is ${JSON.stringify(sessionId)} but mode is empty`;
92
+ throw new errors_1.MacpSessionError(`Progress must be either ambient (sessionId and mode both empty) or session-scoped ` +
93
+ `(both non-empty), but ${detail} (RFC-MACP-0001 §6). ` +
94
+ 'Pass both fields for a session-scoped Progress, or neither for an ambient one.');
95
+ }
64
96
  const MAX_TTL_MS = 86_400_000; // 24 hours
65
97
  function validateTtlMs(ttlMs) {
66
98
  if (!Number.isFinite(ttlMs) || ttlMs < 1 || ttlMs > MAX_TTL_MS) {
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * Kept in sync with package.json by release-please (see the
5
5
  * `x-release-please-version` annotation).
6
6
  */
7
- export declare const VERSION = "0.9.0";
7
+ export declare const VERSION = "0.10.0";
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@ exports.VERSION = void 0;
7
7
  * Kept in sync with package.json by release-please (see the
8
8
  * `x-release-please-version` annotation).
9
9
  */
10
- exports.VERSION = '0.9.0'; // x-release-please-version
10
+ exports.VERSION = '0.10.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macp-sdk-typescript",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "TypeScript SDK for the MACP Rust runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",