@jskit-ai/assistant-core 0.2.46 → 0.2.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - A Claude goal's running time no longer counts the time a server was stopped mid-turn. When Claude starts again after a request that was cut off, it saves a "No response requested." placeholder just before the goal continues, and the history reader counted the wait up to that placeholder as work: a restart in the middle of a turn added the whole downtime to the goal's clock. The wait before that placeholder now counts like the wait before an authored message; the cut-off request's unsaved part is not counted.
6
+ - `AssistantGoalControl`'s chip says why a goal stopped in a few words, so it no longer runs across the message box: a goal stopped at its AI account's usage limit reads **Goal paused: usage limit**, or **Goal paused: no credit** when it is out of credit, with the orange paused light, instead of "Waiting: AI usage limit, resets at Wed 09:00" or "Out of credit: top up or switch AI account". Its details say what happened, when the limit resets and what to do. `jskit.assistant.goal.usageLimitedUntil` is replaced by `jskit.assistant.goal.usageLimitedUntilHint`, and `jskit.assistant.goal.outOfCreditHint` is new; `usageLimited`, `outOfCredit` and `usageLimitedHint` are reworded.
5
7
  - 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
8
  - 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`.
7
9
  - 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.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.46",
12
- "@jskit-ai/http-runtime": "0.2.20",
13
- "@jskit-ai/kernel": "0.2.20",
11
+ "@jskit-ai/assistant-core": "0.2.48",
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",
@@ -188,6 +188,10 @@ const adapter = reactive({
188
188
  <button @click="goalEnabled = !goalEnabled">Toggle goals</button>
189
189
  <button v-if="goal" @click="goal = { ...goal, status: 'blocked', reason: 'An explanation of the blocker. '.repeat(200) }">Block goal with long explanation</button>
190
190
  <button v-if="goal" @click="goal = { ...goal, status: 'complete' }">Complete goal</button>
191
+ <button v-if="goal" @click="goal = { ...goal, status: 'usageLimited', resetsAt: Math.floor(Date.now() / 1000) + 3 * 24 * 60 * 60, outOfCredit: false }">Stop goal at usage limit</button>
192
+ <button v-if="goal" @click="goal = { ...goal, status: 'usageLimited', resetsAt: undefined, outOfCredit: false }">Stop goal at usage limit without reset</button>
193
+ <button v-if="goal" @click="goal = { ...goal, status: 'usageLimited', resetsAt: undefined, outOfCredit: true }">Stop goal out of credit</button>
194
+ <button v-if="goal" @click="goal = { ...goal, status: 'budgetLimited' }">Stop goal at budget</button>
191
195
  <template v-if="previewFixture">
192
196
  <button @click="temporaryWords = 'Exact temporary words'; temporaryPresent = true; temporaryEditing = false">Add temporary message</button>
193
197
  <button @click="newerWords = 'Keep newer live words'; newerPresent = true; newerEditing = false">Add newer temporary message</button>
package/locales/en.json CHANGED
@@ -113,7 +113,8 @@
113
113
  "notRunning": "Goal not running",
114
114
  "notRunningHint": "Nothing is working on this goal right now. Your next message continues it.",
115
115
  "objective": "Goal objective",
116
- "outOfCredit": "Out of credit: top up or switch AI account",
116
+ "outOfCredit": "Goal paused: no credit",
117
+ "outOfCreditHint": "The AI account is out of credit. The goal does not continue by itself: top up or switch AI account, then choose Resume goal.",
117
118
  "pause": "Pause goal",
118
119
  "pauseInterruptsTurn": "Pause stops the current turn and keeps the goal for later.",
119
120
  "pausePreventsTurns": "Pausing prevents further automatic turns. Use Stop to interrupt the current turn.",
@@ -128,9 +129,9 @@
128
129
  "starting": "Starting…",
129
130
  "tokenBudget": "Token budget (optional)",
130
131
  "unavailable": "Goal unavailable",
131
- "usageLimited": "Waiting: AI usage limit",
132
- "usageLimitedHint": "The goal does not continue by itself. Choose Resume goal once the AI account can work again.",
133
- "usageLimitedUntil": "Waiting: AI usage limit, resets at {time}"
132
+ "usageLimited": "Goal paused: usage limit",
133
+ "usageLimitedHint": "The AI account reached its usage limit. The goal does not continue by itself: choose Resume goal once the AI account can work again.",
134
+ "usageLimitedUntilHint": "The AI account reached its usage limit, which resets at {time}. The goal does not continue by itself: choose Resume goal once it resets."
134
135
  },
135
136
  "longText": {
136
137
  "details": "Details",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.46",
3
+ "version": "0.2.48",
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
  },
@@ -22,18 +22,22 @@ const active = computed(() => goal.value?.status === "active");
22
22
  const running = computed(() => active.value && props.state.working === true);
23
23
  // A Start stays Starting… until the server's goal shows it, even while closed.
24
24
  const starting = computed(() => props.state.action === "set");
25
- // A goal waiting at its AI account's limit names it, with the reset time when
26
- // the engine reports one (with its day when that is not today).
25
+ // A goal waiting at its AI account's limit says why in a few words; its details
26
+ // say when the limit resets (with its day when that is not today) and what to do.
27
27
  const resetTime = computed(() => {
28
28
  if (!Number.isSafeInteger(goal.value?.resetsAt)) return "";
29
29
  const reset = new Date(goal.value.resetsAt * 1000);
30
30
  const time = `${String(reset.getHours()).padStart(2, "0")}:${String(reset.getMinutes()).padStart(2, "0")}`;
31
31
  return reset.toDateString() === new Date().toDateString() ? time : `${reset.toLocaleDateString(undefined, { weekday: "short" })} ${time}`;
32
32
  });
33
+ const limitHint = computed(() => goal.value?.status !== "usageLimited" ? ""
34
+ : goal.value.outOfCredit ? t("jskit.assistant.goal.outOfCreditHint")
35
+ : resetTime.value ? t("jskit.assistant.goal.usageLimitedUntilHint", { time: resetTime.value }) : t("jskit.assistant.goal.usageLimitedHint"));
36
+ // Every status label is a few words, as the chip shows it beside the message
37
+ // box: what happened, when it continues and what to do belong in the details.
33
38
  const statusLabel = computed(() => goal.value?.status === "usageLimited" && goal.value.outOfCredit ? t("jskit.assistant.goal.outOfCredit")
34
- : goal.value?.status === "usageLimited" && resetTime.value ? t("jskit.assistant.goal.usageLimitedUntil", { time: resetTime.value })
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"));
39
+ : active.value && !running.value ? t("jskit.assistant.goal.notRunning")
40
+ : t(Object.hasOwn(STATUS_KEYS, goal.value?.status ?? "") ? STATUS_KEYS[goal.value.status] : "jskit.assistant.goal.set"));
37
41
  const label = computed(() => props.state.loading ? t("jskit.assistant.goal.loading") : props.state.available === false ? t("jskit.assistant.goal.unavailable")
38
42
  : starting.value ? t("jskit.assistant.goal.starting") : statusLabel.value);
39
43
  // The clock is the time spent working on the goal: the engine's running time
@@ -89,13 +93,13 @@ async function cancelGoal() {
89
93
  <v-menu v-if="state && state.enabled !== false" v-model="open" location="top" width="360" max-width="calc(100vw - 24px)" :close-on-content-click="false">
90
94
  <template #activator="{ props: menuProps }">
91
95
  <v-btn v-bind="menuProps" class="assistant-goal" size="small" variant="text" :aria-label="label" :title="[label, elapsedText, goal?.objective].filter(Boolean).join('\n')">
92
- <span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--paused': goal.status === 'paused' }" aria-hidden="true" />
96
+ <span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--paused': ['paused', 'usageLimited'].includes(goal.status) }" aria-hidden="true" />
93
97
  <span class="assistant-goal__label">{{ chipLabel }}</span>
94
98
  <span v-if="elapsed && running" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
95
99
  </v-btn>
96
100
  </template>
97
101
  <v-card class="assistant-goal__details pa-3">
98
- <strong role="status">{{ label }}</strong>
102
+ <strong role="status" class="me-2">{{ label }}</strong>
99
103
  <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>
100
104
  <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>
101
105
  <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>
@@ -103,7 +107,7 @@ async function cancelGoal() {
103
107
  <p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
104
108
  <p v-if="elapsed" class="text-body-small my-2">{{ elapsedText }}</p>
105
109
  <p v-if="active && !running" class="text-body-small my-2">{{ t('jskit.assistant.goal.notRunningHint') }}</p>
106
- <p v-if="goal?.status === 'usageLimited'" class="text-body-small mt-2">{{ t('jskit.assistant.goal.usageLimitedHint') }}</p>
110
+ <p v-if="limitHint" class="text-body-small mt-2">{{ limitHint }}</p>
107
111
  <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>
108
112
  <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>
109
113
  <form v-if="canSet" class="assistant-goal__form mt-2" @submit.prevent="setGoal">
@@ -99,15 +99,20 @@ async function claudeHistoryPath({ configRoot, workdir, conversationId }) {
99
99
  return null;
100
100
  }
101
101
 
102
+ // Claude saves this non-error synthetic carrier when it starts again after an
103
+ // interrupted request: a stopped request, or a process that ended mid-turn. Its
104
+ // native placeholder classifier uses this exact provenance; real model replies
105
+ // and API errors with the same wording remain ordinary output.
106
+ function claudeNoResponsePlaceholder(frame) {
107
+ const content = frame.message?.content;
108
+ return frame.type === "assistant" && frame.message?.model === "<synthetic>" && !frame.isApiErrorMessage &&
109
+ Array.isArray(content) && content.length === 1 && content[0]?.type === "text" && content[0].text === "No response requested.";
110
+ }
111
+
102
112
  function claudeMessageBlocks(frame, { includeNested = false, commentary = false } = {}) {
103
113
  if (frame.type !== "assistant" || frame.parent_tool_use_id && !includeNested) return [];
104
114
  const content = frame.message?.content;
105
- if (!Array.isArray(content)) return [];
106
- // Claude's native placeholder classifier excludes this non-error synthetic
107
- // carrier after an interrupted request. Real model replies and API errors
108
- // with the same wording remain ordinary output.
109
- if (frame.message.model === "<synthetic>" && !frame.isApiErrorMessage && content.length === 1 &&
110
- content[0]?.type === "text" && content[0].text === "No response requested.") return [];
115
+ if (!Array.isArray(content) || claudeNoResponsePlaceholder(frame)) return [];
111
116
  // Native frames split one API message into blocks with distinct UUIDs.
112
117
  const messageId = frame.uuid || frame.message.id;
113
118
  if (!messageId) return [];
@@ -277,10 +282,12 @@ async function readClaudeHistory(options) {
277
282
  // Claude saves its history as it works, so the time up to each frame it
278
283
  // saves itself is the goal's running time. The wait before a message the
279
284
  // person or the host sent (between turns, while paused, or across a
280
- // restart) is not.
285
+ // restart) is not, nor the wait before the placeholder Claude saves when it
286
+ // starts again after an interrupted request: that spans a stopped server.
281
287
  const at = ["user", "assistant", "attachment", "system"].includes(frame.type) ? Date.parse(frame.timestamp) / 1000 : NaN;
282
288
  if (Number.isFinite(at)) {
283
- const waited = claudeAuthoredMessage(frame) || frame.type === "attachment" && commandedSentinels.has(frame.uuid);
289
+ const waited = claudeAuthoredMessage(frame) || claudeNoResponsePlaceholder(frame) ||
290
+ frame.type === "attachment" && commandedSentinels.has(frame.uuid);
284
291
  if (goal?.status === "active" && !waited && at > lastAt) goal.timeUsedSeconds += at - lastAt;
285
292
  lastAt = Number.isFinite(lastAt) ? Math.max(lastAt, at) : at;
286
293
  if (goal?.status === "active") goal.updatedAt = lastAt;
@@ -9,21 +9,32 @@ import { renderToString } from "vue/server-renderer";
9
9
  const controlUrl = new URL("../src/client/conversation/AssistantGoalControl.vue", import.meta.url).href;
10
10
  const textUrl = new URL("../src/client/i18n.js", import.meta.url).href;
11
11
 
12
- async function importControl() {
12
+ const TEXT_SOURCES = Object.freeze({
13
+ // Each text shows as its key, with any parameters.
14
+ keys: "export function useJskitText() { return { t: (key, params) => params ? `${key}(${Object.values(params).join(\"=\")})` : key }; }",
15
+ // The real English texts, as en.json words them.
16
+ english: `const texts = ${readFileSync(new URL("../locales/en.json", import.meta.url), "utf8")};
17
+ export function useJskitText() {
18
+ return { t: (key, params = {}) => key.split(".").reduce((value, part) => value?.[part], texts)
19
+ .replace(/\\{(\\w+)\\}/gu, (_match, name) => String(params[name])) };
20
+ }`
21
+ });
22
+
23
+ /** The control, compiled with its texts from one of TEXT_SOURCES. */
24
+ async function importControl(texts = "keys") {
13
25
  const hooks = registerHooks({
14
26
  load(url, context, nextLoad) {
15
- if (url === textUrl) {
16
- return { format: "module", shortCircuit: true, source:
17
- "export function useJskitText() { return { t: (key, params) => params ? `${key}(${Object.values(params).join(\"=\")})` : key }; }" };
18
- }
19
- if (url !== controlUrl) return nextLoad(url, context);
20
- const { descriptor } = parse(readFileSync(new URL(url), "utf8"));
21
- return { format: "module", shortCircuit: true,
22
- source: compileScript(descriptor, { id: "assistant-goal-chip", inlineTemplate: true }).content };
27
+ const [file, query = ""] = url.split("?");
28
+ const source = new URLSearchParams(query).get("text");
29
+ if (file === textUrl && source) return { format: "module", shortCircuit: true, source: TEXT_SOURCES[source] };
30
+ if (file !== controlUrl || !source) return nextLoad(url, context);
31
+ const { descriptor } = parse(readFileSync(new URL(file), "utf8"));
32
+ const compiled = compileScript(descriptor, { id: "assistant-goal-chip", inlineTemplate: true }).content;
33
+ return { format: "module", shortCircuit: true, source: compiled.replace('from "../i18n.js"', `from "../i18n.js?text=${source}"`) };
23
34
  }
24
35
  });
25
36
  try {
26
- return (await import(controlUrl)).default;
37
+ return (await import(`${controlUrl}?text=${texts}`)).default;
27
38
  } finally {
28
39
  hooks.deregister();
29
40
  }
@@ -71,7 +82,7 @@ test("an active goal looks running, with its clock counting on from its running
71
82
  assert.equal(running.label, "jskit.assistant.goal.goal");
72
83
  assert.match(running.chip, /<span class="assistant-goal__elapsed" aria-hidden="true">2:10:0\d<\/span>/u);
73
84
  assert.match(running.chip, /assistant-goal__light--running/u);
74
- assert.match(running.panel, /role="status">jskit\.assistant\.goal\.running</u);
85
+ assert.match(running.panel, /role="status"[^>]*>jskit\.assistant\.goal\.running</u);
75
86
  // Between turns, or after a restart before its continuation starts, nothing
76
87
  // works on it: the chip says so, with no red light and no clock, and its
77
88
  // running time stays where the work stopped.
@@ -97,7 +108,7 @@ test("an active goal looks running, with its clock counting on from its running
97
108
  assert.equal(none.label, "jskit.assistant.goal.set");
98
109
  });
99
110
 
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 () => {
111
+ test("a goal stopped at its AI account's limit says so in a few words; its details say when the limit resets and what to do", async () => {
101
112
  const control = await importControl();
102
113
  const reset = new Date();
103
114
  reset.setSeconds(0, 0);
@@ -105,23 +116,68 @@ test("a goal waiting at its AI account's limit names the limit and when it reset
105
116
  const waiting = { objective: "Finish every check", status: "usageLimited", createdAt: startedTwoHoursAgo, resetsAt: reset.getTime() / 1000 };
106
117
  // Claude's goals can be paused while they wait; the chip shows no clock.
107
118
  const claude = await render(control, waiting, { pauseBlocked: true });
108
- assert.equal(claude.label, `jskit.assistant.goal.usageLimitedUntil(${time})`);
109
- assert.doesNotMatch(claude.chip, /assistant-goal__elapsed|assistant-goal__light--running/u);
110
- assert.match(claude.panel, new RegExp(`role="status">jskit\\.assistant\\.goal\\.usageLimitedUntil\\(${time}\\)<`, "u"));
111
- for (const action of ["pause", "resume", "cancel", "usageLimitedHint"]) {
119
+ assert.equal(claude.label, "jskit.assistant.goal.usageLimited");
120
+ assert.doesNotMatch(claude.chip, /assistant-goal__elapsed|assistant-goal__light--running|usageLimitedUntilHint/u);
121
+ assert.match(claude.chip, /assistant-goal__light--paused/u, "a goal stopped at its limit shows the paused light");
122
+ assert.match(claude.panel, /role="status"[^>]*>jskit\.assistant\.goal\.usageLimited</u);
123
+ assert.match(claude.panel, new RegExp(`jskit\\.assistant\\.goal\\.usageLimitedUntilHint\\(${time}\\)<`, "u"));
124
+ for (const action of ["pause", "resume", "cancel"]) {
112
125
  assert.match(claude.panel, new RegExp(`jskit\\.assistant\\.goal\\.${action}<`, "u"), `a waiting Claude goal offers ${action}`);
113
126
  }
114
127
  // Codex refuses Pause while it waits, so its control offers only Resume and Cancel.
115
128
  const codex = await render(control, waiting);
116
129
  assert.doesNotMatch(codex.panel, /jskit\.assistant\.goal\.pause</u);
117
130
  assert.match(codex.panel, /jskit\.assistant\.goal\.resume</u);
118
- // A reset on another day names its day.
131
+ // A reset on another day names its day, in the details.
119
132
  const later = await render(control, { ...waiting, resetsAt: waiting.resetsAt + 3 * 24 * 60 * 60 });
120
- assert.equal(later.label, `jskit.assistant.goal.usageLimitedUntil(${new Date((waiting.resetsAt + 3 * 24 * 60 * 60) * 1000)
121
- .toLocaleDateString(undefined, { weekday: "short" })} ${time})`);
122
- // Without a reset time it waits for the person; out of credit says what to do.
123
- assert.equal((await render(control, { ...waiting, resetsAt: undefined })).label, "jskit.assistant.goal.usageLimited");
133
+ assert.equal(later.label, "jskit.assistant.goal.usageLimited");
134
+ assert.match(later.panel, new RegExp(`usageLimitedUntilHint\\(${new Date((waiting.resetsAt + 3 * 24 * 60 * 60) * 1000)
135
+ .toLocaleDateString(undefined, { weekday: "short" })} ${time}\\)<`, "u"));
136
+ // Without a reset time it waits for the person; out of credit says what to do, in the details.
137
+ const unknown = await render(control, { ...waiting, resetsAt: undefined });
138
+ assert.equal(unknown.label, "jskit.assistant.goal.usageLimited");
139
+ assert.match(unknown.panel, /jskit\.assistant\.goal\.usageLimitedHint</u);
124
140
  const credit = await render(control, { ...waiting, resetsAt: undefined, outOfCredit: true }, { pauseBlocked: true });
125
141
  assert.equal(credit.label, "jskit.assistant.goal.outOfCredit");
142
+ assert.match(credit.panel, /jskit\.assistant\.goal\.outOfCreditHint</u);
126
143
  assert.match(credit.panel, /jskit\.assistant\.goal\.resume</u);
127
144
  });
145
+
146
+ // The chip sits beside the message box, on a phone too: each of its labels is a
147
+ // few words, and anything longer (why, until when, what to do) is in the details.
148
+ const CHIP_LABEL_MAX_CHARACTERS = 24;
149
+
150
+ test("every goal chip label is a few words, while the details explain a stop in full", async () => {
151
+ const control = await importControl("english");
152
+ const reset = Math.floor(Date.now() / 1000) + 3 * 24 * 60 * 60;
153
+ const goal = { objective: "Finish every check. ".repeat(40), createdAt: startedTwoHoursAgo, ...worked };
154
+ const states = [
155
+ ["no goal", null, {}],
156
+ ["loading", null, { loading: true }],
157
+ ["unavailable", null, { available: false, error: "The goal could not be read. Wait for the assistant connection and try again." }],
158
+ ["starting", null, { action: "set" }],
159
+ ["running", { ...goal, status: "active" }, { working: true }],
160
+ ["not running after an error", { ...goal, status: "active" }, { error: "Goal continuation after restart failed: the AI is busy. Vibe64 tries again shortly." }],
161
+ ["paused", { ...goal, status: "paused" }, {}],
162
+ ["blocked", { ...goal, status: "blocked", reason: "An explanation of the blocker. ".repeat(40) }, {}],
163
+ ["at its usage limit", { ...goal, status: "usageLimited", resetsAt: reset }, { pauseBlocked: true }],
164
+ ["at its usage limit with no reset time", { ...goal, status: "usageLimited" }, { pauseBlocked: true }],
165
+ ["out of credit", { ...goal, status: "usageLimited", outOfCredit: true }, { pauseBlocked: true }],
166
+ ["at its token budget", { ...goal, status: "budgetLimited" }, {}],
167
+ ["complete", { ...goal, status: "complete" }, {}]
168
+ ];
169
+ for (const [name, value, state] of states) {
170
+ const { chip, label, panel } = await render(control, value, state);
171
+ assert.ok(label.length <= CHIP_LABEL_MAX_CHARACTERS, `a goal ${name} has a short chip label, not "${label}"`);
172
+ // The chip's visible text (its tooltip may say more).
173
+ const shown = chip.replace(/<[^>]*>/gu, " ").replace(/\s+/gu, " ").trim();
174
+ assert.doesNotMatch(shown, /Finish every check|explanation of the blocker|tries again|could not be read/u, `a goal ${name} keeps its text out of the chip`);
175
+ if (state.error) assert.match(panel, /role="alert"/u, `a goal ${name} shows its error in the details`);
176
+ }
177
+ const limited = await render(control, { ...goal, status: "usageLimited", resetsAt: reset }, { pauseBlocked: true });
178
+ assert.equal(limited.label, "Goal paused: usage limit");
179
+ assert.match(limited.panel, /The AI account reached its usage limit, which resets at [^.]+\. The goal does not continue by itself: choose Resume goal once it resets\./u);
180
+ const credit = await render(control, { ...goal, status: "usageLimited", outOfCredit: true }, { pauseBlocked: true });
181
+ assert.equal(credit.label, "Goal paused: no credit");
182
+ assert.match(credit.panel, /The AI account is out of credit\. The goal does not continue by itself: top up or switch AI account, then choose Resume goal\./u);
183
+ });
@@ -1028,6 +1028,57 @@ test("optional goals show running, not running and paused lights, count only wor
1028
1028
  });
1029
1029
 
1030
1030
 
1031
+ test("a stopped goal's chip says why in a few words on one line, at desktop and phone widths, and its details explain the stop", {
1032
+ skip: !RUN_BROWSER_TEST,
1033
+ timeout: 60_000
1034
+ }, async (t) => {
1035
+ const { vite, browser } = await openFixture(t);
1036
+ try {
1037
+ for (const width of [1280, 390]) {
1038
+ const page = await browser.newPage({ viewport: { width, height: 844 } });
1039
+ await page.goto(`${vite.baseURL}/?conversation=1&goal=1`);
1040
+ await page.getByRole("button", { name: "Set goal", exact: true }).click();
1041
+ await page.getByRole("textbox", { name: "Goal objective" }).fill("Complete the shared assistant.");
1042
+ await page.getByRole("button", { name: "Start goal", exact: true }).click();
1043
+ await expect(page.getByRole("button", { name: "Goal running", exact: true })).toHaveAttribute("aria-expanded", "false");
1044
+ for (const [stop, chipText, details] of [
1045
+ ["Stop goal at usage limit", "Goal paused: usage limit",
1046
+ /The AI account reached its usage limit, which resets at \S+ \d\d:\d\d\. The goal does not continue by itself/u],
1047
+ ["Stop goal at usage limit without reset", "Goal paused: usage limit", /The AI account reached its usage limit\. The goal does not continue by itself/u],
1048
+ ["Stop goal out of credit", "Goal paused: no credit", /The AI account is out of credit\. The goal does not continue by itself: top up or switch AI account/u],
1049
+ ["Stop goal at budget", "Goal budget reached", /Complete the shared assistant\./u],
1050
+ ["Block goal with long explanation", "Goal blocked", /An explanation of the blocker\./u]
1051
+ ]) {
1052
+ await page.getByRole("button", { name: stop, exact: true }).evaluate(element => element.click());
1053
+ const chip = page.getByRole("button", { name: chipText, exact: true });
1054
+ await expect(chip.locator(".assistant-goal__label")).toHaveText(chipText);
1055
+ // One line, shown whole (no ellipsis), inside the conversation's width.
1056
+ const fit = () => chip.evaluate(element => {
1057
+ const label = element.querySelector(".assistant-goal__label");
1058
+ const lines = document.createRange();
1059
+ lines.selectNodeContents(label);
1060
+ const pane = element.closest(".assistant-conversation") || document.body;
1061
+ return { lines: new Set(Array.from(lines.getClientRects(), rect => Math.round(rect.top))).size,
1062
+ whole: label.scrollWidth <= label.clientWidth, inside: element.getBoundingClientRect().right <= pane.getBoundingClientRect().right + 0.5 };
1063
+ });
1064
+ await expect.poll(fit).toEqual({ lines: 1, whole: true, inside: true }).catch(async failure => {
1065
+ failure.message += `\n${stop} at ${width} px: ${JSON.stringify(await fit())}`;
1066
+ throw failure;
1067
+ });
1068
+ await chip.click();
1069
+ const panel = page.locator(".assistant-goal__details");
1070
+ await expect(panel.getByRole("status")).toHaveText(chipText);
1071
+ await expect(panel).toContainText(details);
1072
+ await page.keyboard.press("Escape");
1073
+ await expect(panel).toBeHidden();
1074
+ }
1075
+ await page.close();
1076
+ }
1077
+ } finally {
1078
+ await browser.close();
1079
+ }
1080
+ });
1081
+
1031
1082
  test("shared model choices and file delivery preserve a responsive composer", {
1032
1083
  skip: !RUN_BROWSER_TEST,
1033
1084
  timeout: 60_000
@@ -150,6 +150,9 @@ function claudeGoalFrames(condition) {
150
150
  local(`${prefix}-set`, `${prefix}-command`, seconds + 0.003, condition, `Goal set: ${condition}`),
151
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
152
  return { at, set, answer, attachment, command, local, sentinel,
153
+ // Claude 2.1.322 saves this when it starts again after a request a restart cut off.
154
+ placeholder: (uuid, parentUuid, seconds) => ({ type: "assistant", uuid, parentUuid, timestamp: at(seconds), isApiErrorMessage: false,
155
+ message: { id: `api-${uuid}`, model: "<synthetic>", role: "assistant", content: [{ type: "text", text: "No response requested." }] } }),
153
156
  toolUse: (uuid, parentUuid, seconds) => answer(uuid, parentUuid, seconds, [{ type: "tool_use", id: `tool-${uuid}`, name: "Bash", input: { command: "npm test" } }]),
154
157
  toolResult: (uuid, parentUuid, seconds) => ({ type: "user", uuid, parentUuid, timestamp: at(seconds),
155
158
  message: { role: "user", content: [{ type: "tool_result", tool_use_id: `tool-${parentUuid}`, content: "ok" }] }, toolUseResult: { stdout: "ok" } }),
@@ -215,8 +218,10 @@ test("a Claude goal's running time counts only the time Claude spent working, ca
215
218
  assert.equal(Math.round(goal.timeUsedSeconds), 100);
216
219
  assert.equal(goal.updatedAt, Date.parse(f.at(3600)) / 1000, "its running time is current as of the latest history");
217
220
  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));
221
+ // The server stopped mid-turn; its restart sends the goal again an hour later. That gap does not
222
+ // count, though Claude saves its no-response placeholder for the cut-off request just before.
223
+ await append(f.toolUse("cut", "next-answer", 3640), f.placeholder("cut-placeholder", "cut", 7199.7),
224
+ f.set("restart", "cut-placeholder", 7200), f.answer("restart-answer", "restart-meta", 7260));
220
225
  assert.equal(Math.round((await read()).timeUsedSeconds), 100 + 40 + 60);
221
226
  // Compacting the context is work, and timestamps running backwards subtract nothing.
222
227
  await append(f.compaction("restart-answer", 7300), f.answer("compacted", "credential", 7320));