mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(webchat): preserve sends through reconnect (#87531)
* fix(webchat): preserve sends through reconnect * fix(webchat): scope queued sends by session * fix(webchat): localize queue retry labels * fix(secrets): remove unused path helper --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -33,7 +33,9 @@ overrides:
|
||||
packageExtensionsChecksum: sha256-zZ8fyodhMTumshonC7kktCqTPsiHL3UAyS9vltFAlMo=
|
||||
|
||||
patchedDependencies:
|
||||
'@agentclientprotocol/claude-agent-acp@0.37.0': 3c1bd768608166e6b2799e51a56ede1fdda010fd60ab52a64f7d309dc6192b35
|
||||
'@agentclientprotocol/claude-agent-acp@0.37.0':
|
||||
hash: 3c1bd768608166e6b2799e51a56ede1fdda010fd60ab52a64f7d309dc6192b35
|
||||
path: patches/@agentclientprotocol__claude-agent-acp@0.37.0.patch
|
||||
|
||||
importers:
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@ import { isDeepStrictEqual } from "node:util";
|
||||
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
|
||||
import { isRecord } from "./shared.js";
|
||||
|
||||
function isArrayIndexSegment(segment: string): boolean {
|
||||
return parseArrayIndexSegment(segment) !== undefined;
|
||||
}
|
||||
|
||||
function looksLikeArrayIndexSegment(segment: string): boolean {
|
||||
return /^\d+$/.test(segment);
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/ar.meta.json
generated
16
ui/src/i18n/.i18n/ar.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:53:50.534Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:45.085Z",
|
||||
"locale": "ar",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/de.meta.json
generated
16
ui/src/i18n/.i18n/de.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:52:45.886Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:40.369Z",
|
||||
"locale": "de",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/es.meta.json
generated
16
ui/src/i18n/.i18n/es.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:53:14.311Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:41.157Z",
|
||||
"locale": "es",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/fa.meta.json
generated
16
ui/src/i18n/.i18n/fa.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:54:58.861Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:52.569Z",
|
||||
"locale": "fa",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/fr.meta.json
generated
16
ui/src/i18n/.i18n/fr.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:53:26.331Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:44.275Z",
|
||||
"locale": "fr",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/id.meta.json
generated
16
ui/src/i18n/.i18n/id.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:54:22.983Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:48.486Z",
|
||||
"locale": "id",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/it.meta.json
generated
16
ui/src/i18n/.i18n/it.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:53:54.298Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:45.949Z",
|
||||
"locale": "it",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/ja-JP.meta.json
generated
16
ui/src/i18n/.i18n/ja-JP.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:53:24.087Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:41.993Z",
|
||||
"locale": "ja-JP",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/ko.meta.json
generated
16
ui/src/i18n/.i18n/ko.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:53:22.295Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:43.457Z",
|
||||
"locale": "ko",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/nl.meta.json
generated
16
ui/src/i18n/.i18n/nl.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:54:59.941Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:51.760Z",
|
||||
"locale": "nl",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/pl.meta.json
generated
16
ui/src/i18n/.i18n/pl.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:54:27.579Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:49.306Z",
|
||||
"locale": "pl",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/pt-BR.meta.json
generated
16
ui/src/i18n/.i18n/pt-BR.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:52:48.434Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:39.541Z",
|
||||
"locale": "pt-BR",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/th.meta.json
generated
16
ui/src/i18n/.i18n/th.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:54:34.944Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:50.121Z",
|
||||
"locale": "th",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/tr.meta.json
generated
16
ui/src/i18n/.i18n/tr.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:54:02.199Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:46.798Z",
|
||||
"locale": "tr",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/uk.meta.json
generated
16
ui/src/i18n/.i18n/uk.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:54:01.227Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:47.638Z",
|
||||
"locale": "uk",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/vi.meta.json
generated
16
ui/src/i18n/.i18n/vi.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:54:47.002Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:50.934Z",
|
||||
"locale": "vi",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/zh-CN.meta.json
generated
16
ui/src/i18n/.i18n/zh-CN.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:52:44.622Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:37.925Z",
|
||||
"locale": "zh-CN",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
16
ui/src/i18n/.i18n/zh-TW.meta.json
generated
16
ui/src/i18n/.i18n/zh-TW.meta.json
generated
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-05-28T05:52:42.451Z",
|
||||
"fallbackKeys": [
|
||||
"chat.queue.retry",
|
||||
"chat.queue.retryQueuedMessage",
|
||||
"chat.queue.retrySend"
|
||||
],
|
||||
"generatedAt": "2026-05-28T16:05:38.739Z",
|
||||
"locale": "zh-TW",
|
||||
"model": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea",
|
||||
"totalKeys": 1155,
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
|
||||
"totalKeys": 1158,
|
||||
"translatedKeys": 1155,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
5
ui/src/i18n/locales/ar.ts
generated
5
ui/src/i18n/locales/ar.ts
generated
@@ -1110,6 +1110,11 @@ export const ar: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/de.ts
generated
5
ui/src/i18n/locales/de.ts
generated
@@ -1134,6 +1134,11 @@ export const de: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
@@ -1116,6 +1116,11 @@ export const en: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/es.ts
generated
5
ui/src/i18n/locales/es.ts
generated
@@ -1131,6 +1131,11 @@ export const es: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/fa.ts
generated
5
ui/src/i18n/locales/fa.ts
generated
@@ -1127,6 +1127,11 @@ export const fa: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/fr.ts
generated
5
ui/src/i18n/locales/fr.ts
generated
@@ -1138,6 +1138,11 @@ export const fr: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/id.ts
generated
5
ui/src/i18n/locales/id.ts
generated
@@ -1125,6 +1125,11 @@ export const id: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/it.ts
generated
5
ui/src/i18n/locales/it.ts
generated
@@ -1132,6 +1132,11 @@ export const it: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/ja-JP.ts
generated
5
ui/src/i18n/locales/ja-JP.ts
generated
@@ -1129,6 +1129,11 @@ export const ja_JP: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/ko.ts
generated
5
ui/src/i18n/locales/ko.ts
generated
@@ -1118,6 +1118,11 @@ export const ko: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/nl.ts
generated
5
ui/src/i18n/locales/nl.ts
generated
@@ -1130,6 +1130,11 @@ export const nl: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/pl.ts
generated
5
ui/src/i18n/locales/pl.ts
generated
@@ -1130,6 +1130,11 @@ export const pl: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/pt-BR.ts
generated
5
ui/src/i18n/locales/pt-BR.ts
generated
@@ -1126,6 +1126,11 @@ export const pt_BR: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/th.ts
generated
5
ui/src/i18n/locales/th.ts
generated
@@ -1095,6 +1095,11 @@ export const th: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/tr.ts
generated
5
ui/src/i18n/locales/tr.ts
generated
@@ -1131,6 +1131,11 @@ export const tr: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/uk.ts
generated
5
ui/src/i18n/locales/uk.ts
generated
@@ -1128,6 +1128,11 @@ export const uk: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/vi.ts
generated
5
ui/src/i18n/locales/vi.ts
generated
@@ -1117,6 +1117,11 @@ export const vi: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
5
ui/src/i18n/locales/zh-CN.ts
generated
5
ui/src/i18n/locales/zh-CN.ts
generated
@@ -1090,6 +1090,11 @@ export const zh_CN: TranslationMap = {
|
||||
send: "发送",
|
||||
sendMessage: "发送消息",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "给 {name} 发消息(Enter 发送)",
|
||||
placeholderWithAttachments: "添加消息或继续粘贴图片...",
|
||||
|
||||
5
ui/src/i18n/locales/zh-TW.ts
generated
5
ui/src/i18n/locales/zh-TW.ts
generated
@@ -1092,6 +1092,11 @@ export const zh_TW: TranslationMap = {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
},
|
||||
queue: {
|
||||
retry: "Retry",
|
||||
retrySend: "Retry send",
|
||||
retryQueuedMessage: "Retry queued message",
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Message {name} (Enter to send)",
|
||||
placeholderWithAttachments: "Add a message or paste more images...",
|
||||
|
||||
@@ -1025,7 +1025,8 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-queue__steer {
|
||||
.chat-queue__steer,
|
||||
.chat-queue__retry {
|
||||
align-self: start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1036,7 +1037,8 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-queue__steer svg {
|
||||
.chat-queue__steer svg,
|
||||
.chat-queue__retry svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
fill: none;
|
||||
@@ -1046,7 +1048,8 @@
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-queue__steer:hover:not(:disabled) {
|
||||
.chat-queue__steer:hover:not(:disabled),
|
||||
.chat-queue__retry:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -3834,6 +3834,13 @@ td.data-table-key-col {
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.chat-queue__error {
|
||||
margin-top: 4px;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.chat-queue__remove {
|
||||
align-self: start;
|
||||
padding: 4px 10px;
|
||||
|
||||
@@ -44,9 +44,18 @@ export type ControlUiE2eServer = {
|
||||
};
|
||||
|
||||
export type MockGatewayControls = {
|
||||
closeLatest: (code?: number, reason?: string) => Promise<void>;
|
||||
deferNext: (method: string) => Promise<void>;
|
||||
emitChatFinal: (params: { runId: string; sessionKey?: string; text: string }) => Promise<void>;
|
||||
emitGatewayEvent: (event: string, payload?: unknown) => Promise<void>;
|
||||
getRequests: (method?: string) => Promise<MockGatewayRequest[]>;
|
||||
getSocketCount: () => Promise<number>;
|
||||
rejectDeferred: (
|
||||
method: string,
|
||||
error?: { code?: string; message?: string; details?: unknown; retryable?: boolean },
|
||||
) => Promise<void>;
|
||||
resolveDeferred: (method: string, payload?: unknown) => Promise<void>;
|
||||
setHistoryMessages: (messages: unknown[]) => Promise<void>;
|
||||
waitForRequest: (method: string) => Promise<MockGatewayRequest>;
|
||||
};
|
||||
|
||||
@@ -183,10 +192,25 @@ function installControlUiMockGateway(input: {
|
||||
type BrowserMethodResponseCases = {
|
||||
cases?: BrowserMethodResponseCase[];
|
||||
};
|
||||
type DeferredResponse = {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
socket: { deliver: (frame: unknown) => void };
|
||||
};
|
||||
type ExposedGateway = {
|
||||
closeLatest: (code?: number, reason?: string) => void;
|
||||
deferNext: (method: string) => void;
|
||||
emit: (event: string, payload?: unknown) => void;
|
||||
findRequests: (method?: string) => BrowserRequest[];
|
||||
rejectDeferred: (
|
||||
method: string,
|
||||
error?: { code?: string; message?: string; details?: unknown; retryable?: boolean },
|
||||
) => void;
|
||||
requests: BrowserRequest[];
|
||||
resolveDeferred: (method: string, payload?: unknown) => void;
|
||||
setHistoryMessages: (messages: unknown[]) => void;
|
||||
socketCount: () => number;
|
||||
};
|
||||
type WindowWithGateway = Window & {
|
||||
openclawControlUiE2eGateway?: ExposedGateway;
|
||||
@@ -194,7 +218,10 @@ function installControlUiMockGateway(input: {
|
||||
|
||||
const scenario: BrowserScenario = input.scenario;
|
||||
const protocolVersion = input.protocolVersion;
|
||||
const deferredMethods: string[] = [];
|
||||
const deferredResponses: DeferredResponse[] = [];
|
||||
const requests: BrowserRequest[] = [];
|
||||
const sockets: unknown[] = [];
|
||||
let seq = 0;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -337,7 +364,13 @@ function installControlUiMockGateway(input: {
|
||||
thinkingLevel: null,
|
||||
};
|
||||
case "chat.send":
|
||||
return { ok: true, queued: false, params };
|
||||
return {
|
||||
runId:
|
||||
isRecord(params) && typeof params.idempotencyKey === "string"
|
||||
? params.idempotencyKey
|
||||
: "control-ui-e2e-run",
|
||||
status: "started",
|
||||
};
|
||||
case "commands.list":
|
||||
return { commands: [] };
|
||||
case "health":
|
||||
@@ -371,6 +404,15 @@ function installControlUiMockGateway(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldDefer(method: string): boolean {
|
||||
const index = deferredMethods.indexOf(method);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
deferredMethods.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseFrame(raw: string | ArrayBufferLike | Blob | ArrayBufferView): BrowserFrame | null {
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
@@ -405,6 +447,7 @@ function installControlUiMockGateway(input: {
|
||||
super();
|
||||
this.url = String(url);
|
||||
MockWebSocket.latest = this;
|
||||
sockets.push(this);
|
||||
window.setTimeout(() => {
|
||||
if (this.readyState !== MockWebSocket.CONNECTING) {
|
||||
return;
|
||||
@@ -452,6 +495,10 @@ function installControlUiMockGateway(input: {
|
||||
return;
|
||||
}
|
||||
requests.push({ id, method, params: frame.params });
|
||||
if (shouldDefer(method)) {
|
||||
deferredResponses.push({ id, method, params: frame.params, socket: this });
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
this.deliver({
|
||||
id,
|
||||
@@ -471,6 +518,12 @@ function installControlUiMockGateway(input: {
|
||||
}
|
||||
|
||||
const exposed: ExposedGateway = {
|
||||
closeLatest(code, reason) {
|
||||
MockWebSocket.latest?.close(code ?? 1006, reason ?? "mock close");
|
||||
},
|
||||
deferNext(method) {
|
||||
deferredMethods.push(method);
|
||||
},
|
||||
emit(event, payload) {
|
||||
MockWebSocket.latest?.deliver({
|
||||
event,
|
||||
@@ -482,7 +535,44 @@ function installControlUiMockGateway(input: {
|
||||
findRequests(method) {
|
||||
return method ? requests.filter((request) => request.method === method) : [...requests];
|
||||
},
|
||||
rejectDeferred(method, error) {
|
||||
const index = deferredResponses.findIndex((response) => response.method === method);
|
||||
if (index < 0) {
|
||||
throw new Error(`No deferred mock Gateway response for ${method}`);
|
||||
}
|
||||
const [response] = deferredResponses.splice(index, 1);
|
||||
response.socket.deliver({
|
||||
error: {
|
||||
code: error?.code ?? "INVALID_REQUEST",
|
||||
message: error?.message ?? "mock Gateway rejected request",
|
||||
...(error?.details ? { details: error.details } : {}),
|
||||
...(error?.retryable ? { retryable: true } : {}),
|
||||
},
|
||||
id: response.id,
|
||||
ok: false,
|
||||
type: "res",
|
||||
});
|
||||
},
|
||||
requests,
|
||||
resolveDeferred(method, payload) {
|
||||
const index = deferredResponses.findIndex((response) => response.method === method);
|
||||
if (index < 0) {
|
||||
throw new Error(`No deferred mock Gateway response for ${method}`);
|
||||
}
|
||||
const [response] = deferredResponses.splice(index, 1);
|
||||
response.socket.deliver({
|
||||
id: response.id,
|
||||
ok: true,
|
||||
payload: payload ?? buildResponse(response.method, response.params),
|
||||
type: "res",
|
||||
});
|
||||
},
|
||||
setHistoryMessages(messages) {
|
||||
scenario.historyMessages = Array.isArray(messages) ? messages : [];
|
||||
},
|
||||
socketCount() {
|
||||
return sockets.length;
|
||||
},
|
||||
};
|
||||
|
||||
(window as WindowWithGateway).openclawControlUiE2eGateway = exposed;
|
||||
@@ -538,6 +628,39 @@ function createMockGatewayControls(page: Page, defaultSessionKey: string): MockG
|
||||
}, method);
|
||||
|
||||
return {
|
||||
async closeLatest(code, reason) {
|
||||
await page.evaluate(
|
||||
({ closeCode, closeReason }) => {
|
||||
const gateway = (
|
||||
window as Window & {
|
||||
openclawControlUiE2eGateway?: {
|
||||
closeLatest: (code?: number, reason?: string) => void;
|
||||
};
|
||||
}
|
||||
).openclawControlUiE2eGateway;
|
||||
if (!gateway) {
|
||||
throw new Error("Mock Gateway is not installed");
|
||||
}
|
||||
gateway.closeLatest(closeCode, closeReason);
|
||||
},
|
||||
{ closeCode: code, closeReason: reason },
|
||||
);
|
||||
},
|
||||
async deferNext(method) {
|
||||
await page.evaluate((targetMethod) => {
|
||||
const gateway = (
|
||||
window as Window & {
|
||||
openclawControlUiE2eGateway?: {
|
||||
deferNext: (method: string) => void;
|
||||
};
|
||||
}
|
||||
).openclawControlUiE2eGateway;
|
||||
if (!gateway) {
|
||||
throw new Error("Mock Gateway is not installed");
|
||||
}
|
||||
gateway.deferNext(targetMethod);
|
||||
}, method);
|
||||
},
|
||||
async emitChatFinal(params) {
|
||||
await emitGatewayEvent("chat", {
|
||||
message: {
|
||||
@@ -552,6 +675,77 @@ function createMockGatewayControls(page: Page, defaultSessionKey: string): MockG
|
||||
},
|
||||
emitGatewayEvent,
|
||||
getRequests,
|
||||
async getSocketCount() {
|
||||
return await page.evaluate(() => {
|
||||
const gateway = (
|
||||
window as Window & {
|
||||
openclawControlUiE2eGateway?: {
|
||||
socketCount: () => number;
|
||||
};
|
||||
}
|
||||
).openclawControlUiE2eGateway;
|
||||
return gateway?.socketCount() ?? 0;
|
||||
});
|
||||
},
|
||||
async rejectDeferred(method, error) {
|
||||
await page.evaluate(
|
||||
({ targetMethod, responseError }) => {
|
||||
const gateway = (
|
||||
window as Window & {
|
||||
openclawControlUiE2eGateway?: {
|
||||
rejectDeferred: (
|
||||
method: string,
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
retryable?: boolean;
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
).openclawControlUiE2eGateway;
|
||||
if (!gateway) {
|
||||
throw new Error("Mock Gateway is not installed");
|
||||
}
|
||||
gateway.rejectDeferred(targetMethod, responseError);
|
||||
},
|
||||
{ targetMethod: method, responseError: error },
|
||||
);
|
||||
},
|
||||
async resolveDeferred(method, payload) {
|
||||
await page.evaluate(
|
||||
({ targetMethod, responsePayload }) => {
|
||||
const gateway = (
|
||||
window as Window & {
|
||||
openclawControlUiE2eGateway?: {
|
||||
resolveDeferred: (method: string, payload?: unknown) => void;
|
||||
};
|
||||
}
|
||||
).openclawControlUiE2eGateway;
|
||||
if (!gateway) {
|
||||
throw new Error("Mock Gateway is not installed");
|
||||
}
|
||||
gateway.resolveDeferred(targetMethod, responsePayload);
|
||||
},
|
||||
{ targetMethod: method, responsePayload: payload },
|
||||
);
|
||||
},
|
||||
async setHistoryMessages(messages) {
|
||||
await page.evaluate((nextMessages) => {
|
||||
const gateway = (
|
||||
window as Window & {
|
||||
openclawControlUiE2eGateway?: {
|
||||
setHistoryMessages: (messages: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
).openclawControlUiE2eGateway;
|
||||
if (!gateway) {
|
||||
throw new Error("Mock Gateway is not installed");
|
||||
}
|
||||
gateway.setHistoryMessages(nextMessages);
|
||||
}, messages);
|
||||
},
|
||||
async waitForRequest(method) {
|
||||
await page.waitForFunction(
|
||||
(targetMethod) => {
|
||||
|
||||
@@ -49,6 +49,7 @@ let refreshChat: typeof import("./app-chat.ts").refreshChat;
|
||||
let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar;
|
||||
let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun;
|
||||
let removeQueuedMessage: typeof import("./app-chat.ts").removeQueuedMessage;
|
||||
let markQueuedChatSendsWaitingForReconnect: typeof import("./app-chat.ts").markQueuedChatSendsWaitingForReconnect;
|
||||
|
||||
async function loadChatHelpers(): Promise<void> {
|
||||
({
|
||||
@@ -61,6 +62,7 @@ async function loadChatHelpers(): Promise<void> {
|
||||
refreshChatAvatar,
|
||||
clearPendingQueueItemsForRun,
|
||||
removeQueuedMessage,
|
||||
markQueuedChatSendsWaitingForReconnect,
|
||||
} = await import("./app-chat.ts"));
|
||||
}
|
||||
|
||||
@@ -132,6 +134,7 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
|
||||
chatDraftBeforeHistory: null,
|
||||
chatAttachments: [],
|
||||
chatQueue: [],
|
||||
chatQueueBySession: {},
|
||||
chatRunId: null,
|
||||
chatSending: false,
|
||||
lastError: null,
|
||||
@@ -1241,16 +1244,187 @@ describe("handleSendChat", () => {
|
||||
const second = handleSendChat(host, "same prompt");
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(host.chatQueue).toStrictEqual([]);
|
||||
expect(host.chatMessages).toHaveLength(1);
|
||||
expect(host.chatQueue).toHaveLength(1);
|
||||
expect(host.chatQueue[0]?.text).toBe("same prompt");
|
||||
expect(host.chatQueue[0]?.sendState).toBe("sending");
|
||||
expect(host.chatMessages).toStrictEqual([]);
|
||||
|
||||
sent.resolve({ runId: host.chatRunId, status: "started" });
|
||||
const queuedRunId = host.chatQueue[0]?.sendRunId;
|
||||
sent.resolve({ runId: queuedRunId, status: "started" });
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(host.chatQueue).toStrictEqual([]);
|
||||
expect(host.chatMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps normal prompt text visible as pending until chat.send is acknowledged", async () => {
|
||||
const sent = createDeferred<unknown>();
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "chat.send") {
|
||||
return sent.promise;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
chatMessage: "do not lose this",
|
||||
});
|
||||
|
||||
const send = handleSendChat(host);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(host.chatMessage).toBe("");
|
||||
expect(host.chatMessages).toStrictEqual([]);
|
||||
expect(host.chatQueue).toHaveLength(1);
|
||||
expect(host.chatQueue[0]).toMatchObject({
|
||||
text: "do not lose this",
|
||||
sendState: "sending",
|
||||
sessionKey: "agent:main",
|
||||
});
|
||||
const runId = host.chatQueue[0]?.sendRunId;
|
||||
expect(typeof runId).toBe("string");
|
||||
|
||||
sent.resolve({ runId, status: "started" });
|
||||
await send;
|
||||
|
||||
expect(host.chatQueue).toStrictEqual([]);
|
||||
expect(host.chatRunId).toBe(runId);
|
||||
expect(host.chatMessages).toHaveLength(1);
|
||||
const userMessage = requireRecord(host.chatMessages[0], "user message");
|
||||
expect(userMessage.role).toBe("user");
|
||||
});
|
||||
|
||||
it("keeps delayed chat.send ACK effects scoped to the submitted session", async () => {
|
||||
const sent = createDeferred<unknown>();
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "chat.send") {
|
||||
return sent.promise;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
chatMessage: "stay with session A",
|
||||
sessionKey: "agent:a",
|
||||
});
|
||||
|
||||
const send = handleSendChat(host);
|
||||
await Promise.resolve();
|
||||
|
||||
const queuedRunId = host.chatQueue[0]?.sendRunId;
|
||||
expect(queuedRunId).toEqual(expect.any(String));
|
||||
|
||||
host.chatQueueBySession = { "agent:a": [...host.chatQueue] };
|
||||
host.chatQueue = [];
|
||||
host.sessionKey = "agent:b";
|
||||
host.chatMessages = [];
|
||||
host.chatRunId = null;
|
||||
host.chatStream = null;
|
||||
|
||||
sent.resolve({ runId: queuedRunId, status: "started" });
|
||||
await send;
|
||||
|
||||
expect(host.sessionKey).toBe("agent:b");
|
||||
expect(host.chatMessages).toStrictEqual([]);
|
||||
expect(host.chatRunId).toBeNull();
|
||||
expect(host.chatStream).toBeNull();
|
||||
expect(host.chatQueue).toStrictEqual([]);
|
||||
expect(host.chatQueueBySession?.["agent:a"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a pre-ack socket close recoverable with the same run id", async () => {
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "chat.send") {
|
||||
throw new Error("gateway closed (1006): network lost");
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
chatMessage: "retry after reconnect",
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
expect(host.chatMessages).toStrictEqual([]);
|
||||
expect(host.chatQueue).toHaveLength(1);
|
||||
const queued = host.chatQueue[0];
|
||||
expect(queued?.text).toBe("retry after reconnect");
|
||||
expect(queued?.sendState).toBe("waiting-reconnect");
|
||||
expect(queued?.sendRunId).toEqual(expect.any(String));
|
||||
expect(host.lastError).toBe("Message will send when the Gateway reconnects.");
|
||||
});
|
||||
|
||||
it("queues normal sends made while disconnected", async () => {
|
||||
const host = makeHost({
|
||||
client: null,
|
||||
connected: false,
|
||||
chatMessage: "send after reconnect",
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
expect(host.chatMessage).toBe("");
|
||||
expect(host.chatMessages).toStrictEqual([]);
|
||||
expect(host.chatQueue).toHaveLength(1);
|
||||
expect(host.chatQueue[0]).toMatchObject({
|
||||
text: "send after reconnect",
|
||||
sendState: "waiting-reconnect",
|
||||
sessionKey: "agent:main",
|
||||
});
|
||||
expect(host.chatQueue[0]?.sendRunId).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("marks saved session queued sends waiting after a disconnect", () => {
|
||||
const host = makeHost({
|
||||
chatQueue: [],
|
||||
chatQueueBySession: {
|
||||
"agent:a": [
|
||||
{
|
||||
id: "pending-send-a",
|
||||
text: "pending",
|
||||
createdAt: 1,
|
||||
sendRunId: "run-a",
|
||||
sendState: "sending",
|
||||
sessionKey: "agent:a",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
markQueuedChatSendsWaitingForReconnect(host);
|
||||
|
||||
expect(host.chatQueueBySession?.["agent:a"]?.[0]).toMatchObject({
|
||||
sendRunId: "run-a",
|
||||
sendState: "waiting-reconnect",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks validation failures visible and restores the composer", async () => {
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "chat.send") {
|
||||
throw new Error("send blocked by session policy");
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
chatMessage: "blocked prompt",
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
expect(host.chatMessage).toBe("blocked prompt");
|
||||
expect(host.chatMessages).toStrictEqual([]);
|
||||
expect(host.chatQueue).toHaveLength(1);
|
||||
expect(host.chatQueue[0]).toMatchObject({
|
||||
text: "blocked prompt",
|
||||
sendState: "failed",
|
||||
sendError: "send blocked by session policy",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the BTW draft when detached send fails", async () => {
|
||||
const host = makeHost({
|
||||
client: {
|
||||
|
||||
@@ -21,11 +21,13 @@ import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts";
|
||||
import type { ChatSideResult } from "./chat/side-result.ts";
|
||||
import { executeSlashCommand } from "./chat/slash-command-executor.ts";
|
||||
import { parseSlashCommand, refreshSlashCommands } from "./chat/slash-commands.ts";
|
||||
import { formatConnectError } from "./connect-error.ts";
|
||||
import { resolveControlUiAuthHeader } from "./control-ui-auth.ts";
|
||||
import {
|
||||
abortChatRun,
|
||||
appendUserChatMessage,
|
||||
loadChatHistory,
|
||||
sendChatMessage,
|
||||
requestChatSend,
|
||||
sendDetachedChatMessage,
|
||||
sendSteerChatMessage,
|
||||
type ChatState,
|
||||
@@ -36,7 +38,7 @@ import {
|
||||
type LoadSessionsOverrides,
|
||||
type SessionsState,
|
||||
} from "./controllers/sessions.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import { GatewayRequestError, type GatewayBrowserClient, type GatewayHelloOk } from "./gateway.ts";
|
||||
import { normalizeBasePath } from "./navigation.ts";
|
||||
import { parseAgentSessionKey } from "./session-key.ts";
|
||||
import { isSessionRunActive } from "./session-run-state.ts";
|
||||
@@ -53,6 +55,7 @@ export type ChatHost = ChatInputHistoryState & {
|
||||
connected: boolean;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatQueue: ChatQueueItem[];
|
||||
chatQueueBySession?: Record<string, ChatQueueItem[]>;
|
||||
chatRunId: string | null;
|
||||
chatSending: boolean;
|
||||
lastError?: string | null;
|
||||
@@ -223,24 +226,24 @@ function enqueueChatMessage(
|
||||
attachments?: ChatAttachment[],
|
||||
refreshSessions?: boolean,
|
||||
localCommand?: { args: string; name: string },
|
||||
) {
|
||||
): ChatQueueItem | null {
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
if (!trimmed && !hasAttachments) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
host.chatQueue = [
|
||||
...host.chatQueue,
|
||||
{
|
||||
id: generateUUID(),
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined,
|
||||
refreshSessions,
|
||||
localCommandArgs: localCommand?.args,
|
||||
localCommandName: localCommand?.name,
|
||||
},
|
||||
];
|
||||
const item: ChatQueueItem = {
|
||||
id: generateUUID(),
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined,
|
||||
refreshSessions,
|
||||
localCommandArgs: localCommand?.args,
|
||||
localCommandName: localCommand?.name,
|
||||
sessionKey: host.sessionKey,
|
||||
};
|
||||
host.chatQueue = [...host.chatQueue, item];
|
||||
return item;
|
||||
}
|
||||
|
||||
function enqueuePendingRunMessage(
|
||||
@@ -267,10 +270,268 @@ function enqueuePendingRunMessage(
|
||||
];
|
||||
}
|
||||
|
||||
function enqueuePendingSendMessage(
|
||||
host: ChatHost,
|
||||
text: string,
|
||||
attachments?: ChatAttachment[],
|
||||
refreshSessions?: boolean,
|
||||
): ChatQueueItem | null {
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
if (!trimmed && !hasAttachments) {
|
||||
return null;
|
||||
}
|
||||
const pending: ChatQueueItem = {
|
||||
id: generateUUID(),
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
attachments: hasAttachments ? attachments : undefined,
|
||||
refreshSessions,
|
||||
sendAttempts: 0,
|
||||
sendRunId: generateUUID(),
|
||||
sendState: host.connected && host.client ? "sending" : "waiting-reconnect",
|
||||
sessionKey: host.sessionKey,
|
||||
};
|
||||
host.chatQueue = [...host.chatQueue, pending];
|
||||
return pending;
|
||||
}
|
||||
|
||||
function updateQueuedMessage(
|
||||
host: ChatHost,
|
||||
id: string,
|
||||
update: (item: ChatQueueItem) => ChatQueueItem,
|
||||
): ChatQueueItem | null {
|
||||
return updateQueuedMessageForSession(host, host.sessionKey, id, update);
|
||||
}
|
||||
|
||||
function readChatQueueForSession(host: ChatHost, sessionKey: string): ChatQueueItem[] {
|
||||
return sessionKey === host.sessionKey
|
||||
? host.chatQueue
|
||||
: (host.chatQueueBySession?.[sessionKey] ?? []);
|
||||
}
|
||||
|
||||
function writeChatQueueForSession(host: ChatHost, sessionKey: string, queue: ChatQueueItem[]) {
|
||||
if (sessionKey === host.sessionKey) {
|
||||
host.chatQueue = queue;
|
||||
return;
|
||||
}
|
||||
const queueBySession = { ...host.chatQueueBySession };
|
||||
if (queue.length > 0) {
|
||||
queueBySession[sessionKey] = queue;
|
||||
} else {
|
||||
delete queueBySession[sessionKey];
|
||||
}
|
||||
host.chatQueueBySession = queueBySession;
|
||||
host.requestUpdate?.();
|
||||
}
|
||||
|
||||
function updateQueuedMessageForSession(
|
||||
host: ChatHost,
|
||||
sessionKey: string,
|
||||
id: string,
|
||||
update: (item: ChatQueueItem) => ChatQueueItem,
|
||||
): ChatQueueItem | null {
|
||||
let nextItem: ChatQueueItem | null = null;
|
||||
const nextQueue = readChatQueueForSession(host, sessionKey).map((item) => {
|
||||
if (item.id !== id) {
|
||||
return item;
|
||||
}
|
||||
nextItem = update(item);
|
||||
return nextItem;
|
||||
});
|
||||
writeChatQueueForSession(host, sessionKey, nextQueue);
|
||||
return nextItem;
|
||||
}
|
||||
|
||||
function removeQueuedMessageWithoutReleasing(
|
||||
host: ChatHost,
|
||||
id: string,
|
||||
sessionKey = host.sessionKey,
|
||||
): ChatQueueItem | null {
|
||||
const queue = readChatQueueForSession(host, sessionKey);
|
||||
const item = queue.find((entry) => entry.id === id) ?? null;
|
||||
writeChatQueueForSession(
|
||||
host,
|
||||
sessionKey,
|
||||
queue.filter((entry) => entry.id !== id),
|
||||
);
|
||||
return item;
|
||||
}
|
||||
|
||||
function isRecoverableChatSendError(err: unknown, formattedError: string): boolean {
|
||||
if (err instanceof GatewayRequestError) {
|
||||
return err.retryable;
|
||||
}
|
||||
return /gateway (?:not connected|closed)|websocket|disconnected/i.test(formattedError);
|
||||
}
|
||||
|
||||
function restoreComposerAfterFailedSend(
|
||||
host: ChatHost,
|
||||
opts: {
|
||||
previousAttachments?: ChatAttachment[];
|
||||
previousDraft?: string;
|
||||
},
|
||||
) {
|
||||
if (opts.previousDraft != null && !host.chatMessage.trim()) {
|
||||
host.chatMessage = opts.previousDraft;
|
||||
}
|
||||
if (opts.previousAttachments?.length && host.chatAttachments.length === 0) {
|
||||
host.chatAttachments = opts.previousAttachments;
|
||||
}
|
||||
}
|
||||
|
||||
type QueuedChatSendResult = "sent" | "pending" | "failed";
|
||||
|
||||
function ensureQueuedSendState(
|
||||
host: ChatHost,
|
||||
item: ChatQueueItem,
|
||||
fallbackSessionKey = host.sessionKey,
|
||||
): ChatQueueItem {
|
||||
if (item.sendRunId && item.sendState) {
|
||||
return item;
|
||||
}
|
||||
const sessionKey = item.sessionKey ?? fallbackSessionKey;
|
||||
const prepared: ChatQueueItem = {
|
||||
...item,
|
||||
sendAttempts: item.sendAttempts ?? 0,
|
||||
sendRunId: item.sendRunId ?? generateUUID(),
|
||||
sendState: host.connected && host.client ? "sending" : "waiting-reconnect",
|
||||
sessionKey,
|
||||
};
|
||||
updateQueuedMessageForSession(host, sessionKey, item.id, () => prepared);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
async function sendQueuedChatMessage(
|
||||
host: ChatHost,
|
||||
id: string,
|
||||
opts?: {
|
||||
previousAttachments?: ChatAttachment[];
|
||||
previousDraft?: string;
|
||||
},
|
||||
queuedSessionKey = host.sessionKey,
|
||||
): Promise<QueuedChatSendResult> {
|
||||
const queued = readChatQueueForSession(host, queuedSessionKey).find((item) => item.id === id);
|
||||
if (!queued || queued.pendingRunId || queued.localCommandName) {
|
||||
return "failed";
|
||||
}
|
||||
const prepared = ensureQueuedSendState(host, queued, queuedSessionKey);
|
||||
const message = prepared.text.trim();
|
||||
const attachments = prepared.attachments ?? [];
|
||||
const hasAttachments = attachments.length > 0;
|
||||
if (!message && !hasAttachments) {
|
||||
removeQueuedMessageWithoutReleasing(host, id, prepared.sessionKey ?? host.sessionKey);
|
||||
return "sent";
|
||||
}
|
||||
const sessionKey = prepared.sessionKey ?? host.sessionKey;
|
||||
if (!host.connected || !host.client) {
|
||||
updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
|
||||
...item,
|
||||
sendState: "waiting-reconnect",
|
||||
sendError: undefined,
|
||||
}));
|
||||
return "pending";
|
||||
}
|
||||
|
||||
const runId = prepared.sendRunId ?? generateUUID();
|
||||
const startedAt = Date.now();
|
||||
updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
|
||||
...item,
|
||||
sendAttempts: (item.sendAttempts ?? 0) + 1,
|
||||
sendError: undefined,
|
||||
sendRunId: runId,
|
||||
sendState: "sending",
|
||||
sessionKey,
|
||||
}));
|
||||
host.chatSending = true;
|
||||
if (host.sessionKey === sessionKey) {
|
||||
host.lastError = null;
|
||||
reconcileChatRunLifecycle(host as unknown as Parameters<typeof reconcileChatRunLifecycle>[0], {
|
||||
clearRunStatus: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const ack = await requestChatSend(host as unknown as ChatState, {
|
||||
message,
|
||||
attachments: hasAttachments ? attachments : undefined,
|
||||
runId,
|
||||
sessionKey,
|
||||
});
|
||||
removeQueuedMessageWithoutReleasing(host, id, sessionKey);
|
||||
if (host.sessionKey === sessionKey) {
|
||||
appendUserChatMessage(
|
||||
host as unknown as ChatState,
|
||||
message,
|
||||
hasAttachments ? attachments : undefined,
|
||||
startedAt,
|
||||
);
|
||||
if (ack.status === "ok") {
|
||||
reconcileChatRunLifecycle(
|
||||
host as unknown as Parameters<typeof reconcileChatRunLifecycle>[0],
|
||||
{
|
||||
sessionStatus: "done",
|
||||
runId: ack.runId,
|
||||
sessionKey,
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
clearToolStream: true,
|
||||
clearSideResultTerminalRuns: true,
|
||||
clearRunStatus: true,
|
||||
},
|
||||
);
|
||||
void loadChatHistory(host as unknown as ChatState);
|
||||
} else {
|
||||
host.chatRunId = ack.runId;
|
||||
host.chatStream = "";
|
||||
(host as ChatHost & { chatStreamStartedAt?: number | null }).chatStreamStartedAt =
|
||||
startedAt;
|
||||
}
|
||||
}
|
||||
if (prepared.refreshSessions) {
|
||||
if (ack.status === "ok") {
|
||||
void loadSessions(host as unknown as SessionsState, {
|
||||
...createChatSessionsLoadOverrides(host),
|
||||
});
|
||||
} else {
|
||||
host.refreshSessionsAfterChat.add(ack.runId);
|
||||
}
|
||||
}
|
||||
discardChatAttachmentDataUrls(excludeComposerAttachments(host, attachments));
|
||||
return "sent";
|
||||
} catch (err) {
|
||||
const error = formatConnectError(err);
|
||||
if (isRecoverableChatSendError(err, error)) {
|
||||
updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
|
||||
...item,
|
||||
sendError: error,
|
||||
sendState: "waiting-reconnect",
|
||||
}));
|
||||
if (host.sessionKey === sessionKey) {
|
||||
host.lastError = "Message will send when the Gateway reconnects.";
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
|
||||
...item,
|
||||
sendError: error,
|
||||
sendState: "failed",
|
||||
}));
|
||||
if (host.sessionKey === sessionKey) {
|
||||
host.lastError = error;
|
||||
restoreComposerAfterFailedSend(host, opts ?? {});
|
||||
}
|
||||
return "failed";
|
||||
} finally {
|
||||
host.chatSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChatMessageNow(
|
||||
host: ChatHost,
|
||||
message: string,
|
||||
opts?: {
|
||||
queueItemId?: string;
|
||||
previousDraft?: string;
|
||||
restoreDraft?: boolean;
|
||||
attachments?: ChatAttachment[];
|
||||
@@ -282,38 +543,49 @@ async function sendChatMessageNow(
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
// Reset scroll state before sending to ensure auto-scroll works for the response
|
||||
resetChatScroll(host as unknown as Parameters<typeof resetChatScroll>[0]);
|
||||
const runId = await sendChatMessage(host as unknown as ChatState, message, opts?.attachments);
|
||||
const ok = Boolean(runId);
|
||||
if (!ok && opts?.previousDraft != null) {
|
||||
host.chatMessage = opts.previousDraft;
|
||||
const queued =
|
||||
opts?.queueItemId != null
|
||||
? (host.chatQueue.find((item) => item.id === opts.queueItemId) ?? null)
|
||||
: enqueuePendingSendMessage(host, message, opts?.attachments, opts?.refreshSessions);
|
||||
if (!queued) {
|
||||
return false;
|
||||
}
|
||||
if (!ok && opts?.previousAttachments) {
|
||||
host.chatAttachments = opts.previousAttachments;
|
||||
}
|
||||
if (ok) {
|
||||
const queuedSessionKey = queued.sessionKey ?? host.sessionKey;
|
||||
const result = await sendQueuedChatMessage(host, queued.id, {
|
||||
previousDraft: opts?.previousDraft,
|
||||
previousAttachments: opts?.previousAttachments,
|
||||
});
|
||||
const ok = result === "sent";
|
||||
if (ok && host.sessionKey === queuedSessionKey) {
|
||||
setLastActiveSessionKey(
|
||||
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
|
||||
host.sessionKey,
|
||||
queuedSessionKey,
|
||||
);
|
||||
resetChatInputHistoryNavigation(host);
|
||||
}
|
||||
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
|
||||
if (
|
||||
ok &&
|
||||
host.sessionKey === queuedSessionKey &&
|
||||
opts?.restoreDraft &&
|
||||
opts.previousDraft?.trim()
|
||||
) {
|
||||
host.chatMessage = opts.previousDraft;
|
||||
}
|
||||
if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) {
|
||||
if (
|
||||
ok &&
|
||||
host.sessionKey === queuedSessionKey &&
|
||||
opts?.restoreAttachments &&
|
||||
opts.previousAttachments?.length
|
||||
) {
|
||||
host.chatAttachments = opts.previousAttachments;
|
||||
}
|
||||
// Force scroll after sending to ensure viewport is at bottom for incoming stream
|
||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
|
||||
if (ok && !host.chatRunId) {
|
||||
if (host.sessionKey === queuedSessionKey) {
|
||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
|
||||
}
|
||||
if (ok && host.sessionKey === queuedSessionKey && !host.chatRunId) {
|
||||
void flushChatQueue(host);
|
||||
}
|
||||
if (ok && opts?.refreshSessions && runId) {
|
||||
host.refreshSessionsAfterChat.add(runId);
|
||||
}
|
||||
if (ok) {
|
||||
discardChatAttachmentDataUrls(excludeComposerAttachments(host, opts?.attachments));
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -504,19 +776,26 @@ async function flushChatQueue(host: ChatHost) {
|
||||
if (!host.connected || isChatBusy(host)) {
|
||||
return;
|
||||
}
|
||||
const nextIndex = host.chatQueue.findIndex((item) => !item.pendingRunId);
|
||||
const nextIndex = host.chatQueue.findIndex(
|
||||
(item) =>
|
||||
!item.pendingRunId &&
|
||||
item.sendState !== "sending" &&
|
||||
item.sendState !== "failed" &&
|
||||
(item.sessionKey == null || item.sessionKey === host.sessionKey),
|
||||
);
|
||||
if (nextIndex < 0) {
|
||||
return;
|
||||
}
|
||||
const next = host.chatQueue[nextIndex];
|
||||
host.chatQueue = host.chatQueue.filter((_, index) => index !== nextIndex);
|
||||
let ok = false;
|
||||
try {
|
||||
if (next.localCommandName) {
|
||||
host.chatQueue = host.chatQueue.filter((_, index) => index !== nextIndex);
|
||||
await dispatchSlashCommand(host, next.localCommandName, next.localCommandArgs ?? "");
|
||||
ok = true;
|
||||
} else {
|
||||
ok = await sendChatMessageNow(host, next.text, {
|
||||
queueItemId: next.id,
|
||||
attachments: next.attachments,
|
||||
refreshSessions: next.refreshSessions,
|
||||
});
|
||||
@@ -524,9 +803,9 @@ async function flushChatQueue(host: ChatHost) {
|
||||
} catch (err) {
|
||||
host.lastError = String(err);
|
||||
}
|
||||
if (!ok) {
|
||||
if (!ok && next.localCommandName) {
|
||||
host.chatQueue = [next, ...host.chatQueue];
|
||||
} else if (host.chatQueue.length > 0) {
|
||||
} else if (ok && host.chatQueue.length > 0) {
|
||||
// Continue draining — local commands don't block on server response
|
||||
void flushChatQueue(host);
|
||||
}
|
||||
@@ -536,7 +815,7 @@ export function removeQueuedMessage(host: ChatHost, id: string) {
|
||||
const removed = host.chatQueue.filter((item) => item.id === id);
|
||||
host.chatQueue = host.chatQueue.filter((item) => item.id !== id);
|
||||
for (const item of removed) {
|
||||
releaseChatAttachmentPayloads(item.attachments);
|
||||
releaseChatAttachmentPayloads(excludeComposerAttachments(host, item.attachments));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,7 +826,104 @@ export function clearPendingQueueItemsForRun(host: ChatHost, runId: string | und
|
||||
const removed = host.chatQueue.filter((item) => item.pendingRunId === runId);
|
||||
host.chatQueue = host.chatQueue.filter((item) => item.pendingRunId !== runId);
|
||||
for (const item of removed) {
|
||||
releaseChatAttachmentPayloads(item.attachments);
|
||||
releaseChatAttachmentPayloads(excludeComposerAttachments(host, item.attachments));
|
||||
}
|
||||
}
|
||||
|
||||
type ChatQueueStoreHost = {
|
||||
chatQueue: ChatQueueItem[];
|
||||
chatQueueBySession?: Record<string, ChatQueueItem[]>;
|
||||
};
|
||||
|
||||
function chatQueueCollections(host: ChatQueueStoreHost): ChatQueueItem[][] {
|
||||
return [host.chatQueue, ...Object.values(host.chatQueueBySession ?? {})];
|
||||
}
|
||||
|
||||
export function hasReconnectableQueuedChatSends(host: ChatQueueStoreHost): boolean {
|
||||
return chatQueueCollections(host).some((queue) =>
|
||||
queue.some((item) => item.sendRunId && item.sendState === "waiting-reconnect"),
|
||||
);
|
||||
}
|
||||
|
||||
export function markQueuedChatSendsWaitingForReconnect(host: ChatQueueStoreHost) {
|
||||
const markQueue = (queue: ChatQueueItem[]): { changed: boolean; queue: ChatQueueItem[] } => {
|
||||
let changed = false;
|
||||
const nextQueue = queue.map((item) => {
|
||||
if (!item.sendRunId || item.sendState !== "sending") {
|
||||
return item;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...item,
|
||||
sendState: "waiting-reconnect" as const,
|
||||
};
|
||||
});
|
||||
return { changed, queue: nextQueue };
|
||||
};
|
||||
|
||||
const active = markQueue(host.chatQueue);
|
||||
if (active.changed) {
|
||||
host.chatQueue = active.queue;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const queueBySession = { ...host.chatQueueBySession };
|
||||
for (const [sessionKey, queue] of Object.entries(queueBySession)) {
|
||||
const next = markQueue(queue);
|
||||
if (next.changed) {
|
||||
changed = true;
|
||||
queueBySession[sessionKey] = next.queue;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
host.chatQueueBySession = queueBySession;
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryReconnectableQueuedChatSends(host: ChatHost) {
|
||||
if (!host.connected || !host.client || host.chatSending) {
|
||||
return;
|
||||
}
|
||||
const sessionKeys = [
|
||||
host.sessionKey,
|
||||
...Object.keys(host.chatQueueBySession ?? {}).filter(
|
||||
(sessionKey) => sessionKey !== host.sessionKey,
|
||||
),
|
||||
];
|
||||
for (const sessionKey of sessionKeys) {
|
||||
const item = readChatQueueForSession(host, sessionKey).find(
|
||||
(entry) =>
|
||||
entry.sendRunId &&
|
||||
entry.sendState === "waiting-reconnect" &&
|
||||
!entry.pendingRunId &&
|
||||
!entry.localCommandName,
|
||||
);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
await sendQueuedChatMessage(host, item.id, undefined, sessionKey);
|
||||
if (host.chatRunId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!host.chatRunId) {
|
||||
void flushChatQueue(host);
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryQueuedChatMessage(host: ChatHost, id: string) {
|
||||
const item = host.chatQueue.find((entry) => entry.id === id);
|
||||
if (!item || item.localCommandName || item.pendingRunId || item.sendState === "sending") {
|
||||
return;
|
||||
}
|
||||
updateQueuedMessage(host, id, (entry) => ({
|
||||
...entry,
|
||||
sendError: undefined,
|
||||
sendState: host.connected && host.client ? "sending" : "waiting-reconnect",
|
||||
}));
|
||||
await sendQueuedChatMessage(host, id);
|
||||
if (!host.chatRunId) {
|
||||
void flushChatQueue(host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,9 +932,6 @@ export async function handleSendChat(
|
||||
messageOverride?: string,
|
||||
opts?: ChatSendOptions,
|
||||
) {
|
||||
if (!host.connected) {
|
||||
return;
|
||||
}
|
||||
const previousDraft = host.chatMessage;
|
||||
const message = (messageOverride ?? host.chatMessage).trim();
|
||||
const submittedSessionKey = host.sessionKey;
|
||||
|
||||
@@ -62,6 +62,9 @@ vi.mock("./app-chat.ts", () => ({
|
||||
createChatSessionsLoadOverrides: () => ({ activeMinutes: 60, limit: 50 }),
|
||||
clearPendingQueueItemsForRun: vi.fn(),
|
||||
flushChatQueueForEvent: vi.fn(),
|
||||
hasReconnectableQueuedChatSends: vi.fn(() => false),
|
||||
markQueuedChatSendsWaitingForReconnect: vi.fn(),
|
||||
retryReconnectableQueuedChatSends: vi.fn(async () => undefined),
|
||||
refreshChatAvatar: refreshChatAvatarMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
|
||||
|
||||
type TestGatewayHost = Parameters<typeof connectGateway>[0] & {
|
||||
chatMessages: unknown[];
|
||||
chatQueue: import("./ui-types.ts").ChatQueueItem[];
|
||||
chatQueueBySession: Record<string, import("./ui-types.ts").ChatQueueItem[]>;
|
||||
chatSideResult: unknown;
|
||||
chatSideResultTerminalRuns: Set<string>;
|
||||
chatStream: string | null;
|
||||
@@ -169,6 +171,7 @@ function createHost(): TestGatewayHost {
|
||||
sessionKey: "main",
|
||||
chatMessages: [],
|
||||
chatQueue: [],
|
||||
chatQueueBySession: {},
|
||||
chatToolMessages: [],
|
||||
activityEntries: [],
|
||||
chatStreamSegments: [],
|
||||
@@ -946,6 +949,76 @@ describe("connectGateway", () => {
|
||||
expect(host.pendingAbort).toBeNull();
|
||||
});
|
||||
|
||||
it("retries reconnectable queued chat sends after reconnect hello", async () => {
|
||||
const host = createHost();
|
||||
host.chatQueue = [
|
||||
{
|
||||
id: "pending-send",
|
||||
text: "retry this prompt",
|
||||
createdAt: 1,
|
||||
sendRunId: "run-retry-1",
|
||||
sendState: "waiting-reconnect",
|
||||
sessionKey: "main",
|
||||
},
|
||||
];
|
||||
|
||||
connectGateway(host);
|
||||
const client = requireGatewayClient();
|
||||
|
||||
client.emitHello();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(client.request).toHaveBeenCalledWith("chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "retry this prompt",
|
||||
deliver: false,
|
||||
idempotencyKey: "run-retry-1",
|
||||
attachments: undefined,
|
||||
});
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(host.chatQueue).toStrictEqual([]);
|
||||
expect(host.chatRunId).toBe("run-retry-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("retries saved-session queued chat sends after reconnect hello", async () => {
|
||||
const host = createHost();
|
||||
host.sessionKey = "other";
|
||||
host.chatQueueBySession = {
|
||||
main: [
|
||||
{
|
||||
id: "pending-send-main",
|
||||
text: "retry main prompt",
|
||||
createdAt: 1,
|
||||
sendRunId: "run-retry-main",
|
||||
sendState: "waiting-reconnect",
|
||||
sessionKey: "main",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
connectGateway(host);
|
||||
const client = requireGatewayClient();
|
||||
|
||||
client.emitHello();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(client.request).toHaveBeenCalledWith("chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "retry main prompt",
|
||||
deliver: false,
|
||||
idempotencyKey: "run-retry-main",
|
||||
attachments: undefined,
|
||||
});
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(host.chatQueueBySession.main).toBeUndefined();
|
||||
expect(host.chatMessages).toStrictEqual([]);
|
||||
expect(host.chatRunId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("logs and drops stale queued chat abort failures after reconnect", async () => {
|
||||
const host = createHost();
|
||||
host.pendingAbort = { runId: "run-stale", sessionKey: "main" };
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
clearPendingQueueItemsForRun,
|
||||
createChatSessionsLoadOverrides,
|
||||
flushChatQueueForEvent,
|
||||
hasReconnectableQueuedChatSends,
|
||||
markQueuedChatSendsWaitingForReconnect,
|
||||
refreshChatAvatar,
|
||||
retryReconnectableQueuedChatSends,
|
||||
} from "./app-chat.ts";
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import {
|
||||
@@ -581,10 +584,18 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
clearRunStatus: !hadOrphanedRun,
|
||||
},
|
||||
);
|
||||
if (shutdownHost.resumeChatQueueAfterReconnect) {
|
||||
const hasReconnectableChatSends = hasReconnectableQueuedChatSends(
|
||||
host as unknown as Parameters<typeof hasReconnectableQueuedChatSends>[0],
|
||||
);
|
||||
if (shutdownHost.resumeChatQueueAfterReconnect || hasReconnectableChatSends) {
|
||||
// The interrupted run will never emit its terminal event now that the
|
||||
// old client is gone, so resume any deferred commands after hello.
|
||||
shutdownHost.resumeChatQueueAfterReconnect = false;
|
||||
if (hasReconnectableChatSends) {
|
||||
void retryReconnectableQueuedChatSends(
|
||||
host as unknown as Parameters<typeof retryReconnectableQueuedChatSends>[0],
|
||||
);
|
||||
}
|
||||
void flushChatQueueForEvent(
|
||||
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
|
||||
);
|
||||
@@ -609,6 +620,9 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
return;
|
||||
}
|
||||
host.connected = false;
|
||||
markQueuedChatSendsWaitingForReconnect(
|
||||
host as unknown as Parameters<typeof markQueuedChatSendsWaitingForReconnect>[0],
|
||||
);
|
||||
clearSessionsChangedReloadTimer(host);
|
||||
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
||||
host.lastErrorCode =
|
||||
|
||||
@@ -2837,6 +2837,7 @@ export function renderApp(state: AppViewState) {
|
||||
canAbort: hasAbortableSessionRun(state),
|
||||
onAbort: () => void state.handleAbortChat({ preserveDraft: true }),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
onQueueRetry: (id) => void state.retryQueuedChatMessage(id),
|
||||
onQueueSteer: (id) => void state.steerQueuedChatMessage(id),
|
||||
onDismissSideResult: () => {
|
||||
state.chatSideResult = null;
|
||||
|
||||
@@ -527,6 +527,7 @@ export type AppViewState = {
|
||||
steerQueuedChatMessage: (id: string) => Promise<void>;
|
||||
handleAbortChat: (opts?: ChatAbortOptions) => Promise<void>;
|
||||
removeQueuedMessage: (id: string) => void;
|
||||
retryQueuedChatMessage: (id: string) => Promise<void>;
|
||||
handleChatScroll: (event: Event) => void;
|
||||
resetToolStream: () => void;
|
||||
resetChatScroll: () => void;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
handleSendChat as handleSendChatInternal,
|
||||
removeQueuedMessage as removeQueuedMessageInternal,
|
||||
resetChatInputHistoryNavigation as resetChatInputHistoryNavigationInternal,
|
||||
retryQueuedChatMessage as retryQueuedChatMessageInternal,
|
||||
steerQueuedChatMessage as steerQueuedChatMessageInternal,
|
||||
type ChatInputHistoryKeyInput,
|
||||
type ChatInputHistoryKeyResult,
|
||||
@@ -1061,6 +1062,13 @@ export class OpenClawApp extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
async retryQueuedChatMessage(id: string) {
|
||||
await retryQueuedChatMessageInternal(
|
||||
this as unknown as Parameters<typeof retryQueuedChatMessageInternal>[0],
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
async handleSendChat(
|
||||
messageOverride?: string,
|
||||
opts?: Parameters<typeof handleSendChatInternal>[2],
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import type { ChatQueueItem } from "../ui-types.ts";
|
||||
|
||||
export type ChatQueueProps = {
|
||||
queue: ChatQueueItem[];
|
||||
canAbort?: boolean;
|
||||
onQueueRetry?: (id: string) => void;
|
||||
onQueueSteer?: (id: string) => void;
|
||||
onQueueRemove: (id: string) => void;
|
||||
};
|
||||
|
||||
function sendStateLabel(item: ChatQueueItem): string | null {
|
||||
switch (item.sendState) {
|
||||
case "sending":
|
||||
return "Sending";
|
||||
case "waiting-reconnect":
|
||||
return "Waiting for reconnect";
|
||||
case "failed":
|
||||
return "Failed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderChatQueue(props: ChatQueueProps) {
|
||||
if (!props.queue.length) {
|
||||
return nothing;
|
||||
@@ -17,8 +32,9 @@ export function renderChatQueue(props: ChatQueueProps) {
|
||||
<div class="chat-queue" role="status" aria-live="polite">
|
||||
<div class="chat-queue__title">Queued (${props.queue.length})</div>
|
||||
<div class="chat-queue__list">
|
||||
${props.queue.map(
|
||||
(item) => html`
|
||||
${props.queue.map((item) => {
|
||||
const stateLabel = sendStateLabel(item);
|
||||
return html`
|
||||
<div
|
||||
class="chat-queue__item ${item.kind === "steered" ? "chat-queue__item--steered" : ""}"
|
||||
>
|
||||
@@ -26,15 +42,34 @@ export function renderChatQueue(props: ChatQueueProps) {
|
||||
${item.kind === "steered"
|
||||
? html`<span class="chat-queue__badge">Steered</span>`
|
||||
: nothing}
|
||||
${stateLabel ? html`<span class="chat-queue__badge">${stateLabel}</span>` : nothing}
|
||||
<div class="chat-queue__text">
|
||||
${item.text ||
|
||||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
|
||||
</div>
|
||||
${item.sendError
|
||||
? html`<div class="chat-queue__error">${item.sendError}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="chat-queue__actions">
|
||||
${item.sendState === "failed" && props.onQueueRetry
|
||||
? html`
|
||||
<button
|
||||
class="btn chat-queue__retry"
|
||||
type="button"
|
||||
title=${t("chat.queue.retrySend")}
|
||||
aria-label=${t("chat.queue.retryQueuedMessage")}
|
||||
@click=${() => props.onQueueRetry?.(item.id)}
|
||||
>
|
||||
${icons.refresh}
|
||||
<span>${t("chat.queue.retry")}</span>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
${props.canAbort &&
|
||||
props.onQueueSteer &&
|
||||
item.kind !== "steered" &&
|
||||
!item.sendState &&
|
||||
!item.localCommandName
|
||||
? html`
|
||||
<button
|
||||
@@ -59,8 +94,8 @@ export function renderChatQueue(props: ChatQueueProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1045,6 +1045,21 @@ describe("sendChatMessage", () => {
|
||||
expect(sendParams.message).toBe("continue");
|
||||
});
|
||||
|
||||
it("adopts the run id and terminal status from the chat.send ack", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ runId: "gateway-complete-run", status: "ok" });
|
||||
const state = createState({
|
||||
connected: true,
|
||||
client: { request } as unknown as ChatState["client"],
|
||||
});
|
||||
|
||||
const result = await sendChatMessage(state, "already handled");
|
||||
|
||||
expect(result).toBe("gateway-complete-run");
|
||||
expect(state.chatRunId).toBeNull();
|
||||
expect(state.chatStream).toBeNull();
|
||||
expect(state.chatStreamStartedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("serializes non-image chat attachments as files", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ runId: "run-1", status: "started" });
|
||||
const state = createState({
|
||||
|
||||
@@ -414,22 +414,53 @@ function buildApiAttachments(attachments?: ChatAttachment[]) {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function requestChatSend(
|
||||
export type ChatSendAckStatus = "started" | "in_flight" | "ok";
|
||||
|
||||
export type ChatSendAck = {
|
||||
runId: string;
|
||||
status: ChatSendAckStatus;
|
||||
};
|
||||
|
||||
function normalizeChatSendAck(payload: unknown, fallbackRunId: string): ChatSendAck {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return { runId: fallbackRunId, status: "started" };
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const runId =
|
||||
typeof record.runId === "string" && record.runId.trim() ? record.runId.trim() : fallbackRunId;
|
||||
const status = record.status;
|
||||
return {
|
||||
runId,
|
||||
status: status === "in_flight" || status === "ok" ? status : "started",
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestChatSend(
|
||||
state: ChatState,
|
||||
params: { message: string; attachments?: ChatAttachment[]; runId: string },
|
||||
) {
|
||||
params: {
|
||||
message: string;
|
||||
attachments?: ChatAttachment[];
|
||||
runId: string;
|
||||
sessionKey?: string;
|
||||
},
|
||||
): Promise<ChatSendAck> {
|
||||
const sessionKey = params.sessionKey ?? state.sessionKey;
|
||||
const currentSessionId = state.currentSessionId;
|
||||
const sessionId =
|
||||
typeof state.currentSessionId === "string" && state.currentSessionId.trim()
|
||||
? state.currentSessionId.trim()
|
||||
sessionKey === state.sessionKey &&
|
||||
typeof currentSessionId === "string" &&
|
||||
currentSessionId.trim()
|
||||
? currentSessionId.trim()
|
||||
: undefined;
|
||||
await state.client!.request("chat.send", {
|
||||
sessionKey: state.sessionKey,
|
||||
const payload = await state.client!.request("chat.send", {
|
||||
sessionKey,
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
message: params.message,
|
||||
deliver: false,
|
||||
idempotencyKey: params.runId,
|
||||
attachments: buildApiAttachments(params.attachments),
|
||||
});
|
||||
return normalizeChatSendAck(payload, params.runId);
|
||||
}
|
||||
|
||||
type AssistantMessageNormalizationOptions = {
|
||||
@@ -499,8 +530,61 @@ export async function sendChatMessage(
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
appendUserChatMessage(state, msg, attachments, now);
|
||||
|
||||
// Build user message content blocks
|
||||
state.chatSending = true;
|
||||
state.lastError = null;
|
||||
reconcileChatRunLifecycle(state as unknown as Parameters<typeof reconcileChatRunLifecycle>[0], {
|
||||
clearRunStatus: true,
|
||||
});
|
||||
const runId = generateUUID();
|
||||
state.chatRunId = runId;
|
||||
state.chatStream = "";
|
||||
state.chatStreamStartedAt = now;
|
||||
|
||||
try {
|
||||
const ack = await requestChatSend(state, { message: msg, attachments, runId });
|
||||
if (ack.status === "ok") {
|
||||
state.chatRunId = null;
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} else {
|
||||
state.chatRunId = ack.runId;
|
||||
}
|
||||
return ack.status === "ok" ? ack.runId : runId;
|
||||
} catch (err) {
|
||||
const error = formatConnectError(err);
|
||||
reconcileChatRunLifecycle(state as unknown as Parameters<typeof reconcileChatRunLifecycle>[0], {
|
||||
outcome: "interrupted",
|
||||
sessionStatus: "failed",
|
||||
runId,
|
||||
sessionKey: state.sessionKey,
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
});
|
||||
state.lastError = error;
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Error: " + error }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
return null;
|
||||
} finally {
|
||||
state.chatSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function appendUserChatMessage(
|
||||
state: ChatState,
|
||||
message: string,
|
||||
attachments?: ChatAttachment[],
|
||||
timestamp = Date.now(),
|
||||
) {
|
||||
const msg = message.trim();
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
const contentBlocks: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
@@ -516,7 +600,6 @@ export async function sendChatMessage(
|
||||
if (msg) {
|
||||
contentBlocks.push({ type: "text", text: msg });
|
||||
}
|
||||
// Add image previews to the message for display
|
||||
if (hasAttachments) {
|
||||
for (const att of attachments) {
|
||||
const previewUrl = getChatAttachmentPreviewUrl(att);
|
||||
@@ -546,52 +629,14 @@ export async function sendChatMessage(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: contentBlocks,
|
||||
timestamp: now,
|
||||
timestamp,
|
||||
},
|
||||
];
|
||||
|
||||
state.chatSending = true;
|
||||
state.lastError = null;
|
||||
reconcileChatRunLifecycle(state as unknown as Parameters<typeof reconcileChatRunLifecycle>[0], {
|
||||
clearRunStatus: true,
|
||||
});
|
||||
const runId = generateUUID();
|
||||
state.chatRunId = runId;
|
||||
state.chatStream = "";
|
||||
state.chatStreamStartedAt = now;
|
||||
|
||||
try {
|
||||
await requestChatSend(state, { message: msg, attachments, runId });
|
||||
return runId;
|
||||
} catch (err) {
|
||||
const error = formatConnectError(err);
|
||||
reconcileChatRunLifecycle(state as unknown as Parameters<typeof reconcileChatRunLifecycle>[0], {
|
||||
outcome: "interrupted",
|
||||
sessionStatus: "failed",
|
||||
runId,
|
||||
sessionKey: state.sessionKey,
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
});
|
||||
state.lastError = error;
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Error: " + error }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
return null;
|
||||
} finally {
|
||||
state.chatSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendDetachedChatMessage(
|
||||
@@ -610,8 +655,8 @@ export async function sendDetachedChatMessage(
|
||||
state.lastError = null;
|
||||
const runId = generateUUID();
|
||||
try {
|
||||
await requestChatSend(state, { message: msg, attachments, runId });
|
||||
return runId;
|
||||
const ack = await requestChatSend(state, { message: msg, attachments, runId });
|
||||
return ack.runId;
|
||||
} catch (err) {
|
||||
state.lastError = formatConnectError(err);
|
||||
return null;
|
||||
@@ -634,8 +679,8 @@ export async function sendSteerChatMessage(
|
||||
state.lastError = null;
|
||||
const runId = generateUUID();
|
||||
try {
|
||||
await requestChatSend(state, { message: msg, attachments, runId });
|
||||
return runId;
|
||||
const ack = await requestChatSend(state, { message: msg, attachments, runId });
|
||||
return ack.runId;
|
||||
} catch (err) {
|
||||
state.lastError = formatConnectError(err);
|
||||
return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
installMockGateway,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
type MockGatewayRequest,
|
||||
} from "../../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = chromium.executablePath();
|
||||
@@ -29,6 +30,22 @@ function requireString(value: unknown, label: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
async function waitForRequests(
|
||||
gateway: Awaited<ReturnType<typeof installMockGateway>>,
|
||||
method: string,
|
||||
count: number,
|
||||
): Promise<MockGatewayRequest[]> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
const requests = await gateway.getRequests(method);
|
||||
if (requests.length >= count) {
|
||||
return requests;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${count} ${method} requests`);
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
@@ -84,4 +101,159 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a delayed chat.send ACK visible as pending until the ACK resolves", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page);
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await gateway.deferNext("chat.send");
|
||||
|
||||
const prompt = "hold this until the ack arrives";
|
||||
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
const sendRequest = await gateway.waitForRequest("chat.send");
|
||||
const params = requireRecord(sendRequest.params);
|
||||
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
|
||||
|
||||
await page.locator(".chat-queue").getByText("Sending").waitFor({ timeout: 10_000 });
|
||||
await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 });
|
||||
expect(await page.locator(".chat-thread").getByText(prompt).count()).toBe(0);
|
||||
|
||||
await gateway.resolveDeferred("chat.send", { runId, status: "started" });
|
||||
|
||||
await page.locator(".chat-queue").waitFor({ state: "detached", timeout: 10_000 });
|
||||
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps rejected pre-ACK sends visible and restores the draft", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page);
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await gateway.deferNext("chat.send");
|
||||
|
||||
const prompt = "policy should not eat this";
|
||||
const composer = page.locator(".agent-chat__composer-combobox textarea");
|
||||
await composer.fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
await gateway.waitForRequest("chat.send");
|
||||
|
||||
await gateway.rejectDeferred("chat.send", {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "send blocked by session policy",
|
||||
});
|
||||
|
||||
await page.locator(".chat-queue").getByText("Failed").waitFor({ timeout: 10_000 });
|
||||
await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 });
|
||||
expect(await composer.inputValue()).toBe(prompt);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries an ACK-lost send after reconnect with the same idempotency key", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page);
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await gateway.deferNext("chat.send");
|
||||
|
||||
const prompt = "retry with the same key";
|
||||
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
const firstRequest = await gateway.waitForRequest("chat.send");
|
||||
const firstParams = requireRecord(firstRequest.params);
|
||||
const runId = requireString(firstParams.idempotencyKey, "first idempotency key");
|
||||
|
||||
await gateway.closeLatest(1006, "lost ack");
|
||||
|
||||
const sends = await waitForRequests(gateway, "chat.send", 2);
|
||||
const secondParams = requireRecord(sends[1]?.params);
|
||||
expect(secondParams.idempotencyKey).toBe(runId);
|
||||
expect(secondParams.message).toBe(prompt);
|
||||
await page.locator(".chat-queue").waitFor({ state: "detached", timeout: 10_000 });
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes history after a tool-call window disconnects and reconnects", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page);
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
|
||||
const prompt = "use a tool then reconnect";
|
||||
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
const sendRequest = await gateway.waitForRequest("chat.send");
|
||||
const params = requireRecord(sendRequest.params);
|
||||
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
|
||||
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
|
||||
|
||||
await gateway.emitGatewayEvent("agent", {
|
||||
data: {
|
||||
args: { query: "status" },
|
||||
name: "status",
|
||||
phase: "start",
|
||||
toolCallId: "tool-1",
|
||||
},
|
||||
runId,
|
||||
seq: 1,
|
||||
sessionKey: "main",
|
||||
stream: "tool",
|
||||
ts: Date.now(),
|
||||
});
|
||||
await gateway.setHistoryMessages([
|
||||
{
|
||||
content: [{ text: prompt, type: "text" }],
|
||||
role: "user",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
content: [{ text: "Recovered from refreshed history.", type: "text" }],
|
||||
role: "assistant",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
await gateway.closeLatest(1006, "lost during tool call");
|
||||
|
||||
await page.getByText("Recovered from refreshed history.").waitFor({ timeout: 15_000 });
|
||||
expect(await page.locator(".chat-queue").count()).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,11 @@ export type ChatQueueItem = {
|
||||
localCommandArgs?: string;
|
||||
localCommandName?: string;
|
||||
pendingRunId?: string;
|
||||
sendAttempts?: number;
|
||||
sendError?: string;
|
||||
sendRunId?: string;
|
||||
sendState?: "sending" | "waiting-reconnect" | "failed";
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export const CRON_CHANNEL_LAST = "last";
|
||||
|
||||
@@ -204,6 +204,7 @@ vi.mock("./agents-utils.ts", () => ({
|
||||
function renderQueue(params: {
|
||||
queue: ChatQueueItem[];
|
||||
canAbort?: boolean;
|
||||
onQueueRetry?: (id: string) => void;
|
||||
onQueueSteer?: (id: string) => void;
|
||||
}) {
|
||||
const container = document.createElement("div");
|
||||
@@ -211,6 +212,7 @@ function renderQueue(params: {
|
||||
renderChatQueue({
|
||||
queue: params.queue,
|
||||
canAbort: params.canAbort ?? true,
|
||||
onQueueRetry: params.onQueueRetry,
|
||||
onQueueSteer: params.onQueueSteer,
|
||||
onQueueRemove: () => undefined,
|
||||
}),
|
||||
@@ -1167,6 +1169,34 @@ describe("chat queue", () => {
|
||||
|
||||
expect(inactiveContainer.querySelector(".chat-queue__steer")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders failed send state with retry and remove affordances", () => {
|
||||
const onQueueRetry = vi.fn();
|
||||
const container = renderQueue({
|
||||
onQueueRetry,
|
||||
queue: [
|
||||
{
|
||||
id: "failed-1",
|
||||
text: "still recoverable",
|
||||
createdAt: 1,
|
||||
sendError: "send blocked by session policy",
|
||||
sendRunId: "run-failed-1",
|
||||
sendState: "failed",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-queue__badge")?.textContent?.trim()).toBe("Failed");
|
||||
expect(container.querySelector(".chat-queue__error")?.textContent?.trim()).toBe(
|
||||
"send blocked by session policy",
|
||||
);
|
||||
const retry = container.querySelector<HTMLButtonElement>(".chat-queue__retry");
|
||||
expect(retry?.textContent?.trim()).toBe("Retry");
|
||||
|
||||
retry?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(onQueueRetry).toHaveBeenCalledWith("failed-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat sidebar raw content", () => {
|
||||
|
||||
@@ -154,6 +154,7 @@ export type ChatProps = {
|
||||
onDismissError?: () => void;
|
||||
onAbort?: () => void;
|
||||
onQueueRemove: (id: string) => void;
|
||||
onQueueRetry?: (id: string) => void;
|
||||
onQueueSteer?: (id: string) => void;
|
||||
onDismissSideResult?: () => void;
|
||||
onNewSession: () => void;
|
||||
@@ -1493,6 +1494,7 @@ export function renderChat(props: ChatProps) {
|
||||
${renderChatQueue({
|
||||
queue: props.queue,
|
||||
canAbort: showAbortableUi,
|
||||
onQueueRetry: props.onQueueRetry,
|
||||
onQueueSteer: props.onQueueSteer,
|
||||
onQueueRemove: props.onQueueRemove,
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user