@jskit-ai/assistant-core 0.2.45 → 0.2.47

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,8 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - The Claude engine keeps an application tool result within its tool set's raised `maxToolResultBytes` whole. Claude Code saves an MCP tool result past 50,000 characters, or past its MCP output token cap, to a file and shows the model only a preview, which a tool-free conversation cannot read: a 128 KiB result reached the model as its first 2 KB. A tool set whose result limit is above the default 48 KiB now starts Claude with `MAX_MCP_OUTPUT_TOKENS` at that limit plus 1 KiB, and its application tools declare that many characters as their `anthropic/maxResultSizeChars` (Claude Code allows at most 500,000); a character and a token each cover at least one byte of the result. At the default limit Claude keeps its own. `createClaudeCodeProcess` takes `maximumToolResultBytes`, and conversation tools give engines `maximumResultBytes` beside `maximumArgumentBytes`.
6
+ - A hand-written tool catalogue whose tool sets carry no `limits` keeps the default limits (32 KiB of arguments, 48 KiB of result) instead of every turn with its tools failing with "Cannot read properties of undefined (reading 'maxToolArgumentBytes')". `createServiceToolCatalog`'s module exports `DEFAULT_MAX_TOOL_ARGUMENT_BYTES` and `DEFAULT_MAX_TOOL_RESULT_BYTES`.
5
7
  - 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.
8
+ - A Claude goal keeps its identity when Claude compacts its context. Claude re-attaches the unfinished goal with a `goal_status` sentinel that no `/goal` command follows, and the history read it as a new start, so the goal's `createdAt` and id moved and a host's restart recovery refused it as a changed goal. Only a sentinel that its own authored `/goal` command names as parent starts the goal again; a re-attached one keeps `createdAt`, the id and `goalCommandId`.
9
+ - A Claude goal carries `timeUsedSeconds`, the time Claude spent working on it, current as of its `updatedAt`, as a Codex goal does: the time up to each history frame Claude saved itself, never the wait before a message the person or the host sent (between turns, while paused, or across a restart), with file order winning over timestamps that run slightly backwards. Resume, restart recovery and compaction carry it on; a new goal starts from zero. It replaces `startedAt` (0.2.44), whose wall-clock time counted idle, paused and restart time.
10
+ - `AssistantGoalControl` looks running (red flashing light, clock beside it) only while the conversation works on an active goal. An active goal with nothing working on it says **Goal not running**, with no red light and no clock, and its details say the next message continues it, beside any error. Its clock is the goal's running time (`elapsedSeconds` as of `sampledAt`), counting on only while running, from the later of the sample and the turn start the control saw; a goal without a running time shows none, and the wall-clock fallback and `jskit.assistant.goal.elapsedSinceStart` are removed.
7
11
  - 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.
8
12
  - `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.
9
13
  - `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.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.45",
12
- "@jskit-ai/http-runtime": "0.2.20",
13
- "@jskit-ai/kernel": "0.2.20",
11
+ "@jskit-ai/assistant-core": "0.2.47",
12
+ "@jskit-ai/http-runtime": "0.2.21",
13
+ "@jskit-ai/kernel": "0.2.21",
14
14
  "@jskit-ai/material-web": "0.1.5",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.28",
18
- "@jskit-ai/connectors-catalog": "0.2.28"
17
+ "@jskit-ai/connectors-core": "0.2.29",
18
+ "@jskit-ai/connectors-catalog": "0.2.29"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, reactive, ref } from "vue";
2
+ import { computed, reactive, ref, watch } from "vue";
3
3
  import { createAssistantMessageDelivery } from "../../src/client/conversation/messageDelivery.js";
4
4
  import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
5
5
  import { useAssistantAttachments } from "../../src/client/conversation/useAssistantAttachments.js";
@@ -69,11 +69,19 @@ const models = reactive({
69
69
  });
70
70
  const goalEnabled = ref(new URLSearchParams(location.search).has("goal"));
71
71
  const goal = ref(null);
72
+ // As a server does: a goal's running time counts only while a turn works on
73
+ // it, and is read again when a turn ends, not when one starts.
74
+ let turnStartedAt = null;
75
+ const worked = () => turnStartedAt === null ? 0 : (Date.now() - turnStartedAt) / 1000;
76
+ watch(() => phase.value === "active", working => {
77
+ if (!working && goal.value?.status === "active") goal.value = { ...goal.value, elapsedSeconds: goal.value.elapsedSeconds + worked(), sampledAt: Date.now() };
78
+ turnStartedAt = working ? Date.now() : null;
79
+ });
72
80
  const goalState = reactive({
73
81
  enabled: goalEnabled, goal, pauseBlocked: true, working: computed(() => phase.value === "active"),
74
- set(input) { phase.value = "active"; goal.value = { ...input, status: "active", elapsedSeconds: 0, sampledAt: Date.now() }; },
75
- pause() { goal.value = { ...goal.value, status: "paused", elapsedSeconds: goal.value.elapsedSeconds + (Date.now() - goal.value.sampledAt) / 1000, sampledAt: Date.now() }; },
76
- resume() { phase.value = "active"; goal.value = { ...goal.value, status: "active", reason: "", sampledAt: Date.now() }; },
82
+ set(input) { phase.value = "active"; turnStartedAt = Date.now(); goal.value = { ...input, status: "active", elapsedSeconds: 0, sampledAt: Date.now() }; },
83
+ pause() { goal.value = { ...goal.value, status: "paused", elapsedSeconds: goal.value.elapsedSeconds + worked(), sampledAt: Date.now() }; turnStartedAt = null; },
84
+ resume() { phase.value = "active"; turnStartedAt = Date.now(); goal.value = { ...goal.value, status: "active", reason: "", sampledAt: Date.now() }; },
77
85
  cancel() { goal.value = null; phase.value = "idle"; }
78
86
  });
79
87
  const adapter = reactive({
package/locales/en.json CHANGED
@@ -108,9 +108,10 @@
108
108
  "cancelling": "Cancelling…",
109
109
  "complete": "Goal complete",
110
110
  "elapsed": "{label}: {elapsed}",
111
- "elapsedSinceStart": "Elapsed since goal started (including pauses)",
112
111
  "goal": "Goal",
113
112
  "loading": "Loading goal…",
113
+ "notRunning": "Goal not running",
114
+ "notRunningHint": "Nothing is working on this goal right now. Your next message continues it.",
114
115
  "objective": "Goal objective",
115
116
  "outOfCredit": "Out of credit: top up or switch AI account",
116
117
  "pause": "Pause goal",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.45",
3
+ "version": "0.2.47",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -52,7 +52,7 @@
52
52
  "@ai-sdk/togetherai": "^3.0.49",
53
53
  "@ai-sdk/xai": "^4.0.58",
54
54
  "@jskit-ai/agent": "0.1.3",
55
- "@jskit-ai/resource-core": "0.1.211",
55
+ "@jskit-ai/resource-core": "0.1.212",
56
56
  "@openrouter/ai-sdk-provider": "^3.0.0",
57
57
  "ai": "^7.0.152",
58
58
  "json-rest-schema": "^1.0.20",
@@ -60,8 +60,8 @@
60
60
  "ws": "^8.20.1"
61
61
  },
62
62
  "peerDependencies": {
63
- "@jskit-ai/http-runtime": "0.2.20",
64
- "@jskit-ai/kernel": "0.2.20",
63
+ "@jskit-ai/http-runtime": "0.2.21",
64
+ "@jskit-ai/kernel": "0.2.21",
65
65
  "vue": "^3.5.13",
66
66
  "vuetify": "^4.3.0"
67
67
  },
@@ -16,8 +16,10 @@ const objective = ref("");
16
16
  const tokenBudget = ref("");
17
17
  const now = ref(Date.now());
18
18
  const goal = computed(() => props.state.loading || props.state.available === false ? null : props.state.goal);
19
- const running = computed(() => goal.value?.status === "active");
20
- const working = computed(() => running.value && props.state.working === true);
19
+ const active = computed(() => goal.value?.status === "active");
20
+ // An active goal runs only while its conversation is working; between turns,
21
+ // or before a restart's continuation starts, nothing is working on it.
22
+ const running = computed(() => active.value && props.state.working === true);
21
23
  // A Start stays Starting… until the server's goal shows it, even while closed.
22
24
  const starting = computed(() => props.state.action === "set");
23
25
  // A goal waiting at its AI account's limit names it, with the reset time when
@@ -30,46 +32,39 @@ const resetTime = computed(() => {
30
32
  });
31
33
  const statusLabel = computed(() => goal.value?.status === "usageLimited" && goal.value.outOfCredit ? t("jskit.assistant.goal.outOfCredit")
32
34
  : goal.value?.status === "usageLimited" && resetTime.value ? t("jskit.assistant.goal.usageLimitedUntil", { time: resetTime.value })
33
- : t(Object.hasOwn(STATUS_KEYS, goal.value?.status ?? "") ? STATUS_KEYS[goal.value.status] : "jskit.assistant.goal.set"));
35
+ : active.value && !running.value ? t("jskit.assistant.goal.notRunning")
36
+ : t(Object.hasOwn(STATUS_KEYS, goal.value?.status ?? "") ? STATUS_KEYS[goal.value.status] : "jskit.assistant.goal.set"));
34
37
  const label = computed(() => props.state.loading ? t("jskit.assistant.goal.loading") : props.state.available === false ? t("jskit.assistant.goal.unavailable")
35
38
  : starting.value ? t("jskit.assistant.goal.starting") : statusLabel.value);
