@jskit-ai/assistant-core 0.2.41 → 0.2.42

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.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.41",
11
+ "@jskit-ai/assistant-core": "0.2.42",
12
12
  "@jskit-ai/http-runtime": "0.2.19",
13
13
  "@jskit-ai/kernel": "0.2.19",
14
14
  "@jskit-ai/material-web": "0.1.5",
package/locales/en.json CHANGED
@@ -112,6 +112,7 @@
112
112
  "goal": "Goal",
113
113
  "loading": "Loading goal…",
114
114
  "objective": "Goal objective",
115
+ "outOfCredit": "Out of credit: top up or switch AI account",
115
116
  "pause": "Pause goal",
116
117
  "pauseInterruptsTurn": "Pause stops the current turn and keeps the goal for later.",
117
118
  "pausePreventsTurns": "Pausing prevents further automatic turns. Use Stop to interrupt the current turn.",
@@ -126,7 +127,9 @@
126
127
  "starting": "Starting…",
127
128
  "tokenBudget": "Token budget (optional)",
128
129
  "unavailable": "Goal unavailable",
129
- "usageLimited": "Goal waiting for allowance"
130
+ "usageLimited": "Waiting: AI usage limit",
131
+ "usageLimitedHint": "The goal does not continue by itself. Choose Resume goal once the AI account can work again.",
132
+ "usageLimitedUntil": "Waiting: AI usage limit, resets at {time}"
130
133
  },
