@jskit-ai/assistant-core 0.1.226 → 0.1.227
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 +1 -1
- package/fixtures/responsive-assistant/ConversationFixture.vue +5 -3
- package/fixtures/responsive-assistant/DeliveryFixture.vue +9 -0
- package/package.json +1 -1
- package/src/client/conversation/AssistantConversationElement.vue +7 -4
- package/src/client/conversation/AssistantGoalControl.vue +30 -16
- package/src/client/conversation/AssistantTranscript.vue +14 -7
- package/src/client/conversation/useAssistantAttachments.js +6 -3
- package/src/server/conversation/claudeClient.js +22 -5
- package/src/server/conversation/claudeHistory.js +62 -5
- package/src/server/conversation/claudeProcess.js +7 -6
- package/src/server/conversation/claudeTurn.js +284 -57
- package/src/server/conversation/codexConfiguration.js +24 -1
- package/src/server/conversation/codexGoalCommands.js +8 -3
- package/src/server/conversation/codexTurnRecovery.js +18 -8
- package/src/server/conversation/continuity.js +54 -17
- package/src/server/conversation/providers/claudeDriver.js +6 -5
- package/src/server/conversation/providers/codexDriver.js +3 -3
- package/src/server/conversation/providers/scoped.js +31 -10
- package/src/server/conversation/runtime.js +48 -13
- package/src/server/conversation/streams.js +7 -3
- package/src/server/conversation/transcript.js +2 -1
- package/src/server/hosts/codexConfiguration.js +1 -0
- package/src/shared/conversation/longTextBlocks.js +0 -4
- package/test/assistantScroll.browser.test.js +74 -3
- package/test/claudeClient.test.js +73 -0
- package/test/claudeConversationSession.test.js +120 -4
- package/test/claudeProcess.test.js +61 -0
- package/test/claudeTurn.test.js +1315 -10
- package/test/codexConversationSession.test.js +9 -1
- package/test/codexGoal.test.js +47 -0
- package/test/codexModelCatalog.test.js +55 -0
- package/test/codexTurn.test.js +228 -0
- package/test/conversationAttachments.test.js +17 -0
- package/test/conversationRuntime.test.js +40 -0
- package/test/conversationStreaming.test.js +18 -0
- package/test/messageDelivery.browser.test.js +30 -0
|
@@ -70,10 +70,11 @@ const models = reactive({
|
|
|
70
70
|
const goalEnabled = ref(new URLSearchParams(location.search).has("goal"));
|
|
71
71
|
const goal = ref(null);
|
|
72
72
|
const goalState = reactive({
|
|
73
|
-
enabled: goalEnabled, goal,
|
|
74
|
-
set(input) { goal.value = { ...input, status: "active", elapsedSeconds: 0, sampledAt: Date.now() }; },
|
|
73
|
+
enabled: goalEnabled, goal, working: computed(() => phase.value === "active"),
|
|
74
|
+
set(input) { phase.value = "active"; goal.value = { ...input, status: "active", elapsedSeconds: 0, sampledAt: Date.now() }; },
|
|
75
75
|
pause() { goal.value = { ...goal.value, status: "paused", elapsedSeconds: goal.value.elapsedSeconds + (Date.now() - goal.value.sampledAt) / 1000, sampledAt: Date.now() }; },
|
|
76
|
-
resume() { goal.value = { ...goal.value, status: "active", sampledAt: Date.now() }; }
|
|
76
|
+
resume() { phase.value = "active"; goal.value = { ...goal.value, status: "active", reason: "", sampledAt: Date.now() }; },
|
|
77
|
+
cancel() { goal.value = null; phase.value = "idle"; }
|
|
77
78
|
});
|
|
78
79
|
const adapter = reactive({
|
|
79
80
|
delivery: previewFixture ? previewDelivery : undefined,
|
|
@@ -177,6 +178,7 @@ const adapter = reactive({
|
|
|
177
178
|
<button v-if="supportEnabled" @click="customActivity = !customActivity">Custom activity</button>
|
|
178
179
|
<button v-if="capabilitiesEnabled" @click="finishUploads">Finish uploads</button>
|
|
179
180
|
<button @click="goalEnabled = !goalEnabled">Toggle goals</button>
|
|
181
|
+
<button v-if="goal" @click="goal = { ...goal, status: 'blocked', reason: 'An explanation of the blocker. '.repeat(200) }">Block goal with long explanation</button>
|
|
180
182
|
<template v-if="previewFixture">
|
|
181
183
|
<button @click="temporaryWords = 'Exact temporary words'; temporaryPresent = true; temporaryEditing = false">Add temporary message</button>
|
|
182
184
|
<button @click="newerWords = 'Keep newer live words'; newerPresent = true; newerEditing = false">Add newer temporary message</button>
|
|
@@ -4,6 +4,7 @@ import AssistantConversationElement from "../../src/client/conversation/Assistan
|
|
|
4
4
|
import { createAssistantMessageDelivery } from "../../src/client/conversation/messageDelivery.js";
|
|
5
5
|
|
|
6
6
|
const recoveryFixture = new URLSearchParams(location.search).has("recovery");
|
|
7
|
+
const acceptedFixture = new URLSearchParams(location.search).has("accepted-history");
|
|
7
8
|
const draft = ref("");
|
|
8
9
|
const turns = ref([]);
|
|
9
10
|
const requests = ref([]);
|
|
@@ -43,6 +44,13 @@ if (recoveryFixture) {
|
|
|
43
44
|
];
|
|
44
45
|
delivery.state.messages.push({ id: "uncertain", text: "Discard failed attempt", status: "uncertain", error: "Lost response", createdAt: at("05") });
|
|
45
46
|
}
|
|
47
|
+
if (acceptedFixture) {
|
|
48
|
+
turns.value = [{ turnId: "later", assistant: { text: "Later assistant reply", at: "2026-10-16T11:56:00.000Z" } }];
|
|
49
|
+
delivery.state.messages.push(
|
|
50
|
+
{ id: "accepted-old", text: "Earlier accepted message", status: "accepted", createdAt: "2026-10-16T11:04:00.000Z" },
|
|
51
|
+
{ id: "queued", text: "Still queued", status: "pending", createdAt: "2026-10-16T11:03:00.000Z" }
|
|
52
|
+
);
|
|
53
|
+
}
|
|
46
54
|
const adapter = reactive({
|
|
47
55
|
delivery, attachments,
|
|
48
56
|
conversation: { cancellableDelivery: recoveryFixture, turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation." },
|
|
@@ -57,6 +65,7 @@ const adapter = reactive({
|
|
|
57
65
|
<v-app>
|
|
58
66
|
<main class="fixture">
|
|
59
67
|
<nav>
|
|
68
|
+
<button v-if="acceptedFixture" @click="turns.unshift({ turnId: 'earlier', user: { messageId: 'accepted-old', text: 'Earlier accepted message', at: '2026-10-16T11:05:00.000Z' } })">Load accepted history</button>
|
|
60
69
|
<button @click="pendingRequest.reject(new Error('Delivery unavailable.'))">Fail request</button>
|
|
61
70
|
<button @click="accept">Accept request</button>
|
|
62
71
|
<button @click="attachments.attachments.push({ attachmentId: 'new-file', fileName: 'next.txt' }); configuration = { tone: 'detailed' }">Prepare newer input</button>
|
package/package.json
CHANGED
|
@@ -195,6 +195,9 @@ const observedAvatarElements = new Set();
|
|
|
195
195
|
function measureAvatarSpace() {
|
|
196
196
|
const root = container.value;
|
|
197
197
|
if (!root || !slots.avatar) return;
|
|
198
|
+
const warning = root.querySelector(".assistant-transcript__error");
|
|
199
|
+
const warningBottom = warning ? warning.getBoundingClientRect().bottom - root.getBoundingClientRect().top + root.scrollTop : 0;
|
|
200
|
+
root.style.setProperty("--assistant-conversation-warning-bottom", `${warningBottom}px`);
|
|
198
201
|
const rootStyle = getComputedStyle(root);
|
|
199
202
|
let occupied = parseFloat(rootStyle.paddingTop) + parseFloat(rootStyle.paddingBottom);
|
|
200
203
|
for (const element of observedAvatarElements) {
|
|
@@ -216,6 +219,8 @@ function observeAvatarSpace() {
|
|
|
216
219
|
!element.classList.contains("assistant-conversation__avatar") && !element.classList.contains("assistant-conversation__transcript"));
|
|
217
220
|
const elements = new Set([container.value, ...fixedElements]);
|
|
218
221
|
if (avatarControls.value) elements.add(avatarControls.value);
|
|
222
|
+
const warning = container.value.querySelector(".assistant-transcript__error");
|
|
223
|
+
if (warning) elements.add(warning);
|
|
219
224
|
for (const element of observedAvatarElements) {
|
|
220
225
|
if (!elements.has(element)) {
|
|
221
226
|
avatarObserver.unobserve(element);
|
|
@@ -390,9 +395,7 @@ defineExpose({ focus: () => input.value?.focus(), submit, stop });
|
|
|
390
395
|
.assistant-conversation__preview-actions { display: flex; justify-content: flex-end; }
|
|
391
396
|
.assistant-conversation { position: relative; display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; container: assistant-conversation / inline-size; }
|
|
392
397
|
.assistant-conversation--error { overflow-y: auto; }
|
|
393
|
-
.assistant-conversation__avatar { position: absolute; top:
|
|
394
|
-
.assistant-conversation--error .assistant-conversation__avatar { position: relative; align-self: flex-end; flex: 0 0 auto; }
|
|
395
|
-
.assistant-conversation--error .assistant-conversation__avatar-controls { position: static; }
|
|
398
|
+
.assistant-conversation__avatar { position: absolute; top: var(--assistant-conversation-warning-bottom, 0px); right: 0; z-index: 3; max-width: 100%; min-width: 0; }
|
|
396
399
|
.assistant-conversation__avatar-controls { position: absolute; top: 0; right: 100%; display: flex; align-items: center; justify-content: flex-end; min-height: 44px; }
|
|
397
400
|
.assistant-conversation__avatar--hidden .assistant-conversation__avatar-controls { right: 0; }
|
|
398
401
|
.assistant-conversation__avatar-size { min-width: 44px; min-height: 44px; }
|
|
@@ -400,7 +403,7 @@ defineExpose({ focus: () => input.value?.focus(), submit, stop });
|
|
|
400
403
|
.assistant-conversation__avatar-size-disc--minimise { background: rgba(var(--v-theme-primary), .12); }
|
|
401
404
|
.assistant-conversation__avatar-presentation { display: flex; flex-direction: column; align-items: center; min-width: 0; }
|
|
402
405
|
.assistant-conversation__avatar-tools { display: flex; align-items: center; gap: 0; }
|
|
403
|
-
.assistant-conversation__avatar-visual { display: flex; justify-content: center; min-height: 0; overflow: hidden; }
|
|
406
|
+
.assistant-conversation__avatar-visual { display: flex; justify-content: center; min-height: 0; overflow: hidden; contain: layout paint; }
|
|
404
407
|
.assistant-conversation__avatar-visual :deep(> *) { max-width: 100%; max-height: 100%; }
|
|
405
408
|
.assistant-conversation__goal { display: flex; justify-content: flex-end; flex: 0 0 auto; }
|
|
406
409
|
.assistant-conversation__transcript { flex: 1 1 auto; min-height: 0; }
|
|
@@ -8,15 +8,28 @@ const tokenBudget = ref("");
|
|
|
8
8
|
const now = ref(Date.now());
|
|
9
9
|
const goal = computed(() => props.state?.loading || props.state?.available === false ? null : props.state?.goal);
|
|
10
10
|
const running = computed(() => goal.value?.status === "active");
|
|
11
|
+
const working = computed(() => running.value && props.state?.working === true);
|
|
11
12
|
const label = computed(() => props.state?.loading ? "Loading goal…" : props.state?.available === false ? "Goal unavailable" : ({
|
|
12
13
|
active: "Goal running", paused: "Goal paused", blocked: "Goal blocked",
|
|
13
14
|
usageLimited: "Goal waiting for allowance", budgetLimited: "Goal budget reached", complete: "Goal complete"
|
|
14
15
|
})[goal.value?.status] || "Set goal");
|
|
16
|
+
const nativeElapsed = computed(() => Number.isFinite(goal.value?.elapsedSeconds));
|
|
17
|
+
const createdAt = computed(() => typeof goal.value?.createdAt === "string" ? Date.parse(goal.value.createdAt) : NaN);
|
|
18
|
+
const elapsedLabel = computed(() => nativeElapsed.value ? "Running time" : "Elapsed since goal started (including pauses)");
|
|
19
|
+
const clockRunning = computed(() => nativeElapsed.value ? running.value && Number.isFinite(goal.value?.sampledAt)
|
|
20
|
+
: Boolean(goal.value && goal.value.status !== "complete" && Number.isFinite(createdAt.value)));
|
|
15
21
|
const elapsed = computed(() => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
let secondsUsed;
|
|
23
|
+
if (nativeElapsed.value) {
|
|
24
|
+
const sinceSample = running.value && Number.isFinite(goal.value.sampledAt)
|
|
25
|
+
? Math.max(0, now.value - goal.value.sampledAt) / 1000 : 0;
|
|
26
|
+
secondsUsed = goal.value.elapsedSeconds + sinceSample;
|
|
27
|
+
} else {
|
|
28
|
+
const end = goal.value?.status === "complete" ? goal.value.sampledAt : now.value;
|
|
29
|
+
if (!Number.isFinite(createdAt.value) || !Number.isFinite(end)) return "";
|
|
30
|
+
secondsUsed = (end - createdAt.value) / 1000;
|
|
31
|
+
}
|
|
32
|
+
const total = Math.max(0, Math.floor(secondsUsed));
|
|
20
33
|
const seconds = String(total % 60).padStart(2, "0");
|
|
21
34
|
const minutes = Math.floor(total / 60);
|
|
22
35
|
return minutes < 60 ? `${minutes}:${seconds}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${seconds}`;
|
|
@@ -24,10 +37,10 @@ const elapsed = computed(() => {
|
|
|
24
37
|
const canSet = computed(() => !props.state?.loading && props.state?.available !== false && typeof props.state?.set === "function" && (!goal.value || goal.value.status === "complete"));
|
|
25
38
|
const validBudget = computed(() => props.state?.tokenBudgetSupported === false || tokenBudget.value === "" || (Number.isSafeInteger(Number(tokenBudget.value)) && Number(tokenBudget.value) > 0));
|
|
26
39
|
let timer;
|
|
27
|
-
onMounted(() => watch(() => [props.state?.enabled,
|
|
40
|
+
onMounted(() => watch(() => [props.state?.enabled, clockRunning.value, goal.value?.elapsedSeconds, goal.value?.sampledAt, createdAt.value], () => {
|
|
28
41
|
clearInterval(timer);
|
|
29
42
|
now.value = Date.now();
|
|
30
|
-
if (props.state?.enabled !== false &&
|
|
43
|
+
if (props.state?.enabled !== false && clockRunning.value) {
|
|
31
44
|
timer = setInterval(() => { now.value = Date.now(); }, 1000);
|
|
32
45
|
}
|
|
33
46
|
}, { immediate: true, flush: "sync" }));
|
|
@@ -36,31 +49,31 @@ watch(() => props.state?.enabled, enabled => { if (enabled === false) open.value
|
|
|
36
49
|
async function setGoal() {
|
|
37
50
|
if (!canSet.value || props.state.pending || !objective.value.trim() || !validBudget.value) return;
|
|
38
51
|
const result = await props.state.set({ objective: objective.value.trim(), ...(props.state.tokenBudgetSupported === false || tokenBudget.value === "" ? {} : { tokenBudget: Number(tokenBudget.value) }) });
|
|
39
|
-
if (result !== false && !props.state.error) { objective.value = ""; tokenBudget.value = ""; }
|
|
52
|
+
if (result !== false && result?.ok !== false && !props.state.error) { objective.value = ""; tokenBudget.value = ""; open.value = false; }
|
|
40
53
|
}
|
|
41
54
|
</script>
|
|
42
55
|
|
|
43
56
|
<template>
|
|
44
57
|
<v-menu v-if="state && state.enabled !== false" v-model="open" location="top" :close-on-content-click="false">
|
|
45
58
|
<template #activator="{ props: menuProps }">
|
|
46
|
-
<v-btn v-bind="menuProps" class="assistant-goal" size="small" variant="text" :aria-label="label" :title="[label, elapsed && `${elapsed}
|
|
47
|
-
<span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--paused': goal.status === 'paused' }" aria-hidden="true" />
|
|
59
|
+
<v-btn v-bind="menuProps" class="assistant-goal" size="small" variant="text" :aria-label="label" :title="[label, elapsed && `${elapsedLabel}: ${elapsed}`, goal?.objective].filter(Boolean).join('\n')">
|
|
60
|
+
<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" />
|
|
48
61
|
<span class="assistant-goal__label">{{ state.loading ? 'Loading goal…' : state.available === false ? 'Goal unavailable' : goal ? 'Goal' : 'Set goal' }}</span>
|
|
49
62
|
<span v-if="elapsed" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
|
|
50
63
|
</v-btn>
|
|
51
64
|
</template>
|
|
52
65
|
<v-card class="assistant-goal__details pa-3" max-width="360">
|
|
53
66
|
<strong role="status">{{ label }}</strong>
|
|
54
|
-
<p v-if="goal" class="my-2">{{ goal.objective }}</p>
|
|
55
|
-
<p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
|
|
56
|
-
<p v-if="elapsed" class="text-body-small my-2">Running time: {{ elapsed }}</p>
|
|
57
67
|
<v-btn v-if="running && state.pause" size="small" :disabled="state.pending" @click="state.pause()">{{ state.action === 'pause' ? 'Pausing…' : 'Pause goal' }}</v-btn>
|
|
58
68
|
<v-btn v-else-if="['paused', 'blocked', 'usageLimited'].includes(goal?.status) && state.resume" size="small" :disabled="state.pending" @click="state.resume()">{{ state.action === 'resume' ? 'Resuming…' : 'Resume goal' }}</v-btn>
|
|
59
69
|
<v-btn v-if="goal && goal.status !== 'complete' && state.cancel" color="error" size="small" variant="text" :disabled="state.pending" @click="state.cancel()">{{ state.action === 'cancel' ? 'Cancelling…' : 'Cancel goal' }}</v-btn>
|
|
70
|
+
<p v-if="goal" class="my-2">{{ goal.objective }}</p>
|
|
71
|
+
<p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
|
|
72
|
+
<p v-if="elapsed" class="text-body-small my-2">{{ elapsedLabel }}: {{ elapsed }}</p>
|
|
60
73
|
<p v-if="running || goal?.status === 'paused'" class="text-body-small mt-2">{{ state.pauseInterruptsTurn ? 'Pause stops the current turn and keeps the goal for later.' : 'Pausing prevents further automatic turns. Use Stop to interrupt the current turn.' }}</p>
|
|
61
74
|
<p v-if="goal && goal.status !== 'complete' && state.cancel" class="text-body-small mt-2">{{ state.cancelInterruptsTurn ? 'Cancel stops the current turn and clears the goal.' : 'Cancel removes this goal. Use Stop to interrupt a turn already running.' }}</p>
|
|
62
75
|
<form v-if="canSet" class="assistant-goal__form mt-2" @submit.prevent="setGoal">
|
|
63
|
-
<v-textarea v-model="objective" label="Goal objective" rows="2" maxlength="4000" auto-grow :disabled="state.pending" hide-details />
|
|
76
|
+
<v-textarea v-model="objective" label="Goal objective" rows="2" max-rows="4" maxlength="4000" auto-grow :disabled="state.pending" hide-details />
|
|
64
77
|
<v-text-field v-if="state.tokenBudgetSupported !== false" v-model="tokenBudget" label="Token budget (optional)" type="number" min="1" step="1" :disabled="state.pending" :error="!validBudget" hide-details />
|
|
65
78
|
<v-btn type="submit" size="small" :disabled="state.pending || !objective.trim() || !validBudget">{{ state.action === 'set' ? 'Starting…' : 'Start goal' }}</v-btn>
|
|
66
79
|
</form>
|
|
@@ -73,12 +86,13 @@ async function setGoal() {
|
|
|
73
86
|
<style scoped>
|
|
74
87
|
.assistant-goal { text-transform: none; flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
|
75
88
|
.assistant-goal__light { width: 0.625rem; height: 0.625rem; border-radius: 50%; margin-inline-end: 0.45rem; background: currentColor; }
|
|
76
|
-
.assistant-goal__light--running { background: #d32f2f; box-shadow: 0 0 0 3px #d32f2f20;
|
|
89
|
+
.assistant-goal__light--running { background: #d32f2f; box-shadow: 0 0 0 3px #d32f2f20; }
|
|
90
|
+
.assistant-goal__light--working { animation: assistant-goal-flash 1.5s ease-in-out infinite; }
|
|
77
91
|
.assistant-goal__light--paused { background: #ef6c00; box-shadow: 0 0 0 3px #ef6c0020; }
|
|
78
92
|
.assistant-goal__elapsed { margin-inline-start: 0.45rem; }
|
|
79
|
-
.assistant-goal__details { overflow-wrap: anywhere; }
|
|
93
|
+
.assistant-goal__details { overflow-wrap: anywhere; min-height: 0; max-height: calc(100dvh - 24px); }
|
|
80
94
|
.assistant-goal__form { display: grid; gap: 0.75rem; }
|
|
81
95
|
@keyframes assistant-goal-flash { 50% { opacity: 0.3; } }
|
|
82
96
|
@container assistant-conversation (max-width: 26rem) { .assistant-goal__elapsed { display: none; } }
|
|
83
|
-
@media (prefers-reduced-motion: reduce) { .assistant-goal__light--
|
|
97
|
+
@media (prefers-reduced-motion: reduce) { .assistant-goal__light--working { animation: none; } }
|
|
84
98
|
</style>
|
|
@@ -41,18 +41,20 @@
|
|
|
41
41
|
<v-alert
|
|
42
42
|
v-if="error"
|
|
43
43
|
class="assistant-transcript__error"
|
|
44
|
+
:icon="false"
|
|
44
45
|
density="compact"
|
|
45
46
|
type="warning"
|
|
46
47
|
variant="tonal"
|
|
47
48
|
>
|
|
48
49
|
<div class="assistant-transcript__error-content">
|
|
49
|
-
<span>{{ error }}</span>
|
|
50
50
|
<v-btn
|
|
51
|
-
v-if="errorReloadable && !reloadable" :disabled="reloading" :min-height="
|
|
51
|
+
v-if="errorReloadable && !reloadable" :disabled="reloading" :min-height="32" size="small"
|
|
52
|
+
:aria-label="reloading ? 'Reloading chat' : 'Reload chat'"
|
|
52
53
|
type="button" variant="text" @click="emit('reload')"
|
|
53
54
|
>
|
|
54
|
-
Reload chat
|
|
55
|
+
{{ reloading ? 'Reloading…' : 'Reload chat' }}
|
|
55
56
|
</v-btn>
|
|
57
|
+
<span>{{ error }}</span>
|
|
56
58
|
</div>
|
|
57
59
|
</v-alert>
|
|
58
60
|
|
|
@@ -608,13 +610,15 @@ const displayEntries = computed(() => {
|
|
|
608
610
|
}, key);
|
|
609
611
|
if (!displayed) continue;
|
|
610
612
|
candidates.push({ key, role, turn: { ...turn, turnId }, message: displayed,
|
|
611
|
-
sortAt: turn.optimistic && turn.optimistic.
|
|
613
|
+
sortAt: turn.optimistic && turn.optimistic.status !== "accepted" && turn.optimistic.id === message.messageId
|
|
614
|
+
? NaN : Date.parse(message.at),
|
|
612
615
|
delivery: role === "user" ? userDelivery : null });
|
|
613
616
|
}
|
|
614
617
|
}
|
|
615
618
|
// Native streaming turns and saved authored turns can arrive independently.
|
|
616
619
|
// Local queued timestamps can precede an earlier message's native admission.
|
|
617
|
-
// Keep their supplied positions, along with undated
|
|
620
|
+
// Keep unconfirmed messages in their supplied positions, along with undated
|
|
621
|
+
// notices. Accepted local copies join chronology even before history arrives.
|
|
618
622
|
const dated = candidates.filter(entry => Number.isFinite(entry.sortAt))
|
|
619
623
|
.sort((a, b) => a.sortAt - b.sortAt)[Symbol.iterator]();
|
|
620
624
|
const entries = [];
|
|
@@ -1130,15 +1134,18 @@ watch(() => props.visible, (visible) => {
|
|
|
1130
1134
|
}
|
|
1131
1135
|
|
|
1132
1136
|
.assistant-transcript__error {
|
|
1137
|
+
position: relative;
|
|
1138
|
+
z-index: 4;
|
|
1139
|
+
background: rgb(var(--v-theme-surface));
|
|
1140
|
+
padding: 0.25rem 0.5rem;
|
|
1133
1141
|
font-size: 0.85rem;
|
|
1134
1142
|
min-width: 0;
|
|
1135
1143
|
overflow-wrap: anywhere;
|
|
1136
1144
|
}
|
|
1137
1145
|
|
|
1138
1146
|
.assistant-transcript__error-content {
|
|
1139
|
-
align-items:
|
|
1147
|
+
align-items: center;
|
|
1140
1148
|
display: flex;
|
|
1141
|
-
flex-direction: column;
|
|
1142
1149
|
gap: 0.5rem;
|
|
1143
1150
|
}
|
|
1144
1151
|
|
|
@@ -217,6 +217,9 @@ function useAssistantAttachments({
|
|
|
217
217
|
if (![maxBytes, maxItems, uploadConcurrency].every(value => Number.isSafeInteger(value) && value > 0)) {
|
|
218
218
|
throw new TypeError("Attachment limits must be positive integers.");
|
|
219
219
|
}
|
|
220
|
+
const maxSizeLabel = maxBytes % (1024 * 1024) === 0
|
|
221
|
+
? `${maxBytes / (1024 * 1024)} MiB`
|
|
222
|
+
: `${maxBytes / 1_000_000} MB`;
|
|
220
223
|
const attachments = ref([]);
|
|
221
224
|
const queueItems = ref([]);
|
|
222
225
|
const dragDepth = ref(0);
|
|
@@ -585,7 +588,7 @@ function useAssistantAttachments({
|
|
|
585
588
|
return waiter.promise;
|
|
586
589
|
}
|
|
587
590
|
if (row.size > maxBytes) {
|
|
588
|
-
row.error = `${row.fileName} is larger than the ${
|
|
591
|
+
row.error = `${row.fileName} is larger than the ${maxSizeLabel} attachment limit.`;
|
|
589
592
|
row.failureStage = "validation";
|
|
590
593
|
row.phase = "failed";
|
|
591
594
|
reportAttachmentError(row.error, "Attachment upload failed.", { notify: false });
|
|
@@ -767,7 +770,7 @@ function useAssistantAttachments({
|
|
|
767
770
|
row.size = Number(preparedFile.size) || 0;
|
|
768
771
|
row.totalBytes = row.size;
|
|
769
772
|
if (row.size > maxBytes) {
|
|
770
|
-
row.error = `${row.fileName} is larger than the ${
|
|
773
|
+
row.error = `${row.fileName} is larger than the ${maxSizeLabel} attachment limit.`;
|
|
771
774
|
row.failureStage = "validation";
|
|
772
775
|
row.phase = "failed";
|
|
773
776
|
settleWaiter(row, null, waiter);
|
|
@@ -881,7 +884,7 @@ function useAssistantAttachments({
|
|
|
881
884
|
const waiter = row.waiter;
|
|
882
885
|
waiters.push(waiter.promise);
|
|
883
886
|
if (row.size > maxBytes) {
|
|
884
|
-
row.error = `${row.fileName} is larger than the ${
|
|
887
|
+
row.error = `${row.fileName} is larger than the ${maxSizeLabel} attachment limit.`;
|
|
885
888
|
row.failureStage = "validation";
|
|
886
889
|
row.phase = "failed";
|
|
887
890
|
settleWaiter(row, null);
|
|
@@ -66,6 +66,7 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
|
|
|
66
66
|
const incoming = new Map();
|
|
67
67
|
let failure = null;
|
|
68
68
|
let closed = false;
|
|
69
|
+
let supportsSendNow = false;
|
|
69
70
|
let writes = Promise.resolve();
|
|
70
71
|
let events = Promise.resolve();
|
|
71
72
|
let queuedEventBytes = 0;
|
|
@@ -105,18 +106,23 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
|
|
|
105
106
|
async function request(request, options = {}) {
|
|
106
107
|
if (failure || closed) throw failure || claudeProtocolError("Claude connection is closed.");
|
|
107
108
|
const requestId = randomUUID();
|
|
109
|
+
function progress(stage) {
|
|
110
|
+
try { options.onProgress?.(stage); }
|
|
111
|
+
catch { /* Diagnostic observers cannot change protocol completion. */ }
|
|
112
|
+
}
|
|
108
113
|
let timer;
|
|
109
114
|
const response = new Promise((resolve, reject) => {
|
|
110
115
|
timer = setTimeout(() => {
|
|
111
116
|
requests.delete(requestId);
|
|
112
117
|
reject(claudeProtocolError(`Claude ${request.subtype} timed out.`, "assistant_claude_control_timeout"));
|
|
113
118
|
}, options.timeoutMs ?? timeoutMs);
|
|
114
|
-
requests.set(requestId, { resolve, reject });
|
|
119
|
+
requests.set(requestId, { resolve, reject, received: () => progress("response_received") });
|
|
115
120
|
});
|
|
116
121
|
// Install both observers before a write can fail or an immediate reply arrives.
|
|
117
122
|
try {
|
|
123
|
+
progress("requested");
|
|
118
124
|
const [, result] = await Promise.all([
|
|
119
|
-
write({ type: "control_request", request_id: requestId, request }), response
|
|
125
|
+
write({ type: "control_request", request_id: requestId, request }).then(() => progress("written")), response
|
|
120
126
|
]);
|
|
121
127
|
return result;
|
|
122
128
|
} finally {
|
|
@@ -163,6 +169,7 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
|
|
|
163
169
|
const pending = requests.get(response?.request_id);
|
|
164
170
|
if (!pending) continue; // A timed-out request may still get a reply.
|
|
165
171
|
requests.delete(response.request_id);
|
|
172
|
+
pending.received();
|
|
166
173
|
if (response.subtype === "success") pending.resolve(response.response || {});
|
|
167
174
|
else pending.reject(claudeProtocolError(String(response.error || "Claude control request failed.")));
|
|
168
175
|
} else if (frame.type === "control_request") {
|
|
@@ -170,6 +177,9 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
|
|
|
170
177
|
} else if (frame.type === "control_cancel_request") {
|
|
171
178
|
incoming.get(frame.request_id)?.controller.abort(claudeProtocolError("Claude cancelled this control request."));
|
|
172
179
|
} else {
|
|
180
|
+
if (frame.type === "system" && frame.subtype === "init") {
|
|
181
|
+
supportsSendNow = frame.capabilities?.includes("interrupt_send_now_v1") === true;
|
|
182
|
+
}
|
|
173
183
|
const bytes = Buffer.byteLength(JSON.stringify(frame));
|
|
174
184
|
queuedEventBytes += bytes;
|
|
175
185
|
if (queuedEventBytes > 2 * CLAUDE_JSON_MAX_FRAME_BYTES) {
|
|
@@ -204,13 +214,20 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
|
|
|
204
214
|
|
|
205
215
|
return Object.freeze({
|
|
206
216
|
completion,
|
|
207
|
-
|
|
217
|
+
supportsSendNow: () => supportsSendNow,
|
|
218
|
+
initialize: ({ hooks } = {}, options) => request({ subtype: "initialize", ...(hooks ? { hooks } : {}) }, options),
|
|
208
219
|
interrupt: () => request({ subtype: "interrupt" }),
|
|
209
220
|
request,
|
|
210
221
|
whenRequestsIdle: () => Promise.all([...incoming.values()].map(pending => pending.done)),
|
|
211
|
-
send(message, { messageId = randomUUID(), sessionId = "" } = {}) {
|
|
212
|
-
|
|
222
|
+
async send(message, { messageId = randomUUID(), sessionId = "", sendNow = false } = {}) {
|
|
223
|
+
// Older CLIs treat this control as a plain Stop, cancelling background
|
|
224
|
+
// workers. Require the native capability before writing either frame.
|
|
225
|
+
if (sendNow && !supportsSendNow) throw Object.assign(
|
|
226
|
+
claudeProtocolError("This Claude version does not support targeted Send now."), { delivery: "not-sent" }
|
|
227
|
+
);
|
|
228
|
+
await write({ type: "user", uuid: messageId, session_id: sessionId,
|
|
213
229
|
parent_tool_use_id: null, message: { role: "user", content: message } });
|
|
230
|
+
if (sendNow) await request({ subtype: "interrupt", send_now: true, message_uuid: messageId });
|
|
214
231
|
},
|
|
215
232
|
close() {
|
|
216
233
|
closed = true;
|
|
@@ -72,15 +72,25 @@ function claudeMessageBlocks(frame, { includeNested = false, commentary = false
|
|
|
72
72
|
if (frame.type !== "assistant" || frame.parent_tool_use_id && !includeNested) return [];
|
|
73
73
|
const content = frame.message?.content;
|
|
74
74
|
if (!Array.isArray(content)) return [];
|
|
75
|
+
// Claude's native placeholder classifier excludes this non-error synthetic
|
|
76
|
+
// carrier after an interrupted request. Real model replies and API errors
|
|
77
|
+
// with the same wording remain ordinary output.
|
|
78
|
+
if (frame.message.model === "<synthetic>" && !frame.isApiErrorMessage && content.length === 1 &&
|
|
79
|
+
content[0]?.type === "text" && content[0].text === "No response requested.") return [];
|
|
75
80
|
// Native frames split one API message into blocks with distinct UUIDs.
|
|
76
81
|
const messageId = frame.uuid || frame.message.id;
|
|
77
82
|
if (!messageId) return [];
|
|
83
|
+
const command = frame.local_command_run?.command;
|
|
84
|
+
const output = /^<local-command-(stdout|stderr)>/u.exec(frame.local_command_source || "")?.[1];
|
|
78
85
|
commentary ||= content.some((block) => block.type === "tool_use");
|
|
79
86
|
return content.flatMap((block, index) => {
|
|
80
87
|
const text = block.type === "thinking" ? block.thinking : block.type === "text" ? block.text : "";
|
|
81
88
|
return typeof text === "string" && text.trim() ? [{
|
|
82
89
|
id: `claude_${messageId}_${index}`, text, complete: true,
|
|
83
|
-
role: block.type === "thinking" ? "thinking" : commentary ? "commentary" : "assistant"
|
|
90
|
+
role: block.type === "thinking" ? "thinking" : commentary ? "commentary" : "assistant",
|
|
91
|
+
...(typeof command === "string" && command && output ? {
|
|
92
|
+
data: { native: { engineId: "claude", type: "assistant", command, output } }
|
|
93
|
+
} : {})
|
|
84
94
|
}] : [];
|
|
85
95
|
});
|
|
86
96
|
}
|
|
@@ -89,6 +99,7 @@ async function readClaudeHistory(options) {
|
|
|
89
99
|
const file = await claudeHistoryPath(options);
|
|
90
100
|
const inspected = {
|
|
91
101
|
...(options.includeUserMessages ? { userMessages: [] } : {}),
|
|
102
|
+
...(options.includeCommandMessages ? { commandMessages: [] } : {}),
|
|
92
103
|
...(options.includeFinalCarriers ? { completedResults: [], failedResults: [], structuredOutputs: [] } : {})
|
|
93
104
|
};
|
|
94
105
|
if (!file || file.size === 0) return { exists: false, messages: [], userIds: [], text: "", ...inspected };
|
|
@@ -97,6 +108,8 @@ async function readClaudeHistory(options) {
|
|
|
97
108
|
const userIds = [];
|
|
98
109
|
let lastUserId = "";
|
|
99
110
|
let goal = null;
|
|
111
|
+
let goalStartId = "";
|
|
112
|
+
let goalCommandId = "";
|
|
100
113
|
const frames = [];
|
|
101
114
|
const byId = new Map();
|
|
102
115
|
let leafUuid = "";
|
|
@@ -133,19 +146,60 @@ async function readClaudeHistory(options) {
|
|
|
133
146
|
}
|
|
134
147
|
for (const frame of frames) {
|
|
135
148
|
if (rewound && frame.uuid && !retained.has(frame.uuid)) continue;
|
|
149
|
+
// The exact native local-command receipt owns clear identity. Claude may
|
|
150
|
+
// insert a caveat between the sentinel and authored command, or omit the
|
|
151
|
+
// sentinel after an impossible evaluation. Neither case changes delivery.
|
|
152
|
+
// Ordinary model text is never goal-state evidence.
|
|
153
|
+
if (frame.type === "system" && frame.subtype === "local_command" &&
|
|
154
|
+
!frame.parent_tool_use_id && !frame.isMeta && frame.commandRun?.command === "goal" &&
|
|
155
|
+
frame.commandRun.args === "clear" && typeof frame.content === "string" &&
|
|
156
|
+
/^<local-command-stdout>(?:No goal set|Goal cleared: [\s\S]+)<\/local-command-stdout>$/u.test(frame.content)) {
|
|
157
|
+
goal = null;
|
|
158
|
+
goalStartId = "";
|
|
159
|
+
const command = byId.get(frame.parentUuid);
|
|
160
|
+
goalCommandId = command?.type === "user" && !command.isMeta && !command.parent_tool_use_id
|
|
161
|
+
? command.uuid : "";
|
|
162
|
+
}
|
|
163
|
+
if (options.includeCommandMessages && frame.type === "system" && frame.subtype === "local_command" &&
|
|
164
|
+
frame.uuid && !frame.parent_tool_use_id && !frame.isMeta &&
|
|
165
|
+
typeof frame.commandRun?.command === "string" && frame.commandRun.command) {
|
|
166
|
+
const output = typeof frame.content === "string"
|
|
167
|
+
? /^<local-command-(stdout|stderr)>([\s\S]*)<\/local-command-\1>$/u.exec(frame.content) : null;
|
|
168
|
+
if (output) inspected.commandMessages.push({ id: `claude_${frame.uuid}_0`, text: output[2],
|
|
169
|
+
data: { native: { engineId: "claude", type: "system", subtype: "local_command",
|
|
170
|
+
command: frame.commandRun.command, output: output[1] } } });
|
|
171
|
+
}
|
|
136
172
|
const status = frame.attachment;
|
|
137
|
-
if (
|
|
173
|
+
if (frame.type === "attachment" && !frame.parent_tool_use_id &&
|
|
174
|
+
status?.type === "goal_status" && typeof status.condition === "string") {
|
|
175
|
+
if (status.sentinel === true || goal?.objective !== status.condition) {
|
|
176
|
+
goalStartId = status.sentinel === true && typeof status.met === "boolean" ? frame.uuid || "" : "";
|
|
177
|
+
goalCommandId = "";
|
|
178
|
+
}
|
|
138
179
|
if (status.met === true && status.sentinel === true) goal = null;
|
|
139
180
|
else if (status.met === false || status.met === true) {
|
|
140
181
|
const timestamp = Date.parse(frame.timestamp) / 1000;
|
|
141
182
|
goal = {
|
|
142
183
|
threadId: options.conversationId, objective: status.condition,
|
|
143
184
|
createdAt: goal?.objective === status.condition && !status.sentinel ? goal.createdAt : timestamp,
|
|
144
|
-
status: status.met ? "complete" : "active", updatedAt: timestamp,
|
|
185
|
+
status: status.met ? "complete" : status.failed === true ? "blocked" : "active", updatedAt: timestamp,
|
|
145
186
|
reason: typeof status.reason === "string" ? status.reason : ""
|
|
146
187
|
};
|
|
147
188
|
}
|
|
148
189
|
}
|
|
190
|
+
// This is native terminal evidence, not an inference from the assistant's
|
|
191
|
+
// answer or its wording. Retain it across process and application restarts.
|
|
192
|
+
if (goal?.status === "active" && frame.type === "system" && frame.subtype === "informational" &&
|
|
193
|
+
!frame.parent_tool_use_id && typeof frame.content === "string" &&
|
|
194
|
+
/^A hook blocked the turn from ending \d+ consecutive times — overriding and ending turn\./u.test(frame.content)) {
|
|
195
|
+
goal = { ...goal, status: "blocked", updatedAt: Date.parse(frame.timestamp) / 1000,
|
|
196
|
+
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") };
|
|
197
|
+
}
|
|
198
|
+
// Native /goal appends its sentinel before the authored command, whose
|
|
199
|
+
// parent identifies that exact attachment. A later evaluation keeps it.
|
|
200
|
+
if (frame.type === "user" && !frame.isMeta && frame.uuid && goalStartId && frame.parentUuid === goalStartId) {
|
|
201
|
+
goalCommandId = frame.uuid;
|
|
202
|
+
}
|
|
149
203
|
if (frame.type === "user" && !frame.isMeta && frame.uuid &&
|
|
150
204
|
(typeof frame.message?.content === "string" || frame.message?.content?.some((block) => block.type === "text"))) {
|
|
151
205
|
userIds.push(frame.uuid);
|
|
@@ -182,10 +236,13 @@ async function readClaudeHistory(options) {
|
|
|
182
236
|
}
|
|
183
237
|
}
|
|
184
238
|
for (const message of claudeMessageBlocks(frame, { commentary: toolMessages.has(frame.message?.id) })) {
|
|
185
|
-
messages.set(message.id, { ...message, userId: lastUserId
|
|
239
|
+
messages.set(message.id, { ...message, userId: lastUserId,
|
|
240
|
+
...(frame.message.content.length === 1 && typeof frame.message.id === "string" && frame.message.id &&
|
|
241
|
+
Number.isSafeInteger(frame.apiBlockIndex) && frame.apiBlockIndex >= 0
|
|
242
|
+
? { outputId: `claude_${frame.message.id}_${frame.apiBlockIndex}` } : {}) });
|
|
186
243
|
}
|
|
187
244
|
}
|
|
188
|
-
return { exists: true, messages: [...messages.values()], userIds, goal, ...inspected,
|
|
245
|
+
return { exists: true, messages: [...messages.values()], userIds, goal, goalCommandId, ...inspected,
|
|
189
246
|
text: [...messages.values()].filter((message) => message.role === "assistant" && message.userId === lastUserId)
|
|
190
247
|
.map((message) => message.text).join("\n") };
|
|
191
248
|
}
|
|
@@ -240,7 +240,7 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
240
240
|
accountProcessStops.delete(native);
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
async function readAccountProcess(identity, expectedRevision, read) {
|
|
243
|
+
async function readAccountProcess(identity, expectedRevision, purpose, read) {
|
|
244
244
|
assertCurrent(expectedRevision);
|
|
245
245
|
for (const native of accountProcessStops) await stopAccountProcess(native);
|
|
246
246
|
assertCurrent(expectedRevision);
|
|
@@ -256,7 +256,7 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
256
256
|
cancelled.promise.catch(() => {});
|
|
257
257
|
let native;
|
|
258
258
|
try {
|
|
259
|
-
native = owned?.process || await createProcess({ signal: controller.signal });
|
|
259
|
+
native = owned?.process || await createProcess({ signal: controller.signal, purpose });
|
|
260
260
|
controller.signal.throwIfAborted();
|
|
261
261
|
return await Promise.race([read(native), cancelled.promise]);
|
|
262
262
|
} finally {
|
|
@@ -274,7 +274,7 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
274
274
|
assertCurrent(expectedRevision);
|
|
275
275
|
if (!catalog || catalog.identity !== identity || Date.now() - catalog.at > 600_000) {
|
|
276
276
|
if (!catalogStarts.has(identity)) catalogStarts.set(identity, (async () => {
|
|
277
|
-
const value = await readAccountProcess(identity, expectedRevision, (native) => native.initialization);
|
|
277
|
+
const value = await readAccountProcess(identity, expectedRevision, "catalogue", (native) => native.initialization);
|
|
278
278
|
if (identity !== await accountIdentity(context)) throw error("The signed-in Claude account changed. Refresh the model list.");
|
|
279
279
|
assertCurrent(expectedRevision);
|
|
280
280
|
catalog = { at: Date.now(), identity, value };
|
|
@@ -292,7 +292,7 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
292
292
|
if (planUsage?.identity === identity && Date.now() - planUsage.checkedAt < 60_000) return planUsage;
|
|
293
293
|
if (planUsagePending.has(identity)) return planUsagePending.get(identity);
|
|
294
294
|
const pending = (async () => {
|
|
295
|
-
const value = await readAccountProcess(identity, expectedRevision, (native) => native.client.request(
|
|
295
|
+
const value = await readAccountProcess(identity, expectedRevision, "usage", (native) => native.client.request(
|
|
296
296
|
{ subtype: "get_usage", skip_behaviors: true }));
|
|
297
297
|
if (identity !== await accountIdentity(context)) return { status: "unavailable", windows: [] };
|
|
298
298
|
assertCurrent(expectedRevision);
|
|
@@ -317,7 +317,8 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
317
317
|
/** Native startup and handshake; an execution host owns processes and their identity. */
|
|
318
318
|
export async function createClaudeCodeProcess({
|
|
319
319
|
command = "claude", execution = createLocalConversationExecution(), env = process.env,
|
|
320
|
-
workdir, onEvent, onControlRequest, onFailure, onStarted, executionLimits, signal, commandWrapper,
|
|
320
|
+
workdir, onEvent, onControlRequest, onFailure, onStarted, executionLimits, signal, commandWrapper,
|
|
321
|
+
initializationTimeoutMs = 30_000, onInitializationProgress, ...options
|
|
321
322
|
} = {}) {
|
|
322
323
|
if (commandWrapper !== undefined) validateCommandWrapper(commandWrapper);
|
|
323
324
|
const environment = { ...env, DISABLE_AUTOUPDATER: "1" };
|
|
@@ -358,7 +359,7 @@ export async function createClaudeCodeProcess({
|
|
|
358
359
|
signal?.addEventListener("abort", abort, { once: true });
|
|
359
360
|
const initializing = client.initialize(commandWrapper ? { hooks: {
|
|
360
361
|
PreToolUse: [{ matcher: "^Bash$", hookCallbackIds: ["jskit-command-wrapper"], timeout: 30 }]
|
|
361
|
-
} } : {});
|
|
362
|
+
} } : {}, { timeoutMs: initializationTimeoutMs, onProgress: onInitializationProgress });
|
|
362
363
|
const initialization = await (native.exited ? Promise.race([
|
|
363
364
|
initializing,
|
|
364
365
|
native.exited.then(processExit => {
|