36
- const nativeElapsed = computed(() => Number.isFinite(goal.value?.elapsedSeconds));
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
- });
43
- const elapsedLabel = computed(() => nativeElapsed.value ? t("jskit.assistant.goal.runningTime") : t("jskit.assistant.goal.elapsedSinceStart"));
44
- const elapsedText = computed(() => elapsed.value ? t("jskit.assistant.goal.elapsed", { label: elapsedLabel.value, elapsed: elapsed.value }) : "");
45
- const clockRunning = computed(() => nativeElapsed.value ? running.value && Number.isFinite(goal.value?.sampledAt)
46
- : Boolean(goal.value && goal.value.status !== "complete" && Number.isFinite(startedAt.value)));
39
+ // The clock is the time spent working on the goal: the engine's running time
40
+ // as of sampledAt, plus the time since while the conversation works on it. It
41
+ // never counts idle, paused or restart time, and carries on from its total.
42
+ // A turn seen starting after the sample counts from that start, not from the
43
+ // older sample; a turn already working when the control appeared trusts it.
44
+ const workingSince = ref(-Infinity);
45
+ watch(running, (value, previous) => { if (value && previous === false) workingSince.value = Date.now(); }, { flush: "sync" });
46
+ const elapsedText = computed(() => elapsed.value ? t("jskit.assistant.goal.elapsed", { label: t("jskit.assistant.goal.runningTime"), elapsed: elapsed.value }) : "");
47
+ const clockRunning = computed(() => running.value && Number.isFinite(goal.value?.elapsedSeconds) && Number.isFinite(goal.value?.sampledAt));
47
48
  const elapsed = computed(() => {
48
- let secondsUsed;
49
- if (nativeElapsed.value) {
50
- const sinceSample = running.value && Number.isFinite(goal.value.sampledAt)
51
- ? Math.max(0, now.value - goal.value.sampledAt) / 1000 : 0;
52
- secondsUsed = goal.value.elapsedSeconds + sinceSample;
53
- } else {
54
- const end = goal.value?.status === "complete" ? goal.value.sampledAt : now.value;
55
- if (!Number.isFinite(startedAt.value) || !Number.isFinite(end)) return "";
56
- secondsUsed = (end - startedAt.value) / 1000;
57
- }
58
- const total = Math.max(0, Math.floor(secondsUsed));
49
+ if (!Number.isFinite(goal.value?.elapsedSeconds)) return "";
50
+ const since = clockRunning.value ? Math.max(goal.value.sampledAt, workingSince.value) : now.value;
51
+ const total = Math.max(0, Math.floor(goal.value.elapsedSeconds + Math.max(0, now.value - since) / 1000));
59
52
  const seconds = String(total % 60).padStart(2, "0");
60
53
  const minutes = Math.floor(total / 60);
61
54
  return minutes < 60 ? `${minutes}:${seconds}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${seconds}`;
62
55
  });
63
- // The chip says plainly when a goal is not running (paused, blocked or waiting)
64
- // and shows its clock only while it runs; the details keep the elapsed time.
56
+ // The chip says plainly when a goal is not running (paused, blocked, waiting,
57
+ // or active with nothing working on it) and shows its clock only while it
58
+ // runs; the details keep the running time.
65
59
  const WAITING_STATUSES = Object.freeze(["paused", "blocked", "usageLimited", "budgetLimited"]);
66
60
  const chipLabel = computed(() => props.state.loading || props.state.available === false || starting.value ||
67
- WAITING_STATUSES.includes(goal.value?.status) ? label.value : t(goal.value ? "jskit.assistant.goal.goal" : "jskit.assistant.goal.set"));
61
+ WAITING_STATUSES.includes(goal.value?.status) || (active.value && !running.value) ? label.value
62
+ : t(goal.value ? "jskit.assistant.goal.goal" : "jskit.assistant.goal.set"));
68
63
  // readable-check: allow method-probe - optional operation: a goal state without set() does not allow starting a goal, so Start stays disabled
69
64
  const canSet = computed(() => !props.state.loading && props.state.available !== false && typeof props.state.set === "function" && (!goal.value || goal.value.status === "complete"));
70
65
  const validBudget = computed(() => props.state.tokenBudgetSupported === false || tokenBudget.value === "" || (Number.isSafeInteger(Number(tokenBudget.value)) && Number(tokenBudget.value) > 0));
71
66
  let timer;
