akanjs 3.0.0-beta.3 → 3.0.0-beta.5

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/common/types.ts CHANGED
@@ -5,7 +5,11 @@ export interface FetchPolicy<Returns = unknown> {
5
5
  onError?: (error: string) => void;
6
6
  token?: string;
7
7
  partial?: string[];
8
- timeout?: number;
8
+ /**
9
+ * Milliseconds before this call is abandoned, `false` to wait as long as the runtime will. Overrides the
10
+ * endpoint's declared `timeout`, which overrides the client's own default.
11
+ */
12
+ timeout?: number | false;
9
13
  /**
10
14
  * A `pubsub` subscription only: called after the room has been resubscribed following a dropped connection.
11
15
  *
@@ -173,6 +173,13 @@ export class FetchClient {
173
173
  }
174
174
  : signal;
175
175
  }
176
+ /**
177
+ * The budget for every call that neither names one nor is served by an endpoint declaring one. `false` waits
178
+ * as long as the runtime will, which is the browser's own limit — minutes.
179
+ */
180
+ setTimeout(timeout?: number | false) {
181
+ this.http.setTimeout(timeout);
182
+ }
176
183
  setErrorConstructor(ErrorCls?: ErrorConstructor) {
177
184
  this.ErrorCls = ErrorCls;
178
185
  this.http.setErrorConstructor(ErrorCls);
@@ -316,8 +323,9 @@ export class FetchClient {
316
323
  const url = FetchClient.makeHttpUrl(key, endpoint, prefix, argMap);
317
324
  const headers = this.#makeAuthHeaders(option);
318
325
  const baseUrl = option?.origin;
326
+ const timeout = option?.timeout ?? endpoint.timeout;
319
327
 
320
- const requestQuery = () => this.http.get(url, { headers, baseUrl });
328
+ const requestQuery = () => this.http.get(url, { headers, baseUrl, timeout });
321
329
 
322
330
  const claim = baseUrl
323
331
  ? { value: requestQuery(), owned: true }
@@ -339,6 +347,7 @@ export class FetchClient {
339
347
  const response = await this.http.send(endpoint.method ?? "POST", url, body, {
340
348
  headers: this.#makeAuthHeaders(option),
341
349
  baseUrl: option?.origin,
350
+ timeout: option?.timeout ?? endpoint.timeout,
342
351
  });
343
352
  const parsedReturn = parseReturn(response, { crystalize: option?.crystalize ?? true });
344
353
  return parsedReturn;
@@ -827,7 +836,15 @@ export class FetchClient {
827
836
  connect = false,
828
837
  base,
829
838
  Err,
830
- }: { origin?: string; connect?: boolean; base?: FetchProxy; Err?: ErrorConstructor } = {},
839
+ timeout,
840
+ }: {
841
+ origin?: string;
842
+ connect?: boolean;
843
+ base?: FetchProxy;
844
+ Err?: ErrorConstructor;
845
+ /** This app's own default request budget, for calls no endpoint and no caller gave one. */
846
+ timeout?: number | false;
847
+ } = {},
831
848
  ): {
832
849
  sig: ClientSignalMap<SigType>;
833
850
  fetch: SigType["fetch"];
@@ -838,6 +855,7 @@ export class FetchClient {
838
855
  const proxy =
839
856
  shared ??
840
857
  FetchClient.#makeProxy<unknown, Record<string, SliceMeta>>(new FetchClient(origin, {}, serializedSignal, Err));
858
+ if (timeout !== undefined) proxy.instance.setTimeout(timeout);
841
859
  if (connect) proxy.instance.connect();
842
860
  const sig = {} as any;
843
861
  Object.entries(serializedSignal).forEach(([refName, serializedSignal]) => {
@@ -49,6 +49,10 @@ export class HttpClient {
49
49
  setErrorConstructor(ErrorCls?: ErrorConstructor) {
50
50
  this.ErrorCls = ErrorCls;
51
51
  }
52
+ /** The budget every call that names none takes. `false` waits as long as the runtime will. */
53
+ setTimeout(timeout?: number | false) {
54
+ this.#timeout = timeout;
55
+ }
52
56
  #resolveBaseUrl(baseUrl?: string) {
53
57
  return (baseUrl ?? this.baseUrl).replace(/\/$/, "");
54
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-beta.3",
3
+ "version": "3.0.0-beta.5",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -17,7 +17,7 @@ import { agentTurnConstant, agentTurnDocument } from "../../signal/agentTurn";
17
17
  import { Base, BaseEndpoint, BaseInternal } from "../../signal/base.signal";
18
18
  import type { Endpoint } from "../../signal/endpoint";
19
19
  import type { Internal } from "../../signal/internal";
20
- import { Logging, type MiddlewareCls } from "../../signal/middleware";
20
+ import { Cache, Logging, type MiddlewareCls, Timeout } from "../../signal/middleware";
21
21
  import type { ServerSignal, ServerSignalCls } from "../../signal/serverSignal";
22
22
  import { SignalRegistry } from "../../signal/signalRegistry";
23
23
  import type { AkanLib, DatabaseModule, ScalarModule, ServiceModule } from "../akanLib";
@@ -126,6 +126,10 @@ export class DiLifecycle {
126
126
  : null;
127
127
  if (frameworkAgent) this.#service.set("agent", frameworkAgent);
128
128
  this.#middleware.set(Logging.refName, Logging);
129
+
130
+ this.#middleware.set(Timeout.refName, Timeout);
131
+
132
+ this.#middleware.set(Cache.refName, Cache);
129
133
  const defaultOption = createDefaultAkanOption();
130
134
  defaultOption.getMiddlewares().forEach((middleware) => {
131
135
  this.#middleware.set(middleware.refName, middleware);
@@ -98,14 +98,18 @@ export class AgentService extends serve("agent" as const, ({ plug }) => ({
98
98
  private static isReadable(attachment: AgentWireAttachment, accepts: LlmAccepts): boolean {
99
99
  if (attachment.text) return true;
100
100
  if (!attachment.data && !attachment.url) return false;
101
+
102
+ if (typeof attachment.mimeType !== "string") return false;
101
103
  return attachment.mimeType.startsWith("image/") ? !!accepts.image : !!accepts.document;
102
104
  }
103
105
 
104
106
  private static note(attachment: AgentWireAttachment): string {
105
107
  const why =
106
- attachment.data || attachment.url
107
- ? "this model cannot read that type"
108
- : "its content is no longer available, as a reloaded conversation keeps the name and not the bytes";
108
+ !attachment.data && !attachment.url
109
+ ? "its content is no longer available, as a reloaded conversation keeps the name and not the bytes"
110
+ : typeof attachment.mimeType === "string"
111
+ ? "this model cannot read that type"
112
+ : "it names no type it could be read as";
109
113
  return `[Attachment not read: ${attachment.name} (${attachment.mimeType}) — ${why}. Tell the user it was not read instead of guessing what it holds, and ask for the text if the answer needs it.]`;
110
114
  }
111
115
  }
@@ -2,6 +2,7 @@ import type { BackendEnv, Cls, PromiseOrObject } from "akanjs/base";
2
2
  import { Logger } from "akanjs/common";
3
3
  import { type CacheAdaptor, CacheAdaptorRole } from "akanjs/service";
4
4
  import dayjs from "dayjs";
5
+ import { Exception } from "./exception";
5
6
  import type { SignalContext } from "./signalContext";
6
7
  import { traceCache } from "./trace";
7
8
 
@@ -46,71 +47,105 @@ export class Logging extends middleware("logging") {
46
47
  }
47
48
  }
48
49
 
50
+ /**
51
+ * Serves an endpoint's own answer back for as long as its declared `cache` allows, and stands aside for every
52
+ * endpoint that declared none — this is registered by default, so a TTL of its own would put a stale window on
53
+ * every call in the app.
54
+ *
55
+ * A cached answer is **shared**, which is why only a `query` taking no internal argument can carry one: internal
56
+ * arguments are how a call learns who is asking (`.with(Self)`), so an endpoint that has them answers per caller
57
+ * and one entry would be one caller's answer handed to the next. Guards are re-run on every hit regardless —
58
+ * shared is not public, and `next()`, which is what runs them, is exactly what a hit skips.
59
+ */
49
60
  export class Cache extends middleware("cache") {
61
+ static #topic = "cache";
62
+ static #refused = new Set<string>();
63
+
50
64
  override async use() {
51
65
  return async (context: SignalContext, next: () => Promise<unknown>) => {
66
+ const ttl = context.endpointInfo.signalOption.cache;
67
+ if (!ttl || !Number.isFinite(ttl) || ttl <= 0) return await next();
68
+ if (!Cache.#cacheable(context)) return await next();
52
69
  const cache = context.getAdaptor(CacheAdaptorRole) as unknown as CacheAdaptor;
53
- const topic = "cache";
54
70
  const key = `${context.key}:${JSON.stringify(context.args)}`;
55
-
56
- const cached = await cache.get<string>(topic, key);
57
- if (cached) {
58
- context.adaptor.logger.debug(`Cache hit ${context.key}`);
59
- try {
60
- const parsed = JSON.parse(cached);
61
- traceCache(true);
62
- return parsed;
63
- } catch (parseError) {
64
- context.adaptor.logger.warn(`Cache parse error ${context.key}: ${String(parseError)}`);
65
- await cache.delete(topic, key);
66
- }
71
+ const cached = await Cache.#read(context, cache, key);
72
+ if (cached !== undefined) {
73
+ await context.checkGuards();
74
+ traceCache(true);
75
+ return cached;
67
76
  }
68
77
  traceCache(false);
69
-
70
78
  const result = await next();
71
-
72
- context.adaptor.logger.debug(`Caching result type ${context.key}: ${typeof result} / ${Array.isArray(result)}`);
73
-
74
- const serialized = JSON.stringify(result);
75
- await cache.set(topic, key, serialized, { expireAt: dayjs().add(60, "second") });
76
-
79
+ await Cache.#write(context, cache, key, result, ttl);
77
80
  return result;
78
81
  };
79
82
  }
80
- }
81
-
82
- export class Timeout extends middleware("timeout") {
83
- override async use() {
84
- return async (context: SignalContext, next: () => Promise<unknown>) => {
85
- const timeout = context.endpointInfo.signalOption.timeout ?? 5000;
86
- return Promise.race([
87
- next(),
88
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Request timeout after ${timeout}ms`)), timeout)),
89
- ]);
90
- };
83
+ /**
84
+ * The handler's inputs are its declared arguments and its internal arguments, so an endpoint with none of the
85
+ * latter answers the same thing to everyone who may read it — which is the only answer a shared entry can hold.
86
+ * A `cache` declared anywhere else is named once rather than silently ignored.
87
+ */
88
+ static #cacheable(context: SignalContext) {
89
+ if (context.endpointInfo.type === "query" && context.endpointInfo.internalArgs.length === 0) return true;
90
+ if (Cache.#refused.has(context.key)) return false;
91
+ Cache.#refused.add(context.key);
92
+ const reason =
93
+ context.endpointInfo.type === "query"
94
+ ? "it takes an internal argument, so its answer is the caller's and not a shared one"
95
+ : `a ${context.endpointInfo.type} is never cached`;
96
+ context.adaptor.logger.warn(`"${context.key}" declares \`cache\` and cannot take one: ${reason}.`);
97
+ return false;
98
+ }
99
+ /** A cache backend that is down must not take the endpoint down with it: the call runs uncached instead. */
100
+ static async #read(context: SignalContext, cache: CacheAdaptor, key: string) {
101
+ try {
102
+ const cached = await cache.get<string>(Cache.#topic, key);
103
+ if (cached == null) return undefined;
104
+ return JSON.parse(cached) as unknown;
105
+ } catch (error) {
106
+ context.adaptor.logger.warn(`Cache read failed for ${context.key}: ${String(error)}`);
107
+
108
+ await cache.delete(Cache.#topic, key).catch(() => undefined);
109
+ return undefined;
110
+ }
111
+ }
112
+ static async #write(context: SignalContext, cache: CacheAdaptor, key: string, result: unknown, ttl: number) {
113
+ const serialized = JSON.stringify(result);
114
+
115
+ if (serialized === undefined) return;
116
+ try {
117
+ await cache.set(Cache.#topic, key, serialized, { expireAt: dayjs().add(ttl, "millisecond") });
118
+ } catch (error) {
119
+ context.adaptor.logger.warn(`Cache write failed for ${context.key}: ${String(error)}`);
120
+ }
91
121
  }
92
122
  }
93
123
 
94
- export class Retry extends middleware("retry") {
124
+ /**
125
+ * Bounds an endpoint that declared a `timeout`, and nothing else — this is registered by default, so a default
126
+ * of its own would put a deadline on every endpoint in the app that nobody asked for.
127
+ *
128
+ * XXX losing the race does not cancel the work: `next()` keeps running with nobody holding its result, so a
129
+ * handler that writes is still going to write. The deadline answers the caller; it does not undo the call.
130
+ */
131
+ export class Timeout extends middleware("timeout") {
95
132
  override async use() {
96
133
  return async (context: SignalContext, next: () => Promise<unknown>) => {
97
- const maxRetries = 3;
98
- let lastError: Error | null = null;
99
-
100
- for (let attempt = 0; attempt < maxRetries; attempt++) {
101
- try {
102
- return await next();
103
- } catch (error) {
104
- lastError = error instanceof Error ? error : new Error(String(error));
105
- console.warn(`[${context.key}] Retry ${attempt + 1}/${maxRetries}:`, lastError.message);
134
+ const timeout = context.endpointInfo.signalOption.timeout;
135
+ if (!timeout || !Number.isFinite(timeout) || timeout <= 0) return await next();
136
+ let timer: ReturnType<typeof setTimeout> | undefined;
137
+ try {
138
+ return await Promise.race([
139
+ next(),
106
140
 
107
- if (attempt < maxRetries - 1) {
108
-
109
- await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 100));
110
- }
111
- }
141
+ new Promise((_, reject) => {
142
+ timer = setTimeout(() => reject(new Exception(504, "base.error.gatewayTimeout")), timeout);
143
+ }),
144
+ ]);
145
+ } finally {
146
+
147
+ clearTimeout(timer);
112
148
  }
113
- throw lastError;
114
149
  };
115
150
  }
116
151
  }
@@ -70,6 +70,7 @@ export class FetchSerializer {
70
70
  ...(endpointInfo.signalOption.path ? { path: endpointInfo.signalOption.path } : {}),
71
71
  ...(endpointInfo.signalOption.method ? { method: endpointInfo.signalOption.method } : {}),
72
72
  ...(endpointInfo.signalOption.fileUpload ? { fileUpload: true } : {}),
73
+ ...(endpointInfo.signalOption.timeout ? { timeout: endpointInfo.signalOption.timeout } : {}),
73
74
  ...(guards?.length ? { guards } : {}),
74
75
  ...(endpointInfo.signalOption.mcp === false ? { mcp: false as const } : {}),
75
76
  ...(refusesAgents(endpointInfo.signalOption.guards) ? { agents: false as const } : {}),
@@ -154,10 +154,17 @@ export class SignalContext<
154
154
  throw new Exception.Forbidden(`Access denied by guard: ${GuardCls.name}`);
155
155
  }
156
156
  }
157
+ /**
158
+ * The endpoint's guards, for a middleware that answers a call without executing it — a cache hit skips
159
+ * `next()`, and `next()` is what would otherwise run them. Side-effect free, like the guards themselves.
160
+ */
161
+ async checkGuards() {
162
+ await this.#checkGuards();
163
+ }
157
164
  /**
158
165
  * Re-checks this context's guards outside of a request, for a websocket room that is already
159
166
  * subscribed. Only global middlewares run: they carry the account resolution this depends on,
160
- * while endpoint middlewares (cache/timeout/retry) would observe a call that never executes.
167
+ * while endpoint middlewares (cache/retry) would observe a call that never executes.
161
168
  */
162
169
  async authorize(): Promise<boolean> {
163
170
  try {
package/signal/types.ts CHANGED
@@ -81,8 +81,26 @@ export interface SignalOption<Response = any, Nullable extends boolean = false,
81
81
  default?: boolean;
82
82
  path?: string;
83
83
  serverMode?: "federation" | "batch" | "all";
84
+ /**
85
+ * How long this endpoint may take, in milliseconds. It bounds both ends: the `Timeout` middleware — registered
86
+ * by default — rejects the call with `base.error.gatewayTimeout` once it is spent, and the value is serialized
87
+ * to the client, where it becomes that call's request budget in place of the client default. Declared nowhere,
88
+ * neither side imposes one beyond the client's own default.
89
+ *
90
+ * Losing the race does not stop the work: the handler runs to completion with nobody holding its result.
91
+ */
84
92
  timeout?: number;
85
93
  partial?: _Key[] | readonly _Key[];
94
+ /**
95
+ * How long this endpoint's answer may be reused, in milliseconds. The `Cache` middleware — registered by
96
+ * default — keeps the handler's result under the endpoint's key and its arguments and serves it until it
97
+ * expires; declared nowhere, nothing is cached.
98
+ *
99
+ * **Only a `query` that takes no internal argument may carry one.** Internal arguments are how a call learns
100
+ * who is asking (`.with(Self)`), so an endpoint that has them answers per caller, and one shared entry would be
101
+ * one caller's answer handed to the next; such an endpoint is named in the log and left uncached. Guards still
102
+ * run on every hit — a shared answer is not a public one.
103
+ */
86
104
  cache?: number;
87
105
  guards?: GuardCls[];
88
106
  middlewares?: MiddlewareCls[];
@@ -180,6 +198,12 @@ export interface SerializedArg {
180
198
  export interface SerializedEndpoint extends SerializedSignalOption {
181
199
  type: "query" | "mutation" | "pubsub" | "message";
182
200
  returns: SerializedReturns;
201
+ /**
202
+ * The deadline the endpoint declared, in milliseconds. It travels because the client has to size its own
203
+ * request budget from it: a call the server is allowed to spend five minutes on is one the browser must not
204
+ * abandon after the client default.
205
+ */
206
+ timeout?: number;
183
207
  }
184
208
  export interface SerializedFilter {
185
209
  /** Every filter query the model declares, by key, with the args each one takes. */
@@ -5,7 +5,11 @@ export interface FetchPolicy<Returns = unknown> {
5
5
  onError?: (error: string) => void;
6
6
  token?: string;
7
7
  partial?: string[];
8
- timeout?: number;
8
+ /**
9
+ * Milliseconds before this call is abandoned, `false` to wait as long as the runtime will. Overrides the
10
+ * endpoint's declared `timeout`, which overrides the client's own default.
11
+ */
12
+ timeout?: number | false;
9
13
  /**
10
14
  * A `pubsub` subscription only: called after the room has been resubscribed following a dropped connection.
11
15
  *
@@ -55,6 +55,11 @@ export declare class FetchClient {
55
55
  };
56
56
  static resetSharedRegistry(): void;
57
57
  static resetSharedClient(): void;
58
+ /**
59
+ * The budget for every call that neither names one nor is served by an endpoint declaring one. `false` waits
60
+ * as long as the runtime will, which is the browser's own limit — minutes.
61
+ */
62
+ setTimeout(timeout?: number | false): void;
58
63
  setErrorConstructor(ErrorCls?: ErrorConstructor): void;
59
64
  applySignal(serializedSignal: {
60
65
  [key: string]: SerializedSignal;
@@ -88,11 +93,13 @@ export declare class FetchClient {
88
93
  fetch: any;
89
94
  }>(constant: object, serializedSignal: {
90
95
  [key: string]: SerializedSignal;
91
- }, { origin, connect, base, Err, }?: {
96
+ }, { origin, connect, base, Err, timeout, }?: {
92
97
  origin?: string;
93
98
  connect?: boolean;
94
99
  base?: FetchProxy;
95
100
  Err?: ErrorConstructor;
101
+ /** This app's own default request budget, for calls no endpoint and no caller gave one. */
102
+ timeout?: number | false;
96
103
  }): {
97
104
  sig: ClientSignalMap<SigType>;
98
105
  fetch: SigType["fetch"];
@@ -15,6 +15,8 @@ export declare class HttpClient {
15
15
  private ErrorCls?;
16
16
  constructor(baseUrl: string, options?: HttpClientOptions);
17
17
  setErrorConstructor(ErrorCls?: ErrorConstructor): void;
18
+ /** The budget every call that names none takes. `false` waits as long as the runtime will. */
19
+ setTimeout(timeout?: number | false): void;
18
20
  get<Returns = unknown>(url: string, options?: FetchOptions): Promise<Returns>;
19
21
  send<Returns = unknown>(method: HttpMutationMethod, url: string, data: FormData | Record<string, unknown>, options?: FetchOptions): Promise<Returns>;
20
22
  put<Returns = unknown>(url: string, data: FormData | Record<string, unknown>, options?: FetchOptions): Promise<Returns>;
@@ -27,8 +27,19 @@ declare const Cache_base: {
27
27
  };
28
28
  refName: string;
29
29
  };
30
+ /**
31
+ * Serves an endpoint's own answer back for as long as its declared `cache` allows, and stands aside for every
32
+ * endpoint that declared none — this is registered by default, so a TTL of its own would put a stale window on
33
+ * every call in the app.
34
+ *
35
+ * A cached answer is **shared**, which is why only a `query` taking no internal argument can carry one: internal
36
+ * arguments are how a call learns who is asking (`.with(Self)`), so an endpoint that has them answers per caller
37
+ * and one entry would be one caller's answer handed to the next. Guards are re-run on every hit regardless —
38
+ * shared is not public, and `next()`, which is what runs them, is exactly what a hit skips.
39
+ */
30
40
  export declare class Cache extends Cache_base {
31
- use(): Promise<(context: SignalContext, next: () => Promise<unknown>) => Promise<any>>;
41
+ #private;
42
+ use(): Promise<(context: SignalContext, next: () => Promise<unknown>) => Promise<unknown>>;
32
43
  }
33
44
  declare const Timeout_base: {
34
45
  new (): {
@@ -36,16 +47,14 @@ declare const Timeout_base: {
36
47
  };
37
48
  refName: string;
38
49
  };
50
+ /**
51
+ * Bounds an endpoint that declared a `timeout`, and nothing else — this is registered by default, so a default
52
+ * of its own would put a deadline on every endpoint in the app that nobody asked for.
53
+ *
54
+ * XXX losing the race does not cancel the work: `next()` keeps running with nobody holding its result, so a
55
+ * handler that writes is still going to write. The deadline answers the caller; it does not undo the call.
56
+ */
39
57
  export declare class Timeout extends Timeout_base {
40
58
  use(): Promise<(context: SignalContext, next: () => Promise<unknown>) => Promise<unknown>>;
41
59
  }
42
- declare const Retry_base: {
43
- new (): {
44
- use(env: BackendEnv): Promise<(context: SignalContext, next: () => Promise<unknown>) => Promise<unknown>>;
45
- };
46
- refName: string;
47
- };
48
- export declare class Retry extends Retry_base {
49
- use(): Promise<(context: SignalContext, next: () => Promise<unknown>) => Promise<unknown>>;
50
- }
51
60
  export {};
@@ -56,10 +56,15 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
56
56
  getAdaptor<T extends Adaptor>(adaptorCls: AdaptorCls<T>): T;
57
57
  getService<T>(refName: string): T;
58
58
  init(): Promise<this>;
59
+ /**
60
+ * The endpoint's guards, for a middleware that answers a call without executing it — a cache hit skips
61
+ * `next()`, and `next()` is what would otherwise run them. Side-effect free, like the guards themselves.
62
+ */
63
+ checkGuards(): Promise<void>;
59
64
  /**
60
65
  * Re-checks this context's guards outside of a request, for a websocket room that is already
61
66
  * subscribed. Only global middlewares run: they carry the account resolution this depends on,
62
- * while endpoint middlewares (cache/timeout/retry) would observe a call that never executes.
67
+ * while endpoint middlewares (cache/retry) would observe a call that never executes.
63
68
  */
64
69
  authorize(): Promise<boolean>;
65
70
  /**
@@ -69,8 +69,26 @@ export interface SignalOption<Response = any, Nullable extends boolean = false,
69
69
  default?: boolean;
70
70
  path?: string;
71
71
  serverMode?: "federation" | "batch" | "all";
72
+ /**
73
+ * How long this endpoint may take, in milliseconds. It bounds both ends: the `Timeout` middleware — registered
74
+ * by default — rejects the call with `base.error.gatewayTimeout` once it is spent, and the value is serialized
75
+ * to the client, where it becomes that call's request budget in place of the client default. Declared nowhere,
76
+ * neither side imposes one beyond the client's own default.
77
+ *
78
+ * Losing the race does not stop the work: the handler runs to completion with nobody holding its result.
79
+ */
72
80
  timeout?: number;
73
81
  partial?: _Key[] | readonly _Key[];
82
+ /**
83
+ * How long this endpoint's answer may be reused, in milliseconds. The `Cache` middleware — registered by
84
+ * default — keeps the handler's result under the endpoint's key and its arguments and serves it until it
85
+ * expires; declared nowhere, nothing is cached.
86
+ *
87
+ * **Only a `query` that takes no internal argument may carry one.** Internal arguments are how a call learns
88
+ * who is asking (`.with(Self)`), so an endpoint that has them answers per caller, and one shared entry would be
89
+ * one caller's answer handed to the next; such an endpoint is named in the log and left uncached. Guards still
90
+ * run on every hit — a shared answer is not a public one.
91
+ */
74
92
  cache?: number;
75
93
  guards?: GuardCls[];
76
94
  middlewares?: MiddlewareCls[];
@@ -168,6 +186,12 @@ export interface SerializedArg {
168
186
  export interface SerializedEndpoint extends SerializedSignalOption {
169
187
  type: "query" | "mutation" | "pubsub" | "message";
170
188
  returns: SerializedReturns;
189
+ /**
190
+ * The deadline the endpoint declared, in milliseconds. It travels because the client has to size its own
191
+ * request budget from it: a call the server is allowed to spend five minutes on is one the browser must not
192
+ * abandon after the client default.
193
+ */
194
+ timeout?: number;
171
195
  }
172
196
  export interface SerializedFilter {
173
197
  /** Every filter query the model declares, by key, with the args each one takes. */
@@ -1,8 +1,24 @@
1
1
  import type { ReactNode } from "react";
2
2
  export interface SpinProps {
3
+ className?: string;
4
+ /**
5
+ * Replaces the built-in icon. It carries its own color; the rotation is the wrapper's
6
+ * (`[&>svg]:animate-spin`), so the node needs no `animate-spin` of its own.
7
+ */
3
8
  indicator?: ReactNode;
4
9
  isCenter?: boolean;
5
- className?: string;
6
- size?: "sm" | "md" | "lg";
10
+ /** A named step, or the pixel size the icon is drawn at. */
11
+ size?: "sm" | "md" | "lg" | number;
12
+ /**
13
+ * What the built-in icon is colored with. `"current"` inherits the surface's own foreground, which is what a
14
+ * filled surface needs — `text-primary/70` is legible on the app background and vanishes on a `bg-info` badge
15
+ * or a primary button. Every tone loses to a `text-*` in `className`.
16
+ */
17
+ tone?: "primary" | "current" | "muted";
7
18
  }
8
- export declare const Spin: ({ indicator, isCenter, className, size }: SpinProps) => import("react/jsx-runtime").JSX.Element;
19
+ /**
20
+ * The color and the size sit on the wrapper, not on the icon: the icon is drawn at `1em` in `currentColor`, so
21
+ * both cascade to it — and `className`, which is also the wrapper's, is merged last and therefore wins. On the
22
+ * icon they would have been unreachable, which is what made every `text-*` and `size-*` a caller passed a no-op.
23
+ */
24
+ export declare const Spin: ({ className, indicator, isCenter, size, tone }: SpinProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,4 +1,4 @@
1
- export { AgentProvider, type AgentProviderProps, type AgentRunner, AgentSession, type AgentSessionOptions, type ChatMessage, type CompactOptions, type ContextBlock, httpRunner, type PublishedTool, type RunnerEvent, type RunnerRequest, SessionContext, type SessionHistory, type SurfaceView, useAgent, } from "../vendor/use-agentic.d.ts";
1
+ export { AgentProvider, type AgentProviderProps, type AgentRunner, AgentSession, type AgentSessionOptions, type ChatMessage, type CompactOptions, type ContextBlock, httpRunner, type MessageAttachment, type PublishedTool, type RunnerEvent, type RunnerRequest, SessionContext, type SessionHistory, type SurfaceView, useAgent, } from "../vendor/use-agentic.d.ts";
2
2
  export { Agent } from "./Agent.d.ts";
3
3
  export { type ApprovalProps, DefaultApproval } from "./Agent/Approval.d.ts";
4
4
  export { type AgentSessionSetup, agentSessionOf } from "./Agent/agentSessionOf.d.ts";
@@ -10,9 +10,10 @@ import type { ChatMessage } from "./types.d.ts";
10
10
  * transcript is assembled rather than where each hole is made.
11
11
  */
12
12
  export declare class Transcript {
13
- #private;
14
13
  static readonly unanswered = "The turn was stopped before this call ran.";
15
14
  /** What one turn posts: no host-only message, no unanswered call, no result answering a call nobody sees. */
16
15
  static wire(messages: readonly ChatMessage[]): ChatMessage[];
17
16
  static sanitize(messages: readonly ChatMessage[]): ChatMessage[];
17
+ /** An empty assistant message is a draft a reload or an abort caught before it said anything. */
18
+ static carries(message: ChatMessage): boolean;
18
19
  }
@@ -40,6 +40,7 @@ export const Attach = ({ className, label, onPick }: AttachProps) => {
40
40
 
41
41
  interface ChipsProps {
42
42
  className?: string;
43
+
43
44
  attachments: readonly MessageAttachment[];
44
45
  /** Omitted for a sent message: what is already on the wire cannot be taken back. */
45
46
  onRemove?: (index: number) => void;
@@ -56,7 +57,7 @@ export const Chips = ({ className, attachments, onRemove, removeLabel, pending =
56
57
  className="flex items-center gap-1 rounded-field bg-muted px-2 py-0.5 text-xs"
57
58
  key={`${attachment.name}-${idx}`}
58
59
  >
59
- {attachment.data && attachment.mimeType.startsWith("image/") ? (
60
+ {attachment.data && attachment.mimeType?.startsWith("image/") ? (
60
61
  <img
61
62
  alt={attachment.name}
62
63
  className="size-6 rounded-field object-cover"
@@ -3,26 +3,46 @@ import type { ReactNode } from "react";
3
3
  import { AiOutlineLoading3Quarters } from "react-icons/ai";
4
4
 
5
5
  export interface SpinProps {
6
+ className?: string;
7
+ /**
8
+ * Replaces the built-in icon. It carries its own color; the rotation is the wrapper's
9
+ * (`[&>svg]:animate-spin`), so the node needs no `animate-spin` of its own.
10
+ */
6
11
  indicator?: ReactNode;
7
12
  isCenter?: boolean;
8
- className?: string;
9
- size?: "sm" | "md" | "lg";
13
+ /** A named step, or the pixel size the icon is drawn at. */
14
+ size?: "sm" | "md" | "lg" | number;
15
+ /**
16
+ * What the built-in icon is colored with. `"current"` inherits the surface's own foreground, which is what a
17
+ * filled surface needs — `text-primary/70` is legible on the app background and vanishes on a `bg-info` badge
18
+ * or a primary button. Every tone loses to a `text-*` in `className`.
19
+ */
20
+ tone?: "primary" | "current" | "muted";
10
21
  }
11
22
 
12
23
  const sizeClass = { sm: "text-sm", md: "text-xl", lg: "text-3xl" } as const;
24
+ const toneClass = { primary: "text-primary/70", current: "", muted: "text-muted-foreground" } as const;
13
25
 
14
- export const Spin = ({ indicator, isCenter, className, size = "md" }: SpinProps) => (
26
+ /**
27
+ * The color and the size sit on the wrapper, not on the icon: the icon is drawn at `1em` in `currentColor`, so
28
+ * both cascade to it — and `className`, which is also the wrapper's, is merged last and therefore wins. On the
29
+ * icon they would have been unreachable, which is what made every `text-*` and `size-*` a caller passed a no-op.
30
+ */
31
+ export const Spin = ({ className, indicator, isCenter, size = "md", tone = "primary" }: SpinProps) => (
15
32
  <div
16
33
  className={cn(
17
34
  "inline-block py-1",
35
+ !indicator && toneClass[tone],
36
+ typeof size === "string" && sizeClass[size],
18
37
  isCenter && "absolute inset-0 flex size-full items-center justify-center py-0",
19
38
  className,
20
39
  )}
40
+ style={typeof size === "number" ? { fontSize: size } : undefined}
21
41
  >
22
42
  {indicator ? (
23
43
  <span className="[&>svg]:animate-spin">{indicator}</span>
24
44
  ) : (
25
- <AiOutlineLoading3Quarters className={cn("animate-spin text-primary/70", sizeClass[size])} />
45
+ <AiOutlineLoading3Quarters className="animate-spin" />
26
46
  )}
27
47
  </div>
28
48
  );
package/ui/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  type CompactOptions,
11
11
  type ContextBlock,
12
12
  httpRunner,
13
+ type MessageAttachment,
13
14
  type PublishedTool,
14
15
  type RunnerEvent,
15
16
  type RunnerRequest,
@@ -293,6 +293,9 @@ export class AgentSession {
293
293
  this.#pending = null;
294
294
  this.#question = null;
295
295
  this.#progress = null;
296
+
297
+ const draft = this.#messages[this.#messages.length - 1];
298
+ if (draft?.role === "assistant" && !Transcript.carries(draft)) this.#messages = this.#messages.slice(0, -1);
296
299
  this.#notify();
297
300
  }
298
301
  }
@@ -28,7 +28,7 @@ export class Transcript {
28
28
  if (results.length) kept.push({ ...message, toolResults: results });
29
29
  continue;
30
30
  }
31
- if (message.role === "assistant" && !Transcript.#carries(message)) continue;
31
+ if (message.role === "assistant" && !Transcript.carries(message)) continue;
32
32
  kept.push(message);
33
33
  const calls = message.toolCalls ?? [];
34
34
  for (const call of calls) called.add(call.id);
@@ -44,7 +44,7 @@ export class Transcript {
44
44
  }
45
45
 
46
46
  /** An empty assistant message is a draft a reload or an abort caught before it said anything. */
47
- static #carries(message: ChatMessage) {
47
+ static carries(message: ChatMessage) {
48
48
  return !!message.text || !!message.error || !!message.toolCalls?.length || !!message.attachments?.length;
49
49
  }
50
50
  }