@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 +3 -0
- package/examples/conversation/package.json +1 -1
- package/package.json +1 -1
- package/src/client/conversation/AssistantGoalControl.vue +12 -7
- package/src/client/types.js +3 -1
- package/src/server/conversation/claudeHistory.js +15 -4
- package/src/server/conversation/providers/claudeDriver.js +1 -0
- package/src/server/conversation/tools.js +5 -3
- package/src/server/lib/serviceToolCatalog.js +5 -5
- package/test/assistantGoalControlChip.test.js +8 -0
- package/test/assistantScroll.browser.test.js +15 -1
- package/test/claudeTurn.test.js +38 -0
- package/test/conversationTools.test.js +30 -0
- package/test/openCodeTools.test.js +19 -0
- package/test/serviceToolCatalog.test.js +9 -0
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.
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
51
|
-
secondsUsed = (end -
|
|
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,
|
|
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"
|
|
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>
|
package/src/client/types.js
CHANGED
|
@@ -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
|
|
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)
|
|
286
|
-
|
|
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
|
-
|
|
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) >
|
|
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
|
|
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
|
|
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();
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -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 () => {
|