doover-js 0.6.1 → 0.6.2

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.
@@ -32,6 +32,14 @@ export declare class AgentsApi {
32
32
  private readonly controlApiUrl?;
33
33
  constructor(rest: RestClient, controlApiUrl?: string | undefined);
34
34
  listAgents(options?: GetAgentsOptions): Promise<AgentsResponse>;
35
+ /**
36
+ * Batch-fetch recent messages for many agents. Auto-chunks `agent_id`
37
+ * (and the parallel `agent_before` cursors, if given) at MULTI_AGENT_CHUNK_SIZE
38
+ * so each request's URL stays under CloudFront's 8,192-byte quota. Chunks
39
+ * are fetched in parallel and merged: `results`/`count` concat-and-sum,
40
+ * `next_cursors`/`at_limit_agent_ids` union, legacy `next` becomes the
41
+ * lexically-highest cursor across at-limit agents.
42
+ */
35
43
  getMultiAgentMessages(channelName: string, params: MultiAgentMessagesParams): Promise<BatchMessagesResponse>;
36
44
  /**
37
45
  * Batch-fetch aggregates for many agents. Auto-chunks `agent_id` so each
@@ -144,12 +144,65 @@ class AgentsApi {
144
144
  }
145
145
  return { ...raw, agents: merged, results: merged, count: merged.length };
146
146
  }
147
+ /**
148
+ * Batch-fetch recent messages for many agents. Auto-chunks `agent_id`
149
+ * (and the parallel `agent_before` cursors, if given) at MULTI_AGENT_CHUNK_SIZE
150
+ * so each request's URL stays under CloudFront's 8,192-byte quota. Chunks
151
+ * are fetched in parallel and merged: `results`/`count` concat-and-sum,
152
+ * `next_cursors`/`at_limit_agent_ids` union, legacy `next` becomes the
153
+ * lexically-highest cursor across at-limit agents.
154
+ */
147
155
  async getMultiAgentMessages(channelName, params) {
148
- const response = await this.rest.get(`/agents/channels/${channelName}/messages`, params);
149
- return {
156
+ const path = `/agents/channels/${channelName}/messages`;
157
+ const { agent_id, agent_before, ...rest } = params;
158
+ const stampTimestamps = (response) => ({
150
159
  ...response,
151
160
  results: response.results.map((message) => "timestamp" in message ? message : (0, snowflake_1.addTimestampToMessage)(message)),
161
+ });
162
+ if (agent_id.length <= MULTI_AGENT_CHUNK_SIZE) {
163
+ const response = await this.rest.get(path, params);
164
+ return stampTimestamps(response);
165
+ }
166
+ if (agent_before && agent_before.length !== agent_id.length) {
167
+ throw new Error("agent_before must be the same length as agent_id when set");
168
+ }
169
+ const chunks = [];
170
+ for (let i = 0; i < agent_id.length; i += MULTI_AGENT_CHUNK_SIZE) {
171
+ chunks.push({
172
+ agent_id: agent_id.slice(i, i + MULTI_AGENT_CHUNK_SIZE),
173
+ ...(agent_before
174
+ ? { agent_before: agent_before.slice(i, i + MULTI_AGENT_CHUNK_SIZE) }
175
+ : {}),
176
+ });
177
+ }
178
+ const responses = await Promise.all(chunks.map((chunk) => this.rest
179
+ .get(path, { ...rest, ...chunk })
180
+ .then(stampTimestamps)));
181
+ // Merge: results/count concat-and-sum; next_cursors merge; at_limit_agent_ids
182
+ // concat (both keyed by agent id, unique across chunks). Legacy `next` is the
183
+ // lexically-highest oldest-cursor across at-limit agents — snowflakes sort
184
+ // lexically since they're equal-width digit strings.
185
+ const merged = {
186
+ results: responses.flatMap((r) => r.results),
187
+ count: responses.reduce((acc, r) => acc + r.count, 0),
152
188
  };
189
+ const nexts = responses
190
+ .map((r) => r.next)
191
+ .filter((n) => typeof n === "string" && n.length > 0);
192
+ if (nexts.length > 0) {
193
+ merged.next = nexts.reduce((a, b) => (a > b ? a : b));
194
+ }
195
+ const cursors = {};
196
+ for (const r of responses) {
197
+ if (r.next_cursors)
198
+ Object.assign(cursors, r.next_cursors);
199
+ }
200
+ if (Object.keys(cursors).length > 0)
201
+ merged.next_cursors = cursors;
202
+ const atLimit = responses.flatMap((r) => r.at_limit_agent_ids ?? []);
203
+ if (atLimit.length > 0)
204
+ merged.at_limit_agent_ids = atLimit;
205
+ return merged;
153
206
  }
154
207
  /**
155
208
  * Batch-fetch aggregates for many agents. Auto-chunks `agent_id` so each
@@ -5,7 +5,19 @@ export declare function multiAgentChannelMessagesQueryKey(channelName: string, a
5
5
  fields?: readonly string[];
6
6
  }): readonly ["doover", "channel", string, "messages", string, "src", string] | readonly ["doover", "channel", string, "messages", string, "src", string, Record<string, unknown>];
7
7
  export interface UseMultiAgentChannelMessagesOptions {
8
+ /**
9
+ * Global cap on the total number of messages returned across all agents
10
+ * for a single request. The server distributes this budget across agents
11
+ * in newest-first order, so a noisy agent can starve quieter ones.
12
+ * Prefer `agentMessageLimit` when you want a fair per-agent slice.
13
+ */
8
14
  limit?: number;
15
+ /**
16
+ * Per-agent cap on messages returned. Forwarded as `agent_message_limit`.
17
+ * Use this when fanning out across many agents that have wildly different
18
+ * message rates and you want each agent's window represented.
19
+ */
20
+ agentMessageLimit?: number;
9
21
  /** If false, skip live subscriptions per agent. Defaults true. */
10
22
  liveUpdates?: boolean;
11
23
  /**
@@ -34,6 +34,7 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
34
34
  const client = (0, context_1.useDooverClient)();
35
35
  const queryClient = (0, react_query_1.useQueryClient)();
36
36
  const limit = options?.limit;
37
+ const agentMessageLimit = options?.agentMessageLimit;
37
38
  const liveUpdates = options?.liveUpdates ?? true;
38
39
  const fields = options?.fields;
39
40
  const initialBefore = options?.initialBefore;
@@ -59,8 +60,12 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
59
60
  };
60
61
  });
61
62
  },
63
+ // `key` captures every scope dim (agentIds + sources + after + fields);
64
+ // serialise the whole thing so the closure refreshes when any dim
65
+ // changes — listing dims by hand drops live updates whenever the list
66
+ // grows (we used to omit `after`/`fields`).
62
67
  // eslint-disable-next-line react-hooks/exhaustive-deps
63
- [queryClient, channelName, agentIds.join(","), sources?.join(",")]);
68
+ [queryClient, JSON.stringify(key)]);
64
69
  (0, react_1.useEffect)(() => {
65
70
  if (!liveUpdates || agentIds.length === 0)
66
71
  return;
@@ -88,6 +93,9 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
88
93
  agent_id: agentIds,
89
94
  ...(typeof pageParam === "string" ? { before: pageParam } : {}),
90
95
  ...(limit !== undefined ? { limit } : {}),
96
+ ...(agentMessageLimit !== undefined
97
+ ? { agent_message_limit: agentMessageLimit }
98
+ : {}),
91
99
  ...(fields && fields.length > 0 ? { field_name: fields } : {}),
92
100
  ...(after !== undefined ? { after } : {}),
93
101
  };
@@ -82,6 +82,82 @@ function setupRest(responseFactory) {
82
82
  const returnedIds = aggregates.results.map((r) => r.agent_id).sort();
83
83
  (0, chai_1.expect)(returnedIds).to.deep.equal([...agentIds].sort());
84
84
  });
85
+ (0, mocha_1.it)("chunks getMultiAgentMessages over the per-request agent cap", async () => {
86
+ const baseTs = new Date("2026-01-01T00:00:00Z");
87
+ const { rest, fetchMock } = setupRest((url) => {
88
+ const ids = [...new URL(url).searchParams.getAll("agent_id")];
89
+ return (0, helpers_1.createJsonResponse)({
90
+ results: ids.map((id, i) => ({
91
+ id: (0, snowflake_1.generateSnowflakeIdAtTime)(new Date(baseTs.getTime() + i)),
92
+ author_id: "u",
93
+ channel: { agent_id: id, name: "c1" },
94
+ data: {},
95
+ attachments: [],
96
+ })),
97
+ count: ids.length,
98
+ });
99
+ });
100
+ const api = new agents_api_1.AgentsApi(rest);
101
+ // 600 agents → 3 chunks of 250/250/100.
102
+ const agentIds = Array.from({ length: 600 }, (_, i) => `a${i}`);
103
+ const messages = await api.getMultiAgentMessages("c1", {
104
+ agent_id: agentIds,
105
+ agent_message_limit: 50,
106
+ });
107
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(3);
108
+ (0, chai_1.expect)(messages.results).to.have.length(600);
109
+ (0, chai_1.expect)(messages.count).to.equal(600);
110
+ const returnedIds = messages.results
111
+ .map((m) => m.channel.agent_id)
112
+ .sort();
113
+ (0, chai_1.expect)(returnedIds).to.deep.equal([...agentIds].sort());
114
+ // Per-request param survives chunking.
115
+ for (let i = 0; i < 3; i++) {
116
+ const url = fetchMock.getCall(i).args[0];
117
+ (0, chai_1.expect)(url).to.include("agent_message_limit=50");
118
+ }
119
+ });
120
+ (0, mocha_1.it)("merges next_cursors and at_limit_agent_ids across chunks", async () => {
121
+ let call = 0;
122
+ const cursors = [
123
+ { "a0": "100", "a1": "200" },
124
+ { "a250": "150" },
125
+ ];
126
+ const atLimit = [["a0", "a1"], ["a250"]];
127
+ const { rest } = setupRest((url) => {
128
+ const idx = call++;
129
+ const ids = [...new URL(url).searchParams.getAll("agent_id")];
130
+ return (0, helpers_1.createJsonResponse)({
131
+ results: ids.map((id) => ({
132
+ id: (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2026-01-01T00:00:00Z")),
133
+ author_id: "u",
134
+ channel: { agent_id: id, name: "c1" },
135
+ data: {},
136
+ attachments: [],
137
+ })),
138
+ count: ids.length,
139
+ next: idx === 0 ? "200" : "150",
140
+ next_cursors: cursors[idx],
141
+ at_limit_agent_ids: atLimit[idx],
142
+ });
143
+ });
144
+ const api = new agents_api_1.AgentsApi(rest);
145
+ const agentIds = Array.from({ length: 300 }, (_, i) => `a${i}`);
146
+ const messages = await api.getMultiAgentMessages("c1", {
147
+ agent_id: agentIds,
148
+ });
149
+ (0, chai_1.expect)(messages.next).to.equal("200"); // max across chunks
150
+ (0, chai_1.expect)(messages.next_cursors).to.deep.equal({
151
+ a0: "100",
152
+ a1: "200",
153
+ a250: "150",
154
+ });
155
+ (0, chai_1.expect)(messages.at_limit_agent_ids?.sort()).to.deep.equal([
156
+ "a0",
157
+ "a1",
158
+ "a250",
159
+ ]);
160
+ });
85
161
  (0, mocha_1.it)("covers channels methods", async () => {
86
162
  const { rest, fetchMock } = setupRest(() => (0, helpers_1.createJsonResponse)([]));
87
163
  const api = new channels_api_1.ChannelsApi(rest);
@@ -27,7 +27,7 @@ export interface AuditRequestContext {
27
27
  user_agent?: string;
28
28
  }
29
29
  /** Origin system for the event. */
30
- export type AuditSource = "control" | "fusionauth" | "data" | "tunnels";
30
+ export type AuditSource = "control" | "auth" | "data" | "tunnels";
31
31
  /**
32
32
  * String-literal union of every wired audit action — kept in lock-step with
33
33
  * ``doover_control/events/types.py``'s ``EventType`` enum. Future event types
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doover-js",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "TypeScript client for Doover.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",