@jskit-ai/assistant-core 0.2.41 → 0.2.43
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/examples/conversation/package.json +5 -5
- package/locales/en.json +4 -1
- package/package.json +4 -4
- package/src/client/conversation/AssistantGoalControl.vue +25 -6
- package/src/client/types.js +1 -1
- package/src/server/conversation/claudeHistory.js +15 -0
- package/src/server/conversation/claudeTurn.js +6 -6
- package/test/assistantGoalControlChip.test.js +111 -0
- package/test/assistantScroll.browser.test.js +7 -2
- package/test/claudeTurn.test.js +77 -0
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.2.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
13
|
-
"@jskit-ai/kernel": "0.2.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.2.43",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.2.20",
|
|
13
|
+
"@jskit-ai/kernel": "0.2.20",
|
|
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.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.2.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.2.28",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.2.28"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
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": "
|
|
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.
|
|
3
|
+
"version": "0.2.43",
|
|
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.
|
|
55
|
+
"@jskit-ai/resource-core": "0.1.211",
|
|
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.
|
|
64
|
-
"@jskit-ai/kernel": "0.2.
|
|
63
|
+
"@jskit-ai/http-runtime": "0.2.20",
|
|
64
|
+
"@jskit-ai/kernel": "0.2.20",
|
|
65
65
|
"vue": "^3.5.13",
|
|
66
66
|
"vuetify": "^4.3.0"
|
|
67
67
|
},
|
|
@@ -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
|
-
|
|
24
|
-
|
|
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">{{
|
|
79
|
-
<span v-if="elapsed &&
|
|
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
|
|
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) {
|
|
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>
|
package/src/client/types.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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");
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -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" });
|