@saga-a2ui/core 0.5.6-rc.1 → 0.7.6-rc.1

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.
@@ -22,7 +22,10 @@ export interface ResolvedLimits {
22
22
  }
23
23
 
24
24
  /** Documented product defaults for usable behavior, not measured latency guarantees. */
25
- const defaults: ResolvedLimits = Object.freeze({ deadlineMs: 30_000, hookMs: 5_000 });
25
+ const defaults: ResolvedLimits = Object.freeze({
26
+ deadlineMs: 30_000,
27
+ hookMs: 5_000,
28
+ });
26
29
 
27
30
  /** The longest delay one `setTimeout` honors; a longer one would fire at once instead of later. */
28
31
  const longestTimerMs = 2_147_483_647;
@@ -65,10 +68,16 @@ export function startTimer(ms: number, onExpire: () => void): LimitTimer {
65
68
  };
66
69
  }
67
70
 
68
- function readSetting(owner: string, name: keyof Limits, value: unknown): number | undefined {
71
+ function readSetting(
72
+ owner: string,
73
+ name: keyof Limits,
74
+ value: unknown,
75
+ ): number | undefined {
69
76
  if (value === undefined) return undefined;
70
77
  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
71
- throw new TypeError(`${owner}: ${name} must be a finite number greater than zero.`);
78
+ throw new TypeError(
79
+ `${owner}: ${name} must be a finite number greater than zero.`,
80
+ );
72
81
  }
73
82
  return value;
74
83
  }
