@jskit-ai/assistant-core 0.2.45 → 0.2.46
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 +5 -1
- package/examples/conversation/package.json +1 -1
- package/fixtures/responsive-assistant/ConversationFixture.vue +12 -4
- package/locales/en.json +2 -1
- package/package.json +1 -1
- package/src/client/conversation/AssistantGoalControl.vue +30 -35
- package/src/client/types.js +3 -3
- package/src/server/conversation/claudeHistory.js +42 -6
- package/src/server/conversation/claudeProcess.js +5 -2
- package/src/server/conversation/claudeProcessStart.js +6 -2
- package/src/server/conversation/claudeTools.js +20 -2
- package/src/server/conversation/conversationTurns.js +1 -0
- package/src/server/conversation/providers/claudeDriver.js +1 -1
- package/src/server/conversation/tools.js +7 -3
- package/src/server/lib/serviceToolCatalog.js +1 -1
- package/test/assistantGoalControlChip.test.js +34 -26
- package/test/assistantScroll.browser.test.js +9 -3
- package/test/claudeConversationSession.test.js +28 -0
- package/test/claudeProcess.test.js +22 -0
- package/test/claudeTurn.test.js +103 -29
- package/test/conversationTools.test.js +41 -2
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
|
|
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.
|
|
@@ -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 + (
|
|
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
|
@@ -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
|
|
20
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
});
|
|
43
|
-
const
|
|
44
|
-
const
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
64
|
-
//
|
|
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)
|
|
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
|
|
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--
|
|
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="(
|
|
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="
|
|
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--
|
|
133
|
+
@media (prefers-reduced-motion: reduce) { .assistant-goal__light--running { animation: none; } }
|
|
139
134
|
</style>
|
package/src/client/types.js
CHANGED
|
@@ -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
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
286
|
-
|
|
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 &&
|
|
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 && !
|
|
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
|
|
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
|
-
|
|
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 || [],
|
|
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.
|
|
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
|
|
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
|
|
46
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
57
|
-
|
|
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\.
|
|
61
|
-
`a ${status} goal's details keep the
|
|
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
|
|
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
|
|
73
|
-
|
|
74
|
-
assert.
|
|
75
|
-
assert.match(
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
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/);
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -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
|
-
|
|
133
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
await append(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
assert.equal(
|
|
150
|
-
|
|
151
|
-
await append(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
await
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
assert.equal(read.
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
await
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
|
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;
|