@jskit-ai/assistant-core 0.2.20 → 0.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/examples/conversation/main.js +3 -2
- package/examples/conversation/package.json +6 -6
- package/fixtures/responsive-assistant/main.js +5 -6
- package/package.json +9 -7
- package/src/client/components/AssistantSettingsFormCard.vue +4 -4
- package/src/client/conversation/AssistantAttachmentPreview.vue +1 -2
- package/src/client/conversation/AssistantAttachmentQueue.vue +3 -8
- package/src/client/conversation/AssistantComposerActions.vue +2 -3
- package/src/client/conversation/AssistantComposerSupport.vue +1 -2
- package/src/client/conversation/AssistantConversationElement.vue +8 -9
- package/src/client/conversation/AssistantConversationStatus.vue +1 -1
- package/src/client/conversation/AssistantGoalControl.vue +6 -1
- package/src/client/conversation/AssistantMessageAttachments.vue +1 -2
- package/src/client/conversation/AssistantModelControl.vue +6 -7
- package/src/client/conversation/AssistantQuestionInputs.vue +1 -2
- package/src/client/conversation/AssistantTranscript.css +2 -2
- package/src/client/conversation/AssistantTranscript.vue +5 -11
- package/src/client/conversation/LongTextPreviewBlocks.vue +4 -4
- package/src/client/conversation/attachmentQueueItems.js +3 -4
- package/src/server/conversation/codexNotificationObserver.js +3 -3
- package/src/server/conversation/codexPromptDispatch.js +3 -3
- package/src/server/conversation/codexResultSettlement.js +2 -5
- package/src/server/conversation/codexRunJournal.js +55 -3
- package/src/server/conversation/codexThreadStatus.js +6 -2
- package/src/server/conversation/codexTurnEndings.js +3 -3
- package/src/server/conversation/codexTurnRecovery.js +16 -11
- package/src/server/conversation/codexTurnState.js +14 -1
- package/test/codexTurn.test.js +76 -0
- package/test/conversationAvatarThumbnail.test.js +62 -0
- package/test/transcriptTheme.browser.test.js +10 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- The goal menu closes when Cancel succeeds, as it does after Start; Pause and Resume keep it open.
|
|
6
|
+
- `AssistantConversationElement` has an `avatar-thumbnail` slot, with the `{ size, height }` scope of the other avatar slots: while the avatar is hidden, the **Show avatar** button draws its content instead of the anonymous account icon, such as a still miniature of the avatar, clipped to the button's round 32-pixel disc. The button keeps its label, title and 44-pixel target, and shows the icon as before without the slot.
|
|
7
|
+
- A delivered Codex turn is held as not yet started only until Codex reports it: a `turn/started` that arrives before the `turn/start` answer counts, and a thread read listing exactly that turn as completed or failed settles a turn whose notifications were missed, so its answer is no longer kept waiting for a notification that never comes.
|
|
5
8
|
- The API driver's state note, which carries the current time, follows the conversation as its last message instead of ending the system prompt, so the provider's prompt cache keeps the whole history from one turn to the next instead of charging it uncached on each turn's first model call (`@jskit-ai/agent` `runLoop`).
|
|
9
|
+
- The assistant settings card is an elevated Material 3 card without a border, and the long-text preview's table alignment no longer needs `!important`.
|
|
10
|
+
- Icons are Material Symbols `$aliases` (from `@jskit-ai/material-web`) instead of `@mdi/js` paths, and text uses Vuetify 4's Material 3 type classes (`text-body-large`, `text-title-medium`, ...) instead of Material 2 ones, which Vuetify 4 no longer styles. Upgrading: see the guide's [Move an app to the Material 3 defaults](https://mobily-enterprises.github.io/jskit-ai/guide/app-setup/material-3-migration).
|
|
6
11
|
- Assistant failures that reach a person translate: 63 more undotted codes render from `jskit.errors.<code>`, and 98 sentences that share a code with others, or are sent without one, carry a `messageCode` (`assistant.claude.*`, `assistant.codex.*`, `assistant.opencode.*`, `assistant.conversation.*`, `assistant.turn.*`). The English messages are unchanged.
|
|
7
12
|
- The OpenCode version mismatch, Claude control-request timeout and unreadable saved Claude conversation errors carry `params` for their catalog English.
|
|
8
13
|
- The assistant settings resource sends its system prompt field messages in the coded `{ code, message, params }` form.
|
|
@@ -2,8 +2,9 @@ import { createApp } from "vue";
|
|
|
2
2
|
import { createVuetify } from "vuetify";
|
|
3
3
|
import * as components from "vuetify/components";
|
|
4
4
|
import * as directives from "vuetify/directives";
|
|
5
|
-
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
|
|
6
5
|
import "vuetify/styles";
|
|
6
|
+
import "@jskit-ai/material-web/client/material.css";
|
|
7
|
+
import { createMaterialVuetifyOptions } from "@jskit-ai/material-web/client";
|
|
7
8
|
import App from "./App.vue";
|
|
8
9
|
|
|
9
|
-
createApp(App).use(createVuetify({ components, directives,
|
|
10
|
+
createApp(App).use(createVuetify({ components, directives, ...createMaterialVuetifyOptions() })).mount("#app");
|
|
@@ -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.
|
|
14
|
-
"@
|
|
11
|
+
"@jskit-ai/assistant-core": "0.2.22",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.2.10",
|
|
13
|
+
"@jskit-ai/kernel": "0.2.10",
|
|
14
|
+
"@jskit-ai/material-web": "0.1.0",
|
|
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.11",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.2.11"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
import { createApp } from "vue";
|
|
2
2
|
import "vuetify/styles";
|
|
3
|
+
import "@jskit-ai/material-web/client/material.css";
|
|
4
|
+
import { createMaterialVuetifyOptions } from "@jskit-ai/material-web/client";
|
|
3
5
|
import { createVuetify } from "vuetify";
|
|
4
6
|
import * as components from "vuetify/components";
|
|
5
7
|
import * as directives from "vuetify/directives";
|
|
6
|
-
import { aliases as mdiAliases, mdi } from "vuetify/iconsets/mdi-svg";
|
|
7
8
|
import App from "./App.vue";
|
|
8
9
|
import ConversationFixture from "./ConversationFixture.vue";
|
|
9
10
|
import ProgressFixture from "./ProgressFixture.vue";
|
|
10
11
|
import DeliveryFixture from "./DeliveryFixture.vue";
|
|
11
12
|
|
|
12
13
|
const query = new URLSearchParams(location.search);
|
|
14
|
+
const material = createMaterialVuetifyOptions();
|
|
13
15
|
const vuetify = createVuetify({
|
|
14
16
|
components,
|
|
15
17
|
directives,
|
|
16
|
-
|
|
17
|
-
defaultSet: "mdi",
|
|
18
|
-
aliases: mdiAliases,
|
|
19
|
-
sets: { mdi }
|
|
20
|
-
},
|
|
18
|
+
...material,
|
|
21
19
|
theme: {
|
|
20
|
+
...material.theme,
|
|
22
21
|
defaultTheme: query.has("dark") ? "dark" : "light"
|
|
23
22
|
}
|
|
24
23
|
});
|
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.22",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -52,9 +52,8 @@
|
|
|
52
52
|
"@ai-sdk/togetherai": "^3.0.49",
|
|
53
53
|
"@ai-sdk/xai": "^4.0.58",
|
|
54
54
|
"@jskit-ai/agent": "0.1.1",
|
|
55
|
-
"@jskit-ai/resource-core": "0.1.
|
|
56
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
57
|
-
"@mdi/js": "^7.4.47",
|
|
55
|
+
"@jskit-ai/resource-core": "0.1.201",
|
|
56
|
+
"@jskit-ai/resource-crud-core": "0.1.201",
|
|
58
57
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
59
58
|
"ai": "^7.0.152",
|
|
60
59
|
"json-rest-schema": "^1.0.17",
|
|
@@ -62,10 +61,10 @@
|
|
|
62
61
|
"ws": "^8.20.1"
|
|
63
62
|
},
|
|
64
63
|
"peerDependencies": {
|
|
65
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
66
|
-
"@jskit-ai/kernel": "0.2.
|
|
64
|
+
"@jskit-ai/http-runtime": "0.2.10",
|
|
65
|
+
"@jskit-ai/kernel": "0.2.10",
|
|
67
66
|
"vue": "^3.5.13",
|
|
68
|
-
"vuetify": "^4.
|
|
67
|
+
"vuetify": "^4.3.0"
|
|
69
68
|
},
|
|
70
69
|
"description": "Reusable assistant client/server/shared primitives without surface-specific routes or settings ownership.",
|
|
71
70
|
"jskit": {
|
|
@@ -219,5 +218,8 @@
|
|
|
219
218
|
"capabilities": []
|
|
220
219
|
}
|
|
221
220
|
}
|
|
221
|
+
},
|
|
222
|
+
"devDependencies": {
|
|
223
|
+
"@jskit-ai/material-web": "0.1.0"
|
|
222
224
|
}
|
|
223
225
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<section :class="props.rootClass">
|
|
3
|
-
<v-card
|
|
3
|
+
<v-card>
|
|
4
4
|
<v-card-item>
|
|
5
|
-
<v-card-title class="text-
|
|
5
|
+
<v-card-title class="text-title-large">{{ props.title }}</v-card-title>
|
|
6
6
|
<v-card-subtitle>{{ props.subtitle }}</v-card-subtitle>
|
|
7
7
|
</v-card-item>
|
|
8
8
|
<v-divider />
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
<v-skeleton-loader type="text@2, list-item-two-line@4, button" />
|
|
12
12
|
</template>
|
|
13
13
|
|
|
14
|
-
<p v-else-if="props.addEdit.loadError" class="text-body-
|
|
14
|
+
<p v-else-if="props.addEdit.loadError" class="text-body-medium on-surface-variant mb-4">
|
|
15
15
|
{{ props.addEdit.loadError }}
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
|
-
<p v-else-if="!props.addEdit.canView" class="text-body-
|
|
18
|
+
<p v-else-if="!props.addEdit.canView" class="text-body-medium on-surface-variant mb-4">
|
|
19
19
|
{{ props.noPermissionMessage }}
|
|
20
20
|
</p>
|
|
21
21
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<p v-else>{{ t("jskit.assistant.attachments.previewUnavailable") }}</p>
|
|
14
14
|
</v-card-text>
|
|
15
15
|
<v-card-actions>
|
|
16
|
-
<v-btn :href="downloadUrl" :download="attachment.fileName"
|
|
16
|
+
<v-btn :href="downloadUrl" :download="attachment.fileName" prepend-icon="$download" variant="tonal">{{ t("jskit.assistant.attachments.download") }}</v-btn>
|
|
17
17
|
<v-spacer />
|
|
18
18
|
<v-btn @click="emit('close')">{{ t("jskit.assistant.attachments.close") }}</v-btn>
|
|
19
19
|
</v-card-actions>
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
|
|
24
24
|
<script setup>
|
|
25
25
|
import { ref, watch } from "vue";
|
|
26
|
-
import { mdiDownload } from "@mdi/js";
|
|
27
26
|
import { useJskitText } from "../i18n.js";
|
|
28
27
|
|
|
29
28
|
const { t } = useJskitText();
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
:ref="(element) => setActionElement(item, 'cancel', element)"
|
|
75
75
|
:aria-label="t('jskit.assistant.attachments.cancelItem', { name: itemName(item) })"
|
|
76
76
|
class="assistant-attachment-queue__action"
|
|
77
|
-
|
|
77
|
+
icon="$cancel"
|
|
78
78
|
size="small"
|
|
79
79
|
:title="t('jskit.assistant.attachments.cancelItem', { name: itemName(item) })"
|
|
80
80
|
type="button"
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
:ref="(element) => setActionElement(item, 'retry', element)"
|
|
87
87
|
:aria-label="t('jskit.assistant.attachments.retryItem', { name: itemName(item) })"
|
|
88
88
|
class="assistant-attachment-queue__action"
|
|
89
|
-
|
|
89
|
+
icon="$refresh"
|
|
90
90
|
size="small"
|
|
91
91
|
:title="t('jskit.assistant.attachments.retryItem', { name: itemName(item) })"
|
|
92
92
|
type="button"
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
:ref="(element) => setActionElement(item, 'remove', element)"
|
|
99
99
|
:aria-label="t('jskit.assistant.attachments.removeItem', { name: itemName(item) })"
|
|
100
100
|
class="assistant-attachment-queue__action"
|
|
101
|
-
|
|
101
|
+
icon="$close"
|
|
102
102
|
size="small"
|
|
103
103
|
:title="t('jskit.assistant.attachments.removeItem', { name: itemName(item) })"
|
|
104
104
|
type="button"
|
|
@@ -122,11 +122,6 @@
|
|
|
122
122
|
|
|
123
123
|
<script setup>
|
|
124
124
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
|
125
|
-
import {
|
|
126
|
-
mdiClose,
|
|
127
|
-
mdiCloseCircleOutline,
|
|
128
|
-
mdiRefresh
|
|
129
|
-
} from "@mdi/js";
|
|
130
125
|
|
|
131
126
|
import { hasWindowFunction } from "../browser.js";
|
|
132
127
|
import { useJskitText } from "../i18n.js";
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<div class="assistant-composer-actions__delivery">
|
|
5
5
|
<v-btn
|
|
6
6
|
v-if="state.canStop" color="error" :disabled="state.stopDisabled || state.stopPending"
|
|
7
|
-
:aria-busy="state.stopPending ? 'true' : undefined"
|
|
7
|
+
:aria-busy="state.stopPending ? 'true' : undefined" prepend-icon="$stopCircle"
|
|
8
8
|
size="small" variant="tonal" @click="emit('stop')"
|
|
9
9
|
>
|
|
10
10
|
{{ state.stopPending ? t('jskit.assistant.composer.stopping') : t('jskit.assistant.composer.stop') }}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<v-btn
|
|
13
13
|
ref="sendButton" class="assistant-composer-actions__send" color="primary" :disabled="!state.canSend"
|
|
14
14
|
:aria-busy="state.pending && !state.canSend ? 'true' : undefined" :aria-label="state.submitAriaLabel || state.submitLabel || t('jskit.assistant.composer.sendMessage')"
|
|
15
|
-
:title="state.submitTitle" :prepend-icon="state.submitIcon ||
|
|
15
|
+
:title="state.submitTitle" :prepend-icon="state.submitIcon || '$send'" size="small" variant="flat"
|
|
16
16
|
@click="emit('submit')"
|
|
17
17
|
>
|
|
18
18
|
{{ state.submitLabel || (state.pending && !state.canSend ? t('jskit.assistant.composer.sending') : t('jskit.assistant.composer.send')) }}
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
</template>
|
|
24
24
|
<script setup>
|
|
25
25
|
import { ref } from "vue";
|
|
26
|
-
import { mdiSend, mdiStopCircleOutline } from "@mdi/js";
|
|
27
26
|
import { useJskitText } from "../i18n.js";
|
|
28
27
|
|
|
29
28
|
const { t } = useJskitText();
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
<v-icon
|
|
30
30
|
aria-hidden="true"
|
|
31
31
|
class="assistant-composer-support__marker"
|
|
32
|
-
|
|
32
|
+
icon="$lightbulb"
|
|
33
33
|
size="16"
|
|
34
34
|
/>
|
|
35
35
|
|
|
@@ -73,7 +73,6 @@
|
|
|
73
73
|
|
|
74
74
|
<script setup>
|
|
75
75
|
import { computed } from "vue";
|
|
76
|
-
import { mdiLightbulbOnOutline } from "@mdi/js";
|
|
77
76
|
import { useJskitText } from "../i18n.js";
|
|
78
77
|
|
|
79
78
|
const { t } = useJskitText();
|
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
<div v-show="avatarSize === 'hidden'" ref="avatarControls" class="assistant-conversation__avatar-controls">
|
|
8
8
|
<slot name="avatar-control" :size="avatarSize" :height="avatarHeight" />
|
|
9
9
|
<v-btn
|
|
10
|
-
|
|
10
|
+
icon="$accountCircle" size="small"
|
|
11
11
|
variant="text" :width="44" :min-width="44" :min-height="44"
|
|
12
12
|
class="assistant-conversation__avatar-size"
|
|
13
13
|
:aria-label="t('jskit.assistant.chat.showAvatar')" :title="t('jskit.assistant.chat.showAvatar')"
|
|
14
14
|
:aria-expanded="avatarSize !== 'hidden'" @click="toggleAvatar"
|
|
15
15
|
>
|
|
16
16
|
<span class="assistant-conversation__avatar-size-disc">
|
|
17
|
-
<v-icon size="20"
|
|
17
|
+
<slot name="avatar-thumbnail" :size="avatarSize" :height="avatarHeight"><v-icon size="20" icon="$accountCircle" /></slot>
|
|
18
18
|
</span>
|
|
19
19
|
</v-btn>
|
|
20
20
|
</div>
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
<div class="assistant-conversation__avatar-tools">
|
|
26
26
|
<slot name="avatar-tools" :size="avatarSize" :height="avatarHeight" />
|
|
27
27
|
<v-btn
|
|
28
|
-
|
|
28
|
+
icon="$minus" size="small" variant="text" color="primary"
|
|
29
29
|
:width="40" :min-width="40" :min-height="44" class="assistant-conversation__avatar-size"
|
|
30
30
|
:aria-label="t('jskit.assistant.chat.minimiseAvatar')" :title="t('jskit.assistant.chat.minimiseAvatar')" :aria-expanded="true"
|
|
31
31
|
@click="toggleAvatar"
|
|
32
32
|
>
|
|
33
33
|
<span class="assistant-conversation__avatar-size-disc assistant-conversation__avatar-size-disc--minimise">
|
|
34
|
-
<v-icon size="20"
|
|
34
|
+
<v-icon size="20" icon="$minus" />
|
|
35
35
|
</span>
|
|
36
36
|
</v-btn>
|
|
37
37
|
</div>
|
|
@@ -49,13 +49,13 @@
|
|
|
49
49
|
<template #user-message-actions="scope">
|
|
50
50
|
<div v-if="scope.turn.preview && scope.turn.previewMessage?.actions" class="assistant-conversation__preview-actions">
|
|
51
51
|
<v-btn
|
|
52
|
-
v-if="scope.turn.previewMessage.actions.discard"
|
|
52
|
+
v-if="scope.turn.previewMessage.actions.discard" icon="$close" size="small" variant="text"
|
|
53
53
|
:aria-label="t('jskit.assistant.chat.discardUnsent')" :title="t('jskit.assistant.chat.discardUnsent')"
|
|
54
54
|
:disabled="!scope.turn.previewMessage.actions.canDiscard"
|
|
55
55
|
@click="scope.turn.previewMessage.actions.discard(scope.turn.turnId)"
|
|
56
56
|
/>
|
|
57
57
|
<v-btn
|
|
58
|
-
v-if="scope.turn.previewMessage.actions.edit"
|
|
58
|
+
v-if="scope.turn.previewMessage.actions.edit" icon="$edit" size="small" variant="text"
|
|
59
59
|
:aria-label="t('jskit.assistant.chat.editUnsent')" :title="t('jskit.assistant.chat.editUnsent')"
|
|
60
60
|
:disabled="!scope.turn.previewMessage.actions.canEdit"
|
|
61
61
|
@click="scope.turn.previewMessage.actions.edit(scope.turn.turnId)"
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
@select-variant="adapter.models.selectVariant?.($event)" @apply="applyModel" @reload="adapter.models.reload?.()"
|
|
130
130
|
/>
|
|
131
131
|
<v-btn
|
|
132
|
-
v-if="attachmentsEnabled" :aria-label="t('jskit.assistant.chat.attachFiles')" :title="t('jskit.assistant.chat.attachFiles')" size="small" variant="text"
|
|
132
|
+
v-if="attachmentsEnabled" :aria-label="t('jskit.assistant.chat.attachFiles')" :title="t('jskit.assistant.chat.attachFiles')" size="small" variant="text" icon="$attachFile"
|
|
133
133
|
:disabled="!adapter.attachments.canAddFiles" @click="fileInput?.click()"
|
|
134
134
|
/>
|
|
135
135
|
<slot
|
|
@@ -158,7 +158,6 @@
|
|
|
158
158
|
</template>
|
|
159
159
|
<script setup>
|
|
160
160
|
import { computed, onBeforeUnmount, onMounted, onUpdated, ref, useId, useSlots, watch } from "vue";
|
|
161
|
-
import { mdiAccountCircleOutline, mdiClose, mdiMinus, mdiPaperclip, mdiPencil } from "@mdi/js";
|
|
162
161
|
import AssistantTranscript from "./AssistantTranscript.vue";
|
|
163
162
|
import AssistantPromptInput from "./AssistantPromptInput.vue";
|
|
164
163
|
import AssistantComposerActions from "./AssistantComposerActions.vue";
|
|
@@ -371,7 +370,7 @@ defineExpose({ focus: () => input.value?.focus(), submit, stop });
|
|
|
371
370
|
.assistant-conversation__avatar-controls { position: absolute; top: 0; right: 100%; display: flex; align-items: center; justify-content: flex-end; min-height: 44px; }
|
|
372
371
|
.assistant-conversation__avatar--hidden .assistant-conversation__avatar-controls { right: 0; }
|
|
373
372
|
.assistant-conversation__avatar-size { min-width: 44px; min-height: 44px; }
|
|
374
|
-
.assistant-conversation__avatar-size-disc { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: rgba(var(--v-theme-on-surface), .08); }
|
|
373
|
+
.assistant-conversation__avatar-size-disc { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; overflow: hidden; border-radius: 50%; background: rgba(var(--v-theme-on-surface), .08); }
|
|
375
374
|
.assistant-conversation__avatar-size-disc--minimise { background: rgba(var(--v-theme-primary), .12); }
|
|
376
375
|
.assistant-conversation__avatar-presentation { display: flex; flex-direction: column; align-items: center; min-width: 0; }
|
|
377
376
|
.assistant-conversation__avatar-tools { display: flex; align-items: center; gap: 0; }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div v-if="message.delivery" class="d-flex flex-wrap align-center ga-1 text-
|
|
2
|
+
<div v-if="message.delivery" class="d-flex flex-wrap align-center ga-1 text-body-small text-error" role="status">
|
|
3
3
|
<span>{{ message.delivery.error || message.text }}</span>
|
|
4
4
|
<v-btn
|
|
5
5
|
variant="text"
|
|
@@ -63,6 +63,11 @@ async function setGoal() {
|
|
|
63
63
|
const result = await props.state.set({ objective: objective.value.trim(), ...(props.state.tokenBudgetSupported === false || tokenBudget.value === "" ? {} : { tokenBudget: Number(tokenBudget.value) }) });
|
|
64
64
|
if (result !== false && result?.ok !== false && !props.state.error) { objective.value = ""; tokenBudget.value = ""; open.value = false; }
|
|
65
65
|
}
|
|
66
|
+
// A successful Cancel closes the menu, as Start does; Pause and Resume keep it open.
|
|
67
|
+
async function cancelGoal() {
|
|
68
|
+
const result = await props.state.cancel();
|
|
69
|
+
if (result !== false && result?.ok !== false && !props.state.error) open.value = false;
|
|
70
|
+
}
|
|
66
71
|
</script>
|
|
67
72
|
|
|
68
73
|
<template>
|
|
@@ -78,7 +83,7 @@ async function setGoal() {
|
|
|
78
83
|
<strong role="status">{{ label }}</strong>
|
|
79
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>
|
|
80
85
|
<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>
|
|
81
|
-
<v-btn v-if="goal && goal.status !== 'complete' && state.cancel" color="error" size="small" variant="text" :disabled="state.stopPending" @click="
|
|
86
|
+
<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>
|
|
82
87
|
<p v-if="goal" class="my-2">{{ goal.objective }}</p>
|
|
83
88
|
<p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
|
|
84
89
|
<p v-if="elapsed" class="text-body-small my-2">{{ elapsedText }}</p>
|
|
@@ -41,7 +41,6 @@
|
|
|
41
41
|
</template>
|
|
42
42
|
|
|
43
43
|
<script setup>
|
|
44
|
-
import { mdiFileOutline, mdiImageOutline } from "@mdi/js";
|
|
45
44
|
import { useJskitText } from "../i18n.js";
|
|
46
45
|
import { attachmentSizeText } from "./attachmentSize.js";
|
|
47
46
|
|
|
@@ -53,7 +52,7 @@ defineProps({
|
|
|
53
52
|
});
|
|
54
53
|
const emit = defineEmits(["preview"]);
|
|
55
54
|
function attachmentIcon(attachment) {
|
|
56
|
-
return /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/iu.test(attachment.fileName) ?
|
|
55
|
+
return /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/iu.test(attachment.fileName) ? "$image" : "$description";
|
|
57
56
|
}
|
|
58
57
|
function attachmentKey(attachment, index) {
|
|
59
58
|
return attachment.attachmentId || `${index}:${attachment.fileName}:${attachment.size ?? ""}`;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
:aria-label="t('jskit.assistant.model.chooseAi')"
|
|
12
12
|
class="assistant-model-control__button"
|
|
13
13
|
density="comfortable"
|
|
14
|
-
|
|
14
|
+
icon="$settings"
|
|
15
15
|
rounded="lg"
|
|
16
16
|
size="small"
|
|
17
17
|
:title="buttonTitle ?? t('jskit.assistant.model.chooseAi')"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
>
|
|
30
30
|
<header class="assistant-model-control__header">
|
|
31
31
|
<v-avatar color="primary" size="36" variant="tonal">
|
|
32
|
-
<v-icon
|
|
32
|
+
<v-icon icon="$psychology" size="20" />
|
|
33
33
|
</v-avatar>
|
|
34
34
|
<div>
|
|
35
35
|
<strong class="text-title-small">{{ t("jskit.assistant.model.controls") }}</strong>
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
rounded="lg"
|
|
44
44
|
role="status"
|
|
45
45
|
>
|
|
46
|
-
<v-icon
|
|
46
|
+
<v-icon icon="$schedule" size="18" />
|
|
47
47
|
<span>{{ t("jskit.assistant.model.viewOnly") }}</span>
|
|
48
48
|
</v-sheet>
|
|
49
49
|
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
@click="selectProvider(provider.id)"
|
|
97
97
|
>
|
|
98
98
|
<span>{{ provider.label }}</span>
|
|
99
|
-
<v-icon v-if="modelProviderId === provider.id"
|
|
99
|
+
<v-icon v-if="modelProviderId === provider.id" icon="$check" size="15" />
|
|
100
100
|
</v-btn>
|
|
101
101
|
</div>
|
|
102
102
|
</section>
|
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
@click="selectModel(model.id)"
|
|
136
136
|
>
|
|
137
137
|
<span>{{ model.label }}</span>
|
|
138
|
-
<v-icon v-if="modelId === model.id"
|
|
138
|
+
<v-icon v-if="modelId === model.id" icon="$check" size="15" />
|
|
139
139
|
</v-btn>
|
|
140
140
|
</div>
|
|
141
141
|
<span v-else class="assistant-model-control__empty">{{ t("jskit.assistant.model.noModels") }}</span>
|
|
@@ -165,7 +165,7 @@
|
|
|
165
165
|
@click="selectVariant(variant.id)"
|
|
166
166
|
>
|
|
167
167
|
<span>{{ variant.label }}</span>
|
|
168
|
-
<v-icon v-if="variantId === variant.id"
|
|
168
|
+
<v-icon v-if="variantId === variant.id" icon="$check" size="15" />
|
|
169
169
|
</v-btn>
|
|
170
170
|
</div>
|
|
171
171
|
</section>
|
|
@@ -189,7 +189,6 @@
|
|
|
189
189
|
</template>
|
|
190
190
|
|
|
191
191
|
<script setup>
|
|
192
|
-
import { mdiBrain, mdiCheck, mdiClockOutline, mdiCogOutline } from "@mdi/js";
|
|
193
192
|
import { useJskitText } from "../i18n.js";
|
|
194
193
|
|
|
195
194
|
const { t } = useJskitText();
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { mdiPencilOutline } from "@mdi/js";
|
|
3
2
|
import { useJskitText } from "../i18n.js";
|
|
4
3
|
|
|
5
4
|
const { t } = useJskitText();
|
|
@@ -23,7 +22,7 @@ const emit = defineEmits(["dismiss"]);
|
|
|
23
22
|
:aria-label="t('jskit.assistant.questions.answerNormally')"
|
|
24
23
|
class="ml-auto"
|
|
25
24
|
color="primary"
|
|
26
|
-
|
|
25
|
+
prepend-icon="$edit"
|
|
27
26
|
size="small"
|
|
28
27
|
variant="tonal"
|
|
29
28
|
@click="emit('dismiss')"
|
|
@@ -325,13 +325,13 @@
|
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
.assistant-transcript__message-header time {
|
|
328
|
-
color:
|
|
328
|
+
color: rgb(var(--v-theme-on-surface-variant));
|
|
329
329
|
font-weight: 650;
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
.assistant-transcript__message-footer {
|
|
333
333
|
align-items: center;
|
|
334
|
-
color:
|
|
334
|
+
color: rgb(var(--v-theme-on-surface-variant));
|
|
335
335
|
display: flex;
|
|
336
336
|
font-size: 0.88rem;
|
|
337
337
|
font-weight: 500;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
:aria-label="reloadLabel"
|
|
12
12
|
class="assistant-transcript__reload"
|
|
13
13
|
:disabled="reloading"
|
|
14
|
-
|
|
14
|
+
icon="$refresh"
|
|
15
15
|
size="x-small"
|
|
16
16
|
:title="reloadLabel"
|
|
17
17
|
type="button"
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
>
|
|
79
79
|
<div class="assistant-transcript__assistant-header">
|
|
80
80
|
<span class="assistant-transcript__avatar assistant-transcript__avatar--assistant">
|
|
81
|
-
<v-icon
|
|
81
|
+
<v-icon icon="$smartToy" size="16" />
|
|
82
82
|
</span>
|
|
83
83
|
<div class="assistant-transcript__message-header">
|
|
84
84
|
<span>{{ assistantName }}</span>
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
>
|
|
121
121
|
<v-icon
|
|
122
122
|
class="assistant-transcript__system-icon"
|
|
123
|
-
|
|
123
|
+
icon="$info"
|
|
124
124
|
size="15"
|
|
125
125
|
/>
|
|
126
126
|
<div class="assistant-transcript__system-body">
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
</div>
|
|
226
226
|
</div>
|
|
227
227
|
<span class="assistant-transcript__avatar assistant-transcript__avatar--user">
|
|
228
|
-
<v-icon
|
|
228
|
+
<v-icon icon="$person" size="15" />
|
|
229
229
|
</span>
|
|
230
230
|
</div>
|
|
231
231
|
|
|
@@ -245,7 +245,7 @@
|
|
|
245
245
|
>
|
|
246
246
|
<div class="assistant-transcript__assistant-header">
|
|
247
247
|
<span class="assistant-transcript__avatar assistant-transcript__avatar--assistant">
|
|
248
|
-
<v-icon
|
|
248
|
+
<v-icon icon="$smartToy" size="16" />
|
|
249
249
|
</span>
|
|
250
250
|
<div class="assistant-transcript__message-header">
|
|
251
251
|
<span :title="entry.message.assistantDetails || entry.turn.assistantDetails || undefined">
|
|
@@ -319,12 +319,6 @@
|
|
|
319
319
|
import AssistantMessageAttachments from "./AssistantMessageAttachments.vue";
|
|
320
320
|
import AssistantConversationStatus from "./AssistantConversationStatus.vue";
|
|
321
321
|
import { computed, ref, watch } from "vue";
|
|
322
|
-
import {
|
|
323
|
-
mdiAccountOutline,
|
|
324
|
-
mdiInformationOutline,
|
|
325
|
-
mdiRefresh,
|
|
326
|
-
mdiRobotOutline
|
|
327
|
-
} from "@mdi/js";
|
|
328
322
|
import LongTextInlineParts from "./LongTextInlineParts.vue";
|
|
329
323
|
import LongTextPreviewBlocks from "./LongTextPreviewBlocks.vue";
|
|
330
324
|
import AssistantProgress from "./AssistantProgress.vue";
|
|
@@ -310,13 +310,13 @@ h5.studio-long-text-review__heading {
|
|
|
310
310
|
word-break: normal;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
-
.studio-long-text-review__table-cell--right {
|
|
313
|
+
.studio-long-text-review__table .studio-long-text-review__table-cell--right {
|
|
314
314
|
font-variant-numeric: tabular-nums;
|
|
315
|
-
text-align: right
|
|
315
|
+
text-align: right;
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
-
.studio-long-text-review__table-cell--center {
|
|
319
|
-
text-align: center
|
|
318
|
+
.studio-long-text-review__table .studio-long-text-review__table-cell--center {
|
|
319
|
+
text-align: center;
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
.studio-long-text-review__details {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// How the attachment queue shows each item: its key, name, phase, progress, status text, icon and colour.
|
|
2
|
-
import { mdiAlertCircleOutline, mdiCheckCircleOutline, mdiFileOutline } from "@mdi/js";
|
|
3
2
|
import { useJskitText } from "../i18n.js";
|
|
4
3
|
import { attachmentSizeText } from "./attachmentSize.js";
|
|
5
4
|
|
|
@@ -86,12 +85,12 @@ function itemCancelable(item = {}) {
|
|
|
86
85
|
|
|
87
86
|
function itemIcon(item = {}) {
|
|
88
87
|
if (itemPhase(item) === "ready") {
|
|
89
|
-
return
|
|
88
|
+
return "$checkCircle";
|
|
90
89
|
}
|
|
91
90
|
if (itemPhase(item) === "failed") {
|
|
92
|
-
return
|
|
91
|
+
return "$error";
|
|
93
92
|
}
|
|
94
|
-
return
|
|
93
|
+
return "$description";
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
function itemColor(item = {}) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure,
|
|
1
|
+
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
|
|
2
2
|
import { codexAppServerProviderConnectionGeneration } from "./codexModelCatalog.js";
|
|
3
3
|
import {
|
|
4
4
|
codexAppServerAssistantItemText,
|
|
@@ -239,8 +239,8 @@ export function createCodexNotificationObserver({
|
|
|
239
239
|
threadId: normalizedThreadId,
|
|
240
240
|
turnId: codexAppServerNotificationTurnId(notification)
|
|
241
241
|
};
|
|
242
|
-
// Anything Codex reports about a turn means it has started that turn.
|
|
243
|
-
journal.
|
|
242
|
+
// Anything Codex reports about a turn means it has started that turn, even before its answer to turn/start.
|
|
243
|
+
journal.noteTurnReported(normalizedThreadId, notificationContext.turnId);
|
|
244
244
|
if (method === "thread/tokenUsage/updated") {
|
|
245
245
|
notificationQueue.run(notificationContext, async () => {
|
|
246
246
|
const store = await createStore(normalizedSessionId);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerSteerFailure, codexAppServerMessageRequiresNewTurn, codexAppServerMessageDeferred
|
|
1
|
+
import { codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerSteerFailure, codexAppServerMessageRequiresNewTurn, codexAppServerMessageDeferred } from "./codexTurnState.js";
|
|
2
2
|
import { codexAppServerRequestIsInvalid } from "./codexProviderThreads.js";
|
|
3
3
|
import { codexLocalImageInput, codexAppServerDeliveredTurn, sendCodexAppServerPrompt } from "./codexProviderTurns.js";
|
|
4
4
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
@@ -74,9 +74,9 @@ export function createCodexPromptDispatch({
|
|
|
74
74
|
if (!deliveredTurnId) {
|
|
75
75
|
throw new Error("Codex app-server accepted the prompt without returning a turn id.");
|
|
76
76
|
}
|
|
77
|
-
// Codex answers turn/start before it starts the turn; its notifications about the turn say when it has.
|
|
77
|
+
// Codex usually answers turn/start before it starts the turn; its notifications about the turn say when it has.
|
|
78
78
|
if (!codexAppServerTurnStatusIsComplete(deliveredTurnStatus)) {
|
|
79
|
-
journal.
|
|
79
|
+
journal.markTurnUnstarted(threadId, deliveredTurnId);
|
|
80
80
|
}
|
|
81
81
|
delivery.pendingUserMessage(promptSessionId, clientUserMessageId)?.onNativeReceipt();
|
|
82
82
|
await recovery.markTurnActive(sessionId, {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerProcessedResultEvent, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanAdoptSuccessor, codexAppServerAgentRun, CODEX_APP_SERVER_RUN_STATE
|
|
1
|
+
import { CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerProcessedResultEvent, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanAdoptSuccessor, codexAppServerAgentRun, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
|
|
2
2
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -325,10 +325,7 @@ export function createCodexResultSettlement({ journal, output, createRuntime, pu
|
|
|
325
325
|
normalizedThreadId,
|
|
326
326
|
previousTurnId
|
|
327
327
|
);
|
|
328
|
-
journal.
|
|
329
|
-
normalizedThreadId,
|
|
330
|
-
previousTurnId
|
|
331
|
-
));
|
|
328
|
+
journal.markTurnCompleted(normalizedThreadId, previousTurnId);
|
|
332
329
|
const previousResultKey = output.resultFinalizationKey(
|
|
333
330
|
normalizedSessionId,
|
|
334
331
|
normalizedThreadId,
|
|
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
|
|
3
3
|
import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
4
|
|
|
5
5
|
const CODEX_APP_SERVER_PROMPT_CLAIM_GRACE_MS = 15000;
|
|
6
|
+
const CODEX_APP_SERVER_REPORTED_TURN_LIMIT = 256;
|
|
6
7
|
const CODEX_APP_SERVER_STOPPED_BY_SHUTDOWN_EVENT = "codex-app-server-stopped-by-shutdown";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -46,6 +47,55 @@ function codexAppServerDateValueMs(value = "") {
|
|
|
46
47
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Which turns Codex accepted with turn/start but has not reported yet: until it reports one, its thread still reads
|
|
52
|
+
* idle, so an idle read cannot settle it. Codex's turn/started can also arrive before its answer to turn/start, so the
|
|
53
|
+
* turns it reported most recently are remembered and never marked as not started. Only a turn whose answer is still on
|
|
54
|
+
* its way needs that, so the newest few are enough.
|
|
55
|
+
* @param {Set<string>} completedTurns the journal's settled turn keys, which markTurnCompleted adds to
|
|
56
|
+
*/
|
|
57
|
+
function createCodexAppServerTurnStarts(completedTurns) {
|
|
58
|
+
const unstartedTurns = new Set();
|
|
59
|
+
// Oldest first.
|
|
60
|
+
const reportedTurns = new Set();
|
|
61
|
+
|
|
62
|
+
/** Codex reported this turn, in a notification or by listing it as finished, so it has started it. */
|
|
63
|
+
function noteTurnReported(threadId = "", turnId = "") {
|
|
64
|
+
if (!normalizeText(turnId)) return;
|
|
65
|
+
const key = codexAppServerTurnKey(threadId, turnId);
|
|
66
|
+
unstartedTurns.delete(key);
|
|
67
|
+
reportedTurns.delete(key);
|
|
68
|
+
reportedTurns.add(key);
|
|
69
|
+
if (reportedTurns.size > CODEX_APP_SERVER_REPORTED_TURN_LIMIT) {
|
|
70
|
+
reportedTurns.delete(reportedTurns.values().next().value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A turn Codex accepted waits for its report before an idle read can settle it, unless Codex already reported it. */
|
|
75
|
+
function markTurnUnstarted(threadId = "", turnId = "") {
|
|
76
|
+
const key = codexAppServerTurnKey(threadId, turnId);
|
|
77
|
+
if (!reportedTurns.has(key)) unstartedTurns.add(key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function turnIsUnstarted(threadId = "", turnId = "") {
|
|
81
|
+
return unstartedTurns.has(codexAppServerTurnKey(threadId, turnId));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The run owner settled this turn: nothing waits for Codex to start it any more. */
|
|
85
|
+
function markTurnCompleted(threadId = "", turnId = "") {
|
|
86
|
+
const key = codexAppServerTurnKey(threadId, turnId);
|
|
87
|
+
completedTurns.add(key);
|
|
88
|
+
unstartedTurns.delete(key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
noteTurnReported,
|
|
93
|
+
markTurnUnstarted,
|
|
94
|
+
turnIsUnstarted,
|
|
95
|
+
markTurnCompleted
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
49
99
|
/**
|
|
50
100
|
* Native run claims, guarded journal writes and status retirement. The containing run owner shares these exact Sets
|
|
51
101
|
* and Maps by reference.
|
|
@@ -88,8 +138,7 @@ export function createCodexRunJournal({
|
|
|
88
138
|
isClosing
|
|
89
139
|
}) {
|
|
90
140
|
const codexAppServerCompletedTurns = new Set();
|
|
91
|
-
|
|
92
|
-
const codexAppServerUnstartedTurns = new Set();
|
|
141
|
+
const turnStarts = createCodexAppServerTurnStarts(codexAppServerCompletedTurns);
|
|
93
142
|
const codexAppServerProcessedTurns = new Set();
|
|
94
143
|
const codexAppServerActiveTimers = new Map();
|
|
95
144
|
const codexAppServerFinalizingTimers = new Map();
|
|
@@ -639,7 +688,10 @@ export function createCodexRunJournal({
|
|
|
639
688
|
|
|
640
689
|
return Object.freeze({
|
|
641
690
|
completedTurns: codexAppServerCompletedTurns,
|
|
642
|
-
|
|
691
|
+
noteTurnReported: turnStarts.noteTurnReported,
|
|
692
|
+
markTurnUnstarted: turnStarts.markTurnUnstarted,
|
|
693
|
+
turnIsUnstarted: turnStarts.turnIsUnstarted,
|
|
694
|
+
markTurnCompleted: turnStarts.markTurnCompleted,
|
|
643
695
|
processedTurns: codexAppServerProcessedTurns,
|
|
644
696
|
activeTimers: codexAppServerActiveTimers,
|
|
645
697
|
finalizingTimers: codexAppServerFinalizingTimers,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnCanAdoptSuccessor, codexAppServerErrorMessage
|
|
1
|
+
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerObservedTurnFinished, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnCanAdoptSuccessor, codexAppServerErrorMessage } from "./codexTurnState.js";
|
|
2
2
|
import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
3
|
import { codexAppServerStatusFromValue } from "./codexEvents.js";
|
|
4
4
|
import { codexAppServerThreadRawValue, codexAppServerThreadTurnId, codexAppServerThreadError } from "./codexThreadSnapshot.js";
|
|
@@ -200,12 +200,16 @@ export function createCodexThreadStatus({ journal, settlement, recovery, output,
|
|
|
200
200
|
turnId
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
|
+
// Codex lists the tracked turn as finished: it ran, even if its notifications about it were missed.
|
|
204
|
+
if (trackedProviderTurn && codexAppServerObservedTurnFinished(thread, trackedProviderTurn.turnId)) {
|
|
205
|
+
journal.noteTurnReported(trackedProviderTurn.threadId, trackedProviderTurn.turnId);
|
|
206
|
+
}
|
|
203
207
|
// Codex has accepted the tracked turn but not started it. Until it does, its thread reads idle and lists the
|
|
204
208
|
// turn as interrupted, so only Codex's own report about the turn settles it.
|
|
205
209
|
if (
|
|
206
210
|
trackedProviderTurn?.state === "active" &&
|
|
207
211
|
!codexAppServerTurnStatusIsActive(status) &&
|
|
208
|
-
journal.
|
|
212
|
+
journal.turnIsUnstarted(trackedProviderTurn.threadId, trackedProviderTurn.turnId)
|
|
209
213
|
) {
|
|
210
214
|
debugLog("appServerThread.reconcile.awaitingProviderStart", {
|
|
211
215
|
sessionId: normalizedSessionId,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexAppServerTurnStatusIsActive, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnOwnsActiveGoal, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanReceiveProviderActivity
|
|
1
|
+
import { codexAppServerTurnStatusIsActive, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnOwnsActiveGoal, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanReceiveProviderActivity } from "./codexTurnState.js";
|
|
2
2
|
import { codexAppServerThreadTurnId } from "./codexThreadSnapshot.js";
|
|
3
3
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
4
|
|
|
@@ -201,7 +201,7 @@ export function createCodexTurnEndings({
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
if (journal.runInputSource(codexAppServerAgentRun(session)) === "terminal") {
|
|
204
|
-
journal.
|
|
204
|
+
journal.markTurnCompleted(normalizedThreadId, normalizedTurnId);
|
|
205
205
|
output.cleanupUntrackedTurn(normalizedThreadId, normalizedTurnId);
|
|
206
206
|
await journal.markTurnIdle(normalizedSessionId, {
|
|
207
207
|
status: normalizedStatus,
|
|
@@ -217,7 +217,7 @@ export function createCodexTurnEndings({
|
|
|
217
217
|
const alreadyFinalizing = existingTurn.state === "finalizing" &&
|
|
218
218
|
existingTurn.threadId === normalizedThreadId &&
|
|
219
219
|
existingTurn.turnId === normalizedTurnId;
|
|
220
|
-
journal.
|
|
220
|
+
journal.markTurnCompleted(normalizedThreadId, normalizedTurnId);
|
|
221
221
|
if (!alreadyFinalizing) {
|
|
222
222
|
await journal.markTurnFinalizing(normalizedSessionId, {
|
|
223
223
|
status: normalizedStatus,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerPendingUserMessageOwnership, codexAppServerTurnCanAdoptSuccessor
|
|
1
|
+
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerObservedTurnFinished, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerPendingUserMessageOwnership, codexAppServerTurnCanAdoptSuccessor } from "./codexTurnState.js";
|
|
2
2
|
import { codexAppServerProviderTurnForOperation, codexAppServerThreadTurnId, codexAppServerThreadError } from "./codexThreadSnapshot.js";
|
|
3
3
|
import { createCodexTurnActivity } from "./codexTurnActivity.js";
|
|
4
4
|
import { createCodexTurnEndings } from "./codexTurnEndings.js";
|
|
@@ -270,16 +270,21 @@ export function createCodexTurnRecovery({
|
|
|
270
270
|
if (!codexAppServerTurnStatusIsComplete(status)) {
|
|
271
271
|
return currentSession;
|
|
272
272
|
}
|
|
273
|
-
if (journal.
|
|
274
|
-
// Codex
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
273
|
+
if (journal.turnIsUnstarted(currentTurn.threadId, currentTurn.turnId)) {
|
|
274
|
+
// Codex may have accepted this turn without starting it: its thread reads idle until it does. Only Codex's own
|
|
275
|
+
// listing of the turn as finished settles it before a notification about the turn arrives.
|
|
276
|
+
const observed = await codexAppServerReadThreadStatus(provider, currentTurn.threadId, { observeLatestTurn: true });
|
|
277
|
+
if (!codexAppServerObservedTurnFinished(observed, currentTurn.turnId)) {
|
|
278
|
+
debugLog("appServerTurn.reconcile.awaitingProviderStart", {
|
|
279
|
+
sessionId,
|
|
280
|
+
status,
|
|
281
|
+
threadId: currentTurn.threadId,
|
|
282
|
+
turnId: currentTurn.turnId
|
|
283
|
+
});
|
|
284
|
+
scheduleCodexAppServerActiveRecovery(sessionId);
|
|
285
|
+
return currentSession;
|
|
286
|
+
}
|
|
287
|
+
journal.noteTurnReported(currentTurn.threadId, currentTurn.turnId);
|
|
283
288
|
}
|
|
284
289
|
const completedTurnId = providerTurnId || currentTurn.turnId;
|
|
285
290
|
debugLog("appServerTurn.reconcile.complete", {
|
|
@@ -38,6 +38,19 @@ function codexAppServerThreadStatus(thread = {}) {
|
|
|
38
38
|
return liveStatus;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Whether a thread read lists exactly this turn as finished, completed or failed. Codex lists a turn it has accepted
|
|
43
|
+
* but not yet started as interrupted, so only these two say that it ran.
|
|
44
|
+
* @param {Record<string, unknown> | null} thread a read with its latest turn observed
|
|
45
|
+
* @param {string} turnId
|
|
46
|
+
*/
|
|
47
|
+
function codexAppServerObservedTurnFinished(thread = null, turnId = "") {
|
|
48
|
+
const observed = isRecord(thread?.observedTurn) ? thread.observedTurn : null;
|
|
49
|
+
const normalizedTurnId = normalizeNullableText(turnId);
|
|
50
|
+
if (!observed || !normalizedTurnId || normalizeNullableText(observed.id) !== normalizedTurnId) return false;
|
|
51
|
+
return ["completed", "failed"].includes(normalizeNullableText(observed.status));
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
/**
|
|
42
55
|
* Reads a native thread's status, and with observeLatestTurn its latest turn too.
|
|
43
56
|
* @param {import("./codexProvider.js").CodexAppServerAgentProvider | null} provider
|
|
@@ -604,4 +617,4 @@ function codexPromptInputFromRequest(input = {}) {
|
|
|
604
617
|
return codexAppServerMessageText(input);
|
|
605
618
|
}
|
|
606
619
|
|
|
607
|
-
export { codexAppServerRunInputSource, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerMessageDeferred, codexPromptInputFromRequest, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_ACTIVE_RUN_STATES };
|
|
620
|
+
export { codexAppServerRunInputSource, codexAppServerThreadStatus, codexAppServerObservedTurnFinished, codexAppServerReadThreadStatus, codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerMessageDeferred, codexPromptInputFromRequest, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_ACTIVE_RUN_STATES };
|
package/test/codexTurn.test.js
CHANGED
|
@@ -1955,6 +1955,82 @@ for (const read of ["starting watchdog", "status check"]) {
|
|
|
1955
1955
|
});
|
|
1956
1956
|
}
|
|
1957
1957
|
|
|
1958
|
+
// A turn Codex has accepted and not yet reported; `sendTurn` runs before Codex's answer to turn/start.
|
|
1959
|
+
async function unstartedTurnFixture(t, { sendTurn } = {}) {
|
|
1960
|
+
const notices = [];
|
|
1961
|
+
const f = await receiptFixture({
|
|
1962
|
+
finalizingGraceMs: 30,
|
|
1963
|
+
outcomeNotice(_runtime, _sessionId, _threadId, _turnId, outcome) { notices.push(outcome); }
|
|
1964
|
+
});
|
|
1965
|
+
t.after(() => f.owner.clearSessionRecoveryTimers(f.sessionId));
|
|
1966
|
+
const context = dispatchContext(f);
|
|
1967
|
+
f.codexStatus = "idle";
|
|
1968
|
+
f.notices = notices;
|
|
1969
|
+
f.started = { method: "turn/started", params: { threadId: f.threadId, turn: { id: f.turnId, status: "inProgress" } } };
|
|
1970
|
+
f.run = { ...f.run, state: "starting", providerTurnId: "", providerStatus: "", outerTurnId: context.messageId };
|
|
1971
|
+
f.provider.sendTurn = async () => {
|
|
1972
|
+
await sendTurn?.(f);
|
|
1973
|
+
return { id: f.turnId, status: "inProgress" };
|
|
1974
|
+
};
|
|
1975
|
+
f.provider.readThreadStatus = async () => ({ raw: { status: { type: f.codexStatus } } });
|
|
1976
|
+
f.history = [turn(f.turnId, "interrupted")];
|
|
1977
|
+
f.observe();
|
|
1978
|
+
assert.equal((await f.owner.startTurn(f.sessionId, {}, context)).providerFailure, "");
|
|
1979
|
+
assert.equal(f.run.state, "active");
|
|
1980
|
+
// Codex finishes the turn, and its turn/completed notification is lost: only its thread and history say so.
|
|
1981
|
+
f.finish = () => {
|
|
1982
|
+
f.codexStatus = "idle";
|
|
1983
|
+
f.history = [turn(f.turnId, "completed", "The complete reply.")];
|
|
1984
|
+
};
|
|
1985
|
+
return f;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
async function assertSettledWithReply(f) {
|
|
1989
|
+
await f.drain();
|
|
1990
|
+
assert.equal(f.run.state, "completed");
|
|
1991
|
+
assert.deepEqual((await f.replies()).map(row => row.assistant.text), ["The complete reply."]);
|
|
1992
|
+
assert.deepEqual(f.notices, []);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// Codex usually answers turn/start before its turn/started notification, but may send turn/started first. Either way
|
|
1996
|
+
// Codex has reported the turn, so a thread status check after it finishes settles the turn with its answer.
|
|
1997
|
+
for (const order of ["after", "before"]) {
|
|
1998
|
+
test(`a turn Codex reports ${order} its turn/start answer is settled by a later idle status check`, async t => {
|
|
1999
|
+
const f = await unstartedTurnFixture(t, {
|
|
2000
|
+
sendTurn(fixture) { if (order === "before") fixture.emit(fixture.started); }
|
|
2001
|
+
});
|
|
2002
|
+
if (order === "after") f.emit(f.started);
|
|
2003
|
+
await f.drain();
|
|
2004
|
+
assert.equal(f.run.state, "active");
|
|
2005
|
+
f.finish();
|
|
2006
|
+
await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { source: "wellbeing" });
|
|
2007
|
+
await assertSettledWithReply(f);
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// When turn/started is missed, the mark does not outlive Codex's next report about the turn: a later notification
|
|
2012
|
+
// naming it, or Codex listing it as completed, settles it like any other turn. No timer is involved.
|
|
2013
|
+
for (const report of ["a later notification naming the turn", "the starting watchdog reading it as completed",
|
|
2014
|
+
"a status check reading it as completed"]) {
|
|
2015
|
+
test(`a delivered turn whose turn/started is missed is settled by ${report}`, async t => {
|
|
2016
|
+
const f = await unstartedTurnFixture(t);
|
|
2017
|
+
await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { observeLatestTurn: true, source: "wellbeing" });
|
|
2018
|
+
await f.drain();
|
|
2019
|
+
assert.equal(f.run.state, "active", "Codex's idle thread and interrupted listing before it reports the turn do not end it");
|
|
2020
|
+
f.finish();
|
|
2021
|
+
if (report === "a later notification naming the turn") {
|
|
2022
|
+
f.emit({ method: "item/started", params: { threadId: f.threadId, turnId: f.turnId, item: { id: "thinking", type: "reasoning" } } });
|
|
2023
|
+
await f.drain();
|
|
2024
|
+
await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { source: "wellbeing" });
|
|
2025
|
+
} else if (report === "the starting watchdog reading it as completed") {
|
|
2026
|
+
await f.owner.recoverActiveTurn(f.sessionId, { provider: f.provider, retryOnError: false });
|
|
2027
|
+
} else {
|
|
2028
|
+
await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { observeLatestTurn: true, source: "wellbeing" });
|
|
2029
|
+
}
|
|
2030
|
+
await assertSettledWithReply(f);
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1958
2034
|
function controlContext(f, overrides = {}) {
|
|
1959
2035
|
return {
|
|
1960
2036
|
runtime: f.runtime,
|
|
@@ -0,0 +1,62 @@
|
|
|
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 elementUrl = new URL("../src/client/conversation/AssistantConversationElement.vue", import.meta.url).href;
|
|
10
|
+
const textUrl = new URL("../src/client/i18n.js", import.meta.url).href;
|
|
11
|
+
|
|
12
|
+
async function importElement() {
|
|
13
|
+
const hooks = registerHooks({
|
|
14
|
+
load(url, context, nextLoad) {
|
|
15
|
+
if (url === textUrl) {
|
|
16
|
+
return { format: "module", shortCircuit: true, source: "export function useJskitText() { return { t: (key) => key }; }" };
|
|
17
|
+
}
|
|
18
|
+
if (!url.endsWith(".vue")) return nextLoad(url, context);
|
|
19
|
+
if (url !== elementUrl) return { format: "module", shortCircuit: true, source: "export default { render: () => null };" };
|
|
20
|
+
const { descriptor } = parse(readFileSync(new URL(url), "utf8"));
|
|
21
|
+
return {
|
|
22
|
+
format: "module", shortCircuit: true,
|
|
23
|
+
source: compileScript(descriptor, { id: "assistant-avatar-thumbnail", inlineTemplate: true }).content
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
return (await import(elementUrl)).default;
|
|
29
|
+
} finally {
|
|
30
|
+
hooks.deregister();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function renderShowAvatarButton(element, slots) {
|
|
35
|
+
const app = createSSRApp({ render: () => h(element, { adapter: { conversation: { turns: [] } }, avatarSize: "hidden" }, slots) });
|
|
36
|
+
app.component("VBtn", defineComponent({ setup: (_props, { slots: content }) => () => h("button", content.default?.()) }));
|
|
37
|
+
app.component("VIcon", defineComponent({ props: { icon: { type: String, default: "" } }, setup: (props) => () => h("i", { "data-icon": props.icon }) }));
|
|
38
|
+
const html = await renderToString(app);
|
|
39
|
+
const buttons = [...html.matchAll(/<button([^>]*)>(.*?)<\/button>/gs)].filter(([, attributes]) => attributes.includes('aria-label="jskit.assistant.chat.showAvatar"'));
|
|
40
|
+
assert.equal(buttons.length, 1, "One Show avatar button while the avatar is hidden");
|
|
41
|
+
const [, attributes, content] = buttons[0];
|
|
42
|
+
assert.match(attributes, /title="jskit.assistant.chat.showAvatar"/u);
|
|
43
|
+
assert.match(attributes, /width="44"/u);
|
|
44
|
+
return content;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("the hidden avatar's Show button shows the avatar-thumbnail slot, or the account icon without one", async () => {
|
|
48
|
+
const element = await importElement();
|
|
49
|
+
const avatar = () => h("span", { class: "fixture-face" });
|
|
50
|
+
|
|
51
|
+
const thumbnail = await renderShowAvatarButton(element, {
|
|
52
|
+
avatar,
|
|
53
|
+
"avatar-thumbnail": ({ size, height }) => h("span", { class: "fixture-thumbnail", "data-size": size, "data-height": String(height) })
|
|
54
|
+
});
|
|
55
|
+
assert.match(thumbnail, /class="assistant-conversation__avatar-size-disc"/u);
|
|
56
|
+
assert.match(thumbnail, /<span class="fixture-thumbnail" data-size="hidden" data-height="0"><\/span>/u);
|
|
57
|
+
assert.doesNotMatch(thumbnail, /data-icon=/u);
|
|
58
|
+
|
|
59
|
+
const fallback = await renderShowAvatarButton(element, { avatar });
|
|
60
|
+
assert.match(fallback, /<i data-icon="\$accountCircle"[^>]*><\/i>/u);
|
|
61
|
+
assert.doesNotMatch(fallback, /fixture-thumbnail/u);
|
|
62
|
+
});
|
|
@@ -38,7 +38,7 @@ function readText(element) {
|
|
|
38
38
|
return {
|
|
39
39
|
color: [color.red, color.green, color.blue, color.alpha],
|
|
40
40
|
onSurface: style.getPropertyValue("--v-theme-on-surface").split(",").map(Number),
|
|
41
|
-
|
|
41
|
+
onSurfaceVariant: style.getPropertyValue("--v-theme-on-surface-variant").split(",").map(Number),
|
|
42
42
|
contrast: (lighter + 0.05) / (darker + 0.05)
|
|
43
43
|
};
|
|
44
44
|
}
|
|
@@ -67,15 +67,17 @@ test("the task transcript's user bubble, header time and footer follow the light
|
|
|
67
67
|
element.append(time);
|
|
68
68
|
});
|
|
69
69
|
const samples = {
|
|
70
|
-
"user bubble": [bubble.locator(".assistant-transcript__user-content"),
|
|
71
|
-
"user footer": [bubble.locator(".assistant-transcript__message-footer time"), "
|
|
72
|
-
"assistant footer": [page.locator(".assistant-transcript__message-footer--assistant time").first(), "
|
|
73
|
-
"header time": [header.locator("time"), "
|
|
70
|
+
"user bubble": [bubble.locator(".assistant-transcript__user-content"), "on-surface"],
|
|
71
|
+
"user footer": [bubble.locator(".assistant-transcript__message-footer time"), "on-surface-variant"],
|
|
72
|
+
"assistant footer": [page.locator(".assistant-transcript__message-footer--assistant time").first(), "on-surface-variant"],
|
|
73
|
+
"header time": [header.locator("time"), "on-surface-variant"]
|
|
74
74
|
};
|
|
75
|
-
|
|
75
|
+
// Material 3 secondary text is the solid on-surface-variant role, not on-surface at a lower opacity.
|
|
76
|
+
for (const [name, [locator, role]] of Object.entries(samples)) {
|
|
76
77
|
const text = await locator.evaluate(readText);
|
|
77
|
-
|
|
78
|
-
assert.
|
|
78
|
+
const expected = role === "on-surface" ? text.onSurface : text.onSurfaceVariant;
|
|
79
|
+
assert.deepEqual(text.color.slice(0, 3), expected, `${theme} ${name}: the colour is the theme's ${role}`);
|
|
80
|
+
assert.equal(text.color[3], 1, `${theme} ${name}: the opacity`);
|
|
79
81
|
assert.ok(text.contrast >= 4.5, `${theme} ${name}: contrast ${text.contrast.toFixed(2)}:1 is under 4.5:1`);
|
|
80
82
|
}
|
|
81
83
|
await page.close();
|