@@ -81,9 +90,14 @@ function readSetting(owner: string, name: keyof Limits, value: unknown): number
81
90
  export function readLimits(owner: string, given: unknown): Limits {
82
91
  if (given === undefined) return Object.freeze({});
83
92
  if (typeof given !== 'object' || given === null || Array.isArray(given)) {
84
- throw new TypeError(`${owner}: limits must be an object with optional deadlineMs and hookMs.`);
93
+ throw new TypeError(
94
+ `${owner}: limits must be an object with optional deadlineMs and hookMs.`,
95
+ );
85
96
  }
86
- const { deadlineMs, hookMs } = given as { deadlineMs?: unknown; hookMs?: unknown };
97
+ const { deadlineMs, hookMs } = given as {
98
+ deadlineMs?: unknown;
99
+ hookMs?: unknown;
100
+ };
87
101
  const checkedDeadline = readSetting(owner, 'deadlineMs', deadlineMs);
88
102
  const checkedHook = readSetting(owner, 'hookMs', hookMs);
89
103
  return Object.freeze({
@@ -102,7 +116,10 @@ export function snapshotLimits(limits?: Limits): Limits {
102
116
  }
103
117
 
104
118
  /** Per-setting resolution: the action's override, then the instance configuration, then the default. */
105
- export function resolveLimits(action: Limits, instance: Limits): ResolvedLimits {
119
+ export function resolveLimits(
120
+ action: Limits,
121
+ instance: Limits,
122
+ ): ResolvedLimits {
106
123
  return Object.freeze({
107
124
  deadlineMs: action.deadlineMs ?? instance.deadlineMs ?? defaults.deadlineMs,
108
125
  hookMs: action.hookMs ?? instance.hookMs ?? defaults.hookMs,
@@ -30,8 +30,16 @@ export type InputFailure = (
30
30
 
31
31
  /** Failures while prerequisites run. The handler has not started and `provided` is incomplete. */
32
32
  export type PrerequisiteFailure = (
33
- | { readonly kind: 'refused'; readonly prerequisite: string; readonly reason: string }
34
- | { readonly kind: 'prerequisite-error'; readonly prerequisite: string; readonly error: unknown }
33
+ | {
34
+ readonly kind: 'refused';
35
+ readonly prerequisite: string;
36
+ readonly reason: string;
37
+ }
38
+ | {
39
+ readonly kind: 'prerequisite-error';
40
+ readonly prerequisite: string;
41
+ readonly error: unknown;
42
+ }
35
43
  | { readonly kind: 'timeout' }
36
44
  ) &
37
45
  Categorized;
@@ -46,18 +54,30 @@ export type PrerequisiteFailure = (
46
54
  export type RunningFailure<Result> = (
47
55
  | { readonly kind: 'handler-error'; readonly error: unknown }
48
56
  | { readonly kind: 'timeout' }
49
- | { readonly kind: 'success-hook-error'; readonly error: unknown; readonly result: Result }
57
+ | {
58
+ readonly kind: 'success-hook-error';
59
+ readonly error: unknown;
60
+ readonly result: Result;
61
+ }
50
62
  | { readonly kind: 'success-hook-timeout'; readonly result: Result }
51
63
  ) &
52
64
  Categorized;
53
65
 
54
66
  /** Steps after the outcome is decided. 'adopt' is a transfer callback that returned a thenable. */
55
- export type CleanupStep = 'onFailure' | 'onCanceled' | 'onFinally' | 'release' | 'adopt';
67
+ export type CleanupStep =
68
+ 'onFailure' | 'onCanceled' | 'onFinally' | 'release' | 'adopt';
56
69
 
57
70
  /** A diagnostic about cleanup. It never changes the primary outcome. */
58
71
  export type CleanupError =
59
- | { readonly step: CleanupStep; readonly reason: 'threw'; readonly error: unknown }
60
- | { readonly step: CleanupStep; readonly reason: 'budget-exceeded' | 'obsolete' };
72
+ | {
73
+ readonly step: CleanupStep;
74
+ readonly reason: 'threw';
75
+ readonly error: unknown;
76
+ }
77
+ | {
78
+ readonly step: CleanupStep;
79
+ readonly reason: 'budget-exceeded' | 'obsolete';
80
+ };
61
81
 
62
82
  /**
63
83
  * Canceled stages: 'prerequisites' and 'running' are before the handler's result was observed, so
@@ -66,12 +86,37 @@ export type CleanupError =
66
86
  */
67
87
  export type Outcome<Result> =
68
88
  | { readonly status: 'succeeded'; readonly result: Result }
69
- | { readonly status: 'failed'; readonly stage: 'input'; readonly failure: InputFailure }
70
- | { readonly status: 'failed'; readonly stage: 'prerequisites'; readonly failure: PrerequisiteFailure }
71
- | { readonly status: 'failed'; readonly stage: 'running'; readonly failure: RunningFailure<Result> }
72
- | { readonly status: 'canceled'; readonly cause: CancelCause; readonly stage: 'prerequisites' }
73
- | { readonly status: 'canceled'; readonly cause: CancelCause; readonly stage: 'running' }
74
- | { readonly status: 'canceled'; readonly cause: CancelCause; readonly stage: 'completing'; readonly result: Result };
89
+ | {
90
+ readonly status: 'failed';
91
+ readonly stage: 'input';
92
+ readonly failure: InputFailure;
93
+ }
94
+ | {
95
+ readonly status: 'failed';
96
+ readonly stage: 'prerequisites';
97
+ readonly failure: PrerequisiteFailure;
98
+ }
99
+ | {
100
+ readonly status: 'failed';
101
+ readonly stage: 'running';
102
+ readonly failure: RunningFailure<Result>;
103
+ }
104
+ | {
105
+ readonly status: 'canceled';
106
+ readonly cause: CancelCause;
107
+ readonly stage: 'prerequisites';
108
+ }
109
+ | {
110
+ readonly status: 'canceled';
111
+ readonly cause: CancelCause;
112
+ readonly stage: 'running';
113
+ }
114
+ | {
115
+ readonly status: 'canceled';
116
+ readonly cause: CancelCause;
117
+ readonly stage: 'completing';
118
+ readonly result: Result;
119
+ };
75
120
 
76
121
  /**
77
122
  * The freshness check supplied to one callback. Each prerequisite, the handler and each hook gets
@@ -17,23 +17,31 @@ export interface PrerequisiteContext<Needs, Input> extends Freshness {
17
17
  readonly scope: InvocationScope;
18
18
  }
19
19
 
20
- export type Proceed<Provides> = { readonly kind: 'proceed'; readonly provides: Provides };
20
+ export type Proceed<Provides> = {
21
+ readonly kind: 'proceed';
22
+ readonly provides: Provides;
23
+ };
21
24
  export type Refuse = { readonly kind: 'refuse'; readonly reason: string };
22
25
  export type PrerequisiteResult<Provides> = Proceed<Provides> | Refuse;
23
26
 
24
27
  /** Continues to the next prerequisite, or the handler, adding `provides` for everything after it. */
25
28
  export function proceed(): Proceed<NoValues>;
26
- export function proceed<Provides extends object>(provides: Provides): Proceed<Provides>;
29
+ export function proceed<Provides extends object>(
30
+ provides: Provides,
31
+ ): Proceed<Provides>;
27
32
  export function proceed(provides: object = {}): Proceed<object> {
28
33
  if (typeof provides !== 'object' || provides === null) {
29
- throw new TypeError('proceed() takes an object of provided values, or nothing.');
34
+ throw new TypeError(
35
+ 'proceed() takes an object of provided values, or nothing.',
36
+ );
30
37
  }
31
38
  return Object.freeze({ kind: 'proceed', provides });
32
39
  }
33
40
 
34
41
  /** Ends the invocation as a refusal: the handler never runs. `reason` is for the failure hook. */
35
42
  export function refuse(reason: string): Refuse {
36
- if (typeof reason !== 'string') throw new TypeError('refuse() takes a reason string.');
43
+ if (typeof reason !== 'string')
44
+ throw new TypeError('refuse() takes a reason string.');
37
45
  return Object.freeze({ kind: 'refuse', reason });
38
46
  }
39
47
 
@@ -46,12 +54,18 @@ export interface Prerequisite<Provides, Needs = unknown, Input = unknown> {
46
54
  }
47
55
 
48
56
  /** Fixes the type parameters from the callback; a plain `{ label, run }` object is equally valid. */
49
- export function prerequisite<Provides extends object = NoValues, Needs = unknown, Input = unknown>(
57
+ export function prerequisite<
58
+ Provides extends object = NoValues,
59
+ Needs = unknown,
60
+ Input = unknown,
61
+ >(
50
62
  label: string,
51
63
  run: Prerequisite<Provides, Needs, Input>['run'],
52
64
  ): Prerequisite<Provides, Needs, Input> {
53
- if (typeof label !== 'string' || label === '') throw new TypeError('A prerequisite needs a non-empty label.');
54
- if (typeof run !== 'function') throw new TypeError(`Prerequisite '${label}' needs a function.`);
65
+ if (typeof label !== 'string' || label === '')
66
+ throw new TypeError('A prerequisite needs a non-empty label.');
67
+ if (typeof run !== 'function')
68
+ throw new TypeError(`Prerequisite '${label}' needs a function.`);
55
69
  return Object.freeze({ label, run });
56
70
  }
57
71
 
@@ -64,10 +78,19 @@ export type Answer =
64
78
  /** Reads the unchecked value a prerequisite returned. Unknown shapes are the prerequisite's error. */
65
79
  export function readAnswer(value: unknown): Answer {
66
80
  if (typeof value !== 'object' || value === null) return { kind: 'malformed' };
67
- const answer = value as { kind?: unknown; provides?: unknown; reason?: unknown };
68
- if (answer.kind === 'proceed' && typeof answer.provides === 'object' && answer.provides !== null) {
81
+ const answer = value as {
82
+ kind?: unknown;
83
+ provides?: unknown;
84
+ reason?: unknown;
85
+ };
86
+ if (
87
+ answer.kind === 'proceed' &&
88
+ typeof answer.provides === 'object' &&
89
+ answer.provides !== null
90
+ ) {
69
91
  return { kind: 'proceed', provides: answer.provides };
70
92
  }
71
- if (answer.kind === 'refuse' && typeof answer.reason === 'string') return { kind: 'refuse', reason: answer.reason };
93
+ if (answer.kind === 'refuse' && typeof answer.reason === 'string')
94
+ return { kind: 'refuse', reason: answer.reason };
72
95
  return { kind: 'malformed' };
73
96
  }
@@ -18,7 +18,10 @@ export interface ReleaseContext {
18
18
  * Must affect only the acquisition that `token` identifies, and must be a no-op when the owner has
19
19
  * since superseded that token. The scope calls it once per owned token that was not transferred.
20
20
  */
21
- export type Release<Token> = (token: Token, context: ReleaseContext) => void | PromiseLike<void>;
21
+ export type Release<Token> = (
22
+ token: Token,
23
+ context: ReleaseContext,
24
+ ) => void | PromiseLike<void>;
22
25
 
23
26
  /** A token the scope owns, and the one way to hand it to a longer-lived owner. */
24
27
  export interface Held<Token> {
@@ -36,7 +39,10 @@ export interface Held<Token> {
36
39
  * later rejection reaches the observer as a `cleanup-error` with step `adopt`. Work the callback
37
40
  * already started is not recalled, so the owner's own `end(token)` must be idempotent.
38
41
  */
39
- transfer<Returned>(adopt: (token: Token) => Returned, ...synchronous: Synchronous<Returned>): boolean;
42
+ transfer<Returned>(
43
+ adopt: (token: Token) => Returned,
44
+ ...synchronous: Synchronous<Returned>
45
+ ): boolean;
40
46
  }
41
47
 
42
48
  /** The part of the scope that may be used inside the event turn. */
@@ -60,13 +66,18 @@ export interface InvocationScope extends ActivationScope {
60
66
  * released at once and the acquisition rejects with `InvocationEndedError`; an invocation that has
61
67
  * already ended does not start the acquisition at all.
62
68
  */
63
- acquire<Token>(acquire: () => Token | PromiseLike<Token>, release: Release<Token>): Promise<Held<Token>>;
69
+ acquire<Token>(
70
+ acquire: () => Token | PromiseLike<Token>,
71
+ release: Release<Token>,
72
+ ): Promise<Held<Token>>;
64
73
  }
65
74
 
66
75
  /** The invocation's outcome was already decided, so nothing may be added to its scope. */
67
76
  export class InvocationEndedError extends Error {
68
77
  constructor(label: string) {
69
- super(`Action '${label}': the invocation has ended, so the token was released instead of owned.`);
78
+ super(
79
+ `Action '${label}': the invocation has ended, so the token was released instead of owned.`,
80
+ );
70
81
  this.name = 'InvocationEndedError';
71
82
  }
72
83
  }
@@ -106,7 +117,8 @@ export class InvocationResources {
106
117
 
107
118
  constructor(private readonly host: ScopeHost) {
108
119
  const active = (): boolean => host.isActive();
109
- const own = <Token>(token: Token, release: Release<Token>): Held<Token> => this.own(token, release);
120
+ const own = <Token>(token: Token, release: Release<Token>): Held<Token> =>
121
+ this.own(token, release);
110
122
  this.activation = Object.freeze({
111
123
  get active() {
112
124
  return active();
@@ -118,8 +130,10 @@ export class InvocationResources {
118
130
  return active();
119
131
  },
120
132
  own,
121
- acquire: <Token>(acquire: () => Token | PromiseLike<Token>, release: Release<Token>): Promise<Held<Token>> =>
122
- this.acquire(acquire, release),
133
+ acquire: <Token>(
134
+ acquire: () => Token | PromiseLike<Token>,
135
+ release: Release<Token>,
136
+ ): Promise<Held<Token>> => this.acquire(acquire, release),
123
137
  });
124
138
  }
125
139
 
@@ -141,7 +155,10 @@ export class InvocationResources {
141
155
 
142
156
  private own<Token>(token: Token, release: Release<Token>): Held<Token> {
143
157
  const { host } = this;
144
- if (typeof release !== 'function') throw new TypeError(`Action '${host.label}': scope.own needs a release function.`);
158
+ if (typeof release !== 'function')
159
+ throw new TypeError(
160
+ `Action '${host.label}': scope.own needs a release function.`,
161
+ );
145
162
  // Interop assertion: the entry keeps `token` and `release` together, so the scope only ever
146
163
  // calls this release with the token it was given.
147
164
  const erased = release as Release<unknown>;
@@ -153,15 +170,24 @@ export class InvocationResources {
153
170
  this.entries.push(entry);
154
171
  return Object.freeze({
155
172
  token,
156
- transfer: <Returned>(adopt: (token: Token) => Returned, ...synchronous: Synchronous<Returned>): boolean => {
173
+ transfer: <Returned>(
174
+ adopt: (token: Token) => Returned,
175
+ ...synchronous: Synchronous<Returned>
176
+ ): boolean => {
157
177
  void synchronous;
158
178
  return this.transfer(entry, adopt);
159
179
  },
160
180
  });
161
181
  }
162
182
 
163
- private async acquire<Token>(acquire: () => Token | PromiseLike<Token>, release: Release<Token>): Promise<Held<Token>> {
164
- if (typeof acquire !== 'function') throw new TypeError(`Action '${this.host.label}': scope.acquire needs an acquire function.`);
183
+ private async acquire<Token>(
184
+ acquire: () => Token | PromiseLike<Token>,
185
+ release: Release<Token>,
186
+ ): Promise<Held<Token>> {
187
+ if (typeof acquire !== 'function')
188
+ throw new TypeError(
189
+ `Action '${this.host.label}': scope.acquire needs an acquire function.`,
190
+ );
165
191
  if (!this.host.isActive()) throw new InvocationEndedError(this.host.label);
166
192
  const token = await acquire();
167
193
  // Ownership begins when the token exists. If the invocation ended meanwhile, `own` releases it
@@ -169,9 +195,15 @@ export class InvocationResources {
169
195
  return this.own(token, release);
170
196
  }
171
197
 
172
- private transfer<Token>(entry: Entry, adopt: (token: Token) => unknown): boolean {
198
+ private transfer<Token>(
199
+ entry: Entry,
200
+ adopt: (token: Token) => unknown,
201
+ ): boolean {
173
202
  if (entry.state !== 'held' || !this.host.isActive()) return false;
174
- if (typeof adopt !== 'function') throw new TypeError(`Action '${this.host.label}': transfer needs an adopt function.`);
203
+ if (typeof adopt !== 'function')
204
+ throw new TypeError(
205
+ `Action '${this.host.label}': transfer needs an adopt function.`,
206
+ );
175
207
  // While the owner adopts, the entry is not the runtime's to release, whatever adoption does.
176
208
  entry.state = 'adopting';
177
209
  let completed = false;
@@ -232,7 +264,11 @@ export class InvocationResources {
232
264
  * Releases a token the invocation can no longer own, with no hook involved and within `hookMs`. A
233
265
  * failure or overrun reaches only the observer: the outcome and the hooks are long past.
234
266
  */
235
- private releaseAtOnce(token: unknown, release: Release<unknown>, hookMs: number): void {
267
+ private releaseAtOnce(
268
+ token: unknown,
269
+ release: Release<unknown>,
270
+ hookMs: number,
271
+ ): void {
236
272
  const controller = new AbortController();
237
273
  let returned: unknown;
238
274
  try {
@@ -40,7 +40,10 @@ export interface ServerReply {
40
40
  * request is then unknown. `Reply` lets a transport keep its own reply type.
41
41
  */
42
42
  export interface ServerTransport<Reply extends ServerReply = ServerReply> {
43
- callServerTool(request: ServerRequest, options?: ServerRequestOptions): Promise<Reply>;
43
+ callServerTool(
44
+ request: ServerRequest,
45
+ options?: ServerRequestOptions,
46
+ ): Promise<Reply>;
44
47
  }
45
48
 
46
49
  /** What a handler may ask for beyond the arguments: a shorter wait, and request metadata. */
@@ -66,34 +69,64 @@ export interface ServerCalls<Reply extends ServerReply = ServerReply> {
66
69
  * invocation, and aborting the signal ends the client's wait: it does not show that the host
67
70
  * forwarded the cancellation or that the server stopped.
68
71
  */
69
- call(name: string, args: Readonly<Record<string, unknown>>, options?: ServerCallOptions): Promise<Reply>;
72
+ call(
73
+ name: string,
74
+ args: Readonly<Record<string, unknown>>,
75
+ options?: ServerCallOptions,
76
+ ): Promise<Reply>;
70
77
  }
71
78
 
72
79
  /** Reads the transport an application passed: an object with a `callServerTool` function. */
73
- export function checkTransport(label: string, transport: unknown): ServerTransport {
74
- const candidate = transport as { callServerTool?: unknown } | null | undefined;
75
- if (typeof candidate !== 'object' || candidate === null || typeof candidate.callServerTool !== 'function') {
76
- throw new TypeError(`Action '${label}': a server action needs a transport with a callServerTool function.`);
80
+ export function checkTransport(
81
+ label: string,
82
+ transport: unknown,
83
+ ): ServerTransport {
84
+ const candidate = transport as
85
+ { callServerTool?: unknown } | null | undefined;
86
+ if (
87
+ typeof candidate !== 'object' ||
88
+ candidate === null ||
89
+ typeof candidate.callServerTool !== 'function'
90
+ ) {
91
+ throw new TypeError(
92
+ `Action '${label}': a server action needs a transport with a callServerTool function.`,
93
+ );
77
94
  }
78
95
  return candidate as ServerTransport;
79
96
  }
80
97
 
81
98
  /** The smallest of the time left and the handler's own narrower request, or why there is none. */
82
- export function requestTimeout(label: string, remainingMs: number, supplied: unknown): number {
83
- if (supplied !== undefined && (typeof supplied !== 'number' || !Number.isFinite(supplied) || supplied <= 0)) {
84
- throw new TypeError(`Action '${label}': timeoutMs must be a finite number greater than zero.`);
99
+ export function requestTimeout(
100
+ label: string,
101
+ remainingMs: number,
102
+ supplied: unknown,
103
+ ): number {
104
+ if (
105
+ supplied !== undefined &&
106
+ (typeof supplied !== 'number' ||
107
+ !Number.isFinite(supplied) ||
108
+ supplied <= 0)
109
+ ) {
110
+ throw new TypeError(
111
+ `Action '${label}': timeoutMs must be a finite number greater than zero.`,
112
+ );
85
113
  }
86
114
  return supplied === undefined ? remainingMs : Math.min(remainingMs, supplied);
87
115
  }
88
116
 
89
117
  /** The request metadata a handler supplied, copied and frozen, or why there is none. */
90
- export function requestMeta(label: string, supplied: unknown): Readonly<Record<string, unknown>> | undefined {
118
+ export function requestMeta(
119
+ label: string,
120
+ supplied: unknown,
121
+ ): Readonly<Record<string, unknown>> | undefined {
91
122
  if (supplied === undefined) return undefined;
92
123
  const plain =
93
124
  typeof supplied === 'object' &&
94
125
  supplied !== null &&
95
126
  !Array.isArray(supplied) &&
96
- (Object.getPrototypeOf(supplied) === Object.prototype || Object.getPrototypeOf(supplied) === null);
97
- if (!plain) throw new TypeError(`Action '${label}': meta must be a plain object.`);
127
+ (Object.getPrototypeOf(supplied) === Object.prototype ||
128
+ Object.getPrototypeOf(supplied) === null);
129
+ if (!plain)
130
+ throw new TypeError(`Action '${label}': meta must be a plain object.`);
98
131
  return Object.freeze({ ...(supplied as Record<string, unknown>) });
99
132
  }
@@ -17,7 +17,9 @@ export function isThenable(value: unknown): value is PromiseLike<unknown> {
17
17
  * callback that always throws returns `never` and stays valid. The types cannot see a promise
18
18
  * hidden behind a declared `unknown` or `void`, so the runtime checks the returned value too.
19
19
  */
20
- export type Synchronous<Returned> = [Extract<Returned, PromiseLike<unknown>>] extends [never]
20
+ export type Synchronous<Returned> = [
21
+ Extract<Returned, PromiseLike<unknown>>,
22
+ ] extends [never]
21
23
  ? []
22
24
  : [returnedAThenable: never];
23
25
 
@@ -0,0 +1,31 @@
1
+ // What a delivery hands a view as the result of one operation, in terms no delivery owns. A
2
+ // delivery (MCP today) maps its own result onto this value; an application's reader decides what it
3
+ // means. Nothing here interprets: it names no schema, wording, fallback or error policy, and it
4
+ // imports no delivery, renderer or DOM.
5
+
6
+ /**
7
+ * One piece of a result's ordered content. Its `type` is always kept. A text block also keeps its
8
+ * `text`; any other block is carried exactly as the delivery supplied it, so a reader that knows
9
+ * the block's shape can read it and one that does not leaves it alone.
10
+ */
11
+ export interface ContentBlock {
12
+ readonly type: string;
13
+ readonly text?: string;
14
+ readonly [field: string]: unknown;
15
+ }
16
+
17
+ /**
18
+ * The whole result of one operation, as delivered.
19
+ *
20
+ * - `isError` is the delivery's own error indication, kept as a boolean. What an error result
21
+ * means, and whether beside valid structured data, is the reader's decision.
22
+ * - `content` holds every block in the order delivered, possibly none.
23
+ * - `structuredContent` is unchecked. Absence of the field (`'structuredContent' in result` is
24
+ * false), a present `null` and malformed data are three different results, and a reader may
25
+ * treat them differently.
26
+ */
27
+ export interface DeliveredResult {
28
+ readonly isError: boolean;
29
+ readonly content: readonly ContentBlock[];
30
+ readonly structuredContent?: unknown;
31
+ }
@@ -0,0 +1,49 @@
1
+ // How a view asks its host to open a link the content policy has already validated. The contract
2
+ // is renderer- and delivery-neutral: the card renderers (Lit, React) consume it, MCP delivery
3
+ // supplies an implementation, and neither depends on the other. They share one definition and one
4
+ // set of reader-facing messages.
5
+
6
+ /**
7
+ * What happened to a request to open a validated link.
8
+ *
9
+ * - `opened`: the host accepted the request.
10
+ * - `denied`: the host refused it (user preference, blocked destination, cancelled).
11
+ * - `unavailable`: the host offers no link-opening capability, so nothing was requested.
12
+ * - `failed`: the request could not be completed (connection lost, timeout).
13
+ *
14
+ * Every outcome other than `opened` leaves the link inert; there is no other way to navigate.
15
+ */
16
+ export type LinkOpenOutcome = 'opened' | 'denied' | 'unavailable' | 'failed';
17
+
18
+ /**
19
+ * The only way content may leave the view: a request to the host to open a destination the
20
+ * content policy has already validated. A view receives its own instance explicitly.
21
+ */
22
+ export interface HostLinks {
23
+ open(url: string): Promise<LinkOpenOutcome>;
24
+ }
25
+
26
+ /** For a page that has no host at all: every link stays inert and nothing navigates. */
27
+ export const NO_HOST_LINKS: HostLinks = Object.freeze({
28
+ open: () => Promise.resolve<LinkOpenOutcome>('unavailable'),
29
+ });
30
+
31
+ const LINK_OUTCOME_MESSAGES: Record<LinkOpenOutcome, string> = {
32
+ opened: '',
33
+ denied: 'The host did not open this link.',
34
+ unavailable: 'This host cannot open links.',
35
+ failed: 'The link could not be opened.',
36
+ };
37
+
38
+ /**
39
+ * The text a reader sees for a link request's outcome. A link that was not opened stays readable:
40
+ * its destination follows as plain text.
41
+ */
42
+ export function describeLinkOutcome(
43
+ outcome: LinkOpenOutcome,
44
+ url: string,
45
+ ): string {
46
+ return outcome === 'opened'
47
+ ? LINK_OUTCOME_MESSAGES.opened
48
+ : `${LINK_OUTCOME_MESSAGES[outcome]} Destination: ${url}`;
49
+ }