72
- onMounted(() => watch(() => [props.state.enabled, clockRunning.value, goal.value?.elapsedSeconds, goal.value?.sampledAt, startedAt.value], () => {
67
+ onMounted(() => watch(() => [props.state.enabled, clockRunning.value, goal.value?.elapsedSeconds, goal.value?.sampledAt], () => {
73
68
  clearInterval(timer);
74
69
  now.value = Date.now();
75
70
  if (props.state.enabled !== false && clockRunning.value) {
@@ -94,21 +89,22 @@ async function cancelGoal() {
94
89
  <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">
95
90
  <template #activator="{ props: menuProps }">
96
91
  <v-btn v-bind="menuProps" class="assistant-goal" size="small" variant="text" :aria-label="label" :title="[label, elapsedText, goal?.objective].filter(Boolean).join('\n')">
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" />
92
+ <span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--paused': goal.status === 'paused' }" aria-hidden="true" />
98
93
  <span class="assistant-goal__label">{{ chipLabel }}</span>
99
94
  <span v-if="elapsed && running" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
100
95
  </v-btn>
101
96
  </template>
102
97
  <v-card class="assistant-goal__details pa-3">
103
98
  <strong role="status">{{ label }}</strong>
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>
99
+ <v-btn v-if="(active || (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>
105
100
  <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>
106
101
  <v-btn v-if="goal && goal.status !== 'complete' && state.cancel" color="error" size="small" variant="text" :disabled="state.stopPending" @click="cancelGoal">{{ state.action === 'cancel' ? t('jskit.assistant.goal.cancelling') : t('jskit.assistant.goal.cancel') }}</v-btn>
107
102
  <p v-if="goal" class="my-2">{{ goal.objective }}</p>
108
103
  <p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
109
104
  <p v-if="elapsed" class="text-body-small my-2">{{ elapsedText }}</p>
105
+ <p v-if="active && !running" class="text-body-small my-2">{{ t('jskit.assistant.goal.notRunningHint') }}</p>
110
106
  <p v-if="goal?.status === 'usageLimited'" class="text-body-small mt-2">{{ t('jskit.assistant.goal.usageLimitedHint') }}</p>
111
- <p v-if="running || goal?.status === 'paused'" class="text-body-small mt-2">{{ state.pauseInterruptsTurn ? t('jskit.assistant.goal.pauseInterruptsTurn') : t('jskit.assistant.goal.pausePreventsTurns') }}</p>
107
+ <p v-if="active || goal?.status === 'paused'" class="text-body-small mt-2">{{ state.pauseInterruptsTurn ? t('jskit.assistant.goal.pauseInterruptsTurn') : t('jskit.assistant.goal.pausePreventsTurns') }}</p>
112
108
  <p v-if="goal && goal.status !== 'complete' && state.cancel" class="text-body-small mt-2">{{ state.cancelInterruptsTurn ? t('jskit.assistant.goal.cancelInterruptsTurn') : t('jskit.assistant.goal.cancelRemovesGoal') }}</p>
113
109
  <form v-if="canSet" class="assistant-goal__form mt-2" @submit.prevent="setGoal">
114
110
  <v-textarea v-model="objective" :label="t('jskit.assistant.goal.objective')" rows="2" max-rows="4" maxlength="4000" auto-grow :disabled="state.pending" hide-details />
@@ -124,8 +120,7 @@ async function cancelGoal() {
124
120
  <style scoped>
125
121
  .assistant-goal { text-transform: none; flex-shrink: 0; font-variant-numeric: tabular-nums; }
126
122
  .assistant-goal__light { width: 0.625rem; height: 0.625rem; border-radius: 50%; margin-inline-end: 0.45rem; background: currentColor; }
127
- .assistant-goal__light--running { background: #d32f2f; box-shadow: 0 0 0 3px #d32f2f20; }
128
- .assistant-goal__light--working { animation: assistant-goal-flash 1.5s ease-in-out infinite; }
123
+ .assistant-goal__light--running { background: #d32f2f; box-shadow: 0 0 0 3px #d32f2f20; animation: assistant-goal-flash 1.5s ease-in-out infinite; }
129
124
  .assistant-goal__light--paused { background: #ef6c00; box-shadow: 0 0 0 3px #ef6c0020; }
130
125
  .assistant-goal__elapsed { margin-inline-start: 0.45rem; }
131
126
  .assistant-goal__details { overflow-wrap: anywhere; min-height: 0; max-height: calc(100dvh - 24px); }
@@ -135,5 +130,5 @@ async function cancelGoal() {
135
130
  .assistant-goal__elapsed { display: none; }
136
131
  .assistant-goal__label { max-width: 10rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
137
132
  }
138
- @media (prefers-reduced-motion: reduce) { .assistant-goal__light--working { animation: none; } }
133
+ @media (prefers-reduced-motion: reduce) { .assistant-goal__light--running { animation: none; } }
139
134
  </style>
@@ -48,9 +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,
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.
51
+ * @property {{ status: string, objective?: string, elapsedSeconds?: number, sampledAt?: number, createdAt?: string } | null} [goal]
52
+ * elapsedSeconds is the time spent working on the goal as of sampledAt (milliseconds); its clock adds the time
53
+ * since only while the conversation is working on an active goal.
54
54
  * @property {((input: { objective: string, tokenBudget?: number }) => unknown) | null} [set]
55
55
  * Starts a goal; absent while starting one is not allowed, so Start stays disabled.
56
56
  * @property {() => unknown} [pause]
@@ -30,6 +30,19 @@ function claudeUserText(content) {
30
30
  .filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n");
31
31
  }
32
32
 
33
+ // A message the person or the host sent, a native /goal command among them.
34
+ // Claude's own notes, compaction summary and interrupt marker are not.
35
+ function claudeAuthoredMessage(frame) {
36
+ if (frame.type !== "user" || frame.isMeta || frame.isCompactSummary || frame.parent_tool_use_id) return false;
37
+ const text = claudeUserText(frame.message?.content);
38
+ return Boolean(text) && !/^\[Request interrupted by user/u.test(text);
39
+ }
40
+
41
+ // Native /goal saves its sentinel, then the authored command naming that
42
+ // sentinel as its parent. Claude re-attaches an unfinished goal with a
43
+ // sentinel after compacting its context, and no command follows that one.
44
+ const GOAL_COMMAND_PATTERN = /^\s*(?:<command-name>\/goal<\/command-name>|\/goal(?:\s|$))/u;
45
+
33
46
  async function archivedClaudeProjectDirectory(workdir) {
34
47
  const canonical = await realpath(workdir).catch((error) => {
35
48
  if (error.code === "ENOENT") return workdir;
@@ -221,6 +234,10 @@ async function readClaudeHistory(options) {
221
234
  const byId = new Map();
222
235
  let leafUuid = "";
223
236
  let rewound = false;
237
+ const commandedSentinels = new Set();
238
+ // The newest frame time so far. File order wins over timestamps, which can
239
+ // run slightly backwards between adjacent frames.
240
+ let lastAt = NaN;
224
241
  // Snapshot the file length. A writer can be in the middle of its last frame;
225
242
  // Live history reads allow that unfinished tail. Stopped maintenance can
226
243
  // require the existing strict parser without changing live observation.
@@ -232,6 +249,8 @@ async function readClaudeHistory(options) {
232
249
  }
233
250
  for (const frame of frames) {
234
251
  if (frame.uuid) byId.set(frame.uuid, frame);
252
+ if (claudeAuthoredMessage(frame) && frame.parentUuid &&
253
+ GOAL_COMMAND_PATTERN.test(claudeUserText(frame.message?.content))) commandedSentinels.add(frame.parentUuid);
235
254
  if (["user", "assistant"].includes(frame.type) && frame.uuid) leafUuid = frame.uuid;
236
255
  if (frame.type === "last-prompt" && frame.leafUuid) {
237
256
  leafUuid = frame.leafUuid;
@@ -255,6 +274,17 @@ async function readClaudeHistory(options) {
255
274
  }
256
275
  for (const frame of frames) {
257
276
  if (rewound && frame.uuid && !retained.has(frame.uuid)) continue;
277
+ // Claude saves its history as it works, so the time up to each frame it
278
+ // saves itself is the goal's running time. The wait before a message the
279
+ // person or the host sent (between turns, while paused, or across a
280
+ // restart) is not.
281
+ const at = ["user", "assistant", "attachment", "system"].includes(frame.type) ? Date.parse(frame.timestamp) / 1000 : NaN;
282
+ if (Number.isFinite(at)) {
283
+ const waited = claudeAuthoredMessage(frame) || frame.type === "attachment" && commandedSentinels.has(frame.uuid);
284
+ if (goal?.status === "active" && !waited && at > lastAt) goal.timeUsedSeconds += at - lastAt;
285
+ lastAt = Number.isFinite(lastAt) ? Math.max(lastAt, at) : at;
286
+ if (goal?.status === "active") goal.updatedAt = lastAt;
287
+ }
258
288
  // The exact native local-command receipt owns clear identity. Claude may
259
289
  // insert a caveat between the sentinel and authored command, or omit the
260
290
  // sentinel after an impossible evaluation. Neither case changes delivery.
@@ -282,22 +312,28 @@ async function readClaudeHistory(options) {
282
312
  const status = frame.attachment;
283
313
  if (frame.type === "attachment" && !frame.parent_tool_use_id &&
284
314
  status?.type === "goal_status" && typeof status.condition === "string") {
285
- if (status.sentinel === true || goal?.objective !== status.condition) {
286
- goalStartId = status.sentinel === true && typeof status.met === "boolean" ? frame.uuid || "" : "";
315
+ // A sentinel no command follows re-attaches the unfinished goal after
316
+ // Claude compacts its context: it stays the same goal, with its identity.
317
+ const sentinel = status.sentinel === true && !(status.met === false && status.failed !== true &&
318
+ goal?.objective === status.condition && goal.status !== "complete" && !commandedSentinels.has(frame.uuid));
319
+ if (sentinel || goal?.objective !== status.condition) {
320
+ goalStartId = sentinel && typeof status.met === "boolean" ? frame.uuid || "" : "";
287
321
  goalCommandId = "";
288
322
  }
289
- if (status.met === true && status.sentinel === true) {
323
+ if (status.met === true && sentinel) {
290
324
  if (goal && goal.status !== "complete") clearedGoal = goal;
291
325
  goal = null;
292
326
  } else if (status.met === false || status.met === true) {
293
327
  const timestamp = Date.parse(frame.timestamp) / 1000;
294
- const createdAt = goal?.objective === status.condition && !status.sentinel ? goal.createdAt : timestamp;
328
+ const createdAt = goal?.objective === status.condition && !sentinel ? goal.createdAt : timestamp;
295
329
  // Resume sends the same objective again and Claude starts it anew;
296
- // the unfinished goal still started with its first command.
330
+ // the unfinished goal keeps the running time it already had.
297
331
  const previous = goal || clearedGoal;
332
+ const carried = previous?.objective === status.condition &&
333
+ (previous.status !== "complete" || previous.createdAt === createdAt);
298
334
  goal = {
299
335
  threadId: options.conversationId, objective: status.condition, createdAt,
300
- startedAt: previous?.objective === status.condition && previous.status !== "complete" ? previous.startedAt : createdAt,
336
+ timeUsedSeconds: carried ? previous.timeUsedSeconds : 0,
301
337
  status: status.met ? "complete" : status.failed === true ? "blocked" : "active", updatedAt: timestamp,
302
338
  reason: typeof status.reason === "string" ? status.reason : ""
303
339
  };
@@ -6,7 +6,7 @@ import { homedir } from "node:os";
6
6
  import path from "node:path";
7
7
  import { createClaudeJsonClient } from "./claudeClient.js";
8
8
  import { createLocalConversationExecution } from "./localExecution.js";
9
- import { CLAUDE_APPLICATION_TOOL_SERVER } from "./claudeTools.js";
9
+ import { CLAUDE_APPLICATION_TOOL_SERVER, claudeToolResultAllowance } from "./claudeTools.js";
10
10
  import { nativeCommandHook, validateCommandWrapper } from "./commandWrapper.js";
11
11
 
12
12
  const statusReads = new Map();
@@ -545,15 +545,18 @@ export function createClaudeAccountQueries({
545
545
  * @param {Record<string, any>} [options.commandWrapper]
546
546
  * @param {number} [options.initializationTimeoutMs]
547
547
  * @param {(stage: string) => void} [options.onInitializationProgress]
548
+ * @param {number} [options.maximumToolResultBytes] The application tool set's result limit, which the process's MCP output cap must hold.
548
549
  */
549
550
  export async function createClaudeCodeProcess({
550
551
  command = "claude", execution = createLocalConversationExecution(), env,
551
552
  workdir, onEvent, onControlRequest, onFailure = ignoreProcessFailure, onStarted = ignoreProcessStart, executionLimits, signal,
552
- commandWrapper, initializationTimeoutMs = 30_000, onInitializationProgress, ...options
553
+ commandWrapper, initializationTimeoutMs = 30_000, onInitializationProgress, maximumToolResultBytes, ...options
553
554
  }) {
554
555
  if (!env) throw new TypeError("createClaudeCodeProcess requires the application's environment, such as process.env.");
555
556
  if (commandWrapper !== undefined) validateCommandWrapper(commandWrapper);
556
557
  const environment = { ...env, DISABLE_AUTOUPDATER: "1" };
558
+ const resultAllowance = claudeToolResultAllowance(maximumToolResultBytes);
559
+ if (resultAllowance) environment.MAX_MCP_OUTPUT_TOKENS = String(resultAllowance);
557
560
  for (const name of ["DBUS_SESSION_BUS_ADDRESS", "DBUS_STARTER_ADDRESS", "DBUS_STARTER_BUS_TYPE",
558
561
  "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"]) delete environment[name];
559
562
  let native;
@@ -59,8 +59,11 @@ export function createClaudeProcessStart({
59
59
  let startupCleanup;
60
60
  let startedExecutionId;
61
61
  const processToken = {};
62
+ // A process's MCP output cap is set when it starts, so its tool list declares the same result
63
+ // limit: the one of the application tools it starts for.
64
+ entry.toolResultBytes = entry.command?.tools?.maximumResultBytes;
62
65
  try { entry.process = await createProcess({ ...options,
63
- workdir: entry.nativeWorkdir, sessionId: entry.id, resume: entry.sent,
66
+ workdir: entry.nativeWorkdir, sessionId: entry.id, resume: entry.sent, maximumToolResultBytes: entry.toolResultBytes,
64
67
  onStarted: async (executionId, stop) => {
65
68
  startedExecutionId = executionId;
66
69
  if (typeof stop === "function") {
@@ -114,7 +117,8 @@ export function createClaudeProcessStart({
114
117
  const owner = active?.nativeOwner;
115
118
  const work = applicationCall.run({ entry, command: active, owner,
116
119
  toolUseId: request.message?.params?._meta?.["claudecode/toolUseId"] },
117
- () => claudeApplicationToolResponse(request, { schemas: entry.toolSchemas || [], turn: active, signal,
120
+ () => claudeApplicationToolResponse(request, { schemas: entry.toolSchemas || [], maximumResultBytes: entry.toolResultBytes,
121
+ turn: active, signal,
118
122
  ...(suppliedTools ? {
119
123
  assertCurrent(native) {
120
124
  if (!currentApplicationCommand(entry, active, owner) || active.nativeOwner !== owner ||
@@ -1,7 +1,22 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { DEFAULT_MAX_TOOL_RESULT_BYTES } from "../lib/serviceToolCatalog.js";
2
3
 
3
4
  export const CLAUDE_APPLICATION_TOOL_SERVER = "application";
4
5
 
6
+ /**
7
+ * How large an application tool result Claude must keep whole, in characters and in tokens, or null
8
+ * to keep Claude's own limits. Claude Code saves an MCP tool result longer than its tool's declared
9
+ * maxResultSizeChars (50,000 characters unless declared, at most 500,000) or past
10
+ * MAX_MCP_OUTPUT_TOKENS (25,000 unless set, or raised by Claude's own configuration) to a file, and
11
+ * the model sees only a preview of it. A tool set whose result limit is above the catalogue's default
12
+ * 48 KiB raises both to that limit, since a character and a token each cover at least one byte, with
13
+ * 1 KiB for the result's {"ok":true,"result":…} envelope. At the default, Claude keeps its own.
14
+ * @param {number | undefined} maximumResultBytes The tool set's result limit.
15
+ */
16
+ export function claudeToolResultAllowance(maximumResultBytes) {
17
+ return maximumResultBytes > DEFAULT_MAX_TOOL_RESULT_BYTES ? maximumResultBytes + 1024 : null;
18
+ }
19
+
5
20
  /** A call from tools the conversation owns needs no supplied owner's check. */
6
21
  function acceptNativeCall() {}
7
22
 
@@ -12,6 +27,7 @@ function ignoreExecutionFailure() {}
12
27
  // is no listening socket or separate tool process. The shared executor owns effects.
13
28
  export async function claudeApplicationToolResponse(request, {
14
29
  schemas,
30
+ maximumResultBytes,
15
31
  turn,
16
32
  signal,
17
33
  assertCurrent = acceptNativeCall,
@@ -31,10 +47,12 @@ export async function claudeApplicationToolResponse(request, {
31
47
  case "ping":
32
48
  result = {};
33
49
  break;
34
- case "tools/list":
50
+ case "tools/list": {
51
+ const allowance = claudeToolResultAllowance(maximumResultBytes);
35
52
  result = { tools: schemas.map(({ function: tool }) => ({ name: tool.name, description: tool.description,
36
- inputSchema: tool.parameters })) };
53
+ inputSchema: tool.parameters, ...(allowance ? { _meta: { "anthropic/maxResultSizeChars": allowance } } : {}) })) };
37
54
  break;
55
+ }
38
56
  case "tools/call": {
39
57
  const params = message.params;
40
58
  const id = params?._meta?.["claudecode/toolUseId"];
@@ -292,6 +292,7 @@ function createConversationTurns({ storage, streams, events, entries, reads, del
292
292
  const history = activeHistory.filter(inSegment);
293
293
  prepareTools(initial);
294
294
  const tools = initial.tools && { descriptors: initial.tools.descriptors, schemas: initial.tools.schemas, maximumArgumentBytes: initial.tools.maximumArgumentBytes,
295
+ maximumResultBytes: initial.tools.maximumResultBytes,
295
296
  execute(input, options) {
296
297
  const request = toolOwners.get(input.id) || (options?.messageId ? active.requests.get(options.messageId) : active.request);
297
298
  if (!request?.accepted) throw new Error("The application tool does not belong to an admitted message.");
@@ -19,7 +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
+ ...(Number.isFinite(goal.timeUsedSeconds) ? { timeUsedSeconds: Math.floor(goal.timeUsedSeconds) } : {}),
23
23
  ...(Number.isFinite(goal.updatedAt) ? { updatedAt: new Date(goal.updatedAt * 1000).toISOString() } : {})
24
24
  } : null;
25
25
  const goalCapabilities = Object.freeze({ goals: true, goalBudgets: false,
@@ -1,4 +1,5 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { DEFAULT_MAX_TOOL_ARGUMENT_BYTES, DEFAULT_MAX_TOOL_RESULT_BYTES } from "../lib/serviceToolCatalog.js";
2
3
 
3
4
  const unknownOutcome = "The application operation did not return a verified result. Inspect its target before requesting another execution.";
4
5
 
@@ -17,7 +18,7 @@ const TRANSIENT_HOOKS = new Set([authorizeTransientCall, saveTransientCalls, ann
17
18
  * One turn's application calls. Native/API adapters only transport these calls.
18
19
  * @param {object} options
19
20
  * @param {import("../types.js").ServiceToolCatalog} options.catalog
20
- * @param {{ tools: object[], limits: { maxToolArgumentBytes: number } }} [options.toolSet] A tool set resolved earlier for this turn.
21
+ * @param {{ tools: object[], limits?: { maxToolArgumentBytes?: number, maxToolResultBytes?: number } }} [options.toolSet] A tool set resolved earlier for this turn.
21
22
  * @param {object} options.context The caller context the calls run as.
22
23
  * @param {() => Promise<object>} [options.prepareContext] Maps the context again before each call.
23
24
  * @param {AbortSignal} options.signal
@@ -42,8 +43,10 @@ export function createConversationTools({ catalog, toolSet: retainedToolSet, con
42
43
  // readable-check: allow method-probe - input validation: an optional context mapper must be a function
43
44
  if (prepareContext !== undefined && typeof prepareContext !== "function") throw new TypeError("Application tool context requires a server-owned mapper.");
44
45
  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;
46
+ // The tool set's own limits: a surface-aware catalogue resolves each surface's set with that surface's.
47
+ // A hand-written catalogue's tool sets may carry none, and keep the catalogue's defaults.
48
+ const maximumArgumentBytes = toolSet.limits?.maxToolArgumentBytes ?? DEFAULT_MAX_TOOL_ARGUMENT_BYTES;
49
+ const maximumResultBytes = toolSet.limits?.maxToolResultBytes ?? DEFAULT_MAX_TOOL_RESULT_BYTES;
47
50
  const calls = new Map(structuredClone(previousCalls).map(call => [call.id, call]));
48
51
  let pending = Promise.resolve();
49
52
  let requested = 0;
@@ -120,6 +123,7 @@ export function createConversationTools({ catalog, toolSet: retainedToolSet, con
120
123
  schemas: toolSet.tools.map(catalog.toOpenAiToolSchema),
121
124
  execute,
122
125
  maximumArgumentBytes,
126
+ maximumResultBytes,
123
127
  records
124
128
  });
125
129
  }
@@ -679,4 +679,4 @@ function createServiceToolCatalog(
679
679
  });
680
680
  }
681
681
 
682
- export { createServiceToolCatalog, DISCOVERY_TOOL_DESCRIPTORS };
682
+ export { createServiceToolCatalog, DEFAULT_MAX_TOOL_ARGUMENT_BYTES, DEFAULT_MAX_TOOL_RESULT_BYTES, DISCOVERY_TOOL_DESCRIPTORS };
@@ -48,47 +48,55 @@ async function render(control, goal, state = {}) {
48
48
  }
49
49
 
50
50
  const startedTwoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
51
+ // Two hours spent working, sampled ten minutes ago.
52
+ const worked = { elapsedSeconds: 2 * 60 * 60, sampledAt: Date.now() - 10 * 60 * 1000 };
51
53
 
52
- test("a paused, blocked or waiting goal's chip says so and shows no running clock; its details keep the elapsed time", async () => {
54
+ test("a paused, blocked or waiting goal's chip says so and shows no running clock; its details keep the running time", async () => {
53
55
  const control = await importControl();
54
56
  for (const [status, key] of [["paused", "jskit.assistant.goal.paused"], ["blocked", "jskit.assistant.goal.blocked"],
55
57
  ["usageLimited", "jskit.assistant.goal.usageLimited"], ["budgetLimited", "jskit.assistant.goal.budgetLimited"]]) {
56
- // Claude supplies only its creation time, so its clock is wall time since the goal started.
57
- const { chip, label, panel } = await render(control, { objective: "Finish every check", status, createdAt: startedTwoHoursAgo });
58
+ const { chip, label, panel } = await render(control, { objective: "Finish every check", status, createdAt: startedTwoHoursAgo, ...worked },
59
+ { working: true });
58
60
  assert.equal(label, key, `a ${status} goal's chip names its state`);
59
- assert.doesNotMatch(chip, /assistant-goal__elapsed/u, `a ${status} goal's chip shows no clock`);
60
- assert.match(panel, /jskit\.assistant\.goal\.elapsed\(jskit\.assistant\.goal\.elapsedSinceStart=2:00:0\d\)/u,
61
- `a ${status} goal's details keep the elapsed time`);
61
+ assert.doesNotMatch(chip, /assistant-goal__elapsed|assistant-goal__light--running/u, `a ${status} goal's chip shows no clock`);
62
+ assert.match(panel, /jskit\.assistant\.goal\.elapsed\(jskit\.assistant\.goal\.runningTime=2:00:00\)/u,
63
+ `a ${status} goal's details keep the running time, without the time since`);
62
64
  }
63
- // A native running time (Codex) stops counting while paused; the chip still shows no clock.
64
- const native = await render(control, { objective: "Finish every check", status: "paused", elapsedSeconds: 125, sampledAt: Date.now() });
65
- assert.equal(native.label, "jskit.assistant.goal.paused");
66
- assert.doesNotMatch(native.chip, /assistant-goal__elapsed/u);
67
- assert.match(native.panel, /jskit\.assistant\.goal\.runningTime=2:05/u);
68
65
  });
69
66
 
70
- test("an active goal's chip shows its clock, and a finished goal's chip shows neither state nor clock", async () => {
67
+ test("an active goal looks running, with its clock counting on from its running time, only while its conversation works on it", async () => {
71
68
  const control = await importControl();
72
- const active = await render(control, { objective: "Finish every check", status: "active", createdAt: startedTwoHoursAgo });
73
- assert.equal(active.label, "jskit.assistant.goal.goal");
74
- assert.match(active.chip, /<span class="assistant-goal__elapsed" aria-hidden="true">2:00:0\d<\/span>/u);
75
- assert.match(active.chip, /assistant-goal__light--running/u);
76
- const complete = await render(control, { objective: "Finish every check", status: "complete", createdAt: startedTwoHoursAgo,
77
- sampledAt: Date.now() });
69
+ const goal = { objective: "Finish every check", status: "active", createdAt: startedTwoHoursAgo, ...worked };
70
+ const running = await render(control, goal, { working: true });
71
+ assert.equal(running.label, "jskit.assistant.goal.goal");
72
+ assert.match(running.chip, /<span class="assistant-goal__elapsed" aria-hidden="true">2:10:0\d<\/span>/u);
73
+ assert.match(running.chip, /assistant-goal__light--running/u);
74
+ assert.match(running.panel, /role="status">jskit\.assistant\.goal\.running</u);
75
+ // Between turns, or after a restart before its continuation starts, nothing
76
+ // works on it: the chip says so, with no red light and no clock, and its
77
+ // running time stays where the work stopped.
78
+ const idle = await render(control, goal);
79
+ assert.equal(idle.label, "jskit.assistant.goal.notRunning");
80
+ assert.doesNotMatch(idle.chip, /assistant-goal__elapsed|assistant-goal__light--running/u);
81
+ assert.match(idle.panel, /jskit\.assistant\.goal\.runningTime=2:00:00\)/u);
82
+ assert.match(idle.panel, /jskit\.assistant\.goal\.notRunningHint</u);
83
+ assert.match(idle.panel, /jskit\.assistant\.goal\.pause</u, "a goal that is not running can still be paused");
84
+ // The reason it stopped (a restart's continuation that failed) shows with it.
85
+ const failed = await render(control, goal, { error: "The goal could not continue. Vibe64 tries again shortly." });
86
+ assert.equal(failed.label, "jskit.assistant.goal.notRunning");
87
+ assert.match(failed.panel, /role="alert"[^>]*>The goal could not continue\. Vibe64 tries again shortly\.</u);
88
+ // A goal with no running time shows no clock rather than counting wall time.
89
+ const untimed = await render(control, { objective: "Finish every check", status: "active", createdAt: startedTwoHoursAgo }, { working: true });
90
+ assert.doesNotMatch(untimed.chip, /assistant-goal__elapsed/u);
91
+ assert.doesNotMatch(untimed.panel, /runningTime/u);
92
+ const complete = await render(control, { objective: "Finish every check", status: "complete", ...worked });
78
93
  assert.equal(complete.label, "jskit.assistant.goal.goal");
79
94
  assert.doesNotMatch(complete.chip, /assistant-goal__elapsed/u);
95
+ assert.match(complete.panel, /runningTime=2:00:00\)/u);
80
96
  const none = await render(control, null);
81
97
  assert.equal(none.label, "jskit.assistant.goal.set");
82
98
  });
83
99
 
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
-
92
100
  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 () => {
93
101
  const control = await importControl();
94
102
  const reset = new Date();
@@ -930,7 +930,7 @@ test("long goal objectives keep Start reachable on short phone, tablet and deskt
930
930
  }
931
931
  });
932
932
 
933
- test("optional goals show running and paused lights, count only active time, and adapt to pane width", {
933
+ test("optional goals show running, not running and paused lights, count only working time, and adapt to pane width", {
934
934
  skip: !RUN_BROWSER_TEST,
935
935
  timeout: 60_000
936
936
  }, async (t) => {
@@ -953,14 +953,20 @@ test("optional goals show running and paused lights, count only active time, and
953
953
  await input.fill("Preserve this draft and caret");
954
954
  await input.evaluate(element => element.setSelectionRange(5, 9));
955
955
  const light = page.locator(".assistant-goal__light");
956
+ const elapsed = page.locator(".assistant-goal__elapsed");
956
957
  await expect(light).toHaveCSS("background-color", "rgb(211, 47, 47)");
957
958
  await expect(light).toHaveCSS("animation-name", /^assistant-goal-flash/u);
959
+ // With nothing working on it, the active goal says so: no red light, no
960
+ // clock, and the idle time does not count.
958
961
  await page.getByRole("button", { name: "External idle", exact: true }).evaluate(element => element.click());
962
+ await expect(page.locator(".assistant-goal .assistant-goal__label")).toHaveText("Goal not running");
959
963
  await expect(light).toHaveCSS("animation-name", "none");
960
- await expect(light).toHaveCSS("background-color", "rgb(211, 47, 47)");
964
+ await expect(light).not.toHaveCSS("background-color", "rgb(211, 47, 47)");
965
+ await expect(elapsed).toHaveCount(0);
966
+ await page.clock.fastForward(30_000);
961
967
  await page.getByRole("button", { name: "External active", exact: true }).evaluate(element => element.click());
962
968
  await expect(light).toHaveCSS("animation-name", /^assistant-goal-flash/u);
963
- const elapsed = page.locator(".assistant-goal__elapsed");
969
+ await expect(elapsed).toHaveText("0:00");
964
970
  await page.clock.fastForward(65_000);
965
971
  await expect(elapsed).toHaveText("1:05");
966
972
  await expect(input).toBeFocused();
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { createConversationRuntime, createFileConversationStorage, upgradeConversationRuntimeState } from "../src/server/conversation/index.js";
7
7
  import { createLocalConversationExecution } from "../src/server/conversation/localExecution.js";
8
8
  import { createClaudeConversationDriver } from "../src/server/conversation/providers/claudeDriver.js";
9
+ import { createServiceToolCatalog } from "../src/server/lib/serviceToolCatalog.js";
9
10
  import { conversationRequestText } from "../src/server/conversation/continuity.js";
10
11
  import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
11
12
  import { createSchema } from "json-rest-schema";
@@ -360,6 +361,12 @@ async function fixture(t, options = {}) {
360
361
  input: { hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "pwd", timeout: 100 } } });
361
362
  answer.message.content[0].text = JSON.stringify(output);
362
363
  }
364
+ if (text === "limits") {
365
+ // What Claude would hold the application tools' results to: their declared size and its MCP output cap.
366
+ const listed = await request({ subtype: "mcp_message", server_name: "application", message: { jsonrpc: "2.0", id: "list", method: "tools/list" } });
367
+ answer.message.content[0].text = JSON.stringify({ cap: process.env.MAX_MCP_OUTPUT_TOKENS ?? null,
368
+ sizes: listed.mcp_response.result.tools.map(tool => tool._meta?.["anthropic/maxResultSizeChars"] ?? null) });
369
+ }
363
370
  if (text === "tools" || text === "foreign-tool") {
364
371
  let sequence = 0;
365
372
  const invoke = async (name, input) => {
@@ -406,6 +413,7 @@ async function fixture(t, options = {}) {
406
413
  CLAUDE_CONFIG_DIR: path.join(directory, "claude"), TEST_TRACE: trace, TEST_ACCOUNT: account, ...options.environment };
407
414
  function runtime() {
408
415
  const value = createConversationRuntime({ engine: "claude", storage, authorize: options.authorize || (() => true), actions: options.actions,
416
+ toolCatalog: options.toolCatalog,
409
417
  connections: options.connections, attachments: options.attachments, persistCommentary: options.persistCommentary,
410
418
  host: { workdir: directory, env, commands: { claude: command }, allowAccountChange: options.allowAccountChange, execution: options.execution, nativeTools: options.nativeTools, commandWrapper: options.commandWrapper },
411
419
  limits: { admissionTimeoutMs: 150, ...options.limits } });
@@ -723,6 +731,26 @@ test("Claude uses shared discovery and durable application results across native
723
731
  assert.equal(starts[0].args[starts[0].args.indexOf("--session-id") + 1], starts[1].args[starts[1].args.indexOf("--resume") + 1]);
724
732
  });
725
733
 
734
+ test("Claude keeps a raised result limit's results whole: its tools declare that size and its process's MCP output cap fits it", async t => {
735
+ const context = { actor: { id: "owner" }, surface: "app", permissions: ["numbers.read"] };
736
+ const actions = applicationActions(async () => ({ value: 42 }));
737
+ const limits = async toolCatalog => {
738
+ const f = await fixture(t, { context, toolCatalog });
739
+ await f.conversation.send({ messageId: "limits", text: "limits" });
740
+ const result = await f.conversation.wait();
741
+ assert.equal(result.conversationLog[0].metadata.runtime.status, "complete");
742
+ return JSON.parse(result.conversationLog[0].assistant.text);
743
+ };
744
+ const raised = await limits(createServiceToolCatalog(actions, { maxToolResultBytes: 128 * 1024 }));
745
+ assert.equal(raised.cap, String(128 * 1024 + 1024));
746
+ assert.ok(raised.sizes.length > 0);
747
+ assert.deepEqual(raised.sizes, raised.sizes.map(() => 128 * 1024 + 1024));
748
+ // At the default limit, Claude keeps its own.
749
+ const ordinary = await limits(createServiceToolCatalog(actions));
750
+ assert.equal(ordinary.cap, process.env.MAX_MCP_OUTPUT_TOKENS ?? null);
751
+ assert.deepEqual(ordinary.sizes, ordinary.sizes.map(() => null));
752
+ });
753
+
726
754
  test("Claude rejects an application call without the preceding native tool identity", async t => {
727
755
  let executions = 0;
728
756
  const f = await fixture(t, {
@@ -77,6 +77,28 @@ test("standalone Claude startup uses the supplied execution host and carries its
77
77
  assert.deepEqual(await native.stop(), { scopeEmpty: true, exited: true });
78
78
  });
79
79
 
80
+ test("a Claude process for a raised tool result limit has an MCP output cap that every result within it fits", { skip: process.platform === "win32" }, async (t) => {
81
+ const setup = await fixture(t, `
82
+ import { createInterface } from "node:readline";
83
+ createInterface({ input: process.stdin }).on("line", line => {
84
+ const frame = JSON.parse(line);
85
+ if (frame.type === "control_request") process.stdout.write(JSON.stringify({ type: "control_response", response: {
86
+ subtype: "success", request_id: frame.request_id, response: { cap: process.env.MAX_MCP_OUTPUT_TOKENS ?? null } } }) + "\\n");
87
+ });
88
+ `);
89
+ const cap = async (maximumToolResultBytes, env = setup.env) => {
90
+ const native = await createClaudeCodeProcess({ ...setup, env, maximumToolResultBytes });
91
+ t.after(() => native.stop());
92
+ return native.initialization.cap;
93
+ };
94
+ // A token covers at least one byte of a result's text, and 1 KiB covers its envelope.
95
+ assert.equal(await cap(128 * 1024), String(128 * 1024 + 1024));
96
+ assert.equal(await cap(1024 * 1024), String(1024 * 1024 + 1024));
97
+ // At the default 48 KiB or less, or with no tools, Claude's own cap stays.
98
+ const hostCap = { ...setup.env, MAX_MCP_OUTPUT_TOKENS: "30000" };
99
+ for (const bytes of [48 * 1024, 16 * 1024, undefined]) assert.equal(await cap(bytes, hostCap), "30000");
100
+ });
101
+
80
102
  test("failed native initialization stops its owned process and reports the protocol failure", { skip: process.platform === "win32" }, async (t) => {
81
103
  const setup = await fixture(t, 'process.stdin.once("data", () => process.stdout.write("invalid frame\\n"));');
82
104
  await assert.rejects(createClaudeCodeProcess(setup), /invalid UTF-8 or JSON/);
@@ -129,42 +129,116 @@ 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-"));
132
+ // Frame shapes from a hosted Claude goal's history (Claude 2.1.3xx, 2026-11-11):
133
+ // the /goal command's sentinel comes first in the file, its authored command
134
+ // (a string with command tags) names it as parent with a slightly earlier
135
+ // timestamp, and an auto-compaction re-attaches the goal with a sentinel that
136
+ // no command follows, after a summary timestamped before its boundary.
137
+ function claudeGoalFrames(condition) {
138
+ const at = (seconds) => new Date(Date.parse("2026-11-11T07:00:00.000Z") + seconds * 1000).toISOString();
139
+ const sentinel = (uuid, parentUuid, seconds) => ({ type: "attachment", uuid, parentUuid, timestamp: at(seconds),
140
+ attachment: { type: "goal_status", condition, met: false, sentinel: true } });
141
+ const command = (uuid, parentUuid, seconds, args = condition) => ({ type: "user", uuid, parentUuid, timestamp: at(seconds),
142
+ isMeta: false, promptId: `prompt-${uuid}`, message: { role: "user", content: `<command-name>/goal</command-name>\n <command-message>goal</command-message>\n <command-args>${args}</command-args>` } });
143
+ const local = (uuid, parentUuid, seconds, args, output) => ({ type: "system", subtype: "local_command", uuid, parentUuid,
144
+ timestamp: at(seconds), isMeta: false, level: "info", commandRun: { command: "goal", args }, content: `<local-command-stdout>${output}</local-command-stdout>` });
145
+ const answer = (uuid, parentUuid, seconds, content = [{ type: "text", text: "Progress report" }]) => ({ type: "assistant", uuid, parentUuid,
146
+ timestamp: at(seconds), message: { id: `api-${uuid}`, role: "assistant", content } });
147
+ const attachment = (uuid, parentUuid, seconds, type) => ({ type: "attachment", uuid, parentUuid, timestamp: at(seconds), attachment: { type } });
148
+ const set = (prefix, parent, seconds) => [sentinel(`${prefix}-sentinel`, parent, seconds + 0.002),
149
+ command(`${prefix}-command`, `${prefix}-sentinel`, seconds),
150
+ local(`${prefix}-set`, `${prefix}-command`, seconds + 0.003, condition, `Goal set: ${condition}`),
151
+ { type: "user", uuid: `${prefix}-meta`, parentUuid: `${prefix}-set`, timestamp: at(seconds + 0.004), isMeta: true, message: { role: "user", content: "Work on the goal." } }];
152
+ return { at, set, answer, attachment, command, local, sentinel,
153
+ toolUse: (uuid, parentUuid, seconds) => answer(uuid, parentUuid, seconds, [{ type: "tool_use", id: `tool-${uuid}`, name: "Bash", input: { command: "npm test" } }]),
154
+ toolResult: (uuid, parentUuid, seconds) => ({ type: "user", uuid, parentUuid, timestamp: at(seconds),
155
+ message: { role: "user", content: [{ type: "tool_result", tool_use_id: `tool-${parentUuid}`, content: "ok" }] }, toolUseResult: { stdout: "ok" } }),
156
+ message: (uuid, parentUuid, seconds, text) => ({ type: "user", uuid, parentUuid, timestamp: at(seconds), message: { role: "user", content: text } }),
157
+ compaction: (parent, seconds) => [
158
+ { type: "system", subtype: "compact_boundary", uuid: "boundary", parentUuid: null, logicalParentUuid: parent, timestamp: at(seconds + 0.295), compactMetadata: { trigger: "auto" } },
159
+ attachment("instructions", "boundary", seconds + 0.296, "instructions"),
160
+ { type: "user", uuid: "summary", parentUuid: "instructions", timestamp: at(seconds - 0.069), isCompactSummary: true, isVisibleInTranscriptOnly: true,
161
+ message: { role: "user", content: "This session is being continued from a previous conversation." } },
162
+ attachment("model", "summary", seconds + 0.2, "model"),
163
+ sentinel("reattached", "model", seconds + 0.203),
164
+ attachment("credential", "reattached", seconds + 0.204, "credential_org")] };
165
+ }
166
+
167
+ test("a Claude goal Claude re-attaches after compacting its context stays the same goal; only its own /goal command starts it again", async t => {
168
+ const root = await mkdtemp(path.join(os.tmpdir(), "claude-goal-compaction-"));
169
+ t.after(() => rm(root, { recursive: true, force: true }));
170
+ const directory = path.join(root, "projects", root.replace(/[^a-zA-Z0-9]/gu, "-"));
171
+ await mkdir(directory, { recursive: true });
172
+ const conversationId = "1d6f6949-185c-46b2-9475-a9f080d505f3";
173
+ const file = path.join(directory, `${conversationId}.jsonl`);
174
+ const options = { configRoot: root, workdir: root, conversationId };
175
+ const append = (...frames) => writeFile(file, frames.flat().map(JSON.stringify).join("\n") + "\n", { flag: "a" });
176
+ const f = claudeGoalFrames("Continue till all of the pending items are at 100%");
177
+ await append(f.set("first", "start", 0), f.answer("first-answer", "first-meta", 60));
178
+ // The person's Resume while it was active sends the same objective again: a new start.
179
+ await append(f.set("resumed", "first-answer", 600), f.toolUse("working", "resumed-meta", 700));
180
+ const resumed = await readClaudeHistory(options);
181
+ assert.equal(resumed.goal.createdAt, Date.parse(f.at(600.002)) / 1000);
182
+ assert.equal(resumed.goalCommandId, "resumed-command");
183
+ await append(f.compaction("working", 9000), f.answer("after-compaction", "credential", 9100));
184
+ const compacted = await readClaudeHistory(options);
185
+ assert.equal(compacted.goal.status, "active");
186
+ assert.equal(compacted.goal.createdAt, resumed.goal.createdAt, "the re-attached goal keeps the identity a restart's recovery saved");
187
+ assert.equal(compacted.goalCommandId, "resumed-command");
188
+ // A /goal command for the same objective after the compaction starts it again.
189
+ await append(f.set("again", "after-compaction", 9200));
190
+ const again = await readClaudeHistory(options);
191
+ assert.equal(again.goal.createdAt, Date.parse(f.at(9200.002)) / 1000);
192
+ assert.equal(again.goalCommandId, "again-command");
193
+ // An ordinary message after a sentinel is not that sentinel's /goal command.
194
+ await append(f.sentinel("bare", "again-meta", 9300), f.message("bare-reply", "bare", 9301, "Goal noted"));
195
+ assert.equal((await readClaudeHistory(options)).goal.createdAt, again.goal.createdAt);
196
+ });
197
+
198
+ test("a Claude goal's running time counts only the time Claude spent working, carried across a restart, a compaction, Pause and Resume", async t => {
199
+ const root = await mkdtemp(path.join(os.tmpdir(), "claude-goal-time-"));
134
200
  t.after(() => rm(root, { recursive: true, force: true }));
135
201
  const directory = path.join(root, "projects", root.replace(/[^a-zA-Z0-9]/gu, "-"));
136
202
  await mkdir(directory, { recursive: true });
137
203
  const conversationId = "6b1f2c9e-4d3a-4c1b-9e7f-2a5d8c0b1e3f";
138
204
  const file = path.join(directory, `${conversationId}.jsonl`);
139
205
  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",
206
+ const append = (...frames) => writeFile(file, frames.flat().map(JSON.stringify).join("\n") + "\n", { flag: "a" });
207
+ const f = claudeGoalFrames("Finish all checks");
208
+ const read = async () => (await readClaudeHistory(options)).goal;
209
+ // Ten seconds' thinking, a one-minute tool run and thirty seconds' answer.
210
+ await append(f.set("set", "start", 0), f.toolUse("tool", "set-meta", 10), f.toolResult("result", "tool", 70), f.answer("report", "result", 100));
211
+ assert.equal(Math.round((await read()).timeUsedSeconds), 100);
212
+ // An hour between turns waits for the person's next message and does not count.
213
+ await append(f.message("next", "report", 3600, "Keep going"));
214
+ let goal = await read();
215
+ assert.equal(Math.round(goal.timeUsedSeconds), 100);
216
+ assert.equal(goal.updatedAt, Date.parse(f.at(3600)) / 1000, "its running time is current as of the latest history");
217
+ await append(f.answer("next-answer", "next", 3630));
218
+ // The server stopped mid-turn; its restart sends the goal again an hour later. That gap does not count.
219
+ await append(f.toolUse("cut", "next-answer", 3640), f.set("restart", "cut", 7200), f.answer("restart-answer", "restart-meta", 7260));
220
+ assert.equal(Math.round((await read()).timeUsedSeconds), 100 + 40 + 60);
221
+ // Compacting the context is work, and timestamps running backwards subtract nothing.
222
+ await append(f.compaction("restart-answer", 7300), f.answer("compacted", "credential", 7320));
223
+ assert.equal(Math.round((await read()).timeUsedSeconds), 200 + 60);
224
+ // Pause clears Claude's checker; Resume sends it again half an hour later. Neither wait counts.
225
+ await append(f.command("pause", "compacted", 7400, "clear"), f.local("cleared", "pause", 7400.01, "clear", "Goal cleared: Finish all checks"));
226
+ assert.equal(await read(), null);
227
+ await append(f.set("resume", "cleared", 9000), f.answer("resume-answer", "resume-meta", 9030));
228
+ goal = await read();
229
+ assert.equal(goal.status, "active");
230
+ assert.equal(Math.round(goal.timeUsedSeconds), 260 + 30);
231
+ assert.equal(goal.updatedAt, Date.parse(f.at(9030)) / 1000);
232
+ // Finishing keeps the total; a new goal starts from zero.
233
+ await append({ type: "attachment", uuid: "done", parentUuid: "resume-answer", timestamp: f.at(9040),
159
234
  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);
235
+ goal = await read();
236
+ assert.equal(goal.status, "complete");
237
+ assert.equal(Math.round(goal.timeUsedSeconds), 300);
238
+ await append(claudeGoalFrames("Write the release notes").set("other", "done", 9100), f.answer("other-answer", "other-meta", 9110));
239
+ goal = await read();
240
+ assert.equal(goal.objective, "Write the release notes");
241
+ assert.equal(Math.round(goal.timeUsedSeconds), 10);
168
242
  });
169
243
 
170
244
  // Claude 2.1.308 history at a hosted goal's usage limit (2026-11-10), and the
@@ -360,7 +360,7 @@ 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 () => {
363
+ test("the resolved tool set's limits are the ones its calls are held to and the ones engines are given", async () => {
364
364
  const lengths = [];
365
365
  const actions = createActionCatalogue();
366
366
  actions.register({ contributorId: "test.documents", domain: "test", actions: [{
@@ -372,7 +372,8 @@ test("the resolved tool set's argument limit is the one its calls are held to an
372
372
  }] });
373
373
  // One catalogue per surface behind one, as assistant-runtime's surface-aware catalogue is:
374
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) };
375
+ const surfaces = { app: createServiceToolCatalog(actions, { maxToolArgumentBytes: 64 * 1024, maxToolResultBytes: 96 * 1024 }),
376
+ home: createServiceToolCatalog(actions) };
376
377
  const catalog = {
377
378
  resolveToolSet: (toolContext, options) => surfaces[toolContext.surface].resolveToolSet(toolContext, options),
378
379
  toOpenAiToolSchema: surfaces.home.toOpenAiToolSchema,
@@ -383,13 +384,51 @@ test("the resolved tool set's argument limit is the one its calls are held to an
383
384
  const proposal = name => ({ id: "propose", name, arguments: JSON.stringify({ text: "x".repeat(40 * 1024) }) });
384
385
  const app = tools("app");
385
386
  assert.equal(app.maximumArgumentBytes, 64 * 1024);
387
+ assert.equal(app.maximumResultBytes, 96 * 1024);
386
388
  assert.deepEqual(await app.execute(proposal(app.descriptors[0].name)), { ok: true, result: { length: 40 * 1024 } });
387
389
  const home = tools("home");
388
390
  assert.equal(home.maximumArgumentBytes, 32 * 1024);
391
+ assert.equal(home.maximumResultBytes, 48 * 1024);
389
392
  await assert.rejects(home.execute(proposal(home.descriptors[0].name)), { message: "The application tool arguments exceed their size limit." });
390
393
  assert.deepEqual(lengths, [40 * 1024]);
391
394
  });
392
395
 
396
+ test("a hand-written catalogue whose tool sets carry no limits keeps the default limits, and its API turns run", { timeout: 30_000 }, async t => {
397
+ const executed = [];
398
+ const tool = { name: "numbers_add", description: "Adds two numbers.",
399
+ parameters: { type: "object", properties: { left: { type: "number" }, right: { type: "number" } } } };
400
+ const toolCatalog = {
401
+ resolveToolSet: () => ({ tools: [tool] }),
402
+ toOpenAiToolSchema: descriptor => ({ type: "function", function: { name: descriptor.name, description: descriptor.description,
403
+ parameters: descriptor.parameters } }),
404
+ async executeToolCall({ toolName, argumentsText }) {
405
+ executed.push([toolName, argumentsText]);
406
+ return { ok: true, result: { total: 5 } };
407
+ }
408
+ };
409
+ const requests = [];
410
+ const runtime = createConversationRuntime({ storage: createMemoryConversationStorage(), toolCatalog, authorize: () => true,
411
+ connections: { resolve: async () => ({ providerId: "test", model: "exact-model", sdkPackage: "@ai-sdk/openai-compatible", apiKey: "test", baseURL: "http://test.invalid/v1" }) },
412
+ fetch: (_url, request) => {
413
+ requests.push(JSON.parse(request.body));
414
+ return response(requests.length === 1 ? { name: tool.name } : {});
415
+ } });
416
+ t.after(() => runtime.close());
417
+ const conversation = await runtime.open({ id: "hand-written", context, configuration });
418
+ await conversation.send(message);
419
+ const result = await conversation.wait();
420
+ assert.equal(result.error, "");
421
+ assert.equal(result.conversationLog[0].assistant.text, "The total is five.");
422
+ assert.deepEqual(result.conversationLog[0].metadata.applicationTools[0].result, { ok: true, result: { total: 5 } });
423
+ assert.deepEqual(executed, [[tool.name, call.arguments]]);
424
+
425
+ const tools = createConversationTools({ catalog: toolCatalog, context, signal: new AbortController().signal, transient: true });
426
+ assert.equal(tools.maximumArgumentBytes, 32 * 1024);
427
+ assert.equal(tools.maximumResultBytes, 48 * 1024);
428
+ await assert.rejects(tools.execute({ id: "large", name: tool.name, arguments: JSON.stringify({ text: "x".repeat(32 * 1024) }) }),
429
+ { message: "The application tool arguments exceed their size limit." });
430
+ });
431
+
393
432
  test("closing a native tool connection prevents queued calls while retaining an invoked result", async () => {
394
433
  const entered = Promise.withResolvers(), complete = Promise.withResolvers();
395
434
  let executions = 0;