@jskit-ai/assistant-core 0.2.43 → 0.2.45

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
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Each tool set `createServiceToolCatalog` resolves carries its catalogue's `limits` (`maxToolArgumentBytes` and `maxToolResultBytes`), and an application conversation's tools hold calls to that tool set's `maxToolArgumentBytes` and give engines that value as `maximumArgumentBytes`, instead of reading the catalogue's own `limits`. A catalogue that resolves each surface's tool set from a catalogue of its own, as `@jskit-ai/assistant-runtime`'s does, now has each surface's argument limit enforced; its result limit already was, by that surface's catalogue. **Breaking** for a hand-written tool catalogue passed as `toolCatalog`: its `resolveToolSet` returns `limits: { maxToolArgumentBytes }` with its tools.
6
+ - A Claude goal's clock counts from its first start: Resume and restart recovery send the same unfinished objective again, which Claude starts anew, so the clock went back to zero after every Pause and Resume and after a server restart. The Claude history keeps that first start, also across the clear that suspends a paused goal's checker, and the goal value carries it as `startedAt` (ISO), which `AssistantGoalControl` counts its wall-clock time from (else `createdAt`). A goal finished or cancelled and then set again starts a new clock.
7
+ - The goal menu is 360 pixels wide, or the screen's width less the menu's 12-pixel margins on a narrower phone (the menu's `width` and `max-width`), so the new-goal objective box fills it instead of shrinking to its label.
5
8
  - `assistantConversationListItemOutputValidator` (from `@jskit-ai/assistant-core/shared`) is one conversation of `assistantContract`'s `conversationsList`, the record without its `metadata`; the contract's list output is built from it, and `@jskit-ai/assistant-runtime`'s conversations list route serializes its items with it.
6
9
  - `createConversationStorage`'s `write` saves nothing when its callback called no write method: the adapter's `writeRecord` does not run, so file storage no longer rewrites and flushes the whole conversation file, or creates an empty one for a missing conversation, when a write only looked (a delivery check, a message already saved), and the saved record stays cached.
7
10
  - Checking or recovering one message's delivery (`inspectDelivery`, and a send that finds its message already saved) finds that message's turn by its id instead of reading and cloning every turn of the conversation to find it.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.43",
11
+ "@jskit-ai/assistant-core": "0.2.45",
12
12
  "@jskit-ai/http-runtime": "0.2.20",
13
13
  "@jskit-ai/kernel": "0.2.20",
14
14
  "@jskit-ai/material-web": "0.1.5",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.43",
3
+ "version": "0.2.45",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -34,11 +34,16 @@ const statusLabel = computed(() => goal.value?.status === "usageLimited" && goal
34
34
  const label = computed(() => props.state.loading ? t("jskit.assistant.goal.loading") : props.state.available === false ? t("jskit.assistant.goal.unavailable")
35
35
  : starting.value ? t("jskit.assistant.goal.starting") : statusLabel.value);
36
36
  const nativeElapsed = computed(() => Number.isFinite(goal.value?.elapsedSeconds));
37
- const createdAt = computed(() => typeof goal.value?.createdAt === "string" ? Date.parse(goal.value.createdAt) : NaN);
37
+ // A goal resumed by sending it again (Claude) keeps the time its first start
38
+ // gave it, so its clock carries on across Pause, Resume and a server restart.
39
+ const startedAt = computed(() => {
40
+ const value = goal.value?.startedAt ?? goal.value?.createdAt;
41
+ return typeof value === "string" ? Date.parse(value) : NaN;
42
+ });
38
43
  const elapsedLabel = computed(() => nativeElapsed.value ? t("jskit.assistant.goal.runningTime") : t("jskit.assistant.goal.elapsedSinceStart"));
39
44
  const elapsedText = computed(() => elapsed.value ? t("jskit.assistant.goal.elapsed", { label: elapsedLabel.value, elapsed: elapsed.value }) : "");
40
45
  const clockRunning = computed(() => nativeElapsed.value ? running.value && Number.isFinite(goal.value?.sampledAt)
41
- : Boolean(goal.value && goal.value.status !== "complete" && Number.isFinite(createdAt.value)));
46
+ : Boolean(goal.value && goal.value.status !== "complete" && Number.isFinite(startedAt.value)));
42
47
  const elapsed = computed(() => {
43
48
  let secondsUsed;
44
49
  if (nativeElapsed.value) {
@@ -47,8 +52,8 @@ const elapsed = computed(() => {
47
52
  secondsUsed = goal.value.elapsedSeconds + sinceSample;
48
53
  } else {
49
54
  const end = goal.value?.status === "complete" ? goal.value.sampledAt : now.value;
50
- if (!Number.isFinite(createdAt.value) || !Number.isFinite(end)) return "";
51
- secondsUsed = (end - createdAt.value) / 1000;
55
+ if (!Number.isFinite(startedAt.value) || !Number.isFinite(end)) return "";
56
+ secondsUsed = (end - startedAt.value) / 1000;
52
57
  }
53
58
  const total = Math.max(0, Math.floor(secondsUsed));
54
59
  const seconds = String(total % 60).padStart(2, "0");
@@ -64,7 +69,7 @@ const chipLabel = computed(() => props.state.loading || props.state.available ==
64
69
  const canSet = computed(() => !props.state.loading && props.state.available !== false && typeof props.state.set === "function" && (!goal.value || goal.value.status === "complete"));
65
70
  const validBudget = computed(() => props.state.tokenBudgetSupported === false || tokenBudget.value === "" || (Number.isSafeInteger(Number(tokenBudget.value)) && Number(tokenBudget.value) > 0));
66
71
  let timer;
67
- onMounted(() => watch(() => [props.state.enabled, clockRunning.value, goal.value?.elapsedSeconds, goal.value?.sampledAt, createdAt.value], () => {
72
+ onMounted(() => watch(() => [props.state.enabled, clockRunning.value, goal.value?.elapsedSeconds, goal.value?.sampledAt, startedAt.value], () => {
68
73
  clearInterval(timer);
69
74
  now.value = Date.now();
70
75
  if (props.state.enabled !== false && clockRunning.value) {
@@ -86,7 +91,7 @@ async function cancelGoal() {
86
91
  </script>
87
92
 
88
93
  <template>
89
- <v-menu v-if="state && state.enabled !== false" v-model="open" location="top" :close-on-content-click="false">
94
+ <v-menu v-if="state && state.enabled !== false" v-model="open" location="top" width="360" max-width="calc(100vw - 24px)" :close-on-content-click="false">
90
95
  <template #activator="{ props: menuProps }">
91
96
  <v-btn v-bind="menuProps" class="assistant-goal" size="small" variant="text" :aria-label="label" :title="[label, elapsedText, goal?.objective].filter(Boolean).join('\n')">
92
97
  <span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--working': working, 'assistant-goal__light--paused': goal.status === 'paused' }" aria-hidden="true" />
@@ -94,7 +99,7 @@ async function cancelGoal() {
94
99
  <span v-if="elapsed && running" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
95
100
  </v-btn>
96
101
  </template>
97
- <v-card class="assistant-goal__details pa-3" max-width="360">
102
+ <v-card class="assistant-goal__details pa-3">
98
103
  <strong role="status">{{ label }}</strong>
99
104
  <v-btn v-if="(running || (state.pauseBlocked && ['blocked', 'usageLimited'].includes(goal?.status))) && state.pause" size="small" :disabled="state.stopPending" @click="state.pause()">{{ state.action === 'pause' ? t('jskit.assistant.goal.pausing') : t('jskit.assistant.goal.pause') }}</v-btn>
100
105
  <v-btn v-if="['paused', 'blocked', 'usageLimited'].includes(goal?.status) && state.resume" size="small" :disabled="state.pending" @click="state.resume()">{{ state.action === 'resume' ? t('jskit.assistant.goal.resuming') : t('jskit.assistant.goal.resume') }}</v-btn>
@@ -48,7 +48,9 @@
48
48
  * @property {boolean} [cancelInterruptsTurn]
49
49
  * @property {boolean} [tokenBudgetSupported]
50
50
  * @property {string} [error]
51
- * @property {{ status: string, objective?: string, elapsedSeconds?: number, sampledAt?: number, createdAt?: string } | null} [goal]
51
+ * @property {{ status: string, objective?: string, elapsedSeconds?: number, sampledAt?: number, createdAt?: string,
52
+ * startedAt?: string } | null} [goal] Without a native running time, its clock counts from startedAt (when the
53
+ * unfinished goal first started, through any Resume), else createdAt.
52
54
  * @property {((input: { objective: string, tokenBudget?: number }) => unknown) | null} [set]
53
55
  * Starts a goal; absent while starting one is not allowed, so Start stays disabled.
54
56
  * @property {() => unknown} [pause]
@@ -212,6 +212,9 @@ async function readClaudeHistory(options) {
212
212
  const userIds = [];
213
213
  let lastUserId = "";
214
214
  let goal = null;
215
+ // The unfinished goal a clear removed: a paused goal's checker is cleared
216
+ // before its Resume sends the same objective again.
217
+ let clearedGoal = null;
215
218
  let goalStartId = "";
216
219
  let goalCommandId = "";
217
220
  let frames = [];
@@ -260,6 +263,7 @@ async function readClaudeHistory(options) {
260
263
  !frame.parent_tool_use_id && !frame.isMeta && frame.commandRun?.command === "goal" &&
261
264
  frame.commandRun.args === "clear" && typeof frame.content === "string" &&
262
265
  /^<local-command-stdout>(?:No goal set|Goal cleared: [\s\S]+)<\/local-command-stdout>$/u.test(frame.content)) {
266
+ if (goal && goal.status !== "complete") clearedGoal = goal;
263
267
  goal = null;
264
268
  goalStartId = "";
265
269
  const command = byId.get(frame.parentUuid);
@@ -282,15 +286,22 @@ async function readClaudeHistory(options) {
282
286
  goalStartId = status.sentinel === true && typeof status.met === "boolean" ? frame.uuid || "" : "";
283
287
  goalCommandId = "";
284
288
  }
285
- if (status.met === true && status.sentinel === true) goal = null;
286
- else if (status.met === false || status.met === true) {
289
+ if (status.met === true && status.sentinel === true) {
290
+ if (goal && goal.status !== "complete") clearedGoal = goal;
291
+ goal = null;
292
+ } else if (status.met === false || status.met === true) {
287
293
  const timestamp = Date.parse(frame.timestamp) / 1000;
294
+ const createdAt = goal?.objective === status.condition && !status.sentinel ? goal.createdAt : timestamp;
295
+ // Resume sends the same objective again and Claude starts it anew;
296
+ // the unfinished goal still started with its first command.
297
+ const previous = goal || clearedGoal;
288
298
  goal = {
289
- threadId: options.conversationId, objective: status.condition,
290
- createdAt: goal?.objective === status.condition && !status.sentinel ? goal.createdAt : timestamp,
299
+ threadId: options.conversationId, objective: status.condition, createdAt,
300
+ startedAt: previous?.objective === status.condition && previous.status !== "complete" ? previous.startedAt : createdAt,
291
301
  status: status.met ? "complete" : status.failed === true ? "blocked" : "active", updatedAt: timestamp,
292
302
  reason: typeof status.reason === "string" ? status.reason : ""
293
303
  };
304
+ clearedGoal = null;
294
305
  }
295
306
  }
296
307
  // This is native terminal evidence, not an inference from the assistant's
@@ -19,6 +19,7 @@ const nativeInput = input => input.content?.length ? [...(input.text ? [{ type:
19
19
  const claudeGoalValue = (goal, conversationId) => goal ? { ...goal,
20
20
  id: hash([conversationId, goal.createdAt, goal.objective]),
21
21
  ...(Number.isFinite(goal.createdAt) ? { createdAt: new Date(goal.createdAt * 1000).toISOString() } : {}),
22
+ ...(Number.isFinite(goal.startedAt) ? { startedAt: new Date(goal.startedAt * 1000).toISOString() } : {}),
22
23
  ...(Number.isFinite(goal.updatedAt) ? { updatedAt: new Date(goal.updatedAt * 1000).toISOString() } : {})
23
24
  } : null;
24
25
  const goalCapabilities = Object.freeze({ goals: true, goalBudgets: false,
@@ -17,7 +17,7 @@ const TRANSIENT_HOOKS = new Set([authorizeTransientCall, saveTransientCalls, ann
17
17
  * One turn's application calls. Native/API adapters only transport these calls.
18
18
  * @param {object} options
19
19
  * @param {import("../types.js").ServiceToolCatalog} options.catalog
20
- * @param {{ tools: object[] }} [options.toolSet] A tool set resolved earlier for this turn.
20
+ * @param {{ tools: object[], limits: { maxToolArgumentBytes: number } }} [options.toolSet] A tool set resolved earlier for this turn.
21
21
  * @param {object} options.context The caller context the calls run as.
22
22
  * @param {() => Promise<object>} [options.prepareContext] Maps the context again before each call.
23
23
  * @param {AbortSignal} options.signal
@@ -42,6 +42,8 @@ export function createConversationTools({ catalog, toolSet: retainedToolSet, con
42
42
  // readable-check: allow method-probe - input validation: an optional context mapper must be a function
43
43
  if (prepareContext !== undefined && typeof prepareContext !== "function") throw new TypeError("Application tool context requires a server-owned mapper.");
44
44
  const toolSet = retainedToolSet ?? catalog.resolveToolSet(context, { discoveryOnly });
45
+ // The tool set's own limit: a surface-aware catalogue resolves each surface's set with that surface's.
46
+ const maximumArgumentBytes = toolSet.limits.maxToolArgumentBytes;
45
47
  const calls = new Map(structuredClone(previousCalls).map(call => [call.id, call]));
46
48
  let pending = Promise.resolve();
47
49
  let requested = 0;
@@ -61,7 +63,7 @@ export function createConversationTools({ catalog, toolSet: retainedToolSet, con
61
63
  signal.throwIfAborted();
62
64
  if (typeof id !== "string" || !id || id.length > 256 || typeof name !== "string" || !name || name.length > 256 ||
63
65
  typeof argumentsText !== "string") throw new TypeError("An application tool requires its call id, name and JSON arguments.");
64
- if (Buffer.byteLength(argumentsText) > catalog.limits.maxToolArgumentBytes) throw Object.assign(new Error("The application tool arguments exceed their size limit."), {
66
+ if (Buffer.byteLength(argumentsText) > maximumArgumentBytes) throw Object.assign(new Error("The application tool arguments exceed their size limit."), {
65
67
  messageCode: "assistant.conversation.toolArgumentsTooLarge", messageParams: {}
66
68
  });
67
69
  const input = { id, name, arguments: argumentsText };
@@ -117,7 +119,7 @@ export function createConversationTools({ catalog, toolSet: retainedToolSet, con
117
119
  descriptors: toolSet.tools,
118
120
  schemas: toolSet.tools.map(catalog.toOpenAiToolSchema),
119
121
  execute,
120
- maximumArgumentBytes: catalog.limits.maxToolArgumentBytes,
122
+ maximumArgumentBytes,
121
123
  records
122
124
  });
123
125
  }
@@ -381,6 +381,8 @@ function createServiceToolCatalog(
381
381
  );
382
382
  const maxToolArgumentBytes = normalizePositiveInteger(rawMaxToolArgumentBytes, { fallback: DEFAULT_MAX_TOOL_ARGUMENT_BYTES });
383
383
  const maxToolResultBytes = normalizePositiveInteger(rawMaxToolResultBytes, { fallback: DEFAULT_MAX_TOOL_RESULT_BYTES });
384
+ // Every tool set carries these, so the calls made with one surface's set keep that surface's limits.
385
+ const limits = Object.freeze({ maxToolArgumentBytes, maxToolResultBytes });
384
386
  const toolSetStates = new WeakMap();
385
387
  let methodEntries = null;
386
388
 
@@ -446,7 +448,8 @@ function createServiceToolCatalog(
446
448
 
447
449
  const toolSet = Object.freeze({
448
450
  tools: Object.freeze(tools),
449
- byName
451
+ byName,
452
+ limits
450
453
  });
451
454
  const actionEntriesById = new Map();
452
455
  const directEntriesByToolName = new Map();
@@ -669,10 +672,7 @@ function createServiceToolCatalog(
669
672
  }
670
673
 
671
674
  return Object.freeze({
672
- limits: Object.freeze({
673
- maxToolArgumentBytes,
674
- maxToolResultBytes
675
- }),
675
+ limits,
676
676
  resolveToolSet,
677
677
  toOpenAiToolSchema,
678
678
  executeToolCall
@@ -81,6 +81,14 @@ test("an active goal's chip shows its clock, and a finished goal's chip shows ne
81
81
  assert.equal(none.label, "jskit.assistant.goal.set");
82
82
  });
83
83
 
84
+ test("a resumed goal's clock counts from its first start, not from the Resume that sent it again", async () => {
85
+ const control = await importControl();
86
+ const resumed = await render(control, { objective: "Finish every check", status: "active",
87
+ createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), startedAt: startedTwoHoursAgo });
88
+ assert.match(resumed.chip, /<span class="assistant-goal__elapsed" aria-hidden="true">2:00:0\d<\/span>/u);
89
+ assert.match(resumed.panel, /jskit\.assistant\.goal\.elapsedSinceStart=2:00:0\d/u);
90
+ });
91
+
84
92
  test("a goal waiting at its AI account's limit names the limit and when it resets, and Resume is the person's to press", async () => {
85
93
  const control = await importControl();
86
94
  const reset = new Date();
@@ -889,10 +889,24 @@ test("long goal objectives keep Start reachable on short phone, tablet and deskt
889
889
  const { vite, browser } = await openFixture(t);
890
890
  try {
891
891
  const objective = "Complete the requested work and verify the result. ".repeat(80).slice(0, 3990);
892
- for (const width of [390, 800, 1280]) {
892
+ for (const width of [320, 390, 800, 1280]) {
893
893
  const page = await browser.newPage({ viewport: { width, height: 578 } });
894
894
  await page.goto(`${vite.baseURL}/?conversation=1&goal=1`);
895
895
  await page.getByRole("button", { name: "Set goal", exact: true }).click();
896
+ await expect(page.getByRole("textbox", { name: "Goal objective" })).toBeVisible();
897
+ // The new goal's box fills its menu, which is 360 px wide or as wide as a phone allows.
898
+ const measure = () => page.locator(".assistant-goal__details").evaluate(card => {
899
+ const style = getComputedStyle(card);
900
+ const bounds = card.getBoundingClientRect();
901
+ return { left: bounds.left, right: bounds.right, card: bounds.width, field: card.querySelector(".v-textarea").getBoundingClientRect().width,
902
+ inner: card.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) };
903
+ });
904
+ const fills = box => box.card >= Math.min(360, width - 24) - 1 && box.left >= 0 && box.right <= width &&
905
+ Math.abs(box.field - box.inner) <= 1;
906
+ await expect.poll(async () => fills(await measure())).toBe(true).catch(async failure => {
907
+ failure.message += `\nat ${width} px: ${JSON.stringify(await measure())}`;
908
+ throw failure;
909
+ });
896
910
  await page.getByRole("textbox", { name: "Goal objective" }).fill(objective);
897
911
  const start = page.getByRole("button", { name: "Start goal", exact: true });
898
912
  await expect(start).toBeInViewport();
@@ -129,6 +129,44 @@ test("Claude successful native clear removes a failed goal without a clearing se
129
129
  assert.equal((await readClaudeHistory(options)).goal.objective, "Next checks", "a later goal still wins");
130
130
  });
131
131
 
132
+ test("a resumed Claude goal keeps the time it first started, across the clear before Resume", async t => {
133
+ const root = await mkdtemp(path.join(os.tmpdir(), "claude-goal-start-"));
134
+ t.after(() => rm(root, { recursive: true, force: true }));
135
+ const directory = path.join(root, "projects", root.replace(/[^a-zA-Z0-9]/gu, "-"));
136
+ await mkdir(directory, { recursive: true });
137
+ const conversationId = "6b1f2c9e-4d3a-4c1b-9e7f-2a5d8c0b1e3f";
138
+ const file = path.join(directory, `${conversationId}.jsonl`);
139
+ const options = { configRoot: root, workdir: root, conversationId };
140
+ const append = (...frames) => writeFile(file, frames.map(JSON.stringify).join("\n") + "\n", { flag: "a" });
141
+ const goal = (uuid, timestamp, condition, extra = {}) => ({ type: "attachment", uuid, timestamp,
142
+ attachment: { type: "goal_status", condition, met: false, sentinel: true, ...extra } });
143
+ const first = Date.parse("2026-11-11T07:00:00.000Z") / 1000;
144
+ await append(goal("set", "2026-11-11T07:00:00.000Z", "Finish all checks"));
145
+ // Restart recovery sends the same objective again while the goal still runs.
146
+ await append(goal("recovered", "2026-11-11T07:30:00.000Z", "Finish all checks"));
147
+ let read = (await readClaudeHistory(options)).goal;
148
+ assert.equal(read.createdAt, Date.parse("2026-11-11T07:30:00.000Z") / 1000);
149
+ assert.equal(read.startedAt, first);
150
+ // A paused goal's checker is cleared when its process starts; Resume sends it again.
151
+ await append({ type: "system", subtype: "local_command", uuid: "suspended", timestamp: "2026-11-11T08:00:00.000Z",
152
+ commandRun: { command: "goal", args: "clear" }, content: "<local-command-stdout>Goal cleared: Finish all checks</local-command-stdout>" });
153
+ assert.equal((await readClaudeHistory(options)).goal, null);
154
+ await append(goal("resumed", "2026-11-11T08:05:00.000Z", "Finish all checks"));
155
+ read = (await readClaudeHistory(options)).goal;
156
+ assert.equal(read.status, "active");
157
+ assert.equal(read.startedAt, first, "Resume after the clear keeps the first start");
158
+ await append({ type: "attachment", uuid: "done", timestamp: "2026-11-11T09:00:00.000Z",
159
+ attachment: { type: "goal_status", condition: "Finish all checks", met: true } });
160
+ assert.equal((await readClaudeHistory(options)).goal.startedAt, first);
161
+ // A finished goal set again, or another objective, starts its own clock.
162
+ await append(goal("again", "2026-11-11T09:10:00.000Z", "Finish all checks"));
163
+ assert.equal((await readClaudeHistory(options)).goal.startedAt, Date.parse("2026-11-11T09:10:00.000Z") / 1000);
164
+ await append({ type: "attachment", uuid: "cleared", timestamp: "2026-11-11T09:20:00.000Z",
165
+ attachment: { type: "goal_status", condition: "Finish all checks", met: true, sentinel: true } },
166
+ goal("other", "2026-11-11T09:30:00.000Z", "Write the release notes"));
167
+ assert.equal((await readClaudeHistory(options)).goal.startedAt, Date.parse("2026-11-11T09:30:00.000Z") / 1000);
168
+ });
169
+
132
170
  // Claude 2.1.308 history at a hosted goal's usage limit (2026-11-10), and the
133
171
  // message Claude Code writes when the account's credit balance is too low.
134
172
  test("Claude's own limit message makes its goal wait, with the reset time, until a later answer", async t => {
@@ -360,6 +360,36 @@ test("failed reservations and oversized arguments cannot reach application execu
360
360
  assert.equal(executions, 0);
361
361
  });
362
362
 
363
+ test("the resolved tool set's argument limit is the one its calls are held to and the one engines are given", async () => {
364
+ const lengths = [];
365
+ const actions = createActionCatalogue();
366
+ actions.register({ contributorId: "test.documents", domain: "test", actions: [{
367
+ id: "documents.propose", version: 1, kind: "command", channels: ["automation"], surfaces: ["app", "home"],
368
+ permission: { require: "authenticated" },
369
+ input: { schema: createSchema({ text: { type: "string", required: true } }), mode: "replace" },
370
+ output: { schema: createSchema({ length: { type: "number", required: true } }), mode: "replace" },
371
+ execute: async ({ text }) => { lengths.push(text.length); return { length: text.length }; }
372
+ }] });
373
+ // One catalogue per surface behind one, as assistant-runtime's surface-aware catalogue is:
374
+ // the app surface allows 64 KiB of arguments, home keeps the default 32 KiB.
375
+ const surfaces = { app: createServiceToolCatalog(actions, { maxToolArgumentBytes: 64 * 1024 }), home: createServiceToolCatalog(actions) };
376
+ const catalog = {
377
+ resolveToolSet: (toolContext, options) => surfaces[toolContext.surface].resolveToolSet(toolContext, options),
378
+ toOpenAiToolSchema: surfaces.home.toOpenAiToolSchema,
379
+ executeToolCall: payload => surfaces[payload.context.surface].executeToolCall(payload)
380
+ };
381
+ const tools = surface => createConversationTools({ catalog, context: { ...context, surface }, signal: new AbortController().signal,
382
+ transient: true });
383
+ const proposal = name => ({ id: "propose", name, arguments: JSON.stringify({ text: "x".repeat(40 * 1024) }) });
384
+ const app = tools("app");
385
+ assert.equal(app.maximumArgumentBytes, 64 * 1024);
386
+ assert.deepEqual(await app.execute(proposal(app.descriptors[0].name)), { ok: true, result: { length: 40 * 1024 } });
387
+ const home = tools("home");
388
+ assert.equal(home.maximumArgumentBytes, 32 * 1024);
389
+ await assert.rejects(home.execute(proposal(home.descriptors[0].name)), { message: "The application tool arguments exceed their size limit." });
390
+ assert.deepEqual(lengths, [40 * 1024]);
391
+ });
392
+
363
393
  test("closing a native tool connection prevents queued calls while retaining an invoked result", async () => {
364
394
  const entered = Promise.withResolvers(), complete = Promise.withResolvers();
365
395
  let executions = 0;
@@ -54,6 +54,25 @@ test("the native tool connection requires its private credential and bounds inco
54
54
  assert.deepEqual(calls, [{}]);
55
55
  });
56
56
 
57
+ test("arguments up to the limit reach the application through the native tool connection with the longest call identity", async t => {
58
+ const limit = 128 * 1024;
59
+ const sizes = [];
60
+ const bridge = await createOpenCodeToolBridge({ schemas: [], maxArgumentBytes: limit,
61
+ execute: async value => { sizes.push(Buffer.byteLength(JSON.stringify(value.input))); return { ok: true }; } });
62
+ t.after(() => bridge.close());
63
+ const { url, token } = bridge.configuration;
64
+ // The plugin's request: the call's identities, each at most 256 characters, around its input.
65
+ const identity = { sessionId: "s".repeat(256), messageId: "m".repeat(256), id: "c".repeat(256), name: "n".repeat(256) };
66
+ const send = text => fetch(url, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
67
+ body: JSON.stringify({ ...identity, input: { text } }) });
68
+ assert.deepEqual(await (await send("x".repeat(limit - 11))).json(), { ok: true });
69
+ assert.deepEqual(sizes, [limit]);
70
+ const tooLarge = await send("x".repeat(limit + 4096));
71
+ assert.equal(tooLarge.status, 400);
72
+ assert.match((await tooLarge.json()).error, /size limit/);
73
+ assert.deepEqual(sizes, [limit]);
74
+ });
75
+
57
76
  test("native plugin definitions retain exact schemas and native identity without enabling built-in tools", async t => {
58
77
  const schema = { type: "object", properties: { query: { type: "string" } }, additionalProperties: false };
59
78
  const calls = [];
@@ -631,6 +631,15 @@ test("discovery pages and persisted tool results stay within configured bounds",
631
631
  });
632
632
  });
633
633
 
634
+ test("each tool set carries its catalog's size limits for the calls made with it", () => {
635
+ const context = { actor: { id: "7" }, surface: "admin" };
636
+ const actions = createActions([action()]);
637
+ const large = createServiceToolCatalog(actions, { maxToolArgumentBytes: 64 * 1024 });
638
+ assert.deepEqual(large.resolveToolSet(context).limits, { maxToolArgumentBytes: 64 * 1024, maxToolResultBytes: 48 * 1024 });
639
+ assert.equal(large.resolveToolSet(context, { discoveryOnly: true }).limits, large.limits);
640
+ assert.equal(createServiceToolCatalog(actions).resolveToolSet(context).limits.maxToolArgumentBytes, 32 * 1024);
641
+ });
642
+
634
643
  test("assistant tools reject unknown tools and return safe action failures", async () => {
635
644
  const actions = createActions([action({
636
645
  execute: async () => {