131
134
  "longText": {
132
135
  "details": "Details",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.41",
3
+ "version": "0.2.42",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -20,9 +20,19 @@ const running = computed(() => goal.value?.status === "active");
20
20
  const working = computed(() => running.value && props.state.working === true);
21
21
  // A Start stays Starting… until the server's goal shows it, even while closed.
22
22
  const starting = computed(() => props.state.action === "set");
23
- const label = computed(() => props.state.loading ? t("jskit.assistant.goal.loading") : props.state.available === false ? t("jskit.assistant.goal.unavailable")
24
- : starting.value ? t("jskit.assistant.goal.starting")
23
+ // A goal waiting at its AI account's limit names it, with the reset time when
24
+ // the engine reports one (with its day when that is not today).
25
+ const resetTime = computed(() => {
26
+ if (!Number.isSafeInteger(goal.value?.resetsAt)) return "";
27
+ const reset = new Date(goal.value.resetsAt * 1000);
28
+ const time = `${String(reset.getHours()).padStart(2, "0")}:${String(reset.getMinutes()).padStart(2, "0")}`;
29
+ return reset.toDateString() === new Date().toDateString() ? time : `${reset.toLocaleDateString(undefined, { weekday: "short" })} ${time}`;
30
+ });
31
+ const statusLabel = computed(() => goal.value?.status === "usageLimited" && goal.value.outOfCredit ? t("jskit.assistant.goal.outOfCredit")
32
+ : goal.value?.status === "usageLimited" && resetTime.value ? t("jskit.assistant.goal.usageLimitedUntil", { time: resetTime.value })
25
33
  : t(Object.hasOwn(STATUS_KEYS, goal.value?.status ?? "") ? STATUS_KEYS[goal.value.status] : "jskit.assistant.goal.set"));
34
+ const label = computed(() => props.state.loading ? t("jskit.assistant.goal.loading") : props.state.available === false ? t("jskit.assistant.goal.unavailable")
35
+ : starting.value ? t("jskit.assistant.goal.starting") : statusLabel.value);
26
36
  const nativeElapsed = computed(() => Number.isFinite(goal.value?.elapsedSeconds));
27
37
  const createdAt = computed(() => typeof goal.value?.createdAt === "string" ? Date.parse(goal.value.createdAt) : NaN);
28
38
  const elapsedLabel = computed(() => nativeElapsed.value ? t("jskit.assistant.goal.runningTime") : t("jskit.assistant.goal.elapsedSinceStart"));
@@ -45,6 +55,11 @@ const elapsed = computed(() => {
45
55
  const minutes = Math.floor(total / 60);
46
56
  return minutes < 60 ? `${minutes}:${seconds}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${seconds}`;
47
57
  });
58
+ // The chip says plainly when a goal is not running (paused, blocked or waiting)
59
+ // and shows its clock only while it runs; the details keep the elapsed time.
60
+ const WAITING_STATUSES = Object.freeze(["paused", "blocked", "usageLimited", "budgetLimited"]);
61
+ const chipLabel = computed(() => props.state.loading || props.state.available === false || starting.value ||
62
+ WAITING_STATUSES.includes(goal.value?.status) ? label.value : t(goal.value ? "jskit.assistant.goal.goal" : "jskit.assistant.goal.set"));
48
63
  // readable-check: allow method-probe - optional operation: a goal state without set() does not allow starting a goal, so Start stays disabled
49
64
  const canSet = computed(() => !props.state.loading && props.state.available !== false && typeof props.state.set === "function" && (!goal.value || goal.value.status === "complete"));
50
65
  const validBudget = computed(() => props.state.tokenBudgetSupported === false || tokenBudget.value === "" || (Number.isSafeInteger(Number(tokenBudget.value)) && Number(tokenBudget.value) > 0));
@@ -75,18 +90,19 @@ async function cancelGoal() {
75
90
  <template #activator="{ props: menuProps }">
76
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')">
77
92
  <span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--working': working, 'assistant-goal__light--paused': goal.status === 'paused' }" aria-hidden="true" />
78
- <span class="assistant-goal__label">{{ state.loading ? t('jskit.assistant.goal.loading') : state.available === false ? t('jskit.assistant.goal.unavailable') : starting ? t('jskit.assistant.goal.starting') : goal ? t('jskit.assistant.goal.goal') : t('jskit.assistant.goal.set') }}</span>
79
- <span v-if="elapsed && goal.status !== 'complete'" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
93
+ <span class="assistant-goal__label">{{ chipLabel }}</span>
94
+ <span v-if="elapsed && running" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
80
95
  </v-btn>
81
96
  </template>
82
97
  <v-card class="assistant-goal__details pa-3" max-width="360">
83
98
  <strong role="status">{{ label }}</strong>
84
- <v-btn v-if="(running || (state.pauseBlocked && goal?.status === 'blocked')) && state.pause" size="small" :disabled="state.stopPending" @click="state.pause()">{{ state.action === 'pause' ? t('jskit.assistant.goal.pausing') : t('jskit.assistant.goal.pause') }}</v-btn>
99
+ <v-btn v-if="(running || (state.pauseBlocked && ['blocked', 'usageLimited'].includes(goal?.status))) && state.pause" size="small" :disabled="state.stopPending" @click="state.pause()">{{ state.action === 'pause' ? t('jskit.assistant.goal.pausing') : t('jskit.assistant.goal.pause') }}</v-btn>
85
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>
86
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>
87
102
  <p v-if="goal" class="my-2">{{ goal.objective }}</p>
88
103
  <p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
89
104
  <p v-if="elapsed" class="text-body-small my-2">{{ elapsedText }}</p>
105
+ <p v-if="goal?.status === 'usageLimited'" class="text-body-small mt-2">{{ t('jskit.assistant.goal.usageLimitedHint') }}</p>
90
106
  <p v-if="running || goal?.status === 'paused'" class="text-body-small mt-2">{{ state.pauseInterruptsTurn ? t('jskit.assistant.goal.pauseInterruptsTurn') : t('jskit.assistant.goal.pausePreventsTurns') }}</p>
91
107
  <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>
92
108
  <form v-if="canSet" class="assistant-goal__form mt-2" @submit.prevent="setGoal">
@@ -110,6 +126,9 @@ async function cancelGoal() {
110
126
  .assistant-goal__details { overflow-wrap: anywhere; min-height: 0; max-height: calc(100dvh - 24px); }
111
127
  .assistant-goal__form { display: grid; gap: 0.75rem; }
112
128
  @keyframes assistant-goal-flash { 50% { opacity: 0.3; } }
113
- @container assistant-conversation (max-width: 26rem) { .assistant-goal__elapsed { display: none; } }
129
+ @container assistant-conversation (max-width: 26rem) {
130
+ .assistant-goal__elapsed { display: none; }
131
+ .assistant-goal__label { max-width: 10rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
132
+ }
114
133
  @media (prefers-reduced-motion: reduce) { .assistant-goal__light--working { animation: none; } }
115
134
  </style>
@@ -43,7 +43,7 @@
43
43
  * @property {boolean} [working]
44
44
  * @property {boolean} [pending]
45
45
  * @property {boolean} [stopPending]
46
- * @property {boolean} [pauseBlocked] True when a blocked goal can still be paused.
46
+ * @property {boolean} [pauseBlocked] True when a goal that is not running (blocked, or waiting at its usage limit) can still be paused.
47
47
  * @property {boolean} [pauseInterruptsTurn]
48
48
  * @property {boolean} [cancelInterruptsTurn]
49
49
  * @property {boolean} [tokenBudgetSupported]
@@ -301,6 +301,21 @@ async function readClaudeHistory(options) {
301
301
  goal = { ...goal, status: "blocked", updatedAt: Date.parse(frame.timestamp) / 1000,
302
302
  reason: ["Claude stopped after repeated attempts to continue the unfinished goal. Resolve the blocker, then resume the goal.", goal.reason].filter(Boolean).join("\n\n") };
303
303
  }
304
+ // Claude's own API-error message ends the goal's turn at the account's
305
+ // usage limit (with its reset time) or an exhausted credit balance. The
306
+ // goal waits there until a later answer shows the account working again.
307
+ if (["active", "usageLimited"].includes(goal?.status) && frame.type === "assistant" &&
308
+ !frame.parent_tool_use_id && !frame.isSidechain) {
309
+ const outOfCredit = frame.isApiErrorMessage === true && frame.error === "billing_error";
310
+ const limited = outOfCredit || (frame.isApiErrorMessage === true &&
311
+ ["usage_limit_reached", "model_requires_usage_credits"].includes(frame.apiError));
312
+ if (limited || (goal.status === "usageLimited" && frame.isApiErrorMessage !== true)) {
313
+ const resetsAt = frame.apiErrorParams?.rate_limit_info?.resetsAt;
314
+ const { resetsAt: _resetsAt, outOfCredit: _outOfCredit, ...rest } = goal;
315
+ goal = { ...rest, status: limited ? "usageLimited" : "active", updatedAt: Date.parse(frame.timestamp) / 1000,
316
+ ...(outOfCredit ? { outOfCredit: true } : limited && Number.isSafeInteger(resetsAt) && resetsAt > 0 ? { resetsAt } : {}) };
317
+ }
318
+ }
304
319
  // Native /goal appends its sentinel before the authored command, whose
305
320
  // parent identifies that exact attachment. A later evaluation keeps it.
306
321
  if (frame.type === "user" && !frame.isMeta && frame.uuid && goalStartId && frame.parentUuid === goalStartId) {
@@ -118,26 +118,27 @@ export async function runClaudeRenewalTurn(conversation, {
118
118
  * @param {{
119
119
  * conversationId: string,
120
120
  * readHistory: () => Promise<Record<string, any>>,
121
- * readState: () => { running: boolean, active: boolean },
122
121
  * interrupt: () => Promise<unknown>,
123
122
  * send: (input: Record<string, any>) => Promise<unknown>,
124
123
  * readRetainedGoal: () => Record<string, any> | null,
125
124
  * saveRetainedGoal: (goal: Record<string, any>) => Promise<void>
126
125
  * }} options
127
126
  */
128
- export function createClaudeConversationGoals({ conversationId, readHistory, readState, interrupt, send, readRetainedGoal, saveRetainedGoal }) {
127
+ export function createClaudeConversationGoals({ conversationId, readHistory, interrupt, send, readRetainedGoal, saveRetainedGoal }) {
129
128
  // These checks run before interrupt/send. Native execution and receipt
130
129
  // failures keep their own codes and must not be treated as safe rejections.
131
130
  const goalError = (message, { messageCode }) => Object.assign(
132
131
  new AppError(400, message, { code: "conversation_invalid_goal", messageCode }), { delivery: "not-sent" });
133
132
 
133
+ // Only a retained Pause is paused. A goal whose turn has ended keeps
134
+ // Claude's checker armed: the next turn, or a host's restart recovery,
135
+ // continues it, so between turns it stays active unless its history shows
136
+ // it complete, blocked or waiting at the account's usage limit.
134
137
  async function read(history = null) {
135
138
  const { goal = null } = history || await readHistory();
136
139
  const retained = readRetainedGoal();
137
140
  if (retained) return { ...retained };
138
- const state = readState();
139
- return goal ? { ...goal, status: ["complete", "blocked"].includes(goal.status) ? goal.status :
140
- state.running && state.active ? "active" : "paused" } : null;
141
+ return goal ? { ...goal, status: ["complete", "blocked", "usageLimited"].includes(goal.status) ? goal.status : "active" } : null;
141
142
  }
142
143
 
143
144
  async function update(input = {}, { interrupt: interruptGoal = interrupt, send: sendGoal = send, signal } = {}) {
@@ -386,7 +387,6 @@ export function createClaudeConversationOwner({
386
387
  try { await store.save(entry); }
387
388
  catch (error) { Object.assign(entry, previous); throw error; }
388
389
  },
389
- readState: () => ({ running: Boolean(entry.process), active: isTurnActive(entry) }),
390
390
  interrupt: () => interrupt(entry), send: input => delivery.send(entry, input) });
391
391
  entries.set(key, entry);
392
392
  await store.save(entry);
@@ -0,0 +1,111 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { readFileSync } from "node:fs";
4
+ import { registerHooks } from "node:module";
5
+ import { compileScript, parse } from "@vue/compiler-sfc";
6
+ import { createSSRApp, defineComponent, h } from "vue";
7
+ import { renderToString } from "vue/server-renderer";
8
+
9
+ const controlUrl = new URL("../src/client/conversation/AssistantGoalControl.vue", import.meta.url).href;
10
+ const textUrl = new URL("../src/client/i18n.js", import.meta.url).href;
11
+
12
+ async function importControl() {
13
+ const hooks = registerHooks({
14
+ 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 };
23
+ }
24
+ });
25
+ try {
26
+ return (await import(controlUrl)).default;
27
+ } finally {
28
+ hooks.deregister();
29
+ }
30
+ }
31
+
32
+ const slotted = tag => defineComponent({ setup: (_props, { slots }) => () => h(tag, slots.default?.()) });
33
+
34
+ /** The chip (the menu's activator) and the details panel of one goal state. */
35
+ async function render(control, goal, state = {}) {
36
+ const app = createSSRApp({ render: () => h(control, { state: { enabled: true, goal, pause() {}, resume() {}, cancel() {}, ...state } }) });
37
+ app.component("VMenu", defineComponent({ setup: (_props, { slots }) => () => h("div", [
38
+ h("div", { class: "chip" }, slots.activator?.({ props: {} })), h("div", { class: "panel" }, slots.default?.())
39
+ ]) }));
40
+ app.component("VBtn", slotted("button"));
41
+ app.component("VCard", slotted("div"));
42
+ app.component("VTextarea", defineComponent({ render: () => null }));
43
+ app.component("VTextField", defineComponent({ render: () => null }));
44
+ const html = await renderToString(app);
45
+ const chip = /<div class="chip">([\s\S]*?)<\/div><div class="panel">/u.exec(html)[1];
46
+ const panel = html.slice(html.indexOf('<div class="panel">'));
47
+ return { chip, label: /<span class="assistant-goal__label">([^<]*)<\/span>/u.exec(chip)[1], panel };
48
+ }
49
+
50
+ const startedTwoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
51
+
52
+ test("a paused, blocked or waiting goal's chip says so and shows no running clock; its details keep the elapsed time", async () => {
53
+ const control = await importControl();
54
+ for (const [status, key] of [["paused", "jskit.assistant.goal.paused"], ["blocked", "jskit.assistant.goal.blocked"],
55
+ ["usageLimited", "jskit.assistant.goal.usageLimited"], ["budgetLimited", "jskit.assistant.goal.budgetLimited"]]) {
56
+ // Claude supplies only its creation time, so its clock is wall time since the goal started.
57
+ const { chip, label, panel } = await render(control, { objective: "Finish every check", status, createdAt: startedTwoHoursAgo });
58
+ assert.equal(label, key, `a ${status} goal's chip names its state`);
59
+ assert.doesNotMatch(chip, /assistant-goal__elapsed/u, `a ${status} goal's chip shows no clock`);
60
+ assert.match(panel, /jskit\.assistant\.goal\.elapsed\(jskit\.assistant\.goal\.elapsedSinceStart=2:00:0\d\)/u,
61
+ `a ${status} goal's details keep the elapsed time`);
62
+ }
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
+ });
69
+
70
+ test("an active goal's chip shows its clock, and a finished goal's chip shows neither state nor clock", async () => {
71
+ const control = await importControl();
72
+ const active = await render(control, { objective: "Finish every check", status: "active", createdAt: startedTwoHoursAgo });
73
+ assert.equal(active.label, "jskit.assistant.goal.goal");
74
+ assert.match(active.chip, /<span class="assistant-goal__elapsed" aria-hidden="true">2:00:0\d<\/span>/u);
75
+ assert.match(active.chip, /assistant-goal__light--running/u);
76
+ const complete = await render(control, { objective: "Finish every check", status: "complete", createdAt: startedTwoHoursAgo,
77
+ sampledAt: Date.now() });
78
+ assert.equal(complete.label, "jskit.assistant.goal.goal");
79
+ assert.doesNotMatch(complete.chip, /assistant-goal__elapsed/u);
80
+ const none = await render(control, null);
81
+ assert.equal(none.label, "jskit.assistant.goal.set");
82
+ });
83
+
84
+ test("a goal waiting at its AI account's limit names the limit and when it resets, and Resume is the person's to press", async () => {
85
+ const control = await importControl();
86
+ const reset = new Date();
87
+ reset.setSeconds(0, 0);
88
+ const time = `${String(reset.getHours()).padStart(2, "0")}:${String(reset.getMinutes()).padStart(2, "0")}`;
89
+ const waiting = { objective: "Finish every check", status: "usageLimited", createdAt: startedTwoHoursAgo, resetsAt: reset.getTime() / 1000 };
90
+ // Claude's goals can be paused while they wait; the chip shows no clock.
91
+ const claude = await render(control, waiting, { pauseBlocked: true });
92
+ assert.equal(claude.label, `jskit.assistant.goal.usageLimitedUntil(${time})`);
93
+ assert.doesNotMatch(claude.chip, /assistant-goal__elapsed|assistant-goal__light--running/u);
94
+ assert.match(claude.panel, new RegExp(`role="status">jskit\\.assistant\\.goal\\.usageLimitedUntil\\(${time}\\)<`, "u"));
95
+ for (const action of ["pause", "resume", "cancel", "usageLimitedHint"]) {
96
+ assert.match(claude.panel, new RegExp(`jskit\\.assistant\\.goal\\.${action}<`, "u"), `a waiting Claude goal offers ${action}`);
97
+ }
98
+ // Codex refuses Pause while it waits, so its control offers only Resume and Cancel.
99
+ const codex = await render(control, waiting);
100
+ assert.doesNotMatch(codex.panel, /jskit\.assistant\.goal\.pause</u);
101
+ assert.match(codex.panel, /jskit\.assistant\.goal\.resume</u);
102
+ // A reset on another day names its day.
103
+ const later = await render(control, { ...waiting, resetsAt: waiting.resetsAt + 3 * 24 * 60 * 60 });
104
+ assert.equal(later.label, `jskit.assistant.goal.usageLimitedUntil(${new Date((waiting.resetsAt + 3 * 24 * 60 * 60) * 1000)
105
+ .toLocaleDateString(undefined, { weekday: "short" })} ${time})`);
106
+ // Without a reset time it waits for the person; out of credit says what to do.
107
+ assert.equal((await render(control, { ...waiting, resetsAt: undefined })).label, "jskit.assistant.goal.usageLimited");
108
+ const credit = await render(control, { ...waiting, resetsAt: undefined, outOfCredit: true }, { pauseBlocked: true });
109
+ assert.equal(credit.label, "jskit.assistant.goal.outOfCredit");
110
+ assert.match(credit.panel, /jskit\.assistant\.goal\.resume</u);
111
+ });
@@ -956,9 +956,14 @@ test("optional goals show running and paused lights, count only active time, and
956
956
  await page.getByRole("button", { name: "Pause goal", exact: true }).click();
957
957
  await expect(light).toHaveCSS("background-color", "rgb(239, 108, 0)");
958
958
  await expect(light).toHaveCSS("animation-name", "none");
959
- const pausedTime = await elapsed.textContent();
959
+ // A paused goal's chip says so without a clock; its open details keep the running time, which stops.
960
+ await expect(elapsed).toHaveCount(0);
961
+ await expect(page.locator(".assistant-goal .assistant-goal__label")).toHaveText("Goal paused");
962
+ const pausedTime = page.locator(".assistant-goal__details").getByText(/^Running time: /u);
963
+ const pausedText = await pausedTime.textContent();
960
964
  await page.clock.fastForward(120_000);
961
- await expect(elapsed).toHaveText(pausedTime);
965
+ await expect(pausedTime).toHaveText(pausedText);
966
+ await expect(elapsed).toHaveCount(0);
962
967
  await page.getByRole("button", { name: "Resume goal", exact: true }).click();
963
968
  await page.clock.fastForward(5_000);
964
969
  await expect(elapsed).toHaveText("1:10");
@@ -129,6 +129,45 @@ 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
+ // Claude 2.1.308 history at a hosted goal's usage limit (2026-11-10), and the
133
+ // message Claude Code writes when the account's credit balance is too low.
134
+ test("Claude's own limit message makes its goal wait, with the reset time, until a later answer", async t => {
135
+ const root = await mkdtemp(path.join(os.tmpdir(), "claude-limit-goal-"));
136
+ t.after(() => rm(root, { recursive: true, force: true }));
137
+ const directory = path.join(root, "projects", root.replace(/[^a-zA-Z0-9]/gu, "-"));
138
+ await mkdir(directory, { recursive: true });
139
+ const conversationId = "1d6f6949-185c-46b2-9475-a9f080d505f2";
140
+ const file = path.join(directory, `${conversationId}.jsonl`);
141
+ const options = { configRoot: root, workdir: root, conversationId };
142
+ const append = (...frames) => writeFile(file, frames.map(JSON.stringify).join("\n") + "\n", { flag: "a" });
143
+ const limit = { type: "assistant", uuid: "limit", timestamp: "2026-11-10T04:54:47.014Z", isApiErrorMessage: true,
144
+ error: "rate_limit", apiError: "usage_limit_reached",
145
+ apiErrorParams: { rate_limit_info: { status: "rejected", resetsAt: 1794286800, rateLimitType: "five_hour" } },
146
+ message: { model: "<synthetic>", role: "assistant", content: [{ type: "text", text: "You've hit your session limit · resets 5am (UTC)" }] } };
147
+ const answer = { type: "assistant", uuid: "answer", timestamp: "2026-11-10T05:01:00.000Z",
148
+ message: { role: "assistant", content: [{ type: "text", text: "Continuing the checks." }] } };
149
+ await append({ type: "attachment", uuid: "goal", timestamp: "2026-11-10T04:00:00.000Z",
150
+ attachment: { type: "goal_status", condition: "Finish all checks", met: false, sentinel: true } },
151
+ { ...limit, uuid: "nested", parent_tool_use_id: "child" }, { ...limit, uuid: "sidechain", isSidechain: true },
152
+ { ...limit, uuid: "other-error", apiError: "max_output_tokens" });
153
+ assert.equal((await readClaudeHistory(options)).goal.status, "active", "a helper's limit or another API error is not the goal's");
154
+ await append(limit);
155
+ const waiting = (await readClaudeHistory(options)).goal;
156
+ assert.equal(waiting.status, "usageLimited");
157
+ assert.equal(waiting.resetsAt, 1794286800);
158
+ assert.equal(waiting.objective, "Finish all checks");
159
+ await append(answer);
160
+ const resumed = (await readClaudeHistory(options)).goal;
161
+ assert.equal(resumed.status, "active", "an answer after the reset shows the account working again");
162
+ assert.equal("resetsAt" in resumed, false);
163
+ await append({ ...limit, uuid: "credit", error: "billing_error", apiError: undefined, apiErrorParams: undefined,
164
+ message: { ...limit.message, content: [{ type: "text", text: "Credit balance is too low" }] } });
165
+ const credit = (await readClaudeHistory(options)).goal;
166
+ assert.equal(credit.status, "usageLimited");
167
+ assert.equal(credit.outOfCredit, true);
168
+ assert.equal("resetsAt" in credit, false);
169
+ });
170
+
132
171
  test("Claude local-command error output stays distinguishable and result-only success retains provenance", async () => {
133
172
  const { turn, send, events } = fixture();
134
173
  await send();
@@ -2976,6 +3015,44 @@ test("Pausing an idle Claude goal retires its warm process before the next ordin
2976
3015
  });
2977
3016
 
2978
3017
 
3018
+ // A hosted goal (2026-11-10): the account hit its usage limit, the turn ended
3019
+ // with the checker still armed and the control read "paused", so the person
3020
+ // saw it paused while a restart resumed it and ran ~20 continuation turns.
3021
+ test("A Claude goal whose turn ended without Pause is active, at its usage limit it waits; Pause sends nothing and the next start clears it locally", { timeout: 5_000 }, async t => {
3022
+ const f = await publishedClaudeFixture(t);
3023
+ await f.owner.updateGoal(f.entry, { action: "set", objective: "Finish all checks", messageId: "limited-goal" });
3024
+ await f.behavior.goalAttachmentWork;
3025
+ await f.frame({ type: "result", subtype: "success", result: "Partial progress" });
3026
+ assert.equal(f.entry.turn.active, false);
3027
+ assert.equal((await f.owner.readGoal(f.entry)).goal.status, "active",
3028
+ "nothing paused it: the next turn's checker, or a restart, continues it");
3029
+ // Claude ends the next turn at the account's usage limit, as its history records.
3030
+ const history = path.join(f.context.workdir, "projects", f.context.workdir.replace(/[^a-zA-Z0-9]/gu, "-"),
3031
+ `${f.entry.process.options.sessionId}.jsonl`);
3032
+ await appendFile(history, JSON.stringify({ type: "assistant", uuid: "limit", timestamp: new Date().toISOString(),
3033
+ isApiErrorMessage: true, error: "rate_limit", apiError: "usage_limit_reached",
3034
+ apiErrorParams: { rate_limit_info: { status: "rejected", resetsAt: 1794286800 } },
3035
+ message: { role: "assistant", content: [{ type: "text", text: "You've hit your session limit · resets 5am (UTC)" }] } }) + "\n");
3036
+ const goal = (await f.owner.readGoal(f.entry)).goal;
3037
+ assert.equal(goal.status, "usageLimited");
3038
+ assert.equal(goal.resetsAt, 1794286800);
3039
+ await f.owner.stopConversation(f.entry, "The application stopped.");
3040
+ assert.equal(f.entry.process, null);
3041
+ assert.equal((await f.owner.readGoal(f.entry)).goal.status, "usageLimited", "a stopped process keeps the goal waiting");
3042
+ const inputs = f.behavior.nativeInputs.length;
3043
+ const processes = f.processes.length;
3044
+ await f.owner.updateGoal(f.entry, { action: "pause", ...goal });
3045
+ assert.equal((await f.owner.readGoal(f.entry)).goal.status, "paused");
3046
+ assert.equal(f.behavior.nativeInputs.length, inputs, "Pause sends nothing to Claude");
3047
+ assert.equal(f.processes.length, processes, "Pause starts no Claude process");
3048
+ await f.send("person-after-pause");
3049
+ assert.deepEqual(f.behavior.nativeInputs.slice(inputs), ["/goal clear", "Explain"],
3050
+ "the next start stands the native goal down with Claude's local command before the person's own message");
3051
+ assert.equal((await f.owner.readGoal(f.entry)).goal.status, "paused");
3052
+ await f.frame({ type: "result", subtype: "success", result: "Ordinary answer" });
3053
+ });
3054
+
3055
+
2979
3056
  test("Claude Block refuses absent reasons and stale identity before interrupting", { timeout: 5_000 }, async t => {
2980
3057
  const f = await publishedClaudeFixture(t);
2981
3058
  await f.owner.updateGoal(f.entry, { action: "set", objective: "Finish checks", messageId: "block-